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 <kandoimihir@gmail.com>
This commit is contained in:
Kavin
2025-11-24 11:47:14 +05:30
committed by GitHub
parent aeece36d93
commit d01c4b68fe
5 changed files with 181 additions and 10 deletions

View File

@@ -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",

View File

@@ -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):

View File

@@ -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

View File

@@ -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):

View File

@@ -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)