mirror of
https://github.com/frappe/erpnext.git
synced 2026-10-31 23:07:51 +00:00
fix: exclude returned and non-stock qty from installation limits
This commit is contained in:
@@ -239,6 +239,11 @@ class StatusUpdater(Document):
|
||||
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
|
||||
continue
|
||||
|
||||
is_installation = args.get("overflow_type") == "installation"
|
||||
# a later return can lower the installation limit below an already-installed qty;
|
||||
# cancelling a note only reduces it, so don't re-enforce the limit on cancel
|
||||
skip_on_cancel = is_installation and self.docstatus == 2
|
||||
|
||||
# get unique transactions to update
|
||||
for d in self.get_all_children():
|
||||
if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"):
|
||||
@@ -259,15 +264,22 @@ class StatusUpdater(Document):
|
||||
),
|
||||
)
|
||||
|
||||
if d.doctype == args["source_dt"] and d.get(args["join_field"]):
|
||||
if d.doctype == args["source_dt"] and d.get(args["join_field"]) and not skip_on_cancel:
|
||||
args["name"] = d.get(args["join_field"])
|
||||
|
||||
# installation consumes delivered-minus-returned qty (returned_qty is in stock UOM)
|
||||
ref_expr = (
|
||||
"(`{}` - `returned_qty` / `conversion_factor`)".format(args["target_ref_field"])
|
||||
if is_installation
|
||||
else "`{}`".format(args["target_ref_field"])
|
||||
)
|
||||
|
||||
# get all qty where qty > target_field
|
||||
item = frappe.db.sql(
|
||||
"""select item_code, `{target_ref_field}`,
|
||||
"""select item_code, {ref_expr} as `{target_ref_field}`,
|
||||
`{target_field}`, parenttype, parent from `tab{target_dt}`
|
||||
where `{target_ref_field}` < `{target_field}`
|
||||
and name=%s and docstatus=1""".format(**args),
|
||||
where {ref_expr} < `{target_field}`
|
||||
and name=%s and docstatus=1""".format(ref_expr=ref_expr, **args),
|
||||
args["name"],
|
||||
as_dict=1,
|
||||
)
|
||||
@@ -277,8 +289,10 @@ class StatusUpdater(Document):
|
||||
item["target_ref_field"] = args["target_ref_field"].replace("_", " ")
|
||||
|
||||
# skip qty over-allowance check for non-stock items
|
||||
if "qty" in args.get("target_ref_field", "") and not frappe.get_cached_value(
|
||||
"Item", item["item_code"], "is_stock_item"
|
||||
if (
|
||||
"qty" in args.get("target_ref_field", "")
|
||||
and not is_installation
|
||||
and not frappe.get_cached_value("Item", item["item_code"], "is_stock_item")
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -292,6 +306,11 @@ class StatusUpdater(Document):
|
||||
elif item[args["target_ref_field"]]:
|
||||
self.check_overflow_with_allowance(item, args)
|
||||
|
||||
elif is_installation and flt(item[args["target_field"]]) > 0:
|
||||
# nothing left to install (e.g. fully returned) but a positive qty was entered
|
||||
item["reduce_by"] = flt(item[args["target_field"]])
|
||||
self.limits_crossed_error(args, item, "qty")
|
||||
|
||||
def check_overflow_with_allowance(self, item, args):
|
||||
"""
|
||||
Checks if there is overflow condering a relaxation allowance
|
||||
|
||||
@@ -5,7 +5,8 @@ import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import nowdate
|
||||
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_installation_note
|
||||
from erpnext.controllers.status_updater import OverAllowanceError
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_installation_note, make_sales_return
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -41,3 +42,72 @@ class TestInstallationNote(FrappeTestCase):
|
||||
note.items[0].prevdoc_detail_docname = foreign.items[0].name
|
||||
|
||||
self.assertRaises(frappe.ValidationError, note.insert)
|
||||
|
||||
def test_returned_qty_is_not_installable(self):
|
||||
dn = create_delivery_note(qty=5)
|
||||
|
||||
ret = make_sales_return(dn.name)
|
||||
ret.items[0].qty = -2
|
||||
ret.items[0].stock_qty = -2
|
||||
ret.submit()
|
||||
# nudge the is_return status_updater so the DN's returned_qty is set (auto on submit in prod)
|
||||
frappe.get_doc("Delivery Note", ret.name).update_prevdoc_status()
|
||||
|
||||
# mapper should offer only delivered-minus-returned
|
||||
note = make_installation_note(dn.name)
|
||||
self.assertEqual(note.items[0].qty, 3)
|
||||
|
||||
# installing more than delivered-minus-returned is blocked
|
||||
note.inst_date = nowdate()
|
||||
note.items[0].qty = 5
|
||||
note.insert()
|
||||
self.assertRaises(OverAllowanceError, note.submit)
|
||||
|
||||
def test_full_return_blocks_installation(self):
|
||||
dn = create_delivery_note(qty=5)
|
||||
note = make_installation_note(dn.name)
|
||||
note.inst_date = nowdate()
|
||||
note.insert()
|
||||
|
||||
ret = make_sales_return(dn.name)
|
||||
ret.items[0].qty = -5
|
||||
ret.items[0].stock_qty = -5
|
||||
ret.submit()
|
||||
frappe.get_doc("Delivery Note", ret.name).update_prevdoc_status()
|
||||
|
||||
self.assertRaises(OverAllowanceError, note.submit)
|
||||
|
||||
def test_return_does_not_block_note_cancellation(self):
|
||||
dn = create_delivery_note(qty=5)
|
||||
|
||||
note_a = make_installation_note(dn.name)
|
||||
note_a.inst_date = nowdate()
|
||||
note_a.items[0].qty = 2
|
||||
note_a.insert()
|
||||
note_a.submit()
|
||||
|
||||
note_b = make_installation_note(dn.name)
|
||||
note_b.inst_date = nowdate()
|
||||
note_b.items[0].qty = 3
|
||||
note_b.insert()
|
||||
note_b.submit()
|
||||
|
||||
ret = make_sales_return(dn.name)
|
||||
ret.items[0].qty = -3
|
||||
ret.items[0].stock_qty = -3
|
||||
ret.submit()
|
||||
frappe.get_doc("Delivery Note", ret.name).update_prevdoc_status()
|
||||
|
||||
# cancelling reduces installed qty, so it must not be blocked by the lowered limit
|
||||
note_a.cancel()
|
||||
|
||||
def test_over_installation_blocked_for_non_stock_item(self):
|
||||
item = make_item(properties={"is_stock_item": 0}).name
|
||||
dn = create_delivery_note(item=item, qty=2)
|
||||
|
||||
note = make_installation_note(dn.name)
|
||||
note.inst_date = nowdate()
|
||||
note.items[0].qty = 10
|
||||
note.insert()
|
||||
|
||||
self.assertRaises(OverAllowanceError, note.submit)
|
||||
|
||||
@@ -1119,10 +1119,23 @@ def make_delivery_trip(source_name, target_doc=None, kwargs=None):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_installation_note(source_name, target_doc=None, kwargs=None):
|
||||
def make_installation_note(
|
||||
source_name: str, target_doc: str | dict | Document | None = None, kwargs: dict | None = None
|
||||
):
|
||||
from erpnext.controllers.sales_and_purchase_return import get_returned_serial_nos
|
||||
from erpnext.stock.utils import get_valid_serial_nos
|
||||
|
||||
# returned_qty_map is keyed by DN Item row and already in the row's sales UOM
|
||||
returned_qty_map = get_returned_qty_map(source_name)
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
target.qty = flt(obj.qty) - flt(obj.installed_qty)
|
||||
target.serial_no = obj.serial_no
|
||||
target.qty = flt(obj.qty) - flt(returned_qty_map.get(obj.name, 0)) - flt(obj.installed_qty)
|
||||
|
||||
if obj.serial_no:
|
||||
returned = set(get_returned_serial_nos(obj, source_parent))
|
||||
target.serial_no = "\n".join(s for s in get_valid_serial_nos(obj.serial_no) if s not in returned)
|
||||
else:
|
||||
target.serial_no = obj.serial_no
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Delivery Note",
|
||||
@@ -1137,7 +1150,8 @@ def make_installation_note(source_name, target_doc=None, kwargs=None):
|
||||
"parenttype": "prevdoc_doctype",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.installed_qty < doc.qty,
|
||||
"condition": lambda doc: doc.installed_qty
|
||||
< flt(doc.qty) - flt(returned_qty_map.get(doc.name, 0)),
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
|
||||
Reference in New Issue
Block a user