Compare commits

...

36 Commits

Author SHA1 Message Date
Mihir Kandoi
25e5f34c3a Merge branch 'fix/landed-cost-po-invoice-fifo' into fix/refresh-order-line-receipts 2026-09-26 18:53:21 +05:30
Mihir Kandoi
00c5dcb510 Merge branch 'fix/order-billing-by-qty' into fix/landed-cost-po-invoice-fifo 2026-09-26 18:53:19 +05:30
Mihir Kandoi
d3627d6d0e test(buying): pin bill-for-rejected off in the order qty over-billing test 2026-09-26 18:53:16 +05:30
Mihir Kandoi
d9bf56523d Merge branch 'fix/receipt-billing-by-qty' into fix/order-billing-by-qty 2026-09-26 18:52:55 +05:30
Mihir Kandoi
d14a20bde6 test(accounts): pin bill-for-rejected off in the qty over-billing test
The real reason the extra invoice went through in CI: an earlier test leaves "Bill for Rejected Quantity" cached on, and with it on the Purchase Invoice skips the over-billing error entirely. The test now pins that setting off, and the fresh item from the previous attempt is dropped because the allowance was never the cause.
2026-09-26 18:52:52 +05:30
Mihir Kandoi
3348a4fd5f Merge branch 'fix/landed-cost-po-invoice-fifo' into fix/refresh-order-line-receipts 2026-09-26 18:35:10 +05:30
Mihir Kandoi
4bf8e06638 Merge branch 'fix/order-billing-by-qty' into fix/landed-cost-po-invoice-fifo 2026-09-26 18:35:07 +05:30
Mihir Kandoi
6ed0378357 test(buying): give the order qty over-billing test its own item allowance 2026-09-26 18:35:03 +05:30
Mihir Kandoi
eebdae1957 Merge branch 'fix/receipt-billing-by-qty' into fix/order-billing-by-qty 2026-09-26 18:34:46 +05:30
Mihir Kandoi
a06027d164 test(accounts): give the qty over-billing test its own item allowance
In the CI shard an earlier module leaves a large over-billing allowance cached, so even a 100% qty overage passed. The test now uses a fresh item with a 10% item-level allowance, which takes precedence over the global setting and carries no cached state.
2026-09-26 18:34:42 +05:30
Mihir Kandoi
7a29cf011d test(stock): landed cost refreshed on a receipt whose order-invoice share moved 2026-09-26 18:22:59 +05:30
Mihir Kandoi
51a13625b7 fix(stock): refresh landed cost on every receipt of the invoiced order lines
A Purchase Invoice reset the invoice-rate landed cost only on receipts whose billed amount moved, but the order-level invoice split can shift qty onto a receipt whose billed amount stays put. With a 1-qty direct invoice on the first of two receipts and a 69 @ 40 order invoice, the second receipt showed 25% billed but kept a landed cost difference of 0 instead of -400.

Every receipt on the invoiced order lines now gets its landed cost difference refreshed, and a receipt is reposted only when one of its rows actually changed. That also spares the repost that used to run when an invoice left the difference where it was.
2026-09-26 18:22:42 +05:30
Mihir Kandoi
03ab996b25 perf(stock): share one invoice split across receipts on an order line
Under invoice-rate landed cost, every receipt refreshed by a Purchase Invoice worked out the order-level invoice split on its own, and each did it twice (once for billing, once for the landed cost difference). The invoice now builds the split once for all receipts on its order lines and passes it to each receipt's billing update. A receipt updated on its own still builds its split, once.
2026-09-26 18:20:53 +05:30
Mihir Kandoi
e3542495b1 Merge branch 'fix/order-billing-by-qty' into fix/landed-cost-po-invoice-fifo
# Conflicts:
#	erpnext/stock/doctype/purchase_receipt/services/billing_status.py
2026-09-26 18:15:39 +05:30
Mihir Kandoi
56a59dd9d3 test(buying): overbill the order by its full qty in the qty over-billing test 2026-09-26 18:14:42 +05:30
Mihir Kandoi
3b869238bc Merge branch 'fix/receipt-billing-by-qty' into fix/order-billing-by-qty
# Conflicts:
#	erpnext/stock/doctype/purchase_receipt/services/billing_status.py
2026-09-26 18:14:34 +05:30
Mihir Kandoi
afae6ef0df test: pin the settings the qty billing tests depend on
CI shards leak settings between tests. With "Bill for Rejected Quantity" left on, returns stop reducing the billable qty, and a 20% over-billing allowance left on _Test Item let a 1-unit overage through. The returned-receipt test now turns bill-for-rejected off, and the over-billing test overbills by the full receipt qty.
2026-09-26 18:13:52 +05:30
Mihir Kandoi
1cebd2c385 test(stock): fully returned row left out of receipt qty billing 2026-09-26 18:13:18 +05:30
Mihir Kandoi
1946123790 fix(stock): leave fully returned rows out of receipt qty billing
A row returned in full still counted its original qty in the billing basis, so a receipt with one returned row and every other row invoiced stayed Partly Billed. Rows now count only the qty left after returns. Only when the whole receipt is returned is it measured against what it received, as Delivery Note billing already does.
2026-09-26 18:13:01 +05:30
Mihir Kandoi
dea76cf0d8 Merge branch 'develop' of https://github.com/frappe/erpnext into fix/receipt-billing-by-qty
# Conflicts:
#	erpnext/stock/doctype/purchase_receipt/services/billing_status.py
2026-09-26 18:12:07 +05:30
Mihir Kandoi
f567dd3d3b test(stock): landed cost takes order-level invoices oldest first 2026-09-26 17:07:27 +05:30
Mihir Kandoi
5f99e4c19c fix(stock): split order-level invoices over receipts for invoice-rate landed cost
With "Set Landed Cost Based on Purchase Invoice Rate" on, each receipt priced its rows from the whole qty invoiced against the Purchase Order and at the average rate of all those invoices. For an order of 100 @ 50 received as 60 + 40, an invoice of 60 @ 70 moved the untouched second receipt by 800, and a later invoice of 40 @ 55 repriced the first receipt at the 64 average.

