mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-18 04:12:37 +00:00
fix: validate fg and materials qty in the disassemble entry
(cherry picked from commit 4453c1072a)
# Conflicts:
# erpnext/stock/doctype/stock_entry/stock_entry.py
# erpnext/stock/doctype/stock_entry/test_stock_entry.py
This commit is contained in:
committed by
Mergify
parent
2c4b89d1df
commit
ba19a24526
@@ -80,7 +80,17 @@ from erpnext.controllers.stock_controller import StockController
|
||||
form_grid_templates = {"items": "templates/form_grid/stock_entry_grid.html"}
|
||||
|
||||
|
||||
<<<<<<< HEAD
|
||||
class StockEntry(StockController):
|
||||
=======
|
||||
def _qty_tolerance(precision: int) -> float:
|
||||
"""One unit at the column's precision -- absorbs float rounding without letting a real
|
||||
(whole-unit) quantity divergence slip through."""
|
||||
return 1.0 / (10**precision)
|
||||
|
||||
|
||||
class StockEntry(StockController, SubcontractingInwardController):
|
||||
>>>>>>> 4453c1072a (fix: validate fg and materials qty in the disassemble entry)
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
@@ -228,6 +238,13 @@ class StockEntry(StockController):
|
||||
|
||||
self.validate_warehouse()
|
||||
self.validate_with_material_request()
|
||||
|
||||
# Disassembly rows are fully derived from the source manufacture entry / work order;
|
||||
# verify the posted stock quantities have not been tampered with (raw-material minting).
|
||||
# Must run after set_transfer_qty() so row.transfer_qty reflects qty * conversion_factor.
|
||||
if self.purpose == "Disassemble":
|
||||
self.validate_disassembly_quantities()
|
||||
|
||||
self.validate_batch()
|
||||
self.validate_inspection()
|
||||
self.validate_fg_completed_qty()
|
||||
@@ -931,6 +948,93 @@ class StockEntry(StockController):
|
||||
title=_("Excess Disassembly"),
|
||||
)
|
||||
|
||||
def validate_disassembly_quantities(self):
|
||||
self.validate_finished_good_consumption()
|
||||
self.validate_materials_against_source()
|
||||
|
||||
def validate_finished_good_consumption(self):
|
||||
"""The finished good consumed (in stock UOM) must equal the quantity to disassemble."""
|
||||
precision = frappe.get_precision("Stock Entry Detail", "transfer_qty")
|
||||
tolerance = _qty_tolerance(precision)
|
||||
|
||||
fg_stock_qty = sum(flt(row.transfer_qty) for row in self.items if row.is_finished_item)
|
||||
fg_completed_qty = flt(self.fg_completed_qty)
|
||||
|
||||
if abs(flt(fg_stock_qty, precision) - flt(fg_completed_qty, precision)) > tolerance:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Finished good quantity being consumed ({0} in stock UOM) must equal the quantity "
|
||||
"to disassemble ({1}). Do not change the UOM, conversion factor or quantity of the "
|
||||
"finished good row."
|
||||
).format(flt(fg_stock_qty, precision), flt(fg_completed_qty, precision)),
|
||||
title=_("Invalid Disassembly Quantity"),
|
||||
)
|
||||
|
||||
def validate_materials_against_source(self):
|
||||
"""Every non-finished-good row's posted stock qty must equal the source qty x scale."""
|
||||
scale_factor = self._get_disassembly_scale_factor()
|
||||
if not scale_factor:
|
||||
# Standalone BOM disassembly: no source entry to scale against. The finished-good
|
||||
# invariant above still applies; raw-material amounts come from the BOM.
|
||||
return
|
||||
|
||||
source_rows = self.get_items_from_manufacture_stock_entry()
|
||||
source_by_name = {row.name: row for row in source_rows if row.get("name")}
|
||||
source_by_item = defaultdict(float)
|
||||
for row in source_rows:
|
||||
source_by_item[row.item_code] += flt(row.transfer_qty)
|
||||
|
||||
precision = frappe.get_precision("Stock Entry Detail", "transfer_qty")
|
||||
tolerance = _qty_tolerance(precision)
|
||||
|
||||
for row in self.items:
|
||||
if row.is_finished_item:
|
||||
continue # covered by validate_finished_good_consumption
|
||||
|
||||
if row.ste_detail and row.ste_detail in source_by_name:
|
||||
expected = flt(source_by_name[row.ste_detail].transfer_qty) * scale_factor
|
||||
elif row.item_code in source_by_item:
|
||||
expected = source_by_item[row.item_code] * scale_factor
|
||||
else:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Item {1} is not part of the source manufacture entry and cannot be "
|
||||
"added to this disassembly."
|
||||
).format(row.idx, frappe.bold(row.item_code)),
|
||||
title=_("Invalid Disassembly Item"),
|
||||
)
|
||||
|
||||
if abs(flt(row.transfer_qty, precision) - flt(expected, precision)) > tolerance:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Item {1} quantity ({2} in stock UOM) does not match the quantity "
|
||||
"derived from the source ({3}). Do not change the UOM, conversion factor or "
|
||||
"quantity of disassembly rows."
|
||||
).format(
|
||||
row.idx,
|
||||
frappe.bold(row.item_code),
|
||||
flt(row.transfer_qty, precision),
|
||||
flt(expected, precision),
|
||||
),
|
||||
title=_("Invalid Disassembly Quantity"),
|
||||
)
|
||||
|
||||
def _get_disassembly_scale_factor(self) -> float:
|
||||
disassemble_qty = flt(self.fg_completed_qty)
|
||||
if self.source_stock_entry:
|
||||
source_fg_qty = flt(
|
||||
frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty")
|
||||
)
|
||||
elif self.work_order:
|
||||
source_fg_qty = flt(frappe.db.get_value("Work Order", self.work_order, "produced_qty"))
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
if not source_fg_qty:
|
||||
return 0.0
|
||||
|
||||
return disassemble_qty / source_fg_qty
|
||||
|
||||
def check_if_operations_completed(self):
|
||||
"""Check if Time Sheets are completed against before manufacturing to capture operating costs."""
|
||||
prod_order = frappe.get_doc("Work Order", self.work_order)
|
||||
|
||||
@@ -2244,6 +2244,91 @@ class TestStockEntry(FrappeTestCase):
|
||||
se.save()
|
||||
se.submit()
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
def test_disassemble_blocks_finished_good_qty_tampering(self):
|
||||
# A disassembly consuming N finished goods must consume exactly N (in stock UOM).
|
||||
# Switching the finished-good row to a larger UOM with a tiny conversion_factor previously
|
||||
# let a user consume ~0 finished goods while still producing the full raw materials --
|
||||
# minting inventory. The quantity invariant must reject this.
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
|
||||
fg_item = make_item("_Disassemble Mint FG", properties={"is_stock_item": 1}).name
|
||||
rm_item1 = make_item("_Disassemble Mint RM1", properties={"is_stock_item": 1}).name
|
||||
rm_item2 = make_item("_Disassemble Mint RM2", properties={"is_stock_item": 1}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
# Give the finished good a non-stock UOM. When uom == stock_uom the system resets the
|
||||
# conversion_factor to 1, so the tamper is only possible (and worth guarding) on a
|
||||
# non-stock UOM, where the user-supplied conversion_factor is preserved.
|
||||
if not frappe.db.get_value("UOM Conversion Detail", {"parent": fg_item, "uom": "Box"}):
|
||||
item_doc = frappe.get_doc("Item", fg_item)
|
||||
item_doc.append("uoms", {"uom": "Box", "conversion_factor": 0.01})
|
||||
item_doc.save(ignore_permissions=True)
|
||||
|
||||
make_stock_entry(item_code=fg_item, target=warehouse, qty=100, purpose="Material Receipt")
|
||||
bom_no = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]).name
|
||||
|
||||
se = make_stock_entry(item_code=fg_item, qty=100, purpose="Disassemble", do_not_save=True)
|
||||
se.from_bom = 1
|
||||
se.use_multi_level_bom = 1
|
||||
se.bom_no = bom_no
|
||||
se.fg_completed_qty = 100
|
||||
se.from_warehouse = warehouse
|
||||
se.to_warehouse = warehouse
|
||||
se.get_items()
|
||||
|
||||
# Tamper the finished-good row: a tiny conversion factor on the larger UOM means only
|
||||
# 100 * 0.01 = 1 unit is actually consumed, while raw materials are still produced at full
|
||||
# quantity. The finished-good consumption invariant must reject the save.
|
||||
fg_row = next(d for d in se.items if d.is_finished_item)
|
||||
fg_row.uom = "Box"
|
||||
fg_row.conversion_factor = 0.01
|
||||
|
||||
self.assertRaises(frappe.ValidationError, se.save)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"}
|
||||
)
|
||||
def test_sample_retention_stock_entry(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import move_sample_to_retention_warehouse
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
retain_sample_item = make_item(
|
||||
"Retain Sample Item",
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"retain_sample": 1,
|
||||
"sample_quantity": 2,
|
||||
"has_batch_no": 1,
|
||||
"has_serial_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "SAMPLE-RET-.#####",
|
||||
"serial_no_series": "SAMPLE-RET-SN-.#####",
|
||||
},
|
||||
)
|
||||
material_receipt = make_stock_entry(
|
||||
item_code=retain_sample_item.item_code, target=warehouse, qty=10, purpose="Material Receipt"
|
||||
)
|
||||
|
||||
source_sabb = frappe.get_doc(
|
||||
"Serial and Batch Bundle", material_receipt.items[0].serial_and_batch_bundle
|
||||
)
|
||||
batch = source_sabb.entries[0].batch_no
|
||||
serial_nos = [entry.serial_no for entry in source_sabb.entries]
|
||||
|
||||
sample_entry = frappe.get_doc(
|
||||
move_sample_to_retention_warehouse(material_receipt.company, material_receipt.items)
|
||||
)
|
||||
sample_entry.submit()
|
||||
target_sabb = frappe.get_doc("Serial and Batch Bundle", sample_entry.items[0].serial_and_batch_bundle)
|
||||
|
||||
self.assertEqual(sample_entry.items[0].transfer_qty, 2)
|
||||
self.assertEqual(target_sabb.entries[0].batch_no, batch)
|
||||
self.assertEqual([entry.serial_no for entry in target_sabb.entries], serial_nos[:2])
|
||||
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 0})
|
||||
>>>>>>> 4453c1072a (fix: validate fg and materials qty in the disassemble entry)
|
||||
def test_raw_material_missing_validation(self):
|
||||
original_value = frappe.db.get_single_value("Manufacturing Settings", "material_consumption")
|
||||
frappe.db.set_single_value("Manufacturing Settings", "material_consumption", 0)
|
||||
|
||||
Reference in New Issue
Block a user