From d01c4b68feba8c71dddfe33640e0a464f5344649 Mon Sep 17 00:00:00 2001 From: Kavin <78342682+kavin-114@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:47:14 +0530 Subject: [PATCH] fix: add validation for FG Items as per BOM qty (#50579) Co-authored-by: Kavin <78342682+kavin0411@users.noreply.github.com> Co-authored-by: Mihir Kandoi --- .../buying_settings/buying_settings.json | 11 +- .../buying_settings/buying_settings.py | 1 + .../controllers/subcontracting_controller.py | 6 +- .../subcontracting_receipt.py | 65 ++++++++++- .../test_subcontracting_receipt.py | 108 +++++++++++++++++- 5 files changed, 181 insertions(+), 10 deletions(-) diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 9a73760d269..1ce2dc24d1b 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -36,6 +36,7 @@ "backflush_raw_materials_of_subcontract_based_on", "column_break_11", "over_transfer_allowance", + "validate_consumed_qty", "section_break_xcug", "auto_create_subcontracting_order", "column_break_izrr", @@ -270,6 +271,14 @@ "label": "Fixed Outgoing Email Account", "link_filters": "[[\"Email Account\",\"enable_outgoing\",\"=\",1]]", "options": "Email Account" + }, + { + "default": "0", + "depends_on": "eval:doc.backflush_raw_materials_of_subcontract_based_on == \"Material Transferred for Subcontract\"", + "description": "Raw materials consumed qty will be validated based on FG BOM required qty", + "fieldname": "validate_consumed_qty", + "fieldtype": "Check", + "label": "Validate Consumed Qty (as per BOM)" } ], "grid_page_length": 50, @@ -278,7 +287,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-08-20 22:13:38.506889", + "modified": "2025-11-20 12:59:09.925862", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.py b/erpnext/buying/doctype/buying_settings/buying_settings.py index 8b83418f6f8..3634f8a9069 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.py +++ b/erpnext/buying/doctype/buying_settings/buying_settings.py @@ -44,6 +44,7 @@ class BuyingSettings(Document): supp_master_name: DF.Literal["Supplier Name", "Naming Series", "Auto Name"] supplier_group: DF.Link | None use_transaction_date_exchange_rate: DF.Check + validate_consumed_qty: DF.Check # end: auto-generated types def validate(self): diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index ff7342607c3..8eb494947c0 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -549,7 +549,7 @@ class SubcontractingController(StockController): if item.get("serial_and_batch_bundle"): frappe.delete_doc("Serial and Batch Bundle", item.serial_and_batch_bundle, force=True) - def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0): + def _get_materials_from_bom(self, item_code, bom_no, exploded_item=0): data = [] doctype = "BOM Item" if not exploded_item else "BOM Explosion Item" @@ -590,7 +590,7 @@ class SubcontractingController(StockController): to_remove = [] for item in data: if item.is_phantom_item: - data += self.__get_materials_from_bom( + data += self._get_materials_from_bom( item.rm_item_code, item.bom_no, exploded_item=exploded_item ) to_remove.append(item) @@ -921,7 +921,7 @@ class SubcontractingController(StockController): if self.doctype == self.subcontract_data.order_doctype or ( self.backflush_based_on == "BOM" or self.is_return ): - for bom_item in self.__get_materials_from_bom( + for bom_item in self._get_materials_from_bom( row.item_code, row.bom, row.get("include_exploded_items") ): qty = flt(bom_item.qty_consumed_per_unit) * flt(row.qty) * row.conversion_factor diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index a622b1fb9c4..7cb7531a7af 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -1,6 +1,8 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +from collections import defaultdict + import frappe from frappe import _ from frappe.model.mapper import get_mapped_doc @@ -18,6 +20,10 @@ from erpnext.stock.get_item_details import get_default_cost_center, get_default_ from erpnext.stock.stock_ledger import get_valuation_rate +class BOMQuantityError(frappe.ValidationError): + pass + + class SubcontractingReceipt(SubcontractingController): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -157,6 +163,7 @@ class SubcontractingReceipt(SubcontractingController): def on_submit(self): self.validate_closed_subcontracting_order() self.validate_available_qty_for_consumption() + self.validate_bom_required_qty() self.update_status_updater_args() self.update_prevdoc_status() self.set_subcontracting_order_status(update_bin=False) @@ -540,12 +547,60 @@ class SubcontractingReceipt(SubcontractingController): item.available_qty_for_consumption and flt(item.available_qty_for_consumption, precision) - flt(item.consumed_qty, precision) < 0 ): - msg = f"""Row {item.idx}: Consumed Qty {flt(item.consumed_qty, precision)} - must be less than or equal to Available Qty For Consumption - {flt(item.available_qty_for_consumption, precision)} - in Consumed Items Table.""" + msg = _( + """Row {0}: Consumed Qty {1} {2} must be less than or equal to Available Qty For Consumption + {3} {4} in Consumed Items Table.""" + ).format( + item.idx, + flt(item.consumed_qty, precision), + item.stock_uom, + flt(item.available_qty_for_consumption, precision), + item.stock_uom, + ) - frappe.throw(_(msg)) + frappe.throw(msg) + + def validate_bom_required_qty(self): + if ( + frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on") + == "Material Transferred for Subcontract" + ) and not (frappe.db.get_single_value("Buying Settings", "validate_consumed_qty")): + return + + rm_consumed_dict = self.get_rm_wise_consumed_qty() + + for row in self.items: + precision = row.precision("qty") + for bom_item in self._get_materials_from_bom( + row.item_code, row.bom, row.get("include_exploded_items") + ): + required_qty = flt( + bom_item.qty_consumed_per_unit * row.qty * row.conversion_factor, precision + ) + consumed_qty = rm_consumed_dict.get(bom_item.rm_item_code, 0) + diff = flt(consumed_qty, precision) - flt(required_qty, precision) + + if diff < 0: + msg = _( + """Additional {0} {1} of item {2} required as per BOM to complete this transaction""" + ).format( + frappe.bold(abs(diff)), + frappe.bold(bom_item.stock_uom), + frappe.bold(bom_item.rm_item_code), + ) + + frappe.throw( + msg, + exc=BOMQuantityError, + ) + + def get_rm_wise_consumed_qty(self): + rm_dict = defaultdict(float) + + for row in self.supplied_items: + rm_dict[row.rm_item_code] += row.consumed_qty + + return rm_dict def update_status_updater_args(self): if cint(self.is_return): diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index ac9d956fa08..75dc06e16fc 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -38,6 +38,9 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( make_subcontracting_receipt, ) +from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import ( + BOMQuantityError, +) class TestSubcontractingReceipt(IntegrationTestCase): @@ -174,7 +177,7 @@ class TestSubcontractingReceipt(IntegrationTestCase): def test_subcontracting_over_receipt(self): """ Behaviour: Raise multiple SCRs against one SCO that in total - receive more than the required qty in the SCO. + receive more than the required qty in the SCO. Expected Result: Error Raised for Over Receipt against SCO. """ from erpnext.controllers.subcontracting_controller import ( @@ -1784,6 +1787,109 @@ class TestSubcontractingReceipt(IntegrationTestCase): self.assertEqual(scr.items[0].rm_cost_per_qty, 300) self.assertEqual(scr.items[0].service_cost_per_qty, 100) + def test_bom_required_qty_validation_based_on_bom(self): + set_backflush_based_on("BOM") + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1) + + fg_item = make_item(properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name + rm_item1 = make_item( + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BRQV-.####", + } + ).name + + make_bom(item=fg_item, raw_materials=[rm_item1], rm_qty=2) + se = make_stock_entry( + item_code=rm_item1, + qty=1, + target="_Test Warehouse 1 - _TC", + rate=300, + ) + + batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 1, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 1, + }, + ] + + sco = get_subcontracting_order(service_items=service_items) + scr = make_subcontracting_receipt(sco.name) + scr.save() + scr.reload() + + self.assertEqual(scr.supplied_items[0].batch_no, batch_no) + self.assertEqual(scr.supplied_items[0].consumed_qty, 1) + self.assertEqual(scr.supplied_items[0].required_qty, 2) + + self.assertRaises(BOMQuantityError, scr.submit) + + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0) + + def test_bom_required_qty_validation_based_on_transfer(self): + from erpnext.controllers.subcontracting_controller import ( + make_rm_stock_entry as make_subcontract_transfer_entry, + ) + + set_backflush_based_on("Material Transferred for Subcontract") + frappe.db.set_single_value("Buying Settings", "validate_consumed_qty", 1) + + item_code = "_Test Subcontracted Validation FG Item 1" + rm_item1 = make_item( + properties={ + "is_stock_item": 1, + } + ).name + + make_subcontracted_item(item_code=item_code, raw_materials=[rm_item1]) + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 10, + "rate": 100, + "fg_item": item_code, + "fg_item_qty": 10, + }, + ] + sco = get_subcontracting_order( + service_items=service_items, + include_exploded_items=0, + ) + + # inward raw material stock + make_stock_entry(target="_Test Warehouse - _TC", item_code=rm_item1, qty=10, basic_rate=100) + + rm_items = [ + { + "item_code": item_code, + "rm_item_code": sco.supplied_items[0].rm_item_code, + "qty": sco.supplied_items[0].required_qty - 5, + "warehouse": "_Test Warehouse - _TC", + "stock_uom": "Nos", + }, + ] + + # transfer partial raw materials + ste = frappe.get_doc(make_subcontract_transfer_entry(sco.name, rm_items)) + ste.to_warehouse = "_Test Warehouse 1 - _TC" + ste.save() + ste.submit() + + scr = make_subcontracting_receipt(sco.name) + scr.save() + + self.assertRaises(BOMQuantityError, scr.submit) + def make_return_subcontracting_receipt(**args): args = frappe._dict(args)