Invoices made against the Purchase Order are now handed to its receipts oldest first, each at its own rate, with debit notes netted against the invoice they return. Receipt billing and the landed cost difference read the same split. Invoices that update stock stay out, as they already do for billed amounts.
2026-09-26 17:07:12 +05:30
Mihir Kandoi
8ec6fa403b test(buying): order over-billing by qty under invoice-rate landed cost 2026-09-26 17:07:03 +05:30
Mihir Kandoi
5a106d18b1 fix(buying): check order over-billing by qty when landed cost follows invoice rate
The status updater still compared the order's billed_amt to its amount, so once a higher-rate invoice used up the order value the rest of the order could not be invoiced. Under "Set Landed Cost Based on Purchase Invoice Rate" the Purchase Invoice now checks invoiced qty against the order qty instead, next to the receipt check. A new validate_overflow key lets the status updater skip only the over-limit comparison and keep its row sign and rate checks.
2026-09-26 17:07:03 +05:30
Mihir Kandoi
732d9e6cb7 test(buying): purchase order billing by qty under invoice-rate landed cost 2026-09-26 17:07:03 +05:30
Mihir Kandoi
70bef73e95 fix(buying): bill purchase orders by qty when landed cost follows invoice rate
A Purchase Order of 100 @ 50 went Completed after one invoice of 25 @ 200, same as its receipt did. Under "Set Landed Cost Based on Purchase Invoice Rate" the order's per_billed now follows invoiced qty per row, weighted by row value, using the helper the receipt already uses. The Purchase Invoice status updater still keeps billed_amt current but no longer writes the amount-based percentage; the invoice refreshes the order's billing percentage by qty on submit and cancel instead.
2026-09-26 17:07:03 +05:30
Mihir Kandoi
509386c0bf test(stock): non-updating debit note kept out of receipt qty billing 2026-09-26 17:04:22 +05:30
Mihir Kandoi
2ac495692a fix(stock): leave non-updating debit notes out of receipt qty billing
A debit note with "Update Billed Amount in Purchase Receipt" off still reduced the receipt's invoiced qty whenever billing was recalculated, so 50 invoiced, a -20 debit note and 50 more showed 80% billed. Such debit notes are now left out of the qty invoiced against the receipt, the same way the invoice skips them when updating billing. The invoice-rate landed cost reads the same totals.
2026-09-26 17:04:08 +05:30
Mihir Kandoi
8136baf848 test(stock): zero-rate receipt billed by qty 2026-09-26 17:03:46 +05:30
Mihir Kandoi
52bc00d5d2 fix(stock): bill zero-rate receipts by qty
Qty billing weighted each row by its value, so a receipt where every row has a zero rate always showed 0% billed, even after it was invoiced in full. When no row has a value, rows are now weighted by qty.
2026-09-26 17:03:36 +05:30
Mihir Kandoi
4778cf452a test(stock): fully returned receipt stays out of the order invoice split 2026-09-26 17:03:13 +05:30
Mihir Kandoi
6a28211402 fix(stock): keep fully returned receipts out of the order invoice split
A receipt returned in full still offered its original qty to invoices made against the Purchase Order, so it took qty that belonged to later receipts on the same line. The split now uses the qty left after returns; only the receipt's own billing % still falls back to the original qty. Under qty billing the Purchase Invoice also refreshes every receipt on the invoiced order lines, not only those whose billed amount moved, since the split can shift to any of them.
2026-09-26 17:02:37 +05:30
Mihir Kandoi
b31494407d test(accounts): receipt over-billing by qty under invoice-rate landed cost 2026-09-26 16:38:23 +05:30
Mihir Kandoi
ca2511407b fix(accounts): check receipt over-billing by qty when landed cost follows invoice rate
The Purchase Invoice compared invoiced amount to the receipt amount, so after a 25 @ 200 invoice against a 100 @ 50 receipt the remaining 75 could not be invoiced at all. Under "Set Landed Cost Based on Purchase Invoice Rate" the check now compares invoiced qty to receipt qty, with the same over-billing allowance, and the error shows the limit as qty with its UOM.
2026-09-26 16:37:44 +05:30
Mihir Kandoi
bfdab931a0 test(stock): purchase receipt billing by qty under invoice-rate landed cost 2026-09-26 16:37:02 +05:30
Mihir Kandoi
f513c2b999 fix(stock): bill purchase receipts by qty when landed cost follows invoice rate
With "Set Landed Cost Based on Purchase Invoice Rate" on, the invoice rate replaces the receipt rate, so comparing invoiced amount to receipt amount says nothing about progress. A receipt of 100 @ 50 went Completed after one invoice of 25 @ 200.

Under that setting per_billed now follows invoiced qty per row, weighted by row value. Invoices made against the Purchase Order are spread over its receipts FIFO, net of returns. The negative landed-cost add-back that forced lower-rate invoices to 100% is gone, since the qty basis covers it.
2026-09-26 16:36:00 +05:30
10 changed files with 587 additions and 135 deletions

View File

