Compare commits

..

6 Commits

15 changed files with 518 additions and 330 deletions

View File

@@ -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,

View File

@@ -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",

View File

@@ -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() {

View File

@@ -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

View File

@@ -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)

View 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()
]

View File

@@ -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

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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",
}

View File

@@ -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():

View File

@@ -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])

View 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()
]

View File

@@ -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):

View File

@@ -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":