fix(selling): bill re-delivered sales order quantities

This commit is contained in:
Mihir Kandoi
2026-08-11 12:15:45 +05:30
parent fe8bedef93
commit 114ba42850
2 changed files with 96 additions and 19 deletions

View File

@@ -421,6 +421,13 @@ def make_delivery_note(
return target_doc
def get_qty_net_of_returns(so_item) -> float:
"""Return the ordered quantity billable after returns and re-deliveries."""
qty = flt(so_item.qty)
return min(qty, max(qty - flt(so_item.returned_qty), flt(so_item.delivered_qty)))
@frappe.whitelist()
def make_sales_invoice(
source_name: str,
@@ -434,10 +441,30 @@ def make_sales_invoice(
# 0 qty is accepted, as the qty is uncertain for some items
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
pending_qty_by_item = {}
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
def get_billed_qty(so_item_name):
table = frappe.qb.DocType("Sales Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty).as_("qty"))
.where((table.docstatus == 1) & (table.so_detail == so_item_name))
)
return flt(query.run(pluck="qty")[0])
def get_pending_qty(source):
if source.name not in pending_qty_by_item:
billable_qty = get_qty_net_of_returns(source)
if source.qty and source.billed_amt:
billable_qty -= get_billed_qty(source.name)
pending_qty_by_item[source.name] = max(flt(billable_qty), 0)
return pending_qty_by_item[source.name]
def postprocess(source, target):
set_missing_values(source, target)
# Get the advance paid Journal Entries in Sales Invoice Advance
@@ -476,15 +503,6 @@ def make_sales_invoice(
target.debit_to = get_party_account("Customer", source.customer, source.company)
def update_item(source, target, source_parent):
def get_billed_qty(so_item_name):
table = frappe.qb.DocType("Sales Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty).as_("qty"))
.where((table.docstatus == 1) & (table.so_detail == so_item_name))
)
return query.run(pluck="qty")[0] or 0
if source_parent.has_unit_price_items:
# 0 Amount rows (as seen in Unit Price Items) should be mapped as it is
pending_amount = flt(source.amount) - flt(source.billed_amt)
@@ -493,11 +511,7 @@ def make_sales_invoice(
target.amount = flt(source.amount) - flt(source.billed_amt)
target.base_amount = target.amount * flt(source_parent.conversion_rate)
target.qty = (
source.qty - get_billed_qty(source.name)
if (source.qty and source.billed_amt)
else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty)
)
target.qty = source.qty if is_unit_price_row(source) else get_pending_qty(source)
if source_parent.project:
target.cost_center = frappe.db.get_value("Project", source_parent.project, "cost_center")
@@ -575,13 +589,17 @@ def make_sales_invoice(
"parent": "sales_order",
},
"postprocess": update_item,
"condition": lambda doc: (
"condition": lambda doc: not args.get("skip_item_mapping")
and select_item(doc)
and (
True
if is_unit_price_row(doc)
else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)))
)
and select_item(doc)
and not args.get("skip_item_mapping"),
else (
doc.qty
and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))
and get_pending_qty(doc) > 0
)
),
},
"Sales Taxes and Charges": {
"doctype": "Sales Taxes and Charges",

View File

@@ -288,6 +288,65 @@ class TestSalesOrder(ERPNextTestSuite):
si1 = make_sales_invoice(so.name)
self.assertEqual(len(si1.get("items")), 0)
def test_make_sales_invoice_after_return_and_redelivery(self):
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
so = make_sales_order(qty=10, rate=100)
dn = create_dn_against_so(so.name, 10)
dn_return = frappe.get_doc(make_sales_return(dn.name).as_dict())
dn_return.insert()
dn_return.submit()
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
create_dn_against_so(so.name, 10)
so.load_from_db()
item = so.get("items")[0]
self.assertEqual(item.delivered_qty, 10)
self.assertEqual(item.returned_qty, 10)
si = make_sales_invoice(so.name)
self.assertEqual(si.get("items")[0].qty, 10)
def test_make_sales_invoice_bills_ordered_qty_for_partial_delivery(self):
so = make_sales_order(qty=10, rate=100)
create_dn_against_so(so.name, 4)
si = make_sales_invoice(so.name)
self.assertEqual(si.get("items")[0].qty, 10)
def test_make_sales_invoice_after_partial_billing_return_and_redelivery(self):
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
so = make_sales_order(qty=10, rate=100)
dn = create_dn_against_so(so.name, 10)
si = make_sales_invoice(so.name)
si.get("items")[0].qty = 4
si.insert()
si.submit()
dn_return = frappe.get_doc(make_sales_return(dn.name).as_dict())
dn_return.insert()
dn_return.submit()
create_dn_against_so(so.name, 5)
so.load_from_db()
item = so.get("items")[0]
self.assertEqual(item.delivered_qty, 5)
self.assertEqual(item.returned_qty, 10)
self.assertEqual(item.billed_amt, 400)
pending_invoice = make_sales_invoice(so.name)
self.assertEqual(pending_invoice.get("items")[0].qty, 1)
pending_invoice.insert()
pending_invoice.submit()
so.load_from_db()
self.assertEqual(so.get("items")[0].billed_amt, 500)
def test_so_billed_amount_against_return_entry(self):
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return