diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index 2949d1cb5ba3..92de0837d21e 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -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", diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py index 84dbce2b83f0..cb2916c8ac59 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py @@ -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(): diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 2dc31722d3f6..8623e9edfdb3 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2103,6 +2103,59 @@ def test_disassemby_order(self): 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) diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 78efb46f4c34..2ddfc24adb1c 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -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 ( diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index f0cd19e2d4bb..262114595f01 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -235,6 +235,7 @@ def validate(self): 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 @@ def set_actual_qty(self): 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): """