diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index b52945d8928..cc0813ffd9e 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -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) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index a82a1266651..19b435f8481 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -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)