mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-11 13:41:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user