mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-04 12:49:10 +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 != "Closed") {
|
||||||
if (doc.status != "On Hold") {
|
if (doc.status != "On Hold") {
|
||||||
if (
|
if (
|
||||||
doc.items
|
(doc.items
|
||||||
.filter((item) => !item.delivered_by_supplier)
|
.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
|
allow_receipt
|
||||||
) {
|
) {
|
||||||
this.frm.add_custom_button(
|
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.accounts.party import get_party_account, get_party_account_currency
|
||||||
from erpnext.buying.utils import validate_for_items
|
from erpnext.buying.utils import validate_for_items
|
||||||
from erpnext.controllers.buying_controller import BuyingController
|
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 (
|
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
|
||||||
validate_against_blanket_order,
|
validate_against_blanket_order,
|
||||||
)
|
)
|
||||||
@@ -185,6 +186,7 @@ class PurchaseOrder(BuyingController):
|
|||||||
|
|
||||||
def onload(self):
|
def onload(self):
|
||||||
self.set_onload("can_update_items", self.can_update_items())
|
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):
|
def before_validate(self):
|
||||||
self.set_has_unit_price_items()
|
self.set_has_unit_price_items()
|
||||||
@@ -646,6 +648,19 @@ class PurchaseOrder(BuyingController):
|
|||||||
|
|
||||||
return result
|
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):
|
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
|
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):
|
def is_unit_price_row(source):
|
||||||
return has_unit_price_items and source.qty == 0
|
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):
|
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)
|
received_qty = flt(obj.received_qty)
|
||||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
|
qty = flt(obj.qty)
|
||||||
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
|
pending_qty = qty - received_qty
|
||||||
target.base_amount = (
|
|
||||||
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
|
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):
|
def select_item(d):
|
||||||
filtered_items = args.get("filtered_children", [])
|
filtered_items = args.get("filtered_children", [])
|
||||||
@@ -785,7 +812,9 @@ def make_purchase_receipt(
|
|||||||
},
|
},
|
||||||
"postprocess": update_item,
|
"postprocess": update_item,
|
||||||
"condition": lambda doc: (
|
"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 doc.delivered_by_supplier != 1
|
||||||
and select_item(doc),
|
and select_item(doc),
|
||||||
|
|||||||
@@ -98,6 +98,60 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
|||||||
po.load_from_db()
|
po.load_from_db()
|
||||||
self.assertEqual(po.get("items")[0].received_qty, 4)
|
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):
|
def test_ordered_qty_against_pi_with_update_stock(self):
|
||||||
existing_ordered_qty = get_ordered_qty()
|
existing_ordered_qty = get_ordered_qty()
|
||||||
po = create_purchase_order()
|
po = create_purchase_order()
|
||||||
|
|||||||
Reference in New Issue
Block a user