feat: delivery note billing based on quantity (#56149)

* feat(stock): bill delivery note by qty when invoiced amount is short

* test(stock): add test for qty fallback in delivery note billing
This commit is contained in:
Sudharsanan Ashok
2026-08-13 16:28:35 +05:30
committed by GitHub
parent a2976dd29e
commit 1e583725a1
4 changed files with 290 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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