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):
"""Split each invoice's grand total across the Sales Orders it bills, in proportion to each order's
net line amount on that invoice. A single-order invoice keeps the full grand total (ratio 1), so the
common case is unchanged; the arithmetic is identical on MariaDB and Postgres."""
"""Split each invoice's grand total across the Sales Orders it bills, proportional to each order's net
line amount. A single-order invoice keeps the full grand total (ratio 1). The last order (sorted, so
both engines agree) absorbs the rounding residual, so the shares always sum back to the grand total."""
rows_by_invoice = {}
for row in invoices:
rows_by_invoice.setdefault(row.invoice, []).append(row)
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)
grand_total = flt(rows[0].invoice_grand_total)
for r in rows:
if total_net:
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
if not total_net:
for r in 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):

View File

@@ -430,26 +430,26 @@ class TestPaymentTermsStatusForSalesOrder(ERPNextTestSuite):
so.submit()
return so
# created in order so the OLD Max(sales_order) deterministically picks so_b and starves so_a
so_a = 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.taxes_and_charges = ""
sinv.taxes = ""
sinv.items[0].qty = 6 # so_a: 6 * 100 = 600 net
so_b_item = so_b.items[0]
sinv.append(
"items",
{
"item_code": item.item_code,
"qty": 4, # so_b: 4 * 100 = 400 net
"rate": 100,
"sales_order": so_b.name,
"so_detail": so_b_item.name,
},
)
sinv.items[0].qty = 6 # so_a: 600 net
for so, qty in ((so_b, 4), (so_c, 5)): # so_b: 400, so_c: 500
sinv.append(
"items",
{
"item_code": item.item_code,
"qty": qty,
"rate": 100,
"sales_order": so.name,
"so_detail": so.items[0].name,
},
)
sinv.insert()
sinv.submit()
@@ -464,14 +464,13 @@ class TestPaymentTermsStatusForSalesOrder(ERPNextTestSuite):
sorders, invoices = get_so_with_invoices(filters)
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)
self.assertIn(so_a.name, rows)
self.assertIn(so_b.name, rows)
# grand total (1000, no tax) split 600 / 400 by net line amount, summing back to the grand total
# each order gets its share (old Max(sales_order) collapsed the invoice onto one), and the shares
# always sum back to the grand total (the last order absorbs any rounding residual)
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_c.name].invoice_amount, 500.0, places=2)
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),
places=2,
)