Merge pull request #56101 from mihir-kandoi/pg-arbitrary-representative

fix(selling): split multi-order invoice amount across its sales orders (payment terms status)
This commit is contained in:
Mihir Kandoi
2026-06-18 21:06:33 +05:30
committed by GitHub
2 changed files with 111 additions and 7 deletions

View File

@@ -4,7 +4,8 @@
import frappe
from frappe import _, qb, query_builder
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Max
from frappe.query_builder.functions import Max, Sum
from frappe.utils import flt
from frappe.utils.dateutils import getdate
@@ -230,20 +231,48 @@ def get_so_with_invoices(filters):
.inner_join(soi)
.on(soi.name == sii.so_detail)
.select(
# grouped by the invoice (sii.parent); sales_order is arbitrary per invoice on MySQL and
# base_grand_total is constant per invoice -> Max() keeps the GROUP BY postgres-valid.
Max(sii.sales_order).as_("sales_order"),
# One row per (invoice, sales_order). An invoice can bill several Sales Orders; grouping
# by the invoice alone and taking Max(sales_order) credited the whole invoice to one
# arbitrary order and starved the rest. sales_order/invoice are GROUP BY keys and
# base_grand_total is constant per invoice; the grand total is split across the orders
# below in proportion to each order's net line amount on this invoice.
sii.sales_order.as_("sales_order"),
sii.parent.as_("invoice"),
Max(si.base_grand_total).as_("invoice_amount"),
Sum(sii.base_net_amount).as_("order_net_amount"),
Max(si.base_grand_total).as_("invoice_grand_total"),
)
.where((sii.sales_order.isin([x.name for x in sorders])) & (si.docstatus == 1))
.groupby(sii.parent)
.groupby(sii.parent, sii.sales_order)
)
invoices = query_inv.run(as_dict=True)
allocate_invoice_amount_across_orders(invoices)
return sorders, invoices
def allocate_invoice_amount_across_orders(invoices):
"""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)
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):
"""
compute status for payment terms with associated sales invoice using FIFO

View File

@@ -1,7 +1,7 @@
import datetime
import frappe
from frappe.utils import add_days, add_months, nowdate
from frappe.utils import add_days, add_months, flt, nowdate
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
@@ -399,3 +399,78 @@ class TestPaymentTermsStatusForSalesOrder(ERPNextTestSuite):
# Only the first term should be pulled
self.assertEqual(len(data), 1)
self.assertEqual(data, expected_value)
def test_invoice_billing_multiple_orders_splits_proportionally(self):
"""An invoice that bills several Sales Orders must contribute to each, in proportion to each
order's net line amount. Grouping by the invoice alone and taking Max(sales_order) credited the
whole invoice to one arbitrary order and starved the rest. get_so_with_invoices now returns one
row per (invoice, sales_order) with the grand total split proportionally; the split is identical
on MariaDB and Postgres."""
from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order import (
get_so_with_invoices,
)
self.create_payment_terms_template()
item = create_item(item_code="_Test PT Split Item", is_stock_item=0)
def make_so():
so = make_sales_order(
transaction_date="2021-06-15",
delivery_date=add_days("2021-06-15", 30),
item=item.item_code,
qty=10,
rate=100,
do_not_save=True,
)
so.po_no = ""
so.taxes_and_charges = ""
so.taxes = ""
so.payment_terms_template = self.template.name
so.save()
so.submit()
return so
so_a = make_so()
so_b = make_so()
so_c = make_so()
# 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: 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()
filters = frappe._dict(
{
"company": "_Test Company",
"period_start_date": "2021-06-01",
"period_end_date": "2021-06-30",
"item": item.item_code,
}
)
sorders, invoices = get_so_with_invoices(filters)
rows = {r.sales_order: r for r in invoices if r.invoice == sinv.name}
# 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(
sum(rows[so.name].invoice_amount for so in (so_a, so_b, so_c)),
flt(sinv.base_grand_total),
places=2,
)