mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-10 13:11:47 +00:00
Compare commits
6 Commits
codex/fix-
...
party-impo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04bea0733b | ||
|
|
179c7c0743 | ||
|
|
fafb0b1505 | ||
|
|
3a69ada5ff | ||
|
|
62be241701 | ||
|
|
cfbbf2f165 |
@@ -160,8 +160,7 @@ def _execute(filters, additional_table_columns=None):
|
||||
row.update(
|
||||
{
|
||||
"debit": inv.base_grand_total,
|
||||
# credits the invoice itself posts to the receivable (mirrors its GL)
|
||||
"credit": get_in_invoice_receivable_credit(inv),
|
||||
"credit": 0.0,
|
||||
"outstanding_amount": flt(
|
||||
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
|
||||
),
|
||||
@@ -182,14 +181,6 @@ def _execute(filters, additional_table_columns=None):
|
||||
return columns, res, None, None, None, include_payments
|
||||
|
||||
|
||||
def get_in_invoice_receivable_credit(inv):
|
||||
# amount the invoice settles against its own receivable, matching the invoice's GL entries
|
||||
credit = flt(inv.loyalty_amount) # loyalty redemption, POS or not
|
||||
if inv.is_pos: # POS payments and write-off credit the receivable only on POS invoices
|
||||
credit += flt(inv.base_paid_amount) - flt(inv.base_change_amount) + flt(inv.base_write_off_amount)
|
||||
return credit
|
||||
|
||||
|
||||
def get_columns(invoice_list, additional_table_columns, include_payments=False):
|
||||
"""return columns based on filters"""
|
||||
columns = [
|
||||
@@ -467,11 +458,6 @@ def get_invoices(filters, additional_query_columns):
|
||||
si.base_net_total,
|
||||
si.base_grand_total,
|
||||
si.base_rounded_total,
|
||||
si.is_pos,
|
||||
si.base_paid_amount,
|
||||
si.base_change_amount,
|
||||
si.base_write_off_amount,
|
||||
si.loyalty_amount,
|
||||
si.outstanding_amount,
|
||||
si.is_internal_customer,
|
||||
si.represents_company,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, flt, getdate, today
|
||||
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.sales_register.sales_register import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
@@ -252,46 +251,6 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
result_output = {k: v for k, v in filtered_output[0].items() if k in expected_result}
|
||||
self.assertDictEqual(result_output, expected_result)
|
||||
|
||||
def test_ledger_view_nets_pos_paid_invoice(self):
|
||||
# A POS payment settles the receivable inside the invoice, so the ledger view must credit it
|
||||
# and net to zero instead of showing a phantom outstanding.
|
||||
make_pos_profile()
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
price_list_rate=100,
|
||||
do_not_save=1,
|
||||
)
|
||||
si.is_pos = 1
|
||||
si.append("payments", {"mode_of_payment": "Cash", "amount": 100})
|
||||
si = si.save().submit()
|
||||
self.assertEqual(flt(si.outstanding_amount), 0.0)
|
||||
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"from_date": today(),
|
||||
"to_date": today(),
|
||||
"company": self.company,
|
||||
"include_payments": True,
|
||||
"customer": self.customer,
|
||||
}
|
||||
)
|
||||
rows = execute(filters)[1]
|
||||
inv_row = next(x for x in rows if x.get("voucher_no") == si.name)
|
||||
|
||||
self.assertEqual(flt(inv_row.get("debit")), 100.0)
|
||||
self.assertEqual(flt(inv_row.get("credit")), 100.0)
|
||||
|
||||
# running balance is unchanged by a fully-paid POS invoice
|
||||
idx = rows.index(inv_row)
|
||||
self.assertEqual(flt(inv_row.get("balance")), flt(rows[idx - 1].get("balance")))
|
||||
|
||||
def test_outstanding_currency_conversion(self):
|
||||
foreign_invoice = create_sales_invoice(
|
||||
customer="_Test Customer",
|
||||
|
||||
@@ -116,39 +116,24 @@ frappe.ui.form.on("Asset Repair", {
|
||||
},
|
||||
|
||||
repair_status: (frm) => {
|
||||
if (frm.doc.completion_date && frm.doc.repair_status == "Completed") {
|
||||
frappe.call({
|
||||
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
|
||||
args: {
|
||||
failure_date: frm.doc.failure_date,
|
||||
completion_date: frm.doc.completion_date,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.set_value("downtime", r.message + " Hrs");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (frm.doc.repair_status == "Completed" && !frm.doc.completion_date) {
|
||||
frm.set_value("completion_date", frappe.datetime.now_datetime());
|
||||
}
|
||||
|
||||
frm.events.set_downtime(frm);
|
||||
},
|
||||
|
||||
failure_date: (frm) => {
|
||||
frm.events.set_downtime(frm);
|
||||
},
|
||||
|
||||
completion_date: (frm) => {
|
||||
frm.events.set_downtime(frm);
|
||||
},
|
||||
|
||||
set_downtime: (frm) => {
|
||||
if (frm.doc.repair_status != "Completed" || !frm.doc.failure_date || !frm.doc.completion_date) {
|
||||
frm.set_value("downtime", null);
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
|
||||
args: {
|
||||
failure_date: frm.doc.failure_date,
|
||||
completion_date: frm.doc.completion_date,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.set_value("downtime", r.message + " Hrs");
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
stock_items_on_form_rendered() {
|
||||
|
||||
@@ -67,7 +67,6 @@ class AssetRepair(AccountsController):
|
||||
self.calculate_repair_cost()
|
||||
self.calculate_total_repair_cost()
|
||||
self.check_repair_status()
|
||||
self.set_downtime()
|
||||
|
||||
def validate_asset(self):
|
||||
if self.asset_doc.status in ("Sold", "Scrapped"):
|
||||
@@ -240,13 +239,6 @@ class AssetRepair(AccountsController):
|
||||
if self.repair_status == "Pending" and self.docstatus == 1:
|
||||
frappe.throw(_("Please update Repair Status."))
|
||||
|
||||
def set_downtime(self):
|
||||
# keep downtime in sync with the entered dates, regardless of edit order
|
||||
if self.repair_status == "Completed" and self.failure_date and self.completion_date:
|
||||
self.downtime = f"{get_downtime(self.failure_date, self.completion_date)} Hrs"
|
||||
else:
|
||||
self.downtime = None
|
||||
|
||||
def update_asset_value(self):
|
||||
total_repair_cost = self.total_repair_cost if self.docstatus == 1 else -1 * self.total_repair_cost
|
||||
|
||||
|
||||
@@ -98,21 +98,6 @@ class TestAssetRepair(ERPNextTestSuite):
|
||||
asset_repair = create_asset_repair(submit=1)
|
||||
self.assertNotEqual(asset_repair.repair_status, "Pending")
|
||||
|
||||
def test_downtime_stays_in_sync_with_dates(self):
|
||||
asset = create_asset(submit=1)
|
||||
asset_repair = create_asset_repair(asset=asset)
|
||||
|
||||
asset_repair.failure_date = "2026-07-31 09:00:00"
|
||||
asset_repair.completion_date = "2026-07-31 11:00:00"
|
||||
asset_repair.repair_status = "Completed"
|
||||
asset_repair.save()
|
||||
self.assertEqual(asset_repair.downtime, "2.0 Hrs")
|
||||
|
||||
# editing a date must refresh downtime, not leave a stale value
|
||||
asset_repair.completion_date = "2026-07-31 14:30:00"
|
||||
asset_repair.save()
|
||||
self.assertEqual(asset_repair.downtime, "5.5 Hrs")
|
||||
|
||||
def test_stock_items(self):
|
||||
asset_repair = create_asset_repair(stock_consumption=1)
|
||||
self.assertTrue(asset_repair.stock_consumption)
|
||||
|
||||
238
erpnext/buying/doctype/supplier/supplier_import_provider.py
Normal file
238
erpnext/buying/doctype/supplier/supplier_import_provider.py
Normal file
@@ -0,0 +1,238 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
"""Data Import provider for Supplier.
|
||||
|
||||
Imports a Supplier together with Contact and Address records. Contact and Address are
|
||||
separate doctypes linked through Dynamic Link rows; the provider exposes them as child
|
||||
sections in import schema and creates linked records during row import.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.data_import.import_provider import ImportProvider
|
||||
from frappe.core.doctype.data_import.importer import INSERT, UPDATE
|
||||
|
||||
from erpnext.selling.doctype.customer.mapper import parse_full_name
|
||||
|
||||
|
||||
class SupplierImportProvider(ImportProvider):
|
||||
def get_import_fields(self) -> dict:
|
||||
return {
|
||||
"fields": _doctype_docfields("Supplier"),
|
||||
"child_tables": [
|
||||
*_doctype_child_tables("Supplier"),
|
||||
{
|
||||
"fieldname": "contacts",
|
||||
"label": _("Contact"),
|
||||
"fields": _contact_docfields(),
|
||||
},
|
||||
{
|
||||
"fieldname": "addresses",
|
||||
"label": _("Address"),
|
||||
"fields": _doctype_docfields("Address", prefer_plain_label=True),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def validate(self, import_file) -> list[dict]:
|
||||
warnings = []
|
||||
for payload in import_file.get_payloads_for_import():
|
||||
doc = payload.doc
|
||||
row = payload.rows[0].row_number if payload.rows else None
|
||||
|
||||
for contact in doc.get("contacts") or []:
|
||||
# Keep parity with current Customer provider behavior: require at least one contact method.
|
||||
if not (contact.get("email_id") or contact.get("mobile_no")):
|
||||
missing = [
|
||||
label
|
||||
for field, label in (("email_id", _("Email")), ("mobile_no", _("Mobile No")))
|
||||
if not contact.get(field)
|
||||
]
|
||||
warnings.append(
|
||||
{
|
||||
"row": row,
|
||||
"message": _("Contact in row {0} is missing {1}").format(row, ", ".join(missing)),
|
||||
}
|
||||
)
|
||||
|
||||
for address in doc.get("addresses") or []:
|
||||
if not address.get("address_line1"):
|
||||
continue
|
||||
missing = [
|
||||
label
|
||||
for field, label in (("city", _("City")), ("country", _("Country")))
|
||||
if not address.get(field)
|
||||
]
|
||||
if missing:
|
||||
warnings.append(
|
||||
{
|
||||
"row": row,
|
||||
"message": _("Address in row {0} is missing {1}").format(row, ", ".join(missing)),
|
||||
}
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
def import_row(self, importer, doc):
|
||||
contact_rows = doc.pop("contacts", None) or []
|
||||
address_rows = doc.pop("addresses", None) or []
|
||||
has_child_rows = bool(contact_rows or address_rows)
|
||||
supplier, import_action = self._persist_supplier(importer, doc, has_child_rows)
|
||||
|
||||
self._create_contacts(supplier, contact_rows)
|
||||
self._create_addresses(supplier, address_rows)
|
||||
return supplier, import_action
|
||||
|
||||
def _persist_supplier(self, importer, doc, has_child_rows):
|
||||
if importer.import_type == INSERT:
|
||||
return importer.insert_record(doc), None
|
||||
|
||||
if importer.import_type == UPDATE:
|
||||
return importer.update_record(doc, raise_if_no_changes=not has_child_rows), None
|
||||
|
||||
return importer.upsert_record(doc)
|
||||
|
||||
def _create_contacts(self, supplier, rows):
|
||||
primary = None
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
email = row.pop("email_id", None)
|
||||
mobile = row.pop("mobile_no", None)
|
||||
flagged = frappe.utils.cint(row.pop("is_primary_contact", 0))
|
||||
|
||||
first_name, last_name, company_name = self._resolve_contact_names(supplier, row)
|
||||
|
||||
contact_values = {k: v for k, v in row.items() if v not in (None, "")}
|
||||
if first_name:
|
||||
contact_values["first_name"] = first_name
|
||||
if last_name:
|
||||
contact_values["last_name"] = last_name
|
||||
if company_name:
|
||||
contact_values["company_name"] = company_name
|
||||
|
||||
contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
**contact_values,
|
||||
"links": [{"link_doctype": "Supplier", "link_name": supplier.name}],
|
||||
}
|
||||
)
|
||||
if email:
|
||||
contact.add_email(email, is_primary=True)
|
||||
if mobile:
|
||||
contact.add_phone(mobile, is_primary_mobile_no=True)
|
||||
contact.insert()
|
||||
# First created contact is the default primary; an explicit flag overrides.
|
||||
if flagged or primary is None:
|
||||
primary = contact
|
||||
if primary:
|
||||
# Contact has no cross-contact auto-demotion (unlike Address's
|
||||
# validate_preferred_address), so explicitly demote any other primary Contact on
|
||||
# this party first — otherwise get_default_contact may return a Contact other
|
||||
# than supplier_primary_contact.
|
||||
_demote_other_primary_contacts("Supplier", supplier.name, primary.name)
|
||||
frappe.db.set_value("Contact", primary.name, "is_primary_contact", 1)
|
||||
supplier.db_set("supplier_primary_contact", primary.name)
|
||||
supplier.db_set("mobile_no", primary.mobile_no)
|
||||
supplier.db_set("email_id", primary.email_id)
|
||||
|
||||
def _resolve_contact_names(self, supplier, row):
|
||||
first_name = row.pop("first_name", None)
|
||||
last_name = row.pop("last_name", None)
|
||||
company_name = row.pop("company_name", None)
|
||||
supplier_get = getattr(supplier, "get", None)
|
||||
|
||||
def get_supplier_value(fieldname):
|
||||
if callable(supplier_get):
|
||||
return supplier_get(fieldname)
|
||||
return getattr(supplier, fieldname, None)
|
||||
|
||||
if supplier.supplier_type == "Individual":
|
||||
first_name = first_name or get_supplier_value("first_name")
|
||||
last_name = last_name or get_supplier_value("last_name")
|
||||
if not first_name and supplier.supplier_name:
|
||||
parsed_first, _, parsed_last = parse_full_name(supplier.supplier_name)
|
||||
first_name = parsed_first
|
||||
last_name = last_name or parsed_last
|
||||
|
||||
return first_name, last_name, company_name
|
||||
|
||||
def _create_addresses(self, supplier, rows):
|
||||
from frappe.contacts.doctype.address.address import get_address_display
|
||||
|
||||
primary = None
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
flagged = frappe.utils.cint(row.pop("is_primary_address", 0))
|
||||
if not row.get("address_line1"):
|
||||
continue
|
||||
row["address_type"] = row.get("address_type") or "Billing"
|
||||
row["address_title"] = row.get("address_title") or supplier.supplier_name
|
||||
address = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Address",
|
||||
**{k: v for k, v in row.items() if v not in (None, "")},
|
||||
"links": [{"link_doctype": "Supplier", "link_name": supplier.name}],
|
||||
}
|
||||
)
|
||||
address.insert()
|
||||
# First created address is the default primary; an explicit flag overrides.
|
||||
# (Must not key off the loop index — skipped rows would leave no primary.)
|
||||
if flagged or primary is None:
|
||||
primary = address
|
||||
if primary:
|
||||
# Save (not db.set_value) so Address.validate_preferred_address() clears any
|
||||
# existing primary address on the party — a raw write would leave two flagged.
|
||||
primary.is_primary_address = 1
|
||||
primary.save()
|
||||
supplier.db_set("supplier_primary_address", primary.name)
|
||||
supplier.db_set("primary_address", get_address_display(primary.name))
|
||||
|
||||
|
||||
def _demote_other_primary_contacts(link_doctype: str, link_name: str, keep: str) -> None:
|
||||
"""Clear ``is_primary_contact`` on the party's other Contacts (keeps ``keep``)."""
|
||||
linked = frappe.get_all(
|
||||
"Dynamic Link",
|
||||
filters={"link_doctype": link_doctype, "link_name": link_name, "parenttype": "Contact"},
|
||||
pluck="parent",
|
||||
)
|
||||
for other in frappe.get_all(
|
||||
"Contact", filters={"name": ["in", linked or [""]], "is_primary_contact": 1}, pluck="name"
|
||||
):
|
||||
if other != keep:
|
||||
frappe.db.set_value("Contact", other, "is_primary_contact", 0)
|
||||
|
||||
|
||||
def _doctype_docfields(doctype: str, prefer_plain_label: bool = False) -> list[dict]:
|
||||
from frappe.model import display_fieldtypes, no_value_fields
|
||||
|
||||
fields = []
|
||||
for df in frappe.get_meta(doctype).fields:
|
||||
if df.fieldtype in no_value_fields or df.fieldtype in display_fieldtypes:
|
||||
continue
|
||||
if df.fieldname in ("lft", "rgt") or df.get("is_virtual"):
|
||||
continue
|
||||
field_dict = df.as_dict()
|
||||
if prefer_plain_label:
|
||||
field_dict["prefer_plain_label"] = 1
|
||||
fields.append(field_dict)
|
||||
return fields
|
||||
|
||||
|
||||
def _contact_docfields() -> list[dict]:
|
||||
fields = _doctype_docfields("Contact", prefer_plain_label=True)
|
||||
for field in fields:
|
||||
if field.get("fieldname") == "email_id":
|
||||
field["import_labels"] = ["Email ID"]
|
||||
return fields
|
||||
|
||||
|
||||
def _doctype_child_tables(doctype: str) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"fieldname": tf.fieldname,
|
||||
"label": _(tf.label or tf.fieldname),
|
||||
"fields": _doctype_docfields(tf.options),
|
||||
}
|
||||
for tf in frappe.get_meta(doctype).get_table_fields()
|
||||
]
|
||||
@@ -702,11 +702,6 @@ def is_reposting_pending():
|
||||
)
|
||||
|
||||
|
||||
def invalidate_future_sle_cache(voucher_type, voucher_no):
|
||||
if hasattr(frappe.local, "future_sle"):
|
||||
frappe.local.future_sle.pop((voucher_type, voucher_no), None)
|
||||
|
||||
|
||||
def future_sle_exists(args, sl_entries=None):
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
|
||||
@@ -40,140 +40,3 @@ class TestStockControllerConversions(ERPNextTestSuite):
|
||||
sl_entries = [frappe._dict(item_code=item, warehouse="_Test Warehouse - _TC")]
|
||||
|
||||
self.assertTrue(future_sle_exists(args, sl_entries))
|
||||
|
||||
def _make_opening_entry(self, item, warehouse):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
opening = make_stock_entry(
|
||||
item_code=item,
|
||||
target=warehouse,
|
||||
qty=100,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(today(), -5),
|
||||
posting_time="01:00:00",
|
||||
)
|
||||
self.addCleanup(self._cancel_and_delete, "Stock Entry", opening.name)
|
||||
|
||||
return opening
|
||||
|
||||
def _later_sle(self, item, warehouse, opening):
|
||||
sle = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Ledger Entry",
|
||||
"item_code": item,
|
||||
"warehouse": warehouse,
|
||||
"posting_date": today(),
|
||||
"posting_time": "12:00:00",
|
||||
"voucher_type": "Stock Entry",
|
||||
"voucher_no": opening.name,
|
||||
"actual_qty": 7,
|
||||
"incoming_rate": 100,
|
||||
"qty_after_transaction": 107,
|
||||
"valuation_rate": 100,
|
||||
"stock_value": 10700,
|
||||
"company": opening.company,
|
||||
"stock_uom": "Nos",
|
||||
}
|
||||
)
|
||||
sle.flags.ignore_permissions = True
|
||||
sle.flags.ignore_links = True
|
||||
|
||||
return sle
|
||||
|
||||
def _submit_entry(self, item, warehouse, inject=None):
|
||||
from erpnext.stock import stock_ledger
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
original_make_entry = stock_ledger.make_entry
|
||||
injected = []
|
||||
|
||||
def make_entry_with_injection(*args, **kwargs):
|
||||
if inject is not None and not injected:
|
||||
injected.append(True)
|
||||
inject.submit()
|
||||
return original_make_entry(*args, **kwargs)
|
||||
|
||||
stock_ledger.make_entry = make_entry_with_injection
|
||||
try:
|
||||
entry = make_stock_entry(
|
||||
item_code=item,
|
||||
target=warehouse,
|
||||
qty=5,
|
||||
basic_rate=500,
|
||||
posting_date=today(),
|
||||
posting_time="06:00:00",
|
||||
)
|
||||
finally:
|
||||
stock_ledger.make_entry = original_make_entry
|
||||
|
||||
self.addCleanup(self._cancel_and_delete, "Stock Entry", entry.name)
|
||||
if inject is not None:
|
||||
self.assertTrue(injected, "the later SL Entry was not written during the submit")
|
||||
|
||||
return entry
|
||||
|
||||
def _reposts_queued_for(self, item, warehouse, voucher_no):
|
||||
names = set(
|
||||
frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={"docstatus": 1, "item_code": item, "warehouse": warehouse},
|
||||
pluck="name",
|
||||
)
|
||||
) | set(
|
||||
frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={"docstatus": 1, "voucher_no": voucher_no},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
for name in names:
|
||||
self.addCleanup(frappe.delete_doc, "Repost Item Valuation", name, force=1)
|
||||
|
||||
return names
|
||||
|
||||
def test_repost_queued_for_entry_backdated_while_its_sl_entries_were_written(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item("_Test Concurrent Backdated Item", {"is_stock_item": 1}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
opening = self._make_opening_entry(item, warehouse)
|
||||
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
|
||||
|
||||
self.assertTrue(
|
||||
self._reposts_queued_for(item, warehouse, backdated.name),
|
||||
"No Repost Item Valuation was queued for an entry that a later SL Entry made backdated",
|
||||
)
|
||||
|
||||
def test_repost_queued_against_voucher_when_item_based_reposting_is_off(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item("_Test Voucher Based Repost Item", {"is_stock_item": 1}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
with self.change_settings("Stock Reposting Settings", item_based_reposting=0):
|
||||
opening = self._make_opening_entry(item, warehouse)
|
||||
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
|
||||
|
||||
self.assertTrue(
|
||||
frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={"docstatus": 1, "voucher_no": backdated.name},
|
||||
pluck="name",
|
||||
),
|
||||
"No voucher based Repost Item Valuation was queued",
|
||||
)
|
||||
|
||||
def test_no_repost_queued_when_nothing_was_written_after_the_entry(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item("_Test Unconcurrent Item", {"is_stock_item": 1}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
self._make_opening_entry(item, warehouse)
|
||||
entry = self._submit_entry(item, warehouse)
|
||||
|
||||
self.assertFalse(
|
||||
self._reposts_queued_for(item, warehouse, entry.name),
|
||||
"A Repost Item Valuation was queued for an entry with nothing posted after it",
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ from frappe.model.document import Document
|
||||
from frappe.share import add_docshare
|
||||
from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime
|
||||
from frappe.utils.data import sha256_hash
|
||||
from frappe.utils.html_utils import escape_html
|
||||
|
||||
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
|
||||
|
||||
@@ -270,11 +269,7 @@ class Appointment(Document):
|
||||
if self.customer_details:
|
||||
lead.append(
|
||||
"notes",
|
||||
{
|
||||
"note": escape_html(self.customer_details),
|
||||
"added_by": frappe.session.user,
|
||||
"added_on": now(),
|
||||
},
|
||||
{"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()},
|
||||
)
|
||||
|
||||
self.party = lead.insert(ignore_permissions=True).name
|
||||
|
||||
@@ -752,3 +752,13 @@ repost_allowed_doctypes = [
|
||||
"Payment Entry",
|
||||
"Purchase Receipt",
|
||||
]
|
||||
|
||||
|
||||
# Data Import
|
||||
# -----------
|
||||
# Custom Import Providers plug provider-owned field schema, validation and import logic into
|
||||
# the standard Data Import. See frappe.core.doctype.data_import.import_provider.
|
||||
data_import_providers = {
|
||||
"Customer": "erpnext.selling.doctype.customer.customer_import_provider.CustomerImportProvider",
|
||||
"Supplier": "erpnext.buying.doctype.supplier.supplier_import_provider.SupplierImportProvider",
|
||||
}
|
||||
|
||||
@@ -25,12 +25,10 @@ def fetch_exploded_bom_items(root_bom):
|
||||
recursive CTE -- replaces a query-per-node walk with a single query. UNION keeps it cycle-safe
|
||||
and fetches each sub-BOM's items only once even when it is reused across the tree."""
|
||||
bom_item = frappe.qb.DocType("BOM Item")
|
||||
child_bom = frappe.qb.DocType("BOM").as_("child_bom")
|
||||
tree = frappe.qb.Table("exploded_bom")
|
||||
fields = [
|
||||
bom_item.parent,
|
||||
bom_item.qty,
|
||||
bom_item.stock_qty,
|
||||
bom_item.bom_no,
|
||||
bom_item.item_code,
|
||||
bom_item.item_name,
|
||||
@@ -48,11 +46,7 @@ def fetch_exploded_bom_items(root_bom):
|
||||
.where(tree.bom_no != "")
|
||||
)
|
||||
rows = (
|
||||
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True)
|
||||
.from_(tree)
|
||||
.left_join(child_bom)
|
||||
.on(tree.bom_no == child_bom.name)
|
||||
.select(tree.star, child_bom.quantity.as_("child_bom_qty"))
|
||||
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True).from_(tree).select(tree.star)
|
||||
).run(as_dict=True)
|
||||
|
||||
children_map = defaultdict(list)
|
||||
@@ -77,13 +71,7 @@ def build_exploded_rows(bom, children_map, data, indent=0, qty=1):
|
||||
}
|
||||
)
|
||||
if item.bom_no:
|
||||
build_exploded_rows(
|
||||
item.bom_no,
|
||||
children_map,
|
||||
data,
|
||||
indent + 1,
|
||||
qty * item.stock_qty / item.child_bom_qty,
|
||||
)
|
||||
build_exploded_rows(item.bom_no, children_map, data, indent + 1, item.qty)
|
||||
|
||||
|
||||
def get_columns():
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
from erpnext.manufacturing.report.bom_explorer.bom_explorer import build_exploded_rows, execute
|
||||
from erpnext.manufacturing.report.bom_explorer.bom_explorer import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -78,55 +78,3 @@ class TestBOMExplorer(ERPNextTestSuite):
|
||||
# The leaf belongs to the sub-assembly, so it is exploded one level deeper.
|
||||
self.assertEqual(rows_by_item[leaf_item]["indent"], 1)
|
||||
self.assertEqual(rows_by_item[leaf_item]["bom_level"], 1)
|
||||
|
||||
def test_nested_bom_uses_stock_qty_for_output_normalization(self):
|
||||
parent_bom = create_nested_bom(
|
||||
{"parent": {"sub": {"leaf": {}}}},
|
||||
prefix="_Test explorer converted quantity ",
|
||||
)
|
||||
sub_bom = frappe.get_doc("BOM", parent_bom.items[0].bom_no)
|
||||
|
||||
# The parent needs two boxes (20 units). The child BOM produces five units per batch.
|
||||
frappe.db.set_value("BOM", sub_bom.name, "quantity", 5)
|
||||
frappe.db.set_value("BOM Item", sub_bom.items[0].name, {"qty": 3, "stock_qty": 3})
|
||||
frappe.db.set_value(
|
||||
"BOM Item",
|
||||
parent_bom.items[0].name,
|
||||
{"qty": 2, "uom": "Box", "conversion_factor": 10, "stock_qty": 20},
|
||||
)
|
||||
|
||||
data = self.run_report(parent_bom.name)
|
||||
rows_by_item = {row["item_code"]: row for row in data}
|
||||
|
||||
self.assertEqual(rows_by_item["_Test explorer converted quantity sub"]["qty"], 2)
|
||||
self.assertEqual(rows_by_item["_Test explorer converted quantity leaf"]["qty"], 12)
|
||||
|
||||
def test_nested_bom_multiplies_qty_at_every_level(self):
|
||||
children_map = {
|
||||
"root": [
|
||||
frappe._dict(
|
||||
item_code="parent",
|
||||
idx=1,
|
||||
bom_no="parent-bom",
|
||||
child_bom_qty=1,
|
||||
qty=8,
|
||||
stock_qty=8,
|
||||
)
|
||||
],
|
||||
"parent-bom": [
|
||||
frappe._dict(
|
||||
item_code="child",
|
||||
idx=1,
|
||||
bom_no="child-bom",
|
||||
child_bom_qty=1,
|
||||
qty=4,
|
||||
stock_qty=4,
|
||||
)
|
||||
],
|
||||
"child-bom": [frappe._dict(item_code="raw-material", idx=1, bom_no="", qty=2, stock_qty=2)],
|
||||
}
|
||||
data = []
|
||||
|
||||
build_exploded_rows("root", children_map, data)
|
||||
|
||||
self.assertEqual([row["qty"] for row in data], [8, 32, 64])
|
||||
|
||||
249
erpnext/selling/doctype/customer/customer_import_provider.py
Normal file
249
erpnext/selling/doctype/customer/customer_import_provider.py
Normal file
@@ -0,0 +1,249 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
"""Data Import provider for Customer.
|
||||
|
||||
Imports a Customer together with its Contact and Address records. In the schema Contact and
|
||||
Address are separate doctypes linked to the Customer via a Dynamic Link; the provider exposes
|
||||
them as child tables in the import and wires up the links + primary flags in ``import_row``.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.data_import.import_provider import ImportProvider
|
||||
from frappe.core.doctype.data_import.importer import INSERT, UPDATE
|
||||
|
||||
from .mapper import parse_full_name
|
||||
|
||||
|
||||
class CustomerImportProvider(ImportProvider):
|
||||
def get_import_fields(self) -> dict:
|
||||
"""Field schema for the picker: a Customer with its own child tables, plus Contact and
|
||||
Address (linked doctypes exposed as extra child tables).
|
||||
|
||||
For Customer the columns ARE those doctypes' fields, so we read complete docfields from
|
||||
meta. (This is the provider's choice — Data Import itself never reads meta here.)
|
||||
"""
|
||||
return {
|
||||
"fields": _doctype_docfields("Customer"),
|
||||
"child_tables": [
|
||||
*_doctype_child_tables("Customer"),
|
||||
{
|
||||
"fieldname": "contacts",
|
||||
"label": _("Contact"),
|
||||
"fields": _contact_docfields(),
|
||||
},
|
||||
{
|
||||
"fieldname": "addresses",
|
||||
"label": _("Address"),
|
||||
"fields": _doctype_docfields("Address", prefer_plain_label=True),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def validate(self, import_file) -> list[dict]:
|
||||
"""Business checks on top of the framework's basic Select/Link/Date validation."""
|
||||
warnings = []
|
||||
for payload in import_file.get_payloads_for_import():
|
||||
doc = payload.doc
|
||||
row = payload.rows[0].row_number if payload.rows else None
|
||||
for contact in doc.get("contacts") or []:
|
||||
# A Contact is only useful with a way to reach it — warn when both are missing.
|
||||
if not (contact.get("email_id") or contact.get("mobile_no")):
|
||||
missing = [
|
||||
label
|
||||
for field, label in (("email_id", _("Email")), ("mobile_no", _("Mobile No")))
|
||||
if not contact.get(field)
|
||||
]
|
||||
warnings.append(
|
||||
{
|
||||
"row": row,
|
||||
"message": _("Contact in row {0} is missing {1}").format(row, ", ".join(missing)),
|
||||
}
|
||||
)
|
||||
for address in doc.get("addresses") or []:
|
||||
if not address.get("address_line1"):
|
||||
continue
|
||||
missing = [
|
||||
label
|
||||
for field, label in (("city", _("City")), ("country", _("Country")))
|
||||
if not address.get(field)
|
||||
]
|
||||
if missing:
|
||||
warnings.append(
|
||||
{
|
||||
"row": row,
|
||||
"message": _("Address in row {0} is missing {1}").format(row, ", ".join(missing)),
|
||||
}
|
||||
)
|
||||
return warnings
|
||||
|
||||
def import_row(self, importer, doc):
|
||||
"""Persist Customer per Import Type, then create linked Contacts/Addresses."""
|
||||
contact_rows = doc.pop("contacts", None) or []
|
||||
address_rows = doc.pop("addresses", None) or []
|
||||
has_child_rows = bool(contact_rows or address_rows)
|
||||
customer, import_action = self._persist_customer(importer, doc, has_child_rows)
|
||||
|
||||
self._create_contacts(customer, contact_rows)
|
||||
self._create_addresses(customer, address_rows)
|
||||
return customer, import_action
|
||||
|
||||
def _persist_customer(self, importer, doc, has_child_rows):
|
||||
"""Use the core Importer paths so provider imports honor Insert/Update/Upsert semantics."""
|
||||
if importer.import_type == INSERT:
|
||||
return importer.insert_record(doc), None
|
||||
|
||||
if importer.import_type == UPDATE:
|
||||
# Allow child-only updates (e.g. add Contact/Address) without failing on unchanged Customer fields.
|
||||
return importer.update_record(doc, raise_if_no_changes=not has_child_rows), None
|
||||
|
||||
return importer.upsert_record(doc)
|
||||
|
||||
def _create_contacts(self, customer, rows):
|
||||
primary = None
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
email = row.pop("email_id", None)
|
||||
mobile = row.pop("mobile_no", None)
|
||||
flagged = frappe.utils.cint(row.pop("is_primary_contact", 0))
|
||||
|
||||
first_name, last_name, company_name = self._resolve_contact_names(customer, row)
|
||||
|
||||
contact_values = {k: v for k, v in row.items() if v not in (None, "")}
|
||||
if first_name:
|
||||
contact_values["first_name"] = first_name
|
||||
if last_name:
|
||||
contact_values["last_name"] = last_name
|
||||
if company_name:
|
||||
contact_values["company_name"] = company_name
|
||||
|
||||
contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
**contact_values,
|
||||
"links": [{"link_doctype": "Customer", "link_name": customer.name}],
|
||||
}
|
||||
)
|
||||
if email:
|
||||
contact.add_email(email, is_primary=True)
|
||||
if mobile:
|
||||
contact.add_phone(mobile, is_primary_mobile_no=True)
|
||||
contact.insert()
|
||||
# First created contact is the default primary; an explicit flag overrides.
|
||||
if flagged or primary is None:
|
||||
primary = contact
|
||||
if primary:
|
||||
# Contact has no cross-contact auto-demotion (unlike Address's
|
||||
# validate_preferred_address), so explicitly demote any other primary Contact on
|
||||
# this party first — otherwise get_default_contact may return a Contact other
|
||||
# than customer_primary_contact.
|
||||
_demote_other_primary_contacts("Customer", customer.name, primary.name)
|
||||
frappe.db.set_value("Contact", primary.name, "is_primary_contact", 1)
|
||||
customer.db_set("customer_primary_contact", primary.name)
|
||||
customer.db_set("mobile_no", primary.mobile_no)
|
||||
customer.db_set("email_id", primary.email_id)
|
||||
|
||||
def _resolve_contact_names(self, customer, row):
|
||||
"""Resolve Contact names from row data and fall back to Customer data when needed."""
|
||||
first_name = row.pop("first_name", None)
|
||||
last_name = row.pop("last_name", None)
|
||||
company_name = row.pop("company_name", None)
|
||||
customer_get = getattr(customer, "get", None)
|
||||
|
||||
def get_customer_value(fieldname):
|
||||
if callable(customer_get):
|
||||
return customer_get(fieldname)
|
||||
return getattr(customer, fieldname, None)
|
||||
|
||||
if customer.customer_type == "Individual":
|
||||
first_name = first_name or get_customer_value("first_name")
|
||||
last_name = last_name or get_customer_value("last_name")
|
||||
if not first_name and customer.customer_name:
|
||||
parsed_first, _, parsed_last = parse_full_name(customer.customer_name)
|
||||
first_name = parsed_first
|
||||
last_name = last_name or parsed_last
|
||||
|
||||
return first_name, last_name, company_name
|
||||
|
||||
def _create_addresses(self, customer, rows):
|
||||
from frappe.contacts.doctype.address.address import get_address_display
|
||||
|
||||
primary = None
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
flagged = frappe.utils.cint(row.pop("is_primary_address", 0))
|
||||
if not row.get("address_line1"):
|
||||
continue
|
||||
row["address_type"] = row.get("address_type") or "Billing"
|
||||
row["address_title"] = row.get("address_title") or customer.customer_name
|
||||
address = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Address",
|
||||
**{k: v for k, v in row.items() if v not in (None, "")},
|
||||
"links": [{"link_doctype": "Customer", "link_name": customer.name}],
|
||||
}
|
||||
)
|
||||
address.insert()
|
||||
# First created address is the default primary; an explicit flag overrides.
|
||||
# (Must not key off the loop index — skipped rows would leave no primary.)
|
||||
if flagged or primary is None:
|
||||
primary = address
|
||||
if primary:
|
||||
# Save (not db.set_value) so Address.validate_preferred_address() clears any
|
||||
# existing primary address on the party — a raw write would leave two flagged.
|
||||
primary.is_primary_address = 1
|
||||
primary.save()
|
||||
customer.db_set("customer_primary_address", primary.name)
|
||||
customer.db_set("primary_address", get_address_display(primary.name))
|
||||
|
||||
|
||||
def _demote_other_primary_contacts(link_doctype: str, link_name: str, keep: str) -> None:
|
||||
"""Clear ``is_primary_contact`` on the party's other Contacts (keeps ``keep``)."""
|
||||
linked = frappe.get_all(
|
||||
"Dynamic Link",
|
||||
filters={"link_doctype": link_doctype, "link_name": link_name, "parenttype": "Contact"},
|
||||
pluck="parent",
|
||||
)
|
||||
for other in frappe.get_all(
|
||||
"Contact", filters={"name": ["in", linked or [""]], "is_primary_contact": 1}, pluck="name"
|
||||
):
|
||||
if other != keep:
|
||||
frappe.db.set_value("Contact", other, "is_primary_contact", 0)
|
||||
|
||||
|
||||
def _doctype_docfields(doctype: str, prefer_plain_label: bool = False) -> list[dict]:
|
||||
"""Non-table importable fields of ``doctype`` as complete docfield dicts."""
|
||||
from frappe.model import display_fieldtypes, no_value_fields
|
||||
|
||||
fields = []
|
||||
for df in frappe.get_meta(doctype).fields:
|
||||
if df.fieldtype in no_value_fields or df.fieldtype in display_fieldtypes:
|
||||
continue
|
||||
if df.fieldname in ("lft", "rgt") or df.get("is_virtual"):
|
||||
continue
|
||||
field_dict = df.as_dict()
|
||||
if prefer_plain_label:
|
||||
field_dict["prefer_plain_label"] = 1
|
||||
fields.append(field_dict)
|
||||
return fields
|
||||
|
||||
|
||||
def _contact_docfields() -> list[dict]:
|
||||
"""Contact fields with plain-label and import-header aliases for common CSV headers."""
|
||||
fields = _doctype_docfields("Contact", prefer_plain_label=True)
|
||||
for field in fields:
|
||||
if field.get("fieldname") == "email_id":
|
||||
field["import_labels"] = ["Email ID"]
|
||||
return fields
|
||||
|
||||
|
||||
def _doctype_child_tables(doctype: str) -> list[dict]:
|
||||
"""``doctype``'s own child tables as schema groups (fieldname, label, fields)."""
|
||||
return [
|
||||
{
|
||||
"fieldname": tf.fieldname,
|
||||
"label": _(tf.label or tf.fieldname),
|
||||
"fields": _doctype_docfields(tf.options),
|
||||
}
|
||||
for tf in frappe.get_meta(doctype).get_table_fields()
|
||||
]
|
||||
@@ -789,9 +789,6 @@ class SerialNoValuation(DeprecatedSerialNoValuation):
|
||||
return is_rejected(self.sle.voucher_type, self.sle.voucher_detail_no, self.sle.warehouse)
|
||||
|
||||
def get_incoming_rate(self):
|
||||
if not self.sle.actual_qty and self.sle.voucher_type == "Stock Reconciliation":
|
||||
return 0.0
|
||||
|
||||
return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty))
|
||||
|
||||
def get_incoming_rate_of_serial_no(self, serial_no):
|
||||
|
||||
@@ -137,7 +137,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
|
||||
such cases certain validations need to be ignored (like negative
|
||||
stock)
|
||||
"""
|
||||
from erpnext.controllers.stock_controller import future_sle_exists, invalidate_future_sle_cache
|
||||
from erpnext.controllers.stock_controller import future_sle_exists
|
||||
|
||||
if sl_entries:
|
||||
# Sorted so two vouchers touching the same pairs can't take the gates in opposite order.
|
||||
@@ -195,8 +195,6 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
|
||||
_("Item {0} ignored since it is not a stock item").format(args.get("item_code"))
|
||||
)
|
||||
|
||||
invalidate_future_sle_cache(sl_entries[0].get("voucher_type"), sl_entries[0].get("voucher_no"))
|
||||
|
||||
|
||||
def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_voucher=False, cancelled=False):
|
||||
if args.get("actual_qty") or args.get("voucher_type") == "Stock Reconciliation":
|
||||
|
||||
Reference in New Issue
Block a user