fix: validate component quantity according to BOM (#43011)
This commit is contained in:
@@ -5,18 +5,17 @@
|
||||
"document_type": "Document",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"bom_and_work_order_tab",
|
||||
"raw_materials_consumption_section",
|
||||
"material_consumption",
|
||||
"get_rm_cost_from_consumption_entry",
|
||||
"column_break_3",
|
||||
"backflush_raw_materials_based_on",
|
||||
"capacity_planning",
|
||||
"disable_capacity_planning",
|
||||
"allow_overtime",
|
||||
"allow_production_on_holidays",
|
||||
"column_break_5",
|
||||
"capacity_planning_for_days",
|
||||
"mins_between_operations",
|
||||
"validate_components_quantities_per_bom",
|
||||
"bom_section",
|
||||
"update_bom_costs_automatically",
|
||||
"column_break_lhyt",
|
||||
"manufacture_sub_assembly_in_operation",
|
||||
"section_break_6",
|
||||
"default_wip_warehouse",
|
||||
"default_fg_warehouse",
|
||||
@@ -30,8 +29,14 @@
|
||||
"add_corrective_operation_cost_in_finished_good_valuation",
|
||||
"column_break_24",
|
||||
"job_card_excess_transfer",
|
||||
"capacity_planning",
|
||||
"disable_capacity_planning",
|
||||
"allow_overtime",
|
||||
"allow_production_on_holidays",
|
||||
"column_break_5",
|
||||
"capacity_planning_for_days",
|
||||
"mins_between_operations",
|
||||
"other_settings_section",
|
||||
"update_bom_costs_automatically",
|
||||
"set_op_cost_and_scrape_from_sub_assemblies",
|
||||
"column_break_23",
|
||||
"make_serial_no_batch_from_work_order"
|
||||
@@ -149,7 +154,7 @@
|
||||
{
|
||||
"fieldname": "raw_materials_consumption_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Raw Materials Consumption"
|
||||
"label": "Raw Materials Consumption "
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_16",
|
||||
@@ -183,8 +188,8 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "job_card_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Job Card"
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Job Card and Capacity Planning"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_24",
|
||||
@@ -210,13 +215,41 @@
|
||||
"fieldname": "get_rm_cost_from_consumption_entry",
|
||||
"fieldtype": "Check",
|
||||
"label": "Get Raw Materials Cost from Consumption Entry"
|
||||
},
|
||||
{
|
||||
"fieldname": "bom_and_work_order_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "BOM and Production"
|
||||
},
|
||||
{
|
||||
"fieldname": "bom_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "BOM"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lhyt",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled then system will manufacture Sub-assembly against the Job Card (operation).",
|
||||
"fieldname": "manufacture_sub_assembly_in_operation",
|
||||
"fieldtype": "Check",
|
||||
"label": "Manufacture Sub-assembly in Operation"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.backflush_raw_materials_based_on == \"BOM\"",
|
||||
"fieldname": "validate_components_quantities_per_bom",
|
||||
"fieldtype": "Check",
|
||||
"label": "Validate Components Quantities Per BOM"
|
||||
}
|
||||
],
|
||||
"icon": "icon-wrench",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:04.700433",
|
||||
"modified": "2024-09-02 12:12:03.132567",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Manufacturing Settings",
|
||||
|
||||
@@ -29,15 +29,22 @@ class ManufacturingSettings(Document):
|
||||
get_rm_cost_from_consumption_entry: DF.Check
|
||||
job_card_excess_transfer: DF.Check
|
||||
make_serial_no_batch_from_work_order: DF.Check
|
||||
manufacture_sub_assembly_in_operation: DF.Check
|
||||
material_consumption: DF.Check
|
||||
mins_between_operations: DF.Int
|
||||
overproduction_percentage_for_sales_order: DF.Percent
|
||||
overproduction_percentage_for_work_order: DF.Percent
|
||||
set_op_cost_and_scrape_from_sub_assemblies: DF.Check
|
||||
update_bom_costs_automatically: DF.Check
|
||||
validate_components_quantities_per_bom: DF.Check
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
def before_save(self):
|
||||
self.reset_values()
|
||||
|
||||
def reset_values(self):
|
||||
if self.backflush_raw_materials_based_on != "BOM" and self.validate_components_quantities_per_bom:
|
||||
self.validate_components_quantities_per_bom = 0
|
||||
|
||||
|
||||
def get_mins_between_operations():
|
||||
|
||||
@@ -2103,6 +2103,59 @@ class TestWorkOrder(FrappeTestCase):
|
||||
|
||||
stock_entry.submit()
|
||||
|
||||
def test_components_qty_for_bom_based_manufacture_entry(self):
|
||||
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
|
||||
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)
|
||||
|
||||
fg_item = "Test FG Item For Component Validation"
|
||||
source_warehouse = "Stores - _TC"
|
||||
raw_materials = ["Test Component Validation RM Item 1", "Test Component Validation RM Item 2"]
|
||||
|
||||
make_item(fg_item, {"is_stock_item": 1})
|
||||
for item in raw_materials:
|
||||
make_item(item, {"is_stock_item": 1})
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code=item,
|
||||
target=source_warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=raw_materials)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=fg_item,
|
||||
qty=10,
|
||||
source_warehouse=source_warehouse,
|
||||
)
|
||||
|
||||
transfer_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
|
||||
transfer_entry.save()
|
||||
for row in transfer_entry.items:
|
||||
row.qty = 5
|
||||
|
||||
self.assertRaises(frappe.ValidationError, transfer_entry.save)
|
||||
|
||||
transfer_entry.reload()
|
||||
for row in transfer_entry.items:
|
||||
self.assertEqual(row.qty, 10)
|
||||
|
||||
transfer_entry.submit()
|
||||
|
||||
manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
|
||||
manufacture_entry.save()
|
||||
for row in manufacture_entry.items:
|
||||
if not row.s_warehouse:
|
||||
continue
|
||||
|
||||
row.qty = 5
|
||||
|
||||
self.assertRaises(frappe.ValidationError, manufacture_entry.save)
|
||||
manufacture_entry.reload()
|
||||
manufacture_entry.submit()
|
||||
|
||||
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0)
|
||||
|
||||
|
||||
def make_operation(**kwargs):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
@@ -368,8 +368,28 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
];
|
||||
}
|
||||
|
||||
get_batch_qty(batch_no, callback) {
|
||||
let warehouse = this.item.s_warehouse || this.item.t_warehouse || this.item.warehouse;
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.batch.batch.get_batch_qty",
|
||||
args: {
|
||||
batch_no: batch_no,
|
||||
warehouse: warehouse,
|
||||
item_code: this.item.item_code,
|
||||
posting_date: this.frm.doc.posting_date,
|
||||
posting_time: this.frm.doc.posting_time,
|
||||
},
|
||||
callback: (r) => {
|
||||
if (r.message) {
|
||||
callback(flt(r.message));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get_dialog_table_fields() {
|
||||
let fields = [];
|
||||
let me = this;
|
||||
|
||||
if (this.item.has_serial_no) {
|
||||
fields.push({
|
||||
@@ -395,6 +415,15 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
fieldname: "batch_no",
|
||||
label: __("Batch No"),
|
||||
in_list_view: 1,
|
||||
change() {
|
||||
let doc = this.doc;
|
||||
if (!doc.qty && me.item.type_of_transaction === "Outward") {
|
||||
me.get_batch_qty(doc.batch_no, (qty) => {
|
||||
doc.qty = qty;
|
||||
this.grid.set_value("qty", qty, doc);
|
||||
});
|
||||
}
|
||||
},
|
||||
get_query: () => {
|
||||
let is_inward = false;
|
||||
if (
|
||||
|
||||
@@ -235,6 +235,7 @@ class StockEntry(StockController):
|
||||
self.validate_serialized_batch()
|
||||
self.calculate_rate_and_amount()
|
||||
self.validate_putaway_capacity()
|
||||
self.validate_component_quantities()
|
||||
|
||||
if self.get("purpose") != "Manufacture":
|
||||
# ignore scrap item wh difference and empty source/target wh
|
||||
@@ -764,6 +765,34 @@ class StockEntry(StockController):
|
||||
title=_("Insufficient Stock"),
|
||||
)
|
||||
|
||||
def validate_component_quantities(self):
|
||||
if self.purpose not in ["Manufacture", "Material Transfer for Manufacture"]:
|
||||
return
|
||||
|
||||
if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
|
||||
return
|
||||
|
||||
if not self.fg_completed_qty:
|
||||
return
|
||||
|
||||
raw_materials = self.get_bom_raw_materials(self.fg_completed_qty)
|
||||
|
||||
precision = frappe.get_precision("Stock Entry Detail", "qty")
|
||||
for row in self.items:
|
||||
if not row.s_warehouse:
|
||||
continue
|
||||
|
||||
if details := raw_materials.get(row.item_code):
|
||||
if flt(details.get("qty"), precision) != flt(row.qty, precision):
|
||||
frappe.throw(
|
||||
_("For the item {0}, the quantity should be {1} according to the BOM {2}.").format(
|
||||
frappe.bold(row.item_code),
|
||||
flt(details.get("qty"), precision),
|
||||
get_link_to_form("BOM", self.bom_no),
|
||||
),
|
||||
title=_("Incorrect Component Quantity"),
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_stock_and_rate(self):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user