diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 75cb5516348..0f9431ab440 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -103,6 +103,16 @@ class SubcontractingController(StockController): _("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name) ) + if ( + self.doctype == "Subcontracting Order" and not item.sc_conversion_factor + ): # this condition will only be true if user has recently updated from develop branch + service_item_qty = frappe.get_value( + "Subcontracting Order Service Item", + filters={"purchase_order_item": item.purchase_order_item, "parent": self.name}, + fieldname=["qty"], + ) + item.sc_conversion_factor = service_item_qty / item.qty + if ( self.doctype not in "Subcontracting Receipt" and item.qty diff --git a/erpnext/manufacturing/doctype/bom/bom_item_preview.html b/erpnext/manufacturing/doctype/bom/bom_item_preview.html index eb4135e03ac..2c0f091da58 100644 --- a/erpnext/manufacturing/doctype/bom/bom_item_preview.html +++ b/erpnext/manufacturing/doctype/bom/bom_item_preview.html @@ -3,7 +3,7 @@
{% if data.image %}
- +
{% endif %}
diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py index 2e8dba1ccfe..983dd2d4cd8 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: import frappe from frappe.model.document import Document +from frappe.utils import date_diff, get_datetime, now class BOMUpdateTool(Document): @@ -50,13 +51,21 @@ def auto_update_latest_price_in_all_boms() -> None: if frappe.db.get_single_value("Manufacturing Settings", "update_bom_costs_automatically"): wip_log = frappe.get_all( "BOM Update Log", - {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]}, + fields=["creation", "status"], + filters={"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]}, limit_page_length=1, + order_by="creation desc", ) - if not wip_log: + + if not wip_log or is_older_log(wip_log[0]): create_bom_update_log(update_type="Update Cost") +def is_older_log(log: dict) -> bool: + no_of_days = date_diff(get_datetime(now()), get_datetime(log.creation)) + return no_of_days > 10 + + def create_bom_update_log( boms: dict[str, str] | None = None, update_type: Literal["Replace BOM", "Update Cost"] = "Replace BOM", diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index 26cbc03eeb2..618ccdf8fc8 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -242,14 +242,14 @@ "depends_on": "eval:doc.backflush_raw_materials_based_on == \"BOM\"", "fieldname": "validate_components_quantities_per_bom", "fieldtype": "Check", - "label": "Validate Components Quantities Per BOM" + "label": "Validate Components and Quantities Per BOM" } ], "icon": "icon-wrench", "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-09-02 12:12:03.132567", + "modified": "2025-01-02 12:46:33.520853", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing Settings", @@ -267,4 +267,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index f8ddf007428..86a03e7919c 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2401,6 +2401,56 @@ class TestWorkOrder(FrappeTestCase): frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0) + def test_components_as_per_bom_for_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 1" + source_warehouse = "Stores - _TC" + raw_materials = ["Test Component Validation RM Item 11", "Test Component Validation RM Item 12"] + + 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() + transfer_entry.remove(transfer_entry.items[0]) + + self.assertRaises(frappe.ValidationError, transfer_entry.save) + + transfer_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10)) + transfer_entry.save() + transfer_entry.submit() + + manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10)) + manufacture_entry.save() + + manufacture_entry.remove(manufacture_entry.items[0]) + + self.assertRaises(frappe.ValidationError, manufacture_entry.save) + manufacture_entry.delete() + + manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10)) + manufacture_entry.save() + 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/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 78f69b1ce62..4b4ee6ad0f1 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -201,7 +201,6 @@ class StockEntry(StockController): self.validate_purpose() self.validate_item() self.validate_customer_provided_item() - self.validate_qty() self.set_transfer_qty() self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("stock_uom", "transfer_qty") @@ -232,7 +231,7 @@ class StockEntry(StockController): self.validate_serialized_batch() self.calculate_rate_and_amount() self.validate_putaway_capacity() - self.validate_component_quantities() + self.validate_component_and_quantities() if not self.get("purpose") == "Manufacture": # ignore scrap item wh difference and empty source/target wh @@ -463,40 +462,6 @@ class StockEntry(StockController): flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item) ) - def validate_qty(self): - manufacture_purpose = ["Manufacture", "Material Consumption for Manufacture"] - - if self.purpose in manufacture_purpose and self.work_order: - if not frappe.get_value("Work Order", self.work_order, "skip_transfer"): - item_code = [] - for item in self.items: - if cstr(item.t_warehouse) == "": - req_items = frappe.get_all( - "Work Order Item", - filters={"parent": self.work_order, "item_code": item.item_code}, - fields=["item_code"], - ) - - transferred_materials = frappe.db.sql( - """ - select - sum(sed.qty) as qty - from `tabStock Entry` se,`tabStock Entry Detail` sed - where - se.name = sed.parent and se.docstatus=1 and - (se.purpose='Material Transfer for Manufacture' or se.purpose='Manufacture') - and sed.item_code=%s and se.work_order= %s and ifnull(sed.t_warehouse, '') != '' - """, - (item.item_code, self.work_order), - as_dict=1, - ) - - stock_qty = flt(item.qty) - trans_qty = flt(transferred_materials[0].qty) - if req_items: - if stock_qty > trans_qty: - item_code.append(item.item_code) - def validate_fg_completed_qty(self): item_wise_qty = {} if self.purpose == "Manufacture" and self.work_order: @@ -748,7 +713,7 @@ class StockEntry(StockController): title=_("Insufficient Stock"), ) - def validate_component_quantities(self): + def validate_component_and_quantities(self): if self.purpose not in ["Manufacture", "Material Transfer for Manufacture"]: return @@ -761,20 +726,31 @@ class StockEntry(StockController): 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): + for item_code, details in raw_materials.items(): + if matched_item := self.get_matched_items(item_code): + if flt(details.get("qty"), precision) != flt(matched_item.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), + frappe.bold(item_code), + flt(details.get("qty")), get_link_to_form("BOM", self.bom_no), ), title=_("Incorrect Component Quantity"), ) + else: + frappe.throw( + _("According to the BOM {0}, the Item '{1}' is missing in the stock entry.").format( + get_link_to_form("BOM", self.bom_no), frappe.bold(item_code) + ), + title=_("Missing Item"), + ) + + def get_matched_items(self, item_code): + for row in self.items: + if row.item_code == item_code: + return row + + return {} @frappe.whitelist() def get_stock_and_rate(self):