mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-01 23:53:21 +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
|
import frappe
|
||||||
from frappe import _, qb, query_builder
|
from frappe import _, qb, query_builder
|
||||||
from frappe.query_builder import Criterion
|
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
|
from frappe.utils.dateutils import getdate
|
||||||
|
|
||||||
|
|
||||||
@@ -230,20 +231,48 @@ def get_so_with_invoices(filters):
|
|||||||
.inner_join(soi)
|
.inner_join(soi)
|
||||||
.on(soi.name == sii.so_detail)
|
.on(soi.name == sii.so_detail)
|
||||||
.select(
|
.select(
|
||||||
# grouped by the invoice (sii.parent); sales_order is arbitrary per invoice on MySQL and
|
# One row per (invoice, sales_order). An invoice can bill several Sales Orders; grouping
|
||||||
# base_grand_total is constant per invoice -> Max() keeps the GROUP BY postgres-valid.
|
# by the invoice alone and taking Max(sales_order) credited the whole invoice to one
|
||||||
Max(sii.sales_order).as_("sales_order"),
|
# 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"),
|
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))
|
.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)
|
invoices = query_inv.run(as_dict=True)
|
||||||
|
allocate_invoice_amount_across_orders(invoices)
|
||||||
|
|
||||||
return sorders, 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):
|
def set_payment_terms_statuses(sales_orders, invoices, filters):
|
||||||
"""
|
"""
|
||||||
compute status for payment terms with associated sales invoice using FIFO
|
compute status for payment terms with associated sales invoice using FIFO
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import frappe
|
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.mapper import make_sales_invoice
|
||||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
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
|
# Only the first term should be pulled
|
||||||
self.assertEqual(len(data), 1)
|
self.assertEqual(len(data), 1)
|
||||||
self.assertEqual(data, expected_value)
|
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