From 22d38c2af4fe8d5171bc6ec7565639b7fdc99127 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 30 Dec 2024 20:12:14 +0530 Subject: [PATCH] feat: Validate sub assembly and material request items in Production Plan and fix Production Plan summary reports not showing correct received quantity from subcontracted POs --- .../doctype/purchase_order/purchase_order.py | 20 +++ erpnext/controllers/status_updater.py | 31 ++-- .../material_request_plan_item.json | 7 +- .../material_request_plan_item.py | 8 +- .../production_plan/production_plan.py | 54 ++++++- .../production_plan/test_production_plan.py | 134 +++++++++++++++++- .../production_plan_sub_assembly_item.json | 11 +- .../production_plan_sub_assembly_item.py | 1 + .../production_plan_summary.py | 31 ++-- .../material_request/material_request.py | 27 +++- 10 files changed, 292 insertions(+), 32 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 23a382c050d..913c8c26f57 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -469,6 +469,9 @@ class PurchaseOrder(BuyingController): if self.is_against_so(): self.update_status_updater() + if self.is_against_pp(): + self.update_status_updater_if_from_pp() + self.update_prevdoc_status() if not self.is_subcontracted or self.is_old_subcontracting_flow: self.update_requested_qty() @@ -550,6 +553,20 @@ class PurchaseOrder(BuyingController): } ) + def update_status_updater_if_from_pp(self): + self.status_updater.append( + { + "source_dt": "Purchase Order Item", + "target_dt": "Production Plan Sub Assembly Item", + "join_field": "production_plan_sub_assembly_item", + "target_field": "ordered_qty", + "target_parent_dt": "Production Plan", + "target_parent_field": "", + "target_ref_field": "qty", + "source_field": "fg_item_qty", + } + ) + def update_delivered_qty_in_sales_order(self): """Update delivered qty in Sales Order for drop ship""" sales_orders_to_update = [] @@ -570,6 +587,9 @@ class PurchaseOrder(BuyingController): def is_against_so(self): return any(d.sales_order for d in self.items if d.sales_order) + def is_against_pp(self): + return any(d.production_plan for d in self.items if d.production_plan) + def set_received_qty_for_drop_ship_items(self): for item in self.items: if item.delivered_by_supplier == 1: diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index a221f6e7cb8..4c0f7941b0c 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -201,19 +201,19 @@ class StatusUpdater(Document): Get the status of the document. Returns: - dict: A dictionary containing the status. This allows callers to receive - a dictionary for efficient bulk updates, for example when `per_billed` - and other status fields also need to be updated. + dict: A dictionary containing the status. This allows callers to receive + a dictionary for efficient bulk updates, for example when `per_billed` + and other status fields also need to be updated. Note: - Can be overriden on a doctype to implement more localized status updater logic. + Can be overriden on a doctype to implement more localized status updater logic. Example: - { - "status": "Draft", - "per_billed": 50, - "billing_status": "Partly Billed" - } + { + "status": "Draft", + "per_billed": 50, + "billing_status": "Partly Billed" + } """ if self.doctype not in status_map: return {"status": self.status} @@ -275,9 +275,20 @@ class StatusUpdater(Document): if d.doctype == args["source_dt"] and d.get(args["join_field"]): args["name"] = d.get(args["join_field"]) + is_from_pp = ( + hasattr(d, "production_plan_sub_assembly_item") + and frappe.db.get_value( + "Production Plan Sub Assembly Item", + d.production_plan_sub_assembly_item, + "type_of_manufacturing", + ) + == "Subcontract" + ) + args["item_code"] = "production_item" if is_from_pp else "item_code" + # get all qty where qty > target_field item = frappe.db.sql( - """select item_code, `{target_ref_field}`, + """select `{item_code}` as item_code, `{target_ref_field}`, `{target_field}`, parenttype, parent from `tab{target_dt}` where `{target_ref_field}` < `{target_field}` and name=%s and docstatus=1""".format(**args), diff --git a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json index eb0196fb0c0..db5e19a601f 100644 --- a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json +++ b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json @@ -63,7 +63,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Type", - "options": "\nPurchase\nMaterial Transfer\nMaterial Issue\nManufacture\nCustomer Provided" + "options": "\nPurchase\nMaterial Transfer\nMaterial Issue\nManufacture\nSubcontracting\nCustomer Provided" }, { "fieldname": "column_break_4", @@ -115,9 +115,12 @@ "read_only": 1 }, { + "allow_on_submit": 1, + "default": "0", "fieldname": "requested_qty", "fieldtype": "Float", "label": "Requested Qty", + "no_copy": 1, "read_only": 1 }, { @@ -202,7 +205,7 @@ ], "istable": 1, "links": [], - "modified": "2024-03-27 13:10:05.436575", + "modified": "2024-12-30 18:06:22.288340", "modified_by": "Administrator", "module": "Manufacturing", "name": "Material Request Plan Item", diff --git a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.py b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.py index aa1c72294d3..3d3130f77d8 100644 --- a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.py +++ b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.py @@ -21,7 +21,13 @@ class MaterialRequestPlanItem(Document): item_code: DF.Link item_name: DF.Data | None material_request_type: DF.Literal[ - "", "Purchase", "Material Transfer", "Material Issue", "Manufacture", "Customer Provided" + "", + "Purchase", + "Material Transfer", + "Material Issue", + "Manufacture", + "Subcontracting", + "Customer Provided", ] min_order_qty: DF.Float ordered_qty: DF.Float diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 265f99e47d3..11e048dffd0 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -4,6 +4,7 @@ import copy import json +from collections import defaultdict import frappe from frappe import _, msgprint @@ -722,6 +723,9 @@ class ProductionPlan(Document): if not wo_list: frappe.msgprint(_("No Work Orders were created")) + if not po_list: + frappe.msgprint(_("No Purchase Orders were created")) + def make_work_order_for_finished_goods(self, wo_list, default_warehouses): items_data = self.get_production_items() @@ -781,6 +785,34 @@ class ProductionPlan(Document): if not subcontracted_po: return + def calculate_sub_assembly_items(): + items_to_remove = defaultdict(list) + for supplier, items in subcontracted_po.items(): + for item in items: + table = frappe.qb.DocType("Purchase Order Item") + total_received_qty = ( + frappe.qb.from_(table) + .select( + Sum(table.received_qty / (table.received_qty / table.fg_item_qty)).as_( + "total_received_qty" + ) + ) + .where( + (table.production_plan_sub_assembly_item == item.name) & (table.docstatus == 1) + ) + ).run(as_dict=True)[0].total_received_qty or 0 + + if item.qty == total_received_qty: + items_to_remove[supplier].append(item) + elif total_received_qty: + item.qty -= total_received_qty + + subcontracted_po[supplier] = [item for item in items if item not in items_to_remove[supplier]] + + return {key: value for key, value in subcontracted_po.items() if value} + + subcontracted_po = calculate_sub_assembly_items() + for supplier, po_list in subcontracted_po.items(): po = frappe.new_doc("Purchase Order") po.company = self.company @@ -847,13 +879,31 @@ class ProductionPlan(Document): except OverProductionError: pass + def validate_mr_subcontracted(self): + for row in self.mr_items: + if row.material_request_type == "Subcontracting": + if not frappe.db.get_value("Item", row.item_code, "is_sub_contracted_item"): + frappe.throw( + _("Item {0} is not a subcontracted item").format(row.item_code), + title=_("Invalid Item"), + ) + @frappe.whitelist() def make_material_request(self): + self.validate_mr_subcontracted() + """Create Material Requests grouped by Sales Order and Material Request Type""" material_request_list = [] material_request_map = {} + if all([item.requested_qty == item.quantity for item in self.mr_items]): + msgprint(_("All items are already requested")) + return + for item in self.mr_items: + if item.quantity == item.requested_qty: + continue + item_doc = frappe.get_cached_doc("Item", item.item_code) material_request_type = item.material_request_type or item_doc.default_material_request_type @@ -887,7 +937,7 @@ class ProductionPlan(Document): "from_warehouse": item.from_warehouse if material_request_type == "Material Transfer" else None, - "qty": item.quantity, + "qty": item.quantity - item.requested_qty, "schedule_date": schedule_date, "warehouse": item.warehouse, "sales_order": item.sales_order, @@ -1047,7 +1097,7 @@ class ProductionPlan(Document): filters={ "production_plan": self.name, "status": ("not in", ["Closed", "Stopped"]), - "docstatus": ("<", 2), + "docstatus": 1, }, fields="status", pluck="status", diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 56e593e91a1..340ce2c28c5 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -449,11 +449,38 @@ class TestProductionPlan(IntegrationTestCase): self.assertEqual(plan.sub_assembly_items[0].supplier, "_Test Supplier") def test_production_plan_for_subcontracting_po(self): + from erpnext.controllers.status_updater import OverAllowanceError from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom from erpnext.subcontracting.doctype.subcontracting_bom.test_subcontracting_bom import ( create_subcontracting_bom, ) + def make_purchase_receipt_from_po(po_doc): + from erpnext.buying.doctype.purchase_order.purchase_order import make_subcontracting_order + from erpnext.controllers.subcontracting_controller import make_rm_stock_entry + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( + make_subcontracting_receipt, + ) + from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import ( + make_purchase_receipt as scr_make_purchase_receipt, + ) + + sco = make_subcontracting_order(po_doc.name) + sco.supplier_warehouse = "Work In Progress - _TC1" + sco.items[0].warehouse = "Finished Goods - _TC1" + sco.submit() + make_purchase_receipt( + qty=10, + item_code="Test Motherboard Wires 1", + company="_Test Company 1", + warehouse="Work In Progress - _TC1", + ).submit() + make_rm_stock_entry(sco.name) + scr = make_subcontracting_receipt(sco.name) + scr.submit() + scr_make_purchase_receipt(scr.name).submit() + fg_item = "Test Motherboard 1" bom_tree_1 = {"Test Laptop 1": {fg_item: {"Test Motherboard Wires 1": {}}}} create_nested_bom(bom_tree_1, prefix="") @@ -478,7 +505,12 @@ class TestProductionPlan(IntegrationTestCase): ) plan = create_production_plan( - item_code="Test Laptop 1", planned_qty=10, use_multi_level_bom=1, do_not_submit=True + item_code="Test Laptop 1", + planned_qty=10, + use_multi_level_bom=1, + do_not_submit=True, + company="_Test Company 1", + skip_getting_mr_items=True, ) plan.get_sub_assembly_items() plan.set_default_supplier_for_subcontracting_order() @@ -492,10 +524,108 @@ class TestProductionPlan(IntegrationTestCase): self.assertEqual(po_doc.supplier, "_Test Supplier") self.assertEqual(po_doc.items[0].qty, 10.0) self.assertEqual(po_doc.items[0].fg_item_qty, 10.0) - self.assertEqual(po_doc.items[0].fg_item_qty, 10.0) self.assertEqual(po_doc.items[0].fg_item, fg_item) self.assertEqual(po_doc.items[0].item_code, service_item) + po_doc.items[0].qty = 11 + po_doc.items[0].fg_item_qty = 11 + + # Test - 1 : Quantity of item cannot exceed quantity in production plan + self.assertRaises(OverAllowanceError, po_doc.submit) + + po_doc.cancel() + po_doc = frappe.copy_doc(po_doc) + po_doc.items[0].qty = 5 + po_doc.items[0].fg_item_qty = 5 + po_doc.submit() + make_purchase_receipt_from_po(po_doc) + + plan.make_work_order() + po = frappe.db.get_value("Purchase Order Item", {"production_plan": plan.name}, "parent") + po_doc = frappe.get_doc("Purchase Order", po) + + # Test - 2 : Quantity of item in new PO should be the available quantity from Production Plan + self.assertEqual(po_doc.items[0].qty, 5.0) + + po_doc.submit() + plan.make_work_order() + + # Test - 3 : New POs should not be created since the quantity is already fulfilled + self.assertEqual( + frappe.db.count("Purchase Order Item", {"production_plan": plan.name, "docstatus": 1}), 2 + ) # 2 since we have already created and submitted 2 POs + + def test_production_plan_for_mr_items(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + def setup_item(fg_item): + item_doc = frappe.get_doc("Item", fg_item) + company = "_Test Company" + + item_doc.is_sub_contracted_item = 1 + for row in item_doc.item_defaults: + if row.company == company and not row.default_supplier: + row.default_supplier = "_Test Supplier" + + if not item_doc.item_defaults: + item_doc.append("item_defaults", {"company": company, "default_supplier": "_Test Supplier"}) + + item_doc.save() + + fg_item = "Test Motherboard 1" + fg_item_2 = "Test CPU 1" + bom_tree_1 = { + "Test Laptop 1": {fg_item: {"Test Motherboard Wires 1": {}}, fg_item_2: {"Test Pins 1": {}}} + } + create_nested_bom(bom_tree_1, prefix="") + + setup_item(fg_item) + setup_item(fg_item_2) + + plan = create_production_plan( + item_code="Test Laptop 1", planned_qty=10, use_multi_level_bom=1, do_not_submit=True + ) + plan.get_sub_assembly_items() + plan.set_default_supplier_for_subcontracting_order() + plan.submit() + + plan.make_material_request() + mr_item = frappe.db.get_value("Material Request Item", {"production_plan": plan.name}, "parent") + mr_doc = frappe.get_doc("Material Request", mr_item) + mr_doc.submit() + plan.reload() + plan.make_material_request() + + # Test 1 : No more MRs should be created as quantity from Production Plan is fulfilled + self.assertEqual(frappe.db.count("Material Request Item", {"production_plan": plan.name}), 2) + + mr_doc.cancel() + plan.reload() + + # Test 2 : Requested quantity should be updated in Production Plan on cancellation of MR + self.assertEqual(plan.mr_items[0].requested_qty, 0) + + plan.make_material_request() + mr_item = frappe.db.get_value("Material Request Item", {"production_plan": plan.name}, "parent") + mr_doc = frappe.get_doc("Material Request", mr_item) + mr_doc.items[0].qty = 5 + mr_doc.submit() + plan.reload() + plan.make_material_request() + mr_item = frappe.db.get_value("Material Request Item", {"production_plan": plan.name}, "parent") + mr_doc = frappe.get_doc("Material Request", mr_item) + + # Test 3 : Since Item 2 has been fully requested, it should not be included in the new MR by default + self.assertEqual(len(mr_doc.items), 1) + + # Test 4 : Quantity in new MR should be the available quantity from Production Plan + self.assertEqual(mr_doc.items[0].qty, 5.0) + + mr_doc.items[0].qty = 6 + + # Test 5 : Quantity of item cannot exceed available quantity from Production Plan + self.assertRaises(frappe.ValidationError, mr_doc.submit) + def test_production_plan_combine_subassembly(self): """ Test combining Sub assembly items belonging to the same BOM in Prod Plan. diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json index 3c99bb742c4..14b82403190 100644 --- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json @@ -21,6 +21,7 @@ "purchase_order", "production_plan_item", "column_break_7", + "ordered_qty", "received_qty", "indent", "section_break_19", @@ -204,12 +205,20 @@ "fieldtype": "Float", "label": "Produced Qty", "read_only": 1 + }, + { + "fieldname": "ordered_qty", + "fieldtype": "Float", + "hidden": 1, + "label": "Ordered Qty", + "non_negative": 1, + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:20.876695", + "modified": "2024-12-20 17:00:15.335880", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Sub Assembly Item", diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py index ad1d655de8b..7e29675136c 100644 --- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py @@ -22,6 +22,7 @@ class ProductionPlanSubAssemblyItem(Document): fg_warehouse: DF.Link | None indent: DF.Int item_name: DF.Data | None + ordered_qty: DF.Float parent: DF.Data parent_item_code: DF.Link | None parentfield: DF.Data diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py index c62cab77d61..226677173b0 100644 --- a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py +++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py @@ -35,6 +35,7 @@ def get_production_plan_item_details(filters, data, order_details): "production_plan_item": row.name, "bom_no": row.bom_no, "production_item": row.item_code, + "docstatus": 1, }, pluck="name", ) @@ -84,20 +85,24 @@ def get_production_plan_sub_assembly_item_details(filters, row, production_plan_ subcontracted_item = item.type_of_manufacturing == "Subcontract" if subcontracted_item: - docname = frappe.get_value( + docnames = frappe.get_all( "Purchase Order Item", - {"production_plan_sub_assembly_item": item.name, "docstatus": ("<", 2)}, - "parent", + filters={"production_plan_sub_assembly_item": item.name, "docstatus": 1}, + fields=["parent"], + order_by="creation", + pluck="parent", ) else: - docname = frappe.get_value( + docnames = frappe.get_all( "Work Order", - {"production_plan_sub_assembly_item": item.name, "docstatus": ("<", 2)}, - "name", + filters={"production_plan_sub_assembly_item": item.name, "docstatus": 1}, + fields=["name"], + order_by="creation", + pluck="name", ) - data.append( - { + for docname in docnames: + data_to_append = { "indent": 1 + item.indent, "item_code": item.production_item, "item_name": item.item_name, @@ -111,7 +116,9 @@ def get_production_plan_sub_assembly_item_details(filters, row, production_plan_ "pending_qty": flt(item.qty) - flt(order_details.get((docname, item.production_item), {}).get("produced_qty", 0)), } - ) + if data[-1] and data[-1]["indent"] == data_to_append["indent"]: + data_to_append["pending_qty"] = data[-1]["pending_qty"] - data_to_append["produced_qty"] + data.append(data_to_append) def get_work_order_details(filters, order_details): @@ -127,9 +134,11 @@ def get_purchase_order_details(filters, order_details): for row in frappe.get_all( "Purchase Order Item", filters={"production_plan": filters.get("production_plan")}, - fields=["parent", "received_qty as produced_qty", "item_code"], + fields=["parent", "received_qty as produced_qty", "item_code", "fg_item", "fg_item_qty"], ): - order_details.setdefault((row.parent, row.item_code), row) + if row.fg_item: + row.produced_qty /= row.produced_qty / row.fg_item_qty or 1 + order_details.setdefault((row.parent, row.fg_item or row.item_code), row) def get_column(filters): diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 66e0def290a..84c36244b1e 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -159,6 +159,24 @@ class MaterialRequest(BuyingController): self.reset_default_field_value("set_warehouse", "items", "warehouse") self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse") + self.validate_pp_qty() + + def validate_pp_qty(self): + for item in self.items: + if item.material_request_plan_item: + qty_from_pp = frappe.db.get_value( + "Material Request Plan Item", + item.material_request_plan_item, + ["quantity", "requested_qty"], + as_dict=1, + ) + if item.qty > (qty_from_pp.quantity - qty_from_pp.requested_qty): + frappe.throw( + _("Quantity cannot be greater than {0} for Item {1}").format( + qty_from_pp.quantity - qty_from_pp.requested_qty, item.item_code + ) + ) + def before_update_after_submit(self): self.validate_schedule_date() @@ -233,7 +251,7 @@ class MaterialRequest(BuyingController): ) def on_cancel(self): - self.update_requested_qty_in_production_plan() + self.update_requested_qty_in_production_plan(cancel=True) self.update_requested_qty() def get_mr_items_ordered_qty(self, mr_items): @@ -337,11 +355,14 @@ class MaterialRequest(BuyingController): }, ) - def update_requested_qty_in_production_plan(self): + def update_requested_qty_in_production_plan(self, cancel=False): production_plans = [] for d in self.get("items"): if d.production_plan and d.material_request_plan_item: - qty = d.qty if self.docstatus == 1 else 0 + requested_qty = frappe.get_value( + "Material Request Plan Item", d.material_request_plan_item, "requested_qty" + ) + qty = (requested_qty + d.qty) if not cancel else (requested_qty - d.qty) frappe.db.set_value( "Material Request Plan Item", d.material_request_plan_item, "requested_qty", qty )