mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-17 10:36:31 +00:00
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:
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user