fix(selling): make multi-order invoice split sum exactly to the grand total

Address review (#56101): with 3+ orders the proportional shares could drift by a
sub-cent and not sum back to the grand total, leaving the last payment term
"Partly Paid". The last order (sorted, so MariaDB and Postgres agree) now absorbs
the residual: grand_total - sum(prior shares). Extended the test to three orders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-18 20:48:36 +05:30
parent b1c6666d02
commit 3f6f3abf69
2 changed files with 30 additions and 27 deletions

View File

@@ -251,22 +251,26 @@ def get_so_with_invoices(filters):
def allocate_invoice_amount_across_orders(invoices): def allocate_invoice_amount_across_orders(invoices):
"""Split each invoice's grand total across the Sales Orders it bills, in proportion to each order's """Split each invoice's grand total across the Sales Orders it bills, proportional to each order's net
net line amount on that invoice. A single-order invoice keeps the full grand total (ratio 1), so the line amount. A single-order invoice keeps the full grand total (ratio 1). The last order (sorted, so
common case is unchanged; the arithmetic is identical on MariaDB and Postgres.""" both engines agree) absorbs the rounding residual, so the shares always sum back to the grand total."""
rows_by_invoice = {} rows_by_invoice = {}
for row in invoices: for row in invoices:
rows_by_invoice.setdefault(row.invoice, []).append(row) rows_by_invoice.setdefault(row.invoice, []).append(row)
for rows in rows_by_invoice.values(): for rows in rows_by_invoice.values():
rows.sort(key=lambda r: r.sales_order)
total_net = sum(flt(r.order_net_amount) for r in rows) total_net = sum(flt(r.order_net_amount) for r in rows)
grand_total = flt(rows[0].invoice_grand_total) grand_total = flt(rows[0].invoice_grand_total)
for r in rows: if not total_net:
if total_net: for r in rows:
r.invoice_amount = grand_total * flt(r.order_net_amount) / total_net
else:
# degenerate all-zero-net invoice: split evenly so both engines still agree
r.invoice_amount = grand_total / len(rows) r.invoice_amount = grand_total / len(rows)
continue
allocated = 0.0
for r in rows[:-1]:
r.invoice_amount = grand_total * flt(r.order_net_amount) / total_net
allocated += r.invoice_amount
rows[-1].invoice_amount = grand_total - allocated
def set_payment_terms_statuses(sales_orders, invoices, filters): def set_payment_terms_statuses(sales_orders, invoices, filters):

View File

@@ -430,26 +430,26 @@ class TestPaymentTermsStatusForSalesOrder(ERPNextTestSuite):
so.submit() so.submit()
return so return so
# created in order so the OLD Max(sales_order) deterministically picks so_b and starves so_a
so_a = make_so() so_a = make_so()
so_b = make_so() so_b = make_so()
so_c = make_so()
# one invoice billing both orders, partially (so both stay in a billable status) # one invoice billing all three orders, partially (so each stays in a billable status)
sinv = make_sales_invoice(so_a.name) sinv = make_sales_invoice(so_a.name)
sinv.taxes_and_charges = "" sinv.taxes_and_charges = ""
sinv.taxes = "" sinv.taxes = ""
sinv.items[0].qty = 6 # so_a: 6 * 100 = 600 net sinv.items[0].qty = 6 # so_a: 600 net
so_b_item = so_b.items[0] for so, qty in ((so_b, 4), (so_c, 5)): # so_b: 400, so_c: 500
sinv.append( sinv.append(
"items", "items",
{ {
"item_code": item.item_code, "item_code": item.item_code,
"qty": 4, # so_b: 4 * 100 = 400 net "qty": qty,
"rate": 100, "rate": 100,
"sales_order": so_b.name, "sales_order": so.name,
"so_detail": so_b_item.name, "so_detail": so.items[0].name,
}, },
) )
sinv.insert() sinv.insert()
sinv.submit() sinv.submit()
@@ -464,14 +464,13 @@ class TestPaymentTermsStatusForSalesOrder(ERPNextTestSuite):
sorders, invoices = get_so_with_invoices(filters) sorders, invoices = get_so_with_invoices(filters)
rows = {r.sales_order: r for r in invoices if r.invoice == sinv.name} rows = {r.sales_order: r for r in invoices if r.invoice == sinv.name}
# both orders are represented (the old Max(sales_order) collapsed the invoice onto one) # each order gets its share (old Max(sales_order) collapsed the invoice onto one), and the shares
self.assertIn(so_a.name, rows) # always sum back to the grand total (the last order absorbs any rounding residual)
self.assertIn(so_b.name, rows)
# grand total (1000, no tax) split 600 / 400 by net line amount, summing back to the grand total
self.assertAlmostEqual(rows[so_a.name].invoice_amount, 600.0, places=2) self.assertAlmostEqual(rows[so_a.name].invoice_amount, 600.0, places=2)
self.assertAlmostEqual(rows[so_b.name].invoice_amount, 400.0, places=2) self.assertAlmostEqual(rows[so_b.name].invoice_amount, 400.0, places=2)
self.assertAlmostEqual(rows[so_c.name].invoice_amount, 500.0, places=2)
self.assertAlmostEqual( self.assertAlmostEqual(
rows[so_a.name].invoice_amount + rows[so_b.name].invoice_amount, sum(rows[so.name].invoice_amount for so in (so_a, so_b, so_c)),
flt(sinv.base_grand_total), flt(sinv.base_grand_total),
places=2, places=2,
) )