mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-20 18:06:30 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user