mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-04 04:39:11 +00:00
fix(buying): honour over delivery/receipt allowance in PR mapper (#55247)
This commit is contained in:
@@ -351,9 +351,10 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
if (doc.status != "Closed") {
|
||||
if (doc.status != "On Hold") {
|
||||
if (
|
||||
doc.items
|
||||
(doc.items
|
||||
.filter((item) => !item.delivered_by_supplier)
|
||||
.some((item) => item.received_qty < item.qty) &&
|
||||
.some((item) => item.received_qty < item.qty) ||
|
||||
doc.__onload?.has_pending_receivable_qty) &&
|
||||
allow_receipt
|
||||
) {
|
||||
this.frm.add_custom_button(
|
||||
|
||||
@@ -19,6 +19,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
from erpnext.accounts.party import get_party_account, get_party_account_currency
|
||||
from erpnext.buying.utils import validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.status_updater import get_allowance_for
|
||||
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
|
||||
validate_against_blanket_order,
|
||||
)
|
||||
@@ -185,6 +186,7 @@ class PurchaseOrder(BuyingController):
|
||||
|
||||
def onload(self):
|
||||
self.set_onload("can_update_items", self.can_update_items())
|
||||
self.set_onload("has_pending_receivable_qty", self.has_pending_receivable_qty())
|
||||
|
||||
def before_validate(self):
|
||||
self.set_has_unit_price_items()
|
||||
@@ -646,6 +648,19 @@ class PurchaseOrder(BuyingController):
|
||||
|
||||
return result
|
||||
|
||||
def has_pending_receivable_qty(self) -> bool:
|
||||
"""Return True if any non-drop-ship item can still be received,
|
||||
considering the configured over_delivery_receipt_allowance.
|
||||
"""
|
||||
for item in self.get("items", []):
|
||||
if item.delivered_by_supplier:
|
||||
continue
|
||||
tolerance = flt(get_allowance_for(item.item_code, qty_or_amount="qty")[0])
|
||||
max_receivable_qty = flt(item.qty) * (100 + tolerance) / 100
|
||||
if abs(flt(item.received_qty)) < abs(max_receivable_qty):
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_ordered_qty_in_so_for_removed_items(self, removed_items):
|
||||
"""
|
||||
Updates ordered_qty in linked SO when item rows are removed using Update Items
|
||||
@@ -747,13 +762,25 @@ def make_purchase_receipt(
|
||||
def is_unit_price_row(source):
|
||||
return has_unit_price_items and source.qty == 0
|
||||
|
||||
def get_max_receivable_qty(source):
|
||||
tolerance = flt(get_allowance_for(source.item_code, qty_or_amount="qty")[0])
|
||||
return flt(source.qty) * (100 + tolerance) / 100
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
|
||||
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
|
||||
target.base_amount = (
|
||||
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
)
|
||||
received_qty = flt(obj.received_qty)
|
||||
qty = flt(obj.qty)
|
||||
pending_qty = qty - received_qty
|
||||
|
||||
if is_unit_price_row(obj):
|
||||
target.qty = qty
|
||||
elif pending_qty > 0:
|
||||
target.qty = pending_qty
|
||||
else:
|
||||
target.qty = max(get_max_receivable_qty(obj) - received_qty, 0)
|
||||
|
||||
target.stock_qty = target.qty * flt(obj.conversion_factor)
|
||||
target.amount = target.qty * flt(obj.rate)
|
||||
target.base_amount = target.qty * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -785,7 +812,9 @@ def make_purchase_receipt(
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: (
|
||||
True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty)
|
||||
True
|
||||
if is_unit_price_row(doc)
|
||||
else abs(doc.received_qty) < abs(get_max_receivable_qty(doc))
|
||||
)
|
||||
and doc.delivered_by_supplier != 1
|
||||
and select_item(doc),
|
||||
|
||||
@@ -98,6 +98,60 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
po.load_from_db()
|
||||
self.assertEqual(po.get("items")[0].received_qty, 4)
|
||||
|
||||
def test_make_purchase_receipt_respects_over_receipt_allowance(self):
|
||||
"""make_purchase_receipt must include fully-received PO lines when
|
||||
over_delivery_receipt_allowance permits further receipt.
|
||||
|
||||
Regression test for #55246: the mapper dropped rows once
|
||||
received_qty >= qty, ignoring the configured tolerance.
|
||||
"""
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
|
||||
|
||||
# 50% tolerance — 10 ordered allows up to 15 received
|
||||
frappe.db.set_value("Item", "_Test Item", "over_delivery_receipt_allowance", 50)
|
||||
try:
|
||||
po = create_purchase_order()
|
||||
create_pr_against_po(po.name, received_qty=10)
|
||||
|
||||
po.load_from_db()
|
||||
self.assertEqual(po.get("items")[0].received_qty, 10)
|
||||
|
||||
# onload must flag pending receivable qty so the UI keeps the
|
||||
# "Create > Purchase Receipt" button visible even at per_received = 100
|
||||
po.run_method("onload")
|
||||
self.assertTrue(
|
||||
po.get_onload("has_pending_receivable_qty"),
|
||||
"onload should flag pending receivable qty while tolerance is available",
|
||||
)
|
||||
|
||||
# Re-mapping the same PO must yield a PR with the row present
|
||||
# and qty pre-filled to the remaining tolerance (15 - 10 = 5)
|
||||
pr = make_purchase_receipt(po.name)
|
||||
self.assertEqual(
|
||||
len(pr.get("items")), 1, "Fully-received row dropped despite available tolerance"
|
||||
)
|
||||
self.assertEqual(pr.get("items")[0].item_code, "_Test Item")
|
||||
self.assertEqual(pr.get("items")[0].qty, 5)
|
||||
self.assertEqual(pr.get("items")[0].purchase_order_item, po.get("items")[0].name)
|
||||
|
||||
# Tolerance exhausted → row must be filtered out as before
|
||||
create_pr_against_po(po.name, received_qty=5)
|
||||
po.load_from_db()
|
||||
self.assertEqual(po.get("items")[0].received_qty, 15)
|
||||
|
||||
po.run_method("onload")
|
||||
self.assertFalse(
|
||||
po.get_onload("has_pending_receivable_qty"),
|
||||
"onload should clear pending receivable flag once tolerance is exhausted",
|
||||
)
|
||||
|
||||
pr_empty = make_purchase_receipt(po.name)
|
||||
self.assertEqual(
|
||||
len(pr_empty.get("items")), 0, "Row should be dropped once tolerance is exhausted"
|
||||
)
|
||||
finally:
|
||||
frappe.db.set_value("Item", "_Test Item", "over_delivery_receipt_allowance", 0)
|
||||
|
||||
def test_ordered_qty_against_pi_with_update_stock(self):
|
||||
existing_ordered_qty = get_ordered_qty()
|
||||
po = create_purchase_order()
|
||||
|
||||
Reference in New Issue
Block a user