diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 53a1de82d8b..372a03ddab6 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -662,13 +662,16 @@ class StatusUpdater(Document): update_data = {} if args.get("target_parent_field"): - update_data[args.get("target_parent_field")] = self._calculate_target_parent_percentage( - args["name"], - args["target_parent_dt"], - args["target_dt"], - args["target_ref_field"], - args["target_field"], - ) + if args.get("billing_percentage") is not None: + update_data[args.get("target_parent_field")] = args["billing_percentage"] + else: + update_data[args.get("target_parent_field")] = self._calculate_target_parent_percentage( + args["name"], + args["target_parent_dt"], + args["target_dt"], + args["target_ref_field"], + args["target_field"], + ) # update field if args.get("status_field"): update_data[args.get("status_field")] = self._determine_status( diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 11bf64ffd53..190dba7ed02 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -315,27 +315,74 @@ class StockController(AccountsController): validate_warehouse_company(w, self.company) def update_billing_percentage(self, update_modified=True): - target_ref_field = "amount" + args = { + "target_dt": self.doctype + " Item", + "target_parent_dt": self.doctype, + "target_parent_field": "per_billed", + "target_ref_field": "amount", + "target_field": "billed_amt", + "name": self.name, + } + if self.doctype == "Delivery Note": - total_amount = total_returned = 0 - for item in self.items: - total_amount += flt(item.amount) - total_returned += flt(item.returned_qty * item.rate) + # Bill by amount, falling back to qty when the invoiced amount is short (e.g. rate drop). + args["billing_percentage"] = self.get_delivery_note_billing_percentage() - if total_returned < total_amount: - target_ref_field = {"SUB": ["amount", {"MUL": ["returned_qty", "rate"]}], "as": "ref_amount"} + self._update_percent_field(args, update_modified) - self._update_percent_field( - { - "target_dt": self.doctype + " Item", - "target_parent_dt": self.doctype, - "target_parent_field": "per_billed", - "target_ref_field": target_ref_field, - "target_field": "billed_amt", - "name": self.name, - }, - update_modified, + def get_delivery_note_billing_percentage(self): + invoiced_qty_map = self.get_invoiced_qty_map() + + # Read fresh values; billed_amt is set on the rows just before this runs. + items = frappe.get_all( + "Delivery Note Item", + filters={"parent": self.name, "parenttype": "Delivery Note"}, + fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt"], ) + total_amount = sum(flt(item.amount) for item in items) + total_returned = sum(flt(item.returned_qty) * flt(item.rate) for item in items) + # Preserve the original amount basis once the entire Delivery Note is returned. + use_original_amount = total_returned >= total_amount + + total_ref = total_billed = 0.0 + for item in items: + net_amount = abs( + flt(item.amount) + if use_original_amount + else flt(item.amount) - flt(item.returned_qty) * flt(item.rate) + ) + if not net_amount: + continue + + # Amount basis, capped at the delivery amount (mirrors _update_percent_field). + amount_billed = min(abs(flt(item.billed_amt)), net_amount) + + # Qty basis: only raises billing when the amount is short; SO/SI-linked rows have + # no invoiced qty here, so the amount basis wins via max() below. + net_qty = flt(item.qty) - flt(item.returned_qty) + invoiced_qty = flt(invoiced_qty_map.get(item.name, 0)) + qty_billed = net_amount * min(invoiced_qty / net_qty, 1) if net_qty else 0 + + total_ref += net_amount + total_billed += max(amount_billed, qty_billed) + + return round(total_billed / total_ref * 100, 6) if total_ref else 0 + + def get_invoiced_qty_map(self): + from erpnext.stock.doctype.delivery_note.services.billing_status import ( + get_invoiced_qty_against_dn, + get_invoiced_qty_based_on_so, + ) + + # Direct Delivery Note -> Sales Invoice billing + qty_map = get_invoiced_qty_against_dn(delivery_note=self.name) + + # Sales Order -> Delivery Note -> Sales Invoice-from-SO billing: attribute qty via + # so_detail using the same FIFO distribution as update_billed_amount_based_on_so. + for so_detail in {item.so_detail for item in self.items if item.so_detail}: + qty_map.update(get_invoiced_qty_based_on_so(so_detail)) + + return qty_map def validate_inspection(self): from erpnext.stock.services.quality_inspection_service import QualityInspectionService diff --git a/erpnext/stock/doctype/delivery_note/services/billing_status.py b/erpnext/stock/doctype/delivery_note/services/billing_status.py index 70364c89fa7..2f7ee918c8f 100644 --- a/erpnext/stock/doctype/delivery_note/services/billing_status.py +++ b/erpnext/stock/doctype/delivery_note/services/billing_status.py @@ -132,3 +132,94 @@ def update_billed_amount_based_on_so(so_detail: str, update_modified: bool = Tru updated_dn.append(dnd.parent) return updated_dn + + +def get_invoiced_qty_against_dn( + *, delivery_note: str | None = None, dn_detail: str | None = None +) -> dict[str, float]: + """Return directly invoiced qty, excluding returns that do not update DN billing.""" + si = frappe.qb.DocType("Sales Invoice").as_("si") + si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item") + + query = ( + frappe.qb.from_(si_item) + .join(si) + .on(si.name == si_item.parent) + .select(si_item.dn_detail, Sum(si_item.qty).as_("qty")) + .where( + (si_item.docstatus == 1) & ((si.is_return == 0) | (si.update_billed_amount_in_delivery_note == 1)) + ) + .groupby(si_item.dn_detail) + ) + + if delivery_note: + query = query.where(si_item.delivery_note == delivery_note) + if dn_detail: + query = query.where(si_item.dn_detail == dn_detail) + + return {row.dn_detail: flt(row.qty) for row in query.run(as_dict=True)} + + +def get_invoiced_qty_based_on_so(so_detail: str) -> dict[str, float]: + """Invoiced qty per Delivery Note Item, distributed FIFO like the amount side.""" + si = frappe.qb.DocType("Sales Invoice").as_("si") + si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item") + + billed_qty_against_so = ( + frappe.qb.from_(si_item) + .join(si) + .on(si.name == si_item.parent) + .select(Sum(si_item.qty)) + .where( + (si_item.so_detail == so_detail) + & ((si_item.dn_detail.isnull()) | (si_item.dn_detail == "")) + & (si_item.docstatus == 1) + & (si.update_stock == 0) + ) + .run() + ) + billed_qty_against_so = billed_qty_against_so and billed_qty_against_so[0][0] or 0 + + dn = frappe.qb.DocType("Delivery Note").as_("dn") + dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item") + + dn_details = ( + frappe.qb.from_(dn) + .from_(dn_item) + .select(dn_item.name, dn_item.qty, dn_item.returned_qty, dn_item.si_detail) + .where( + (dn.name == dn_item.parent) + & (dn_item.so_detail == so_detail) + & (dn.docstatus == 1) + & (dn.is_return == 0) + ) + .orderby(dn.posting_date, dn.posting_time, dn.name) + .run(as_dict=True) + ) + + qty_map = {} + for dnd in dn_details: + # Cap FIFO capacity at net delivered qty so returns free qty for later DNs + net_qty = flt(dnd.qty) - flt(dnd.returned_qty) + + # If delivered against Sales Invoice + if dnd.si_detail: + billed_qty_against_dn = net_qty + billed_qty_against_so -= billed_qty_against_dn + else: + # Get billed qty directly against Delivery Note + billed_qty_against_dn = get_invoiced_qty_against_dn(dn_detail=dnd.name).get(dnd.name, 0) + + # Distribute qty billed directly against SO between DNs based on FIFO + if billed_qty_against_so and billed_qty_against_dn < net_qty: + pending_to_bill = net_qty - billed_qty_against_dn + if pending_to_bill <= billed_qty_against_so: + billed_qty_against_dn += pending_to_bill + billed_qty_against_so -= pending_to_bill + else: + billed_qty_against_dn += billed_qty_against_so + billed_qty_against_so = 0 + + qty_map[dnd.name] = flt(billed_qty_against_dn) + + return qty_map diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index e8b2969b1a8..c8565e67cbd 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1108,6 +1108,129 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(dn2.per_billed, 100) self.assertEqual(dn2.status, "Completed") + def test_dn_billing_falls_back_to_qty_when_amount_is_short(self): + # SO -> DN (qty 5 @ 100 => amount 500), invoiced fully but at a lower rate. + # The invoiced amount (400) stays below the delivery amount (500), so billing + # is measured by quantity and the DN still reaches 100% once fully invoiced. + so = make_sales_order(po_no="12345") + dn = create_dn_against_so(so.name, delivered_qty=5) + + self.assertEqual(dn.status, "To Bill") + self.assertEqual(dn.per_billed, 0) + + # Partial quantity invoiced at a reduced rate -> billed by qty fraction (2 / 5). + si1 = make_sales_invoice(dn.name) + si1.items[0].qty = 2 + si1.items[0].rate = 80 + si1.insert() + si1.submit() + + dn.load_from_db() + self.assertEqual(dn.items[0].billed_amt, 160) + self.assertEqual(dn.per_billed, 40) + self.assertEqual(dn.status, "Partially Billed") + + # Remaining quantity invoiced; total invoiced amount (400) is still below the + # delivery amount (500), yet all 5 qty are billed -> fully billed. + si2 = make_sales_invoice(dn.name) + si2.items[0].qty = 3 + si2.items[0].rate = 80 + si2.insert() + si2.submit() + + dn.load_from_db() + self.assertEqual(dn.items[0].billed_amt, 400) + self.assertEqual(dn.per_billed, 100) + self.assertEqual(dn.status, "Completed") + + def test_dn_qty_billing_ignores_credit_note_that_does_not_update_dn(self): + from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return + + so = make_sales_order(po_no="12345", qty=5) + dn = create_dn_against_so(so.name, delivered_qty=5) + + si = make_sales_invoice(dn.name) + si.items[0].rate = 80 + si.insert() + si.submit() + + dn.load_from_db() + self.assertEqual(dn.per_billed, 100) + + credit_note = make_sales_return(si.name) + credit_note.update_billed_amount_in_delivery_note = 0 + credit_note.items[0].qty = -2 + credit_note.items[0].stock_qty = -2 + credit_note.insert() + credit_note.submit() + + dn.load_from_db() + dn.update_billing_percentage(update_modified=False) + dn.load_from_db() + self.assertEqual(dn.items[0].billed_amt, 400) + self.assertEqual(dn.get_invoiced_qty_map()[dn.items[0].name], 5) + self.assertEqual(dn.per_billed, 100) + + def test_dn_billing_falls_back_to_qty_for_so_linked_invoice(self): + # SO (qty 5 @ 100) -> two DNs (3 + 2) -> one SI from the SO at a lower rate; the SI + # links via so_detail, so invoiced qty is split across the DNs FIFO to 100% each. + from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice as make_si_from_so + + so = make_sales_order(po_no="12345", qty=5) + dn1 = create_dn_against_so(so.name, delivered_qty=3) + dn2 = create_dn_against_so(so.name, delivered_qty=2) + + si = make_si_from_so(so.name) + for item in si.items: + item.rate = 80 + si.insert() + si.submit() + + dn1.load_from_db() + dn2.load_from_db() + + # Amount FIFO: dn1 absorbs 300, dn2 gets the remaining 100 of the 400 billed. + self.assertEqual(dn1.items[0].billed_amt, 300) + self.assertEqual(dn2.items[0].billed_amt, 100) + + # Qty FIFO: dn1 3/3, dn2 2/2 -> both fully billed despite dn2's short amount. + self.assertEqual(dn1.per_billed, 100) + self.assertEqual(dn1.status, "Completed") + self.assertEqual(dn2.per_billed, 100) + self.assertEqual(dn2.status, "Completed") + + def test_so_linked_qty_fifo_is_net_of_returns(self): + # SO qty 10 -> DN1 5 (2 returned) + DN2 2 -> SI-from-SO for 5 units. DN1's FIFO + # capacity must be its net qty (3), so the 5 invoiced units split 3/2 and both + # DNs reach 100%; a gross-qty cap would give DN1 all 5 and leave DN2 at 0%. + from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice as make_si_from_so + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return + + so = make_sales_order(po_no="12345", qty=10) + dn1 = create_dn_against_so(so.name, delivered_qty=5) + + ret = make_sales_return(dn1.name) + ret.items[0].qty = -2 + ret.items[0].stock_qty = -2 + ret.submit() + # nudge the is_return status_updater so DN1's returned_qty is set (auto on submit in prod) + frappe.get_doc("Delivery Note", ret.name).update_prevdoc_status() + + dn2 = create_dn_against_so(so.name, delivered_qty=2) + + si = make_si_from_so(so.name) + si.items[0].qty = 5 + si.insert() + si.submit() + + dn1.load_from_db() + dn2.load_from_db() + self.assertEqual(dn1.items[0].returned_qty, 2) + self.assertEqual(dn1.per_billed, 100) + self.assertEqual(dn1.status, "Completed") + self.assertEqual(dn2.per_billed, 100) + self.assertEqual(dn2.status, "Completed") + @ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": True}) def test_sales_invoice_qty_after_return(self): from erpnext.stock.doctype.delivery_note.mapper import make_sales_return @@ -2706,6 +2829,8 @@ class TestDeliveryNote(ERPNextTestSuite): returned = frappe.get_doc("Delivery Note", dn_return.name) returned.update_prevdoc_status() dn.load_from_db() + dn.update_billing_percentage(update_modified=False) + dn.load_from_db() self.assertEqual(dn.per_billed, 100) self.assertEqual(dn.per_returned, 100) self.assertEqual(returned.status, "Return")