@@ -40,6 +40,7 @@ from erpnext.accounts.utils import (
)
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
from erpnext.controllers.buying_controller import BuyingController
from erpnext.stock.doctype.purchase_receipt.services.billing_status import is_billed_by_qty
class WarehouseMissingError(frappe.ValidationError):
@@ -296,7 +297,12 @@ class PurchaseInvoice(BuyingController):
from erpnext.accounts.services.billing_validation import BillingValidationService
BillingValidationService(self).validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
billing_validation = BillingValidationService(self)
if is_billed_by_qty():
billing_validation.validate_multiple_billing("Purchase Receipt", "pr_detail", "qty")
billing_validation.validate_multiple_billing("Purchase Order", "po_detail", "qty")
else:
billing_validation.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
self.set_status()
self.validate_purchase_receipt_if_update_stock()
self.validate_exchange_rate_with_purchase_receipt()
@@ -589,6 +595,9 @@ class PurchaseInvoice(BuyingController):
frappe.throw(_("Purchase Receipt {0} is not submitted").format(d.purchase_receipt))
def update_status_updater_args(self):
if is_billed_by_qty():
self.set_purchase_order_billing_by_qty()
if cint(self.update_stock):
self.status_updater.append(
{
@@ -639,6 +648,13 @@ class PurchaseInvoice(BuyingController):
}
)
def set_purchase_order_billing_by_qty(self):
"""The status updater keeps billed_amt current; billing % and over-billing follow invoiced qty instead."""
for args in self.status_updater:
if args.get("overflow_type") == "billing":
args.pop("percent_join_field", None)
args["validate_overflow"] = False
def validate_purchase_receipt_if_update_stock(self):
if self.update_stock:
for item in self.get("items"):
@@ -672,6 +688,7 @@ class PurchaseInvoice(BuyingController):
self.update_status_updater_args()
self.update_prevdoc_status()
BillingStatusService(self).update_billing_status_in_po()
frappe.get_cached_doc("Authorization Control").validate_approving_authority(
self.doctype, self.company, self.base_grand_total
@@ -786,6 +803,7 @@ class PurchaseInvoice(BuyingController):
self.update_status_updater_args()
self.update_prevdoc_status()
BillingStatusService(self).update_billing_status_in_po()
if not self.is_return:
self.update_billing_status_for_zero_amount_refdoc("Purchase Receipt")

View File

@@ -1,7 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Purchase Receipt billing sync and provisional-entry cancellation for Purchase Invoice."""
"""Purchase Receipt and Purchase Order billing sync and provisional-entry cancellation for Purchase Invoice."""
import frappe
from frappe import qb
@@ -9,6 +9,9 @@ from frappe.query_builder.functions import Sum
from frappe.utils import flt
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
get_invoiced_qty_and_amount,
get_purchase_receipts_against_po_details,
is_billed_by_qty,
update_billed_amount_based_on_po,
update_billing_percentage,
)
@@ -44,16 +47,52 @@ class BillingStatusService:
if po_details:
updated_pr += update_billed_amount_based_on_po(po_details, update_modified)
adjust_incoming_rate = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
if not is_billed_by_qty():
for pr in set(updated_pr):
pr_doc = frappe.get_lazy_doc("Purchase Receipt", pr)
update_billing_percentage(pr_doc, update_modified=update_modified)
return
for pr in set(updated_pr):
pr_doc = frappe.get_lazy_doc("Purchase Receipt", pr)
self.update_billing_status_in_receipts_on_po_lines(set(updated_pr), update_modified)
def update_billing_status_in_receipts_on_po_lines(self, updated_pr: set, update_modified: bool) -> None:
"""Order invoices are spread over every receipt on the line, so all of them are refreshed from one split."""
pr_docs = [
frappe.get_lazy_doc("Purchase Receipt", pr) for pr in updated_pr | self.get_receipts_on_po_lines()
]
pr_items = []
for pr_doc in pr_docs:
pr_items.extend(pr_doc.items)
bill_for_rejected = frappe.db.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
invoiced = get_invoiced_qty_and_amount(pr_items, bill_for_rejected)
for pr_doc in pr_docs:
update_billing_percentage(
pr_doc, update_modified=update_modified, adjust_incoming_rate=adjust_incoming_rate
pr_doc,
update_modified=update_modified,
adjust_incoming_rate=True,
invoiced=invoiced,
)
def get_receipts_on_po_lines(self) -> set:
po_details = list({d.po_detail for d in self.doc.get("items") if d.po_detail})
if not po_details:
return set()
return {pr_item.parent for pr_item in get_purchase_receipts_against_po_details(po_details)}
def update_billing_status_in_po(self) -> None:
doc = self.doc
if not is_billed_by_qty() or (doc.is_return and not doc.update_billed_amount_in_purchase_order):
return
for purchase_order in {item.purchase_order for item in doc.items if item.purchase_order}:
frappe.get_doc("Purchase Order", purchase_order).update_billing_percentage()
def get_pr_details_billed_amt(self) -> dict:
# Get billed amount based on purchase receipt item reference (pr_detail) in purchase invoice

View File

@@ -3787,6 +3787,30 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
self.assertRaises(frappe.ValidationError, pi.submit)
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"maintain_same_rate": 0,
"set_landed_cost_based_on_purchase_invoice_rate": 1,
"bill_for_rejected_quantity_in_purchase_invoice": 0,
},
)
def test_receipt_over_billing_by_qty_when_landed_cost_follows_invoice_rate(self):
pr = make_purchase_receipt(qty=100, rate=50)
for qty in (25, 75):
pi = create_purchase_invoice_from_receipt(pr.name)
pi.items[0].qty = qty
pi.items[0].rate = 200
pi.submit()
pr.reload()
self.assertEqual(pr.status, "Completed")
extra_invoice = frappe.copy_doc(pi)
extra_invoice.items[0].qty = 100
self.assertRaisesRegex(frappe.ValidationError, "Cannot overbill", extra_invoice.submit)
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_po_is_blocked(self):
service_item = create_item(

View File

@@ -49,7 +49,7 @@ class BillingValidationService:
overbilled_items.append(row)
if overbilled_items:
self.throw_overbill_exception(overbilled_items, precision)
self.throw_overbill_exception(overbilled_items, precision, based_on)
if is_overbilling_allowed and total_overbilled_amt > 0.1:
frappe.msgprint(
@@ -92,7 +92,9 @@ class BillingValidationService:
ref_wise_billed_amount.setdefault(
key,
frappe._dict(item_code=item.item_code, billed_amt=0.0, ref_amt=ref_amt, rows=[]),
frappe._dict(
item_code=item.item_code, uom=item.get("uom"), billed_amt=0.0, ref_amt=ref_amt, rows=[]
),
)
ref_wise_billed_amount[key]["rows"].append(item.idx)
ref_wise_billed_amount[key]["ref_amt"] = ref_amt
@@ -131,7 +133,7 @@ class BillingValidationService:
).run()
)
def throw_overbill_exception(self, overbilled_items: list, precision: int) -> None:
def throw_overbill_exception(self, overbilled_items: list, precision: int, based_on: str) -> None:
message = (
_("<p>Cannot overbill for the following Items:</p>")
+ "<ul>"
@@ -139,9 +141,7 @@ class BillingValidationService:
_("<li>Item {0} in row(s) {1} billed more than {2}</li>").format(
frappe.bold(item.item_code),
", ".join(str(x) for x in item.rows),
frappe.bold(
fmt_money(item.max_allowed_amt, precision=precision, currency=self.doc.currency)
),
frappe.bold(self.get_formatted_limit(item, precision, based_on)),
)
for item in overbilled_items
)
@@ -149,3 +149,9 @@ class BillingValidationService:
)
message += _("<p>To allow over-billing, please set allowance in Accounts Settings.</p>")
frappe.throw(message)
def get_formatted_limit(self, item: frappe._dict, precision: int, based_on: str) -> str:
if based_on == "qty":
return f"{flt(item.max_allowed_amt, precision)} {item.uom}"
return fmt_money(item.max_allowed_amt, precision=precision, currency=self.doc.currency)

View File

@@ -10,6 +10,10 @@ from frappe.utils import cstr, flt
from erpnext.buying.doctype.purchase_order.services.subcontracting import SubcontractingService
from erpnext.controllers.item_close import validate_parent_reopen
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
get_invoiced_qty_against_po_items,
get_qty_based_percent_billed,
)
class StatusService:
@@ -61,3 +65,9 @@ class StatusService:
per_received = flt(received_qty / total_qty) * 100 if total_qty else 0
doc.db_set("per_received", per_received, update_modified=False)
def get_percent_billed_by_qty(self) -> float:
items = [item for item in self.doc.items if not item.closed] or self.doc.items
billable_qty = {item.name: flt(item.qty) for item in items}
invoiced_qty = get_invoiced_qty_against_po_items(list(billable_qty))
return get_qty_based_percent_billed(items, billable_qty, invoiced_qty)

View File

@@ -1521,6 +1521,49 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.reload()
self.assertEqual(po.per_billed, 100)
@ERPNextTestSuite.change_settings(
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
)
def test_per_billed_by_qty_when_landed_cost_follows_invoice_rate(self):
po = create_purchase_order(qty=100, rate=50)
pi = make_pi_from_po(po.name)
pi.items[0].qty = 25
pi.items[0].rate = 200
pi.submit()
po.reload()
self.assertEqual(po.per_billed, 25)
self.assertEqual(po.status, "To Receive and Bill")
pi.reload()
pi.cancel()
po.reload()
self.assertEqual(po.per_billed, 0)
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"maintain_same_rate": 0,
"set_landed_cost_based_on_purchase_invoice_rate": 1,
"bill_for_rejected_quantity_in_purchase_invoice": 0,
},
)
def test_over_billing_by_qty_when_landed_cost_follows_invoice_rate(self):
po = create_purchase_order(qty=100, rate=50)
for qty in (25, 75):
pi = make_pi_from_po(po.name)
pi.items[0].qty = qty
pi.items[0].rate = 200
pi.submit()
po.reload()
self.assertEqual(po.per_billed, 100)
extra_invoice = frappe.copy_doc(pi)
extra_invoice.items[0].qty = 100
self.assertRaisesRegex(frappe.ValidationError, "Cannot overbill", extra_invoice.submit)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1})
def test_receive_zero_qty_purchase_order(self):
"""

View File

@@ -394,7 +394,7 @@ class StatusUpdater(Document):
)
)
if items_to_validate:
if items_to_validate and args.get("validate_overflow") is not False:
pp_sub_assembly_items = [
item.production_plan_sub_assembly_item
for item in items_to_validate

View File

@@ -33,6 +33,7 @@ from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock import get_warehouse_account, get_warehouse_account_map
from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.doctype.purchase_receipt.services.billing_status import is_billed_by_qty
from erpnext.stock.services.internal_transfer import StockInternalTransferService
from erpnext.stock.stock_ledger import get_items_to_be_repost
@@ -340,6 +341,10 @@ class StockController(AccountsController):
if self.doctype == "Delivery Note":
# Bill by amount, falling back to qty when the invoiced amount is short (e.g. rate drop).
args["billing_percentage"] = self.get_delivery_note_billing_percentage()
elif self.doctype == "Purchase Order" and is_billed_by_qty():
from erpnext.buying.doctype.purchase_order.services.status import StatusService
args["billing_percentage"] = StatusService(self).get_percent_billed_by_qty()
self._update_percent_field(args, update_modified)

View File

@@ -115,6 +115,7 @@ def get_purchase_receipts_against_po_details(po_details: list) -> list[dict]:
.select(
purchase_receipt_item.name,
purchase_receipt_item.qty,
purchase_receipt_item.rejected_qty,
purchase_receipt_item.parent,
purchase_receipt_item.amount,
purchase_receipt_item.billed_amt,
@@ -180,30 +181,46 @@ def get_billed_amount_against_po(po_items: list) -> dict:
def update_billing_percentage(
pr_doc, update_modified: bool = True, adjust_incoming_rate: bool = False
pr_doc, update_modified: bool = True, adjust_incoming_rate: bool = False, invoiced: dict | None = None
) -> None:
# Update Billing % based on pending accepted qty
"""`invoiced` from get_invoiced_qty_and_amount lets receipts on one order line share its invoice split."""
buying_settings = frappe.get_single("Buying Settings")
bill_for_rejected = buying_settings.bill_for_rejected_quantity_in_purchase_invoice
items = [item for item in pr_doc.items if not item.closed] or pr_doc.items
if buying_settings.set_landed_cost_based_on_purchase_invoice_rate:
if invoiced is None:
invoiced = get_invoiced_qty_and_amount(pr_doc.items, bill_for_rejected)
percent_billed = get_percent_billed_by_qty(pr_doc, items, bill_for_rejected, invoiced)
else:
percent_billed = get_percent_billed_by_amount(pr_doc, items, bill_for_rejected)
pr_doc.db_set("per_billed", percent_billed)
if update_modified:
pr_doc.set_status(update=True)
pr_doc.notify_update()
if adjust_incoming_rate and set_amount_difference_with_purchase_invoice(items, invoiced):
adjust_incoming_rate_for_pr(pr_doc)
def get_percent_billed_by_amount(pr_doc, items: list, bill_for_rejected: bool) -> float:
over_billing_allowance, role_allowed_to_over_bill = frappe.get_single_value(
"Accounts Settings", ["over_billing_allowance", "role_allowed_to_over_bill"]
)
total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0
item_wise_returned_qty = get_item_wise_returned_qty(pr_doc)
billed_qty_amt = frappe._dict()
total_amount, total_billed_amount = 0, 0
item_wise_returned_qty = get_item_wise_returned_qty([item.name for item in pr_doc.items])
if adjust_incoming_rate:
billed_qty_amt = get_billed_qty_amount_against_purchase_receipt(pr_doc)
billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc)
for item in [item for item in pr_doc.items if not item.closed] or pr_doc.items:
for item in items:
returned_qty = flt(item_wise_returned_qty.get(item.name))
returned_amount = flt(returned_qty) * flt(item.rate)
pending_amount = flt(item.amount) - returned_amount
# When rejected qty is billable, its value is part of the billable base too
rejected_amount = 0.0
if buying_settings.bill_for_rejected_quantity_in_purchase_invoice:
if bill_for_rejected:
rejected_amount = flt(item.rejected_qty * item.rate, item.precision("amount"))
pending_amount = flt(item.amount) + rejected_amount
@@ -219,54 +236,7 @@ def update_billing_percentage(
amount = flt(item.amount) + rejected_amount
if adjust_incoming_rate:
adjusted_amt = 0.0
if (
item.billed_amt is not None
and item.amount is not None
and (
billed_qty_amt.get(item.name) or billed_qty_amt_based_on_po.get(item.purchase_order_item)
)
):
qty = None
if billed_qty_amt.get(item.name):
qty = billed_qty_amt.get(item.name).get("qty")
if not qty and billed_qty_amt_based_on_po.get(item.purchase_order_item):
if item.qty < billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"]:
qty = item.qty
else:
qty = billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"]
billed_qty_amt_based_on_po[item.purchase_order_item]["qty"] -= qty
billed_amt = item.billed_amt
if billed_qty_amt.get(item.name):
billed_amt = flt(billed_qty_amt.get(item.name).get("amount"))
elif billed_qty_amt_based_on_po.get(item.purchase_order_item):
total_billed_qty = (
billed_qty_amt_based_on_po.get(item.purchase_order_item).get("qty") + qty
)
if total_billed_qty:
billed_amt = flt(
flt(billed_qty_amt_based_on_po.get(item.purchase_order_item).get("amount"))
* (qty / total_billed_qty)
)
else:
billed_amt = 0.0
# Reduce billed amount based on PO for next iterations
billed_qty_amt_based_on_po[item.purchase_order_item]["amount"] -= billed_amt
if qty:
adjusted_amt = flt(billed_amt / qty) * item.qty - flt(item.base_net_amount)
adjusted_amt = flt(adjusted_amt, item.precision("amount"))
pi_landed_cost_amount += adjusted_amt
item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False)
elif amount and item.billed_amt > amount:
if amount and item.billed_amt > amount:
per_over_billed = (flt(item.billed_amt / amount, 2) * 100) - 100
if (
per_over_billed > over_billing_allowance
@@ -278,22 +248,212 @@ def update_billing_percentage(
)
)
if pi_landed_cost_amount < 0:
total_billed_amount += abs(pi_landed_cost_amount)
percent_billed = round(100 * (total_billed_amount / (total_amount or 1)), 6)
pr_doc.db_set("per_billed", percent_billed)
if update_modified:
pr_doc.set_status(update=True)
pr_doc.notify_update()
if adjust_incoming_rate:
adjust_incoming_rate_for_pr(pr_doc)
return round(100 * (total_billed_amount / (total_amount or 1)), 6)
def get_billed_qty_amount_against_purchase_receipt(pr_doc) -> dict:
pr_names = [d.name for d in pr_doc.items]
def is_billed_by_qty() -> bool:
"""Invoice-rate landed cost leaves qty as the only stable measure of billing."""
return bool(
frappe.db.get_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate")
)
def get_percent_billed_by_qty(pr_doc, items: list, bill_for_rejected: bool, invoiced: dict) -> float:
billable_qty = get_billable_qty_by_row(pr_doc, items, bill_for_rejected)
return get_qty_based_percent_billed(items, billable_qty, get_invoiced_qty(pr_doc, invoiced))
def get_qty_based_percent_billed(items: list, billable_qty: dict, invoiced_qty: dict) -> float:
"""Share of each row's billable qty that is invoiced, weighted by the row's value, or by qty when no row has one."""
weigh_by_value = any(flt(item.rate) for item in items)
total_weight, billed_weight = 0.0, 0.0
for item in items:
qty = flt(billable_qty.get(item.name))
if not qty:
continue
weight = abs(qty * flt(item.rate)) if weigh_by_value else abs(qty)
total_weight += weight
billed_weight += weight * min(flt(invoiced_qty.get(item.name)) / qty, 1)
return round(100 * (billed_weight / (total_weight or 1)), 6)
def get_billable_qty_by_row(pr_doc, items: list, bill_for_rejected: bool) -> dict:
"""Qty left to bill per row; a receipt returned in full is measured against what it received."""
returned_qty = get_item_wise_returned_qty([item.name for item in pr_doc.items])
billable_qty = {
item.name: get_billable_qty(item, returned_qty.get(item.name), bill_for_rejected) for item in items
}
if any(qty > 0 for qty in billable_qty.values()):
return billable_qty
return {item.name: flt(item.qty) for item in items}
def get_billable_qty(item, returned_qty: float | None, bill_for_rejected: bool) -> float:
if bill_for_rejected:
return flt(item.qty) + flt(item.rejected_qty)
return flt(item.qty) - flt(returned_qty)
def get_invoiced_qty(pr_doc, invoiced: dict) -> dict:
invoiced_qty = {name: row.qty for name, row in invoiced.items()}
for item in pr_doc.items:
if item.purchase_invoice_item:
invoiced_qty[item.name] = flt(item.qty)
return invoiced_qty
def get_invoiced_qty_and_amount(pr_items: list, bill_for_rejected: bool) -> dict:
"""Invoiced qty and base amount per Purchase Receipt Item, direct and through the Purchase Order."""
billed = get_billed_qty_amount_against_purchase_receipt([item.name for item in pr_items])
invoiced = {
pr_detail: frappe._dict(qty=flt(row["qty"]), amount=flt(row["amount"]))
for pr_detail, row in billed.items()
}
po_details = list({item.purchase_order_item for item in pr_items if item.purchase_order_item})
po_invoice_share = get_po_invoice_share(po_details, bill_for_rejected) if po_details else {}
for item in pr_items:
share = po_invoice_share.get(item.name)
if not share or item.purchase_invoice_item:
continue
row = invoiced.setdefault(item.name, frappe._dict(qty=0.0, amount=0.0))
row.qty += share.qty
row.amount += share.amount
return invoiced
def get_po_invoice_share(po_details: list, bill_for_rejected: bool) -> dict:
"""Split invoices made against the Purchase Order over its receipts, oldest invoice to oldest receipt."""
po_invoices = get_po_invoices(po_details)
pr_items = get_purchase_receipts_against_po_details(po_details)
pr_item_names = [pr_item.name for pr_item in pr_items]
billed_against_pr = get_billed_qty_amount_against_purchase_receipt(pr_item_names)
returned_qty = get_item_wise_returned_qty(pr_item_names)
share = {}
for pr_item in pr_items:
direct_qty = flt(billed_against_pr.get(pr_item.name, {}).get("qty"))
billable_qty = get_billable_qty(pr_item, returned_qty.get(pr_item.name), bill_for_rejected)
invoices = po_invoices.get(pr_item.purchase_order_item, [])
share[pr_item.name] = take_from_invoices(invoices, billable_qty - direct_qty)
return share
def take_from_invoices(invoices: list, pending_qty: float) -> frappe._dict:
"""Take qty from the oldest invoices first, each at its own rate."""
taken = frappe._dict(qty=0.0, amount=0.0)
for invoice in invoices:
qty = min(invoice.qty, pending_qty - taken.qty)
if qty <= 0:
continue
amount = invoice.amount * qty / invoice.qty
invoice.qty -= qty
invoice.amount -= amount
taken.qty += qty
taken.amount += amount
return taken
def get_po_invoices(po_details: list) -> dict:
"""Net qty and base amount of each invoice made against the Purchase Order, oldest first."""
purchase_invoice = frappe.qb.DocType("Purchase Invoice")
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
rows = (
frappe.qb.from_(purchase_invoice_item)
.inner_join(purchase_invoice)
.on(purchase_invoice_item.parent == purchase_invoice.name)
.select(
purchase_invoice_item.po_detail,
purchase_invoice_item.qty,
purchase_invoice_item.base_net_amount,
purchase_invoice.name,
purchase_invoice.is_return,
purchase_invoice.return_against,
)
.where(
(purchase_invoice_item.po_detail.isin(po_details))
& ((purchase_invoice_item.pr_detail.isnull()) | (purchase_invoice_item.pr_detail == ""))
& (purchase_invoice.docstatus == 1)
& (purchase_invoice.update_stock == 0)
)
.orderby(CombineDatetime(purchase_invoice.posting_date, purchase_invoice.posting_time))
.orderby(purchase_invoice.name)
.orderby(purchase_invoice_item.idx)
).run(as_dict=True)
po_invoices = {}
for row in rows:
invoice_name = row.return_against if row.is_return else row.name
invoices = po_invoices.setdefault(row.po_detail, {})
invoice = invoices.setdefault(invoice_name, frappe._dict(qty=0.0, amount=0.0))
invoice.qty += flt(row.qty)
invoice.amount += flt(row.base_net_amount)
return {po_detail: list(invoices.values()) for po_detail, invoices in po_invoices.items()}
def get_invoiced_qty_against_po_items(po_items: list) -> dict:
"""Invoiced qty per Purchase Order Item, leaving out returns that do not touch the order."""
purchase_invoice = frappe.qb.DocType("Purchase Invoice")
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(purchase_invoice_item)
.inner_join(purchase_invoice)
.on(purchase_invoice_item.parent == purchase_invoice.name)
.select(purchase_invoice_item.po_detail, fn.Sum(purchase_invoice_item.qty))
.where(
(purchase_invoice_item.po_detail.isin(po_items))
& (purchase_invoice.docstatus == 1)
& (
(purchase_invoice.is_return == 0)
| (purchase_invoice.update_billed_amount_in_purchase_order == 1)
)
)
.groupby(purchase_invoice_item.po_detail)
)
return frappe._dict(query.run())
def set_amount_difference_with_purchase_invoice(items: list, invoiced: dict) -> bool:
"""Store each row's gap to its invoiced value; returns True when any row moved."""
has_changed = False
for item in items:
adjusted_amt = 0.0
row = invoiced.get(item.name)
if row and row.qty:
adjusted_amt = flt(row.amount / row.qty) * flt(item.qty) - flt(item.base_net_amount)
adjusted_amt = flt(adjusted_amt, item.precision("amount"))
if adjusted_amt == flt(item.amount_difference_with_purchase_invoice, item.precision("amount")):
continue
item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False)
has_changed = True
return has_changed
def get_billed_qty_amount_against_purchase_receipt(pr_names: list) -> dict:
if not pr_names:
return frappe._dict()
parent_table = frappe.qb.DocType("Purchase Invoice")
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
@@ -305,7 +465,11 @@ def get_billed_qty_amount_against_purchase_receipt(pr_doc) -> dict:
fn.Sum(table.base_net_amount).as_("amount"),
fn.Sum(table.qty).as_("qty"),
)
.where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1))
.where(
(table.pr_detail.isin(pr_names))
& (table.docstatus == 1)
& ((parent_table.is_return == 0) | (parent_table.update_billed_amount_in_purchase_receipt == 1))
)
.groupby(table.pr_detail)
)
invoice_data = query.run(as_dict=1)
@@ -325,49 +489,6 @@ def get_billed_qty_amount_against_purchase_receipt(pr_doc) -> dict:
return billed_qty_amt
def get_billed_qty_amount_against_purchase_order(pr_doc) -> dict:
po_names = list(
set(
[
d.purchase_order_item
for d in pr_doc.items
if d.purchase_order_item and not d.purchase_invoice_item
]
)
)
invoice_data_po_based = frappe._dict()
if po_names:
parent_table = frappe.qb.DocType("Purchase Invoice")
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(parent_table)
.inner_join(table)
.on(parent_table.name == table.parent)
.select(
table.po_detail,
fn.Sum(table.qty).as_("qty"),
fn.Sum(table.base_net_amount).as_("amount"),
)
.where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull()))
.groupby(table.po_detail)
)
invoice_data = query.run(as_dict=1)
if not invoice_data:
return frappe._dict()
for row in invoice_data:
if row.po_detail not in invoice_data_po_based:
invoice_data_po_based[row.po_detail] = {"amount": 0, "qty": 0}
invoice_data_po_based[row.po_detail]["amount"] += flt(row.amount)
invoice_data_po_based[row.po_detail]["qty"] += flt(row.qty)
return invoice_data_po_based
def adjust_incoming_rate_for_pr(doc) -> None:
doc.update_valuation_rate(reset_outgoing_rate=False)
@@ -380,9 +501,7 @@ def adjust_incoming_rate_for_pr(doc) -> None:
doc.repost_future_sle_and_gle(force=True)
def get_item_wise_returned_qty(pr_doc) -> dict:
items = [d.name for d in pr_doc.items]
def get_item_wise_returned_qty(items: list) -> dict:
return frappe._dict(
frappe.get_all(
"Purchase Receipt",

View File

@@ -1117,6 +1117,169 @@ class TestPurchaseReceipt(ERPNextTestSuite):
po.reload()
po.cancel()
@ERPNextTestSuite.change_settings(
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
)
def test_per_billed_by_qty_when_landed_cost_follows_invoice_rate(self):
pr = make_purchase_receipt(qty=100, rate=50)
pi = make_purchase_invoice(pr.name)
pi.items[0].qty = 25
pi.items[0].rate = 200
pi.submit()
pr.reload()
self.assertEqual(pr.per_billed, 25)
self.assertEqual(pr.status, "Partly Billed")
@ERPNextTestSuite.change_settings(
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
)
def test_po_invoice_qty_spread_fifo_when_landed_cost_follows_invoice_rate(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
po = create_purchase_order(qty=100, rate=50)
receipts = make_receipts_against_order(po.name, ((60, "08:00"), (40, "10:00")))
make_invoice_against_order(po.name, qty=70, rate=40)
for pr in receipts:
pr.reload()
self.assertEqual(receipts[0].per_billed, 100)
self.assertEqual(receipts[1].per_billed, 25)
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"maintain_same_rate": 0,
"set_landed_cost_based_on_purchase_invoice_rate": 1,
"bill_for_rejected_quantity_in_purchase_invoice": 0,
},
)
def test_fully_returned_receipt_skipped_in_po_invoice_split(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
po = create_purchase_order(qty=100, rate=50)
receipts = make_receipts_against_order(po.name, ((60, "08:00"), (40, "10:00")))
make_purchase_return(receipts[0].name).submit()
make_invoice_against_order(po.name, qty=40, rate=50)
for pr in receipts:
pr.reload()
self.assertEqual(receipts[0].per_billed, 0)
self.assertEqual(receipts[1].per_billed, 100)
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"maintain_same_rate": 0,
"set_landed_cost_based_on_purchase_invoice_rate": 1,
"bill_for_rejected_quantity_in_purchase_invoice": 0,
},
)
def test_fully_returned_row_left_out_of_qty_billing(self):
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
pr = make_purchase_receipt(qty=10, rate=50, do_not_save=True)
pr.append("items", pr.items[0].as_dict(no_default_fields=True))
pr.submit()
returned_row, invoiced_row = pr.items
pr_return = make_purchase_return(pr.name)
pr_return.set(
"items", [row for row in pr_return.items if row.purchase_receipt_item == returned_row.name]
)
pr_return.submit()
pi = make_purchase_invoice(pr.name)
pi.set("items", [row for row in pi.items if row.pr_detail == invoiced_row.name])
pi.submit()
pr.reload()
self.assertEqual(pr.per_billed, 100)
@ERPNextTestSuite.change_settings(
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
)
def test_zero_rate_receipt_billed_by_qty(self):
pr = make_purchase_receipt(item_code="_Test Non Stock Item", qty=10, rate=0)
pi = make_purchase_invoice(pr.name)
pi.items[0].rate = 5
pi.submit()
pr.reload()
self.assertEqual(pr.per_billed, 100)
@ERPNextTestSuite.change_settings(
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
)
def test_non_updating_debit_note_kept_out_of_qty_billing(self):
from erpnext.accounts.doctype.purchase_invoice.mapper import make_debit_note
pr = make_purchase_receipt(qty=100, rate=50)
pi = make_purchase_invoice(pr.name)
pi.items[0].qty = 50
pi.submit()
debit_note = make_debit_note(pi.name)
debit_note.items[0].qty = -20
debit_note.update_billed_amount_in_purchase_receipt = 0
debit_note.submit()
pi = make_purchase_invoice(pr.name)
pi.items[0].qty = 50
pi.submit()
pr.reload()
self.assertEqual(pr.per_billed, 100)
@ERPNextTestSuite.change_settings(
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
)
def test_landed_cost_takes_order_invoices_oldest_first(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
def get_amount_differences():
return [
frappe.db.get_value(
"Purchase Receipt Item", pr.items[0].name, "amount_difference_with_purchase_invoice"
)
for pr in receipts
]
po = create_purchase_order(qty=100, rate=50)
receipts = make_receipts_against_order(po.name, ((60, "08:00"), (40, "10:00")))
make_invoice_against_order(po.name, qty=60, rate=70)
self.assertEqual(get_amount_differences(), [1200, 0])
make_invoice_against_order(po.name, qty=40, rate=55)
self.assertEqual(get_amount_differences(), [1200, 200])
@ERPNextTestSuite.change_settings(
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
)
def test_landed_cost_refreshed_on_receipt_whose_share_moved(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
po = create_purchase_order(qty=100, rate=50)
receipts = make_receipts_against_order(po.name, ((60, "08:00"), (40, "10:00")))
pi = make_purchase_invoice(receipts[0].name)
pi.items[0].qty = 1
pi.submit()
make_invoice_against_order(po.name, qty=69, rate=40)
second_row = receipts[1].items[0].name
self.assertEqual(frappe.db.get_value("Purchase Receipt Item", second_row, "billed_amt"), 0)
self.assertEqual(
frappe.db.get_value(
"Purchase Receipt Item", second_row, "amount_difference_with_purchase_invoice"
),
-400,
)
def test_serial_no_against_purchase_receipt(self):
item_code = "Test Manual Created Serial No"
if not frappe.db.exists("Item", item_code):
@@ -7771,6 +7934,31 @@ def get_items(**args):
]
def make_receipts_against_order(purchase_order: str, receipts: tuple) -> list:
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt as make_receipt_from_order
receipt_docs = []
for qty, posting_time in receipts:
pr = make_receipt_from_order(purchase_order)
pr.set_posting_time = 1
pr.posting_time = posting_time
pr.items[0].received_qty = qty
pr.items[0].qty = qty
pr.submit()
receipt_docs.append(pr)
return receipt_docs
def make_invoice_against_order(purchase_order: str, qty: float, rate: float) -> None:
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice as make_invoice_from_order
pi = make_invoice_from_order(purchase_order)
pi.items[0].qty = qty
pi.items[0].rate = rate
pi.submit()
def make_purchase_receipt(**args):
frappe.db.set_single_value("Buying Settings", "allow_multiple_items", 1)
pr = frappe.new_doc("Purchase Receipt")