From 9b303a22722bc425621f96514d3d7bea05771825 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 16 Nov 2025 13:06:21 +0530 Subject: [PATCH] Merge pull request #50235 from mihir-kandoi/sre-sco feat: stock reservation for subcontracting order --- .../doctype/purchase_order/purchase_order.py | 10 + .../purchase_order_item.json | 3 +- erpnext/controllers/stock_controller.py | 122 ++++++++ .../controllers/subcontracting_controller.py | 5 +- .../subcontracting_inward_controller.py | 125 -------- .../tests/test_subcontracting_controller.py | 6 +- .../material_request_plan_item.json | 45 ++- .../material_request_plan_item.py | 3 + .../production_plan/production_plan.py | 67 +++-- .../production_plan/test_production_plan.py | 14 +- .../production_plan_sub_assembly_item.json | 5 +- .../serial_and_batch_bundle.py | 11 +- .../stock/doctype/stock_entry/stock_entry.py | 63 ++-- .../stock_reservation_entry.json | 9 +- .../stock_reservation_entry.py | 42 ++- .../subcontracting_order.js | 268 ++++++++++++++++++ .../subcontracting_order.json | 20 +- .../subcontracting_order.py | 103 ++++++- .../subcontracting_order_dashboard.py | 12 +- .../test_subcontracting_order.py | 120 ++++++++ .../subcontracting_order_item.json | 15 +- .../subcontracting_order_item.py | 1 + .../subcontracting_order_supplied_item.json | 18 +- .../subcontracting_order_supplied_item.py | 1 + .../subcontracting_receipt.py | 16 +- 25 files changed, 895 insertions(+), 209 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index c227ffb7187..5495c557d1b 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -523,6 +523,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() + if self.has_drop_ship_item(): self.update_delivered_qty_in_sales_order() @@ -1007,6 +1010,13 @@ def get_mapped_subcontracting_order(source_name, target_doc=None): "Job Card", item.job_card, "wip_warehouse" ) + production_plan = set([item.production_plan for item in source_doc.items if item.production_plan]) + if production_plan: + target_doc.production_plan = production_plan.pop() + target_doc.reserve_stock = frappe.get_single_value( + "Stock Settings", "auto_reserve_stock" + ) or frappe.get_value("Production Plan", target_doc.production_plan, "reserve_stock") + if target_doc and isinstance(target_doc, str): target_doc = json.loads(target_doc) for key in ["service_items", "items", "supplied_items"]: diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 719fac80bf4..3679e7337e8 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -830,6 +830,7 @@ "fieldname": "production_plan", "fieldtype": "Link", "label": "Production Plan", + "no_copy": 1, "options": "Production Plan", "print_hide": 1, "read_only": 1 @@ -948,7 +949,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-12 10:57:31.552812", + "modified": "2025-10-30 16:51:56.761673", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index ad2581935ba..af4e76495e9 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1645,6 +1645,128 @@ class StockController(AccountsController): gl_entries.append(self.get_gl_dict(gl_entry, item=item)) + def update_stock_reservation_entries(self): + def get_sre_list(): + table = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(table) + .select(table.name) + .where( + (table.docstatus == 1) + & (table.voucher_type == data_map[purpose or self.doctype]["voucher_type"]) + & ( + table.voucher_no + == data_map[purpose or self.doctype].get( + "voucher_no", item.get("subcontracting_order") + ) + ) + ) + .orderby(table.creation) + ) + if reference_field := data_map[purpose or self.doctype].get("voucher_detail_no_field"): + query = query.where(table.voucher_detail_no == item.get(reference_field)) + else: + query = query.where( + (table.item_code == item.rm_item_code) & (table.warehouse == self.supplier_warehouse) + ) + + return query.run(pluck="name") + + def get_data_map(): + return { + "Subcontracting Delivery": { + "table_name": "items", + "voucher_type": "Subcontracting Inward Order", + "voucher_no": self.get("subcontracting_inward_order"), + "voucher_detail_no_field": "scio_detail", + "field": "delivered_qty", + }, + "Send to Subcontractor": { + "table_name": "items", + "voucher_type": "Subcontracting Order", + "voucher_no": self.get("subcontracting_order"), + "voucher_detail_no_field": "sco_rm_detail", + "field": "transferred_qty", + }, + "Subcontracting Receipt": { + "table_name": "supplied_items", + "voucher_type": "Subcontracting Order", + "field": "consumed_qty", + }, + } + + purpose = self.get("purpose") + if ( + purpose == "Subcontracting Delivery" + or ( + purpose == "Send to Subcontractor" + and frappe.get_value("Subcontracting Order", self.subcontracting_order, "reserve_stock") + ) + or (self.doctype == "Subcontracting Receipt" and self.has_reserved_stock() and not self.is_return) + ): + data_map = get_data_map() + + field = data_map[purpose or self.doctype]["field"] + for item in self.get(data_map[purpose or self.doctype]["table_name"]): + sre_list = get_sre_list() + + if not sre_list: + continue + + qty = item.get("transfer_qty", item.get("consumed_qty")) + for sre in sre_list: + if qty <= 0: + break + + sre_doc = frappe.get_doc("Stock Reservation Entry", sre) + + working_qty = 0 + if sre_doc.reservation_based_on == "Serial and Batch": + sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + if sre_doc.has_serial_no: + serial_nos = [d.serial_no for d in sbb.entries] + for entry in sre_doc.sb_entries: + if entry.serial_no in serial_nos: + entry.delivered_qty = 1 if self._action == "submit" else 0 + entry.db_update() + working_qty += 1 + serial_nos.remove(entry.serial_no) + else: + batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries} + for entry in sre_doc.sb_entries: + if entry.batch_no in batch_qty: + delivered_qty = min( + (entry.qty - entry.delivered_qty) + if self._action == "submit" + else entry.delivered_qty, + batch_qty[entry.batch_no], + ) + entry.delivered_qty += ( + delivered_qty if self._action == "submit" else (-1 * delivered_qty) + ) + entry.db_update() + working_qty += delivered_qty + batch_qty[entry.batch_no] -= delivered_qty + else: + working_qty = min( + (sre_doc.reserved_qty - sre_doc.get(field)) + if self._action == "submit" + else sre_doc.get(field), + qty, + ) + + sre_doc.set( + field, + sre_doc.get(field) + + (working_qty if self._action == "submit" else (-1 * working_qty)), + ) + sre_doc.db_update() + sre_doc.update_reserved_qty_in_voucher() + sre_doc.update_status() + sre_doc.update_reserved_stock_in_bin() + + qty -= working_qty + @frappe.whitelist() def show_accounting_ledger_preview(company, doctype, docname): diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index adc5f6ae36b..850e4ad0a6c 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -497,11 +497,10 @@ class SubcontractingController(StockController): if row.serial_no: details.serial_no.extend(get_serial_nos(row.serial_no)) - - elif row.batch_no: + if row.batch_no: details.batch_no[row.batch_no] += row.qty - elif voucher_bundle_data: + if not row.serial_no and not row.batch_no and voucher_bundle_data: bundle_key = (row.rm_item_code, row.main_item_code, row.t_warehouse, row.voucher_no) bundle_data = voucher_bundle_data.get(bundle_key, frappe._dict()) diff --git a/erpnext/controllers/subcontracting_inward_controller.py b/erpnext/controllers/subcontracting_inward_controller.py index eaa30a97cd4..056bfcdec9d 100644 --- a/erpnext/controllers/subcontracting_inward_controller.py +++ b/erpnext/controllers/subcontracting_inward_controller.py @@ -556,131 +556,6 @@ class SubcontractingInwardController: item.basic_rate + (item.additional_cost / item.transfer_qty), item.precision("basic_rate") ) - def update_sre_for_subcontracting_delivery(self) -> None: - if self.purpose == "Subcontracting Delivery": - if self._action == "submit": - self.update_sre_for_subcontracting_delivery_submit() - elif self._action == "cancel": - self.update_sre_for_subcontracting_delivery_cancel() - - def update_sre_for_subcontracting_delivery_submit(self): - for item in self.get("items"): - table = frappe.qb.DocType("Stock Reservation Entry") - query = ( - frappe.qb.from_(table) - .select(table.name) - .where( - (table.docstatus == 1) - & (table.voucher_type == "Subcontracting Inward Order") - & (table.voucher_no == self.subcontracting_inward_order) - & (table.voucher_detail_no == item.scio_detail) - ) - .orderby(table.creation) - ) - sre_list = query.run(pluck="name") - - if not sre_list: - continue - - qty_to_deliver = item.transfer_qty - for sre in sre_list: - if qty_to_deliver <= 0: - break - - sre_doc = frappe.get_doc("Stock Reservation Entry", sre) - - qty_can_be_deliver = 0 - if sre_doc.reservation_based_on == "Serial and Batch": - sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) - if sre_doc.has_serial_no: - delivered_serial_nos = [d.serial_no for d in sbb.entries] - for entry in sre_doc.sb_entries: - if entry.serial_no in delivered_serial_nos: - entry.delivered_qty = 1 - entry.db_update() - qty_can_be_deliver += 1 - delivered_serial_nos.remove(entry.serial_no) - else: - delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries} - for entry in sre_doc.sb_entries: - if entry.batch_no in delivered_batch_qty: - delivered_qty = min( - (entry.qty - entry.delivered_qty), - delivered_batch_qty[entry.batch_no], - ) - entry.delivered_qty += delivered_qty - entry.db_update() - qty_can_be_deliver += delivered_qty - delivered_batch_qty[entry.batch_no] -= delivered_qty - else: - qty_can_be_deliver = min((sre_doc.reserved_qty - sre_doc.delivered_qty), qty_to_deliver) - - sre_doc.delivered_qty += qty_can_be_deliver - sre_doc.db_update() - sre_doc.update_status() - sre_doc.update_reserved_stock_in_bin() - - qty_to_deliver -= qty_can_be_deliver - - def update_sre_for_subcontracting_delivery_cancel(self): - for item in self.get("items"): - table = frappe.qb.DocType("Stock Reservation Entry") - query = ( - frappe.qb.from_(table) - .select(table.name) - .where( - (table.docstatus == 1) - & (table.voucher_type == "Subcontracting Inward Order") - & (table.voucher_no == self.subcontracting_inward_order) - & (table.voucher_detail_no == item.scio_detail) - & (table.warehouse == item.s_warehouse) - ) - .orderby(table.creation) - ) - sre_list = query.run(pluck="name") - - if not sre_list: - continue - - qty_to_undelivered = item.transfer_qty - for sre in sre_list: - if qty_to_undelivered <= 0: - break - - sre_doc = frappe.get_doc("Stock Reservation Entry", sre) - - qty_can_be_undelivered = 0 - if sre_doc.reservation_based_on == "Serial and Batch": - sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) - if sre_doc.has_serial_no: - serial_nos_to_undelivered = [d.serial_no for d in sbb.entries] - for entry in sre_doc.sb_entries: - if entry.serial_no in serial_nos_to_undelivered: - entry.delivered_qty = 0 - entry.db_update() - qty_can_be_undelivered += 1 - serial_nos_to_undelivered.remove(entry.serial_no) - else: - batch_qty_to_undelivered = {d.batch_no: -1 * d.qty for d in sbb.entries} - for entry in sre_doc.sb_entries: - if entry.batch_no in batch_qty_to_undelivered: - undelivered_qty = min( - entry.delivered_qty, batch_qty_to_undelivered[entry.batch_no] - ) - entry.delivered_qty -= undelivered_qty - entry.db_update() - qty_can_be_undelivered += undelivered_qty - batch_qty_to_undelivered[entry.batch_no] -= undelivered_qty - else: - qty_can_be_undelivered = min(sre_doc.delivered_qty, qty_to_undelivered) - - sre_doc.delivered_qty -= qty_can_be_undelivered - sre_doc.db_update() - sre_doc.update_status() - sre_doc.update_reserved_stock_in_bin() - - qty_to_undelivered -= qty_can_be_undelivered - def validate_receive_from_customer_cancel(self): if self.purpose == "Receive from Customer": for item in self.items: diff --git a/erpnext/controllers/tests/test_subcontracting_controller.py b/erpnext/controllers/tests/test_subcontracting_controller.py index b3e06d4db6c..0f8352d78c6 100644 --- a/erpnext/controllers/tests/test_subcontracting_controller.py +++ b/erpnext/controllers/tests/test_subcontracting_controller.py @@ -1308,7 +1308,11 @@ def make_subcontracted_items(): "Subcontracted Item SA7": {}, "Subcontracted Item SA8": {}, "Subcontracted Item SA9": {"stock_uom": "Litre"}, - "Subcontracted Item SA10": {}, + "Subcontracted Item SA10": { + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "SBAT.####", + }, } for item, properties in sub_contracted_items.items(): 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 f10db8d8a7c..8bc37d2e02d 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 @@ -8,12 +8,16 @@ "item_code", "from_warehouse", "warehouse", - "item_name", "material_request_type", "column_break_4", + "item_name", "uom", "conversion_factor", "section_break_azee", + "from_bom", + "column_break_scnz", + "main_item_code", + "section_break_qnpt", "required_bom_qty", "projected_qty", "column_break_wack", @@ -25,6 +29,7 @@ "min_order_qty", "section_break_8", "sales_order", + "sub_assembly_item_reference", "bin_qty_section", "actual_qty", "requested_qty", @@ -220,12 +225,48 @@ "label": "Stock Reserved Qty", "no_copy": 1, "read_only": 1 + }, + { + "depends_on": "from_bom", + "fieldname": "from_bom", + "fieldtype": "Link", + "label": "From BOM", + "mandatory_depends_on": "eval:parent.reserve_stock", + "no_copy": 1, + "options": "BOM", + "read_only": 1 + }, + { + "fieldname": "sub_assembly_item_reference", + "fieldtype": "Data", + "hidden": 1, + "label": "Sub Assembly Item Reference", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "section_break_qnpt", + "fieldtype": "Section Break" + }, + { + "depends_on": "main_item_code", + "fieldname": "main_item_code", + "fieldtype": "Link", + "label": "Main Item Code", + "mandatory_depends_on": "eval:parent.reserve_stock", + "no_copy": 1, + "options": "Item", + "read_only": 1 + }, + { + "fieldname": "column_break_scnz", + "fieldtype": "Column Break" } ], "grid_page_length": 50, "istable": 1, "links": [], - "modified": "2025-05-01 14:50:55.805442", + "modified": "2025-10-30 17:01:25.996352", "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 2b6e0994f46..44c706c10ab 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 @@ -17,9 +17,11 @@ class MaterialRequestPlanItem(Document): actual_qty: DF.Float conversion_factor: DF.Float description: DF.TextEditor | None + from_bom: DF.Link | None from_warehouse: DF.Link | None item_code: DF.Link item_name: DF.Data | None + main_item_code: DF.Link | None material_request_type: DF.Literal[ "", "Purchase", @@ -43,6 +45,7 @@ class MaterialRequestPlanItem(Document): sales_order: DF.Link | None schedule_date: DF.Date | None stock_reserved_qty: DF.Float + sub_assembly_item_reference: DF.Data | None uom: DF.Link | None warehouse: DF.Link # end: auto-generated types diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 8a7133490c9..3de22cb72b0 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -568,6 +568,7 @@ class ProductionPlan(Document): def on_submit(self): self.update_bin_qty() self.update_sales_order() + self.add_reference_to_raw_materials() self.update_stock_reservation() def on_cancel(self): @@ -583,6 +584,24 @@ class ProductionPlan(Document): make_stock_reservation_entries(self) + def add_reference_to_raw_materials(self): + for item in self.mr_items: + if reference := next( + ( + sa_item.name + for sa_item in self.sub_assembly_items + if sa_item.production_item == item.main_item_code and sa_item.bom_no == item.from_bom + ), + None, + ): + item.db_set("sub_assembly_item_reference", reference) + elif self.reserve_stock and item.main_item_code and item.from_bom: + frappe.throw( + _( + "Sub assembly item references are missing. Please fetch the sub assemblies and raw materials again." + ) + ) + def update_sales_order(self): sales_orders = [row.sales_order for row in self.po_items if row.sales_order] if sales_orders: @@ -1382,14 +1401,14 @@ def get_material_request_items( include_safety_stock, warehouse, bin_dict, + total_qty, ): - total_qty = row["qty"] - required_qty = 0 if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: - required_qty = total_qty - elif total_qty > bin_dict.get("projected_qty", 0): - required_qty = total_qty - bin_dict.get("projected_qty", 0) + required_qty = total_qty[row.get("item_code")] + elif total_qty[row.get("item_code")] > bin_dict.get("projected_qty", 0): + required_qty = total_qty[row.get("item_code")] - bin_dict.get("projected_qty", 0) + total_qty[row.get("item_code")] -= required_qty if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]: required_qty = row["min_order_qty"] @@ -1432,7 +1451,7 @@ def get_material_request_items( "item_name": row.item_name, "quantity": required_qty / conversion_factor, "conversion_factor": conversion_factor, - "required_bom_qty": total_qty, + "required_bom_qty": row.get("qty"), "stock_uom": row.get("stock_uom"), "warehouse": warehouse or row.get("source_warehouse") @@ -1448,7 +1467,8 @@ def get_material_request_items( "sales_order": sales_order, "description": row.get("description"), "uom": row.get("purchase_uom") or row.get("stock_uom"), - "main_bom_item": row.get("main_bom_item"), + "main_item_code": row.get("main_bom_item"), + "from_bom": row.get("main_bom"), } @@ -1629,7 +1649,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d sub_assembly_items = defaultdict(int) if doc.get("skip_available_sub_assembly_item") and doc.get("sub_assembly_items"): for d in doc.get("sub_assembly_items"): - sub_assembly_items[(d.get("production_item"), d.get("bom_no"))] += d.get("qty") + sub_assembly_items[ + (d.get("production_item"), d.get("bom_no"), d.get("type_of_manufacturing")) + ] += d.get("qty") + sub_assembly_items = {k[:2]: v for k, v in sub_assembly_items.items()} for data in po_items: if not data.get("include_exploded_items") and doc.get("sub_assembly_items"): @@ -1718,19 +1741,21 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d sales_order = data.get("sales_order") - for item_code, details in item_details.items(): + for key, details in item_details.items(): so_item_details.setdefault(sales_order, frappe._dict()) - if item_code in so_item_details.get(sales_order, {}): - so_item_details[sales_order][item_code]["qty"] = so_item_details[sales_order][item_code].get( + if key in so_item_details.get(sales_order, {}): + so_item_details[sales_order][key]["qty"] = so_item_details[sales_order][key].get( "qty", 0 ) + flt(details.qty) else: - so_item_details[sales_order][item_code] = details + so_item_details[sales_order][key] = details mr_items = [] for sales_order in so_item_details: item_dict = so_item_details[sales_order] + total_qty = defaultdict(float) for details in item_dict.values(): + total_qty[details.item_code] += flt(details.qty) bin_dict = get_bin_details(details, doc.company, warehouse) bin_dict = bin_dict[0] if bin_dict else {} @@ -1744,6 +1769,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d include_safety_stock, warehouse, bin_dict, + total_qty, ) if items: mr_items.append(items) @@ -1998,7 +2024,7 @@ def get_raw_materials_of_sub_assembly_items( item_default = frappe.qb.DocType("Item Default") item_uom = frappe.qb.DocType("UOM Conversion Detail") - items = ( + query = ( frappe.qb.from_(bei) .join(bom) .on(bom.name == bei.parent) @@ -2024,6 +2050,7 @@ def get_raw_materials_of_sub_assembly_items( item_uom.conversion_factor, item.safety_stock, bom.item.as_("main_bom_item"), + bom.name.as_("main_bom"), ) .where( (bei.docstatus == 1) @@ -2032,11 +2059,13 @@ def get_raw_materials_of_sub_assembly_items( & (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1) ) .groupby(bei.item_code, bei.stock_uom) - ).run(as_dict=True) + ) - for item in items: + for item in query.run(as_dict=True): key = (item.item_code, item.bom_no) - if (item.bom_no and key not in sub_assembly_items) or (item.item_code in existing_sub_assembly_items): + if (item.bom_no and key not in sub_assembly_items) or ( + (item.item_code, item.bom_no or item.main_bom) in existing_sub_assembly_items + ): continue if item.bom_no: @@ -2050,15 +2079,15 @@ def get_raw_materials_of_sub_assembly_items( sub_assembly_items, planned_qty=planned_qty, ) - existing_sub_assembly_items.add(item.item_code) + existing_sub_assembly_items.add((item.item_code, item.bom_no or item.main_bom)) else: if not item.conversion_factor and item.purchase_uom: item.conversion_factor = get_uom_conversion_factor(item.item_code, item.purchase_uom) - if details := item_details.get(item.get("item_code")): + if details := item_details.get((item.get("item_code"), item.get("main_bom"))): details.qty += item.get("qty") else: - item_details.setdefault(item.get("item_code"), item) + item_details.setdefault((item.get("item_code"), item.get("main_bom")), item) return item_details diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index e7fe26c0ce5..233f8d06f75 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1944,11 +1944,17 @@ class TestProductionPlan(IntegrationTestCase): mr_items = get_items_for_material_requests(plan.as_dict()) + from collections import defaultdict + + mr_items_dict = defaultdict(float) + for item in mr_items: + mr_items_dict[item.get("item_code")] += item.get("quantity") + # RM Item 1 (FG1 (100 + 100) + FG2 (50) + FG3 (10) - 90 in stock - 80 sub assembly stock) - self.assertEqual(mr_items[0].get("quantity"), 90) + self.assertEqual(mr_items_dict["RM Item 1"], 90) # RM Item 2 (FG1 (100) + FG2 (50) + FG4 (10) - 80 sub assembly stock) - self.assertEqual(mr_items[1].get("quantity"), 80) + self.assertEqual(mr_items_dict["RM Item 2"], 80) def test_stock_reservation_against_production_plan(self): from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt @@ -2364,9 +2370,6 @@ class TestProductionPlan(IntegrationTestCase): def test_production_plan_for_partial_sub_assembly_items(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, - ) frappe.flags.test_print = False @@ -2440,6 +2443,7 @@ def create_production_plan(**args): "skip_available_sub_assembly_item": args.skip_available_sub_assembly_item or 0, "sub_assembly_warehouse": args.sub_assembly_warehouse, "reserve_stock": args.reserve_stock or 0, + "for_warehouse": args.for_warehouse or None, } ) 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 0dfa29b8ddd..5fbb83ae579 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 @@ -79,13 +79,14 @@ "fieldname": "received_qty", "fieldtype": "Float", "label": "Received Qty", + "no_copy": 1, "read_only": 1 }, { "fieldname": "bom_no", "fieldtype": "Link", "in_list_view": 1, - "label": "Bom No", + "label": "BOM No", "options": "BOM" }, { @@ -245,7 +246,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-06-10 13:36:24.759101", + "modified": "2025-11-03 14:33:50.677717", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Sub Assembly Item", diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index b91e8f5d619..ca7e3709b94 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -2502,18 +2502,11 @@ def get_auto_batch_nos(kwargs): def get_batch_nos_from_sre(kwargs): - from frappe.query_builder.functions import Max, Min, Sum + from frappe.query_builder.functions import Sum table = frappe.qb.DocType("Stock Reservation Entry") child_table = frappe.qb.DocType("Serial and Batch Entry") - if kwargs.based_on == "LIFO": - creation_field = Max(child_table.creation).as_("sort_creation") - order = frappe.query_builder.Order.desc - else: - creation_field = Min(child_table.creation).as_("sort_creation") - order = frappe.query_builder.Order.asc - query = ( frappe.qb.from_(table) .join(child_table) @@ -2522,7 +2515,6 @@ def get_batch_nos_from_sre(kwargs): child_table.batch_no, child_table.warehouse, Sum(child_table.qty - child_table.delivered_qty).as_("qty"), - creation_field, ) .where( (table.docstatus == 1) @@ -2530,7 +2522,6 @@ def get_batch_nos_from_sre(kwargs): & (child_table.qty != child_table.delivered_qty) ) .groupby(child_table.batch_no, child_table.warehouse) - .orderby("sort_creation", order=order) .orderby(child_table.batch_no, order=frappe.query_builder.Order.asc) ) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 5906eb36c56..64236aa2a8d 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -202,6 +202,12 @@ class StockEntry(StockController, SubcontractingInwardController): for item in self.get("items"): item.update(get_bin_details(item.item_code, item.s_warehouse)) + def before_insert(self): + if self.subcontracting_order and frappe.get_cached_value( + "Subcontracting Order", self.subcontracting_order, "reserve_stock" + ): + self.set_serial_batch_from_reserved_entry() + def before_validate(self): from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule @@ -274,9 +280,10 @@ class StockEntry(StockController, SubcontractingInwardController): self.update_work_order() self.update_disassembled_order() self.adjust_stock_reservation_entries_for_return() - self.update_sre_for_subcontracting_delivery() + self.update_stock_reservation_entries() self.update_stock_ledger() self.make_stock_reserve_for_wip_and_fg() + self.reserve_stock_for_subcontracting() self.update_subcontract_order_supplied_items() self.update_subcontracting_order_status() @@ -324,7 +331,7 @@ class StockEntry(StockController, SubcontractingInwardController): self.update_transferred_qty() self.update_quality_inspection() self.adjust_stock_reservation_entries_for_return() - self.update_sre_for_subcontracting_delivery() + self.update_stock_reservation_entries() self.delete_auto_created_batches() self.delete_linked_stock_entry() @@ -1889,6 +1896,30 @@ class StockEntry(StockController, SubcontractingInwardController): pro_doc.set_reserved_qty_for_wip_and_fg(self) + def reserve_stock_for_subcontracting(self): + if self.purpose == "Send to Subcontractor" and frappe.get_value( + "Subcontracting Order", self.subcontracting_order, "reserve_stock" + ): + items = {} + for item in self.items: + if item.sco_rm_detail in items: + items[item.sco_rm_detail].qty_to_reserve += item.transfer_qty + items[item.sco_rm_detail].serial_and_batch_bundles.append(item.serial_and_batch_bundle) + else: + items[item.sco_rm_detail] = frappe._dict( + { + "name": item.sco_rm_detail, + "qty_to_reserve": item.transfer_qty, + "warehouse": item.t_warehouse, + "reference_voucher_detail_no": item.name, + "serial_and_batch_bundles": [item.serial_and_batch_bundle], + } + ) + + frappe.get_doc("Subcontracting Order", self.subcontracting_order).reserve_raw_materials( + items=items.values(), stock_entry=self.name + ) + def cancel_stock_reserve_for_wip_and_fg(self): if self.is_stock_reserve_for_work_order(): pro_doc = frappe.get_doc("Work Order", self.work_order) @@ -2230,21 +2261,16 @@ class StockEntry(StockController, SubcontractingInwardController): self.calculate_rate_and_amount(raise_error_if_no_rate=False) def set_serial_batch_from_reserved_entry(self): - if not self.work_order: - return + if self.work_order and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock"): + skip_transfer = frappe.get_cached_value("Work Order", self.work_order, "skip_transfer") - if not frappe.get_cached_value("Work Order", self.work_order, "reserve_stock"): - return - - skip_transfer = frappe.get_cached_value("Work Order", self.work_order, "skip_transfer") - - if ( - self.purpose not in ["Material Transfer for Manufacture"] - and frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on") - != "BOM" - and not skip_transfer - ): - return + if ( + self.purpose not in ["Material Transfer for Manufacture"] + and frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on") + != "BOM" + and not skip_transfer + ): + return reservation_entries = self.get_available_reserved_materials() if not reservation_entries: @@ -2252,6 +2278,9 @@ class StockEntry(StockController, SubcontractingInwardController): new_items_to_add = [] for d in self.items: + if d.serial_and_batch_bundle or d.serial_no or d.batch_no: + continue + key = (d.item_code, d.s_warehouse) if details := reservation_entries.get(key): original_qty = d.qty @@ -2363,7 +2392,7 @@ class StockEntry(StockController, SubcontractingInwardController): ) .where( (doctype.docstatus == 1) - & (doctype.voucher_no == self.work_order) + & (doctype.voucher_no == (self.work_order or self.subcontracting_order)) & (serial_batch_doc.delivered_qty < serial_batch_doc.qty) ) .orderby(serial_batch_doc.idx) diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index 5f81d391b59..795e7eebf35 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -84,7 +84,7 @@ "no_copy": 1, "oldfieldname": "voucher_type", "oldfieldtype": "Data", - "options": "\nSales Order\nWork Order\nSubcontracting Inward Order\nProduction Plan", + "options": "\nSales Order\nWork Order\nSubcontracting Inward Order\nProduction Plan\nSubcontracting Order", "print_width": "150px", "read_only": 1, "width": "150px" @@ -315,8 +315,7 @@ }, { "fieldname": "production_section", - "fieldtype": "Section Break", - "label": "Production" + "fieldtype": "Section Break" }, { "fieldname": "column_break_qdwj", @@ -335,7 +334,7 @@ { "fieldname": "transferred_qty", "fieldtype": "Float", - "label": "Qty in WIP Warehouse" + "label": "Transferred Qty" } ], "grid_page_length": 50, @@ -344,7 +343,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-12 19:48:33.170835", + "modified": "2025-11-10 16:09:10.380024", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index d6ec32aeff8..4c87c40df15 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -64,7 +64,12 @@ class StockReservationEntry(Document): voucher_no: DF.DynamicLink | None voucher_qty: DF.Float voucher_type: DF.Literal[ - "", "Sales Order", "Work Order", "Subcontracting Inward Order", "Production Plan" + "", + "Sales Order", + "Work Order", + "Subcontracting Inward Order", + "Production Plan", + "Subcontracting Order", ] warehouse: DF.Link | None # end: auto-generated types @@ -338,7 +343,7 @@ class StockReservationEntry(Document): def validate_reservation_based_on_serial_and_batch(self) -> None: """Validates `Reserved Qty`, `Serial and Batch Nos` when `Reservation Based On` is `Serial and Batch`.""" - if self.voucher_type == "Work Order": + if self.voucher_type in ["Work Order", "Subcontracting Order"]: return if self.reservation_based_on == "Serial and Batch": @@ -460,13 +465,14 @@ class StockReservationEntry(Document): "Sales Order": "Sales Order Item", "Work Order": "Work Order Item", "Production Plan": "Production Plan Sub Assembly Item", + "Subcontracting Order": "Subcontracting Order Supplied Item", }.get(self.voucher_type, None) if item_doctype: sre = frappe.qb.DocType("Stock Reservation Entry") reserved_qty = ( frappe.qb.from_(sre) - .select(Sum(sre.reserved_qty)) + .select(Sum(sre.reserved_qty - sre.delivered_qty - sre.transferred_qty - sre.consumed_qty)) .where( (sre.docstatus == 1) & (sre.voucher_type == self.voucher_type) @@ -574,7 +580,7 @@ class StockReservationEntry(Document): ) from_voucher_detail_no = None - if self.from_voucher_type and self.from_voucher_type == "Stock Entry": + if self.from_voucher_type and self.from_voucher_type in ["Stock Entry", "Production Plan"]: from_voucher_detail_no = self.from_voucher_detail_no total_reserved_qty = get_sre_reserved_qty_for_voucher_detail_no( @@ -1276,7 +1282,7 @@ class StockReservation: if not reservation_entries: return - entries_to_reserve = frappe._dict({}) + entries_to_reserve = frappe._dict() for row in reservation_entries: reserved_qty_field = "reserved_qty" if row.reservation_based_on == "Qty" else "sabb_qty" delivered_qty_field = ( @@ -1293,7 +1299,7 @@ class StockReservation: if available_qty <= 0: continue - key = (row.item_code, row.warehouse) + key = (row.item_code, row.warehouse, entry.voucher_detail_no) if key not in entries_to_reserve: entries_to_reserve.setdefault( @@ -1303,7 +1309,7 @@ class StockReservation: "qty_to_reserve": 0.0, "item_code": row.item_code, "warehouse": row.warehouse, - "voucher_type": entry.voucher_type, + "voucher_type": entry.voucher_type or to_doctype, "voucher_no": entry.voucher_no, "voucher_detail_no": entry.voucher_detail_no, "serial_nos": [], @@ -1475,6 +1481,9 @@ class StockReservation: .orderby(sabb_entry.idx) ) + if self.items and (data := [item.from_voucher_detail_no for item in self.items]): + query = query.where(sre.voucher_detail_no.isin(data)) + if against_fg_item: query = query.where( sre.voucher_detail_no.isin( @@ -1490,9 +1499,14 @@ class StockReservation: def get_items_to_reserve(self, docnames, from_doctype, to_doctype): field = frappe.scrub(from_doctype) + item_code_fieldname, child_table_suffix = ( + ("rm_item_code", " Supplied Item") + if to_doctype == "Subcontracting Order" + else ("item_code", " Item") + ) doctype = frappe.qb.DocType(to_doctype) - child_doctype = frappe.qb.DocType(to_doctype + " Item") + child_doctype = frappe.qb.DocType(to_doctype + child_table_suffix) query = ( frappe.qb.from_(doctype) @@ -1501,11 +1515,12 @@ class StockReservation: .select( doctype.name.as_("voucher_no"), child_doctype.name.as_("voucher_detail_no"), - child_doctype.item_code, + child_doctype[item_code_fieldname].as_("item_code"), doctype.company, child_doctype.stock_uom, ) .where((doctype.docstatus == 1) & (doctype[field].isin(docnames))) + .groupby(child_doctype.name) ) if to_doctype == "Work Order": @@ -1523,6 +1538,15 @@ class StockReservation: (doctype.qty > doctype.material_transferred_for_manufacturing) & (doctype.status != "Completed") ) + elif to_doctype == "Subcontracting Order": + query = query.select( + child_doctype.stock_reserved_qty, + child_doctype.required_qty.as_("qty"), + child_doctype.reserve_warehouse.as_("source_warehouse"), + ) + + if self.items and (data := [item.voucher_detail_no for item in self.items]): + query = query.where(child_doctype.name.isin(data)) data = query.run(as_dict=True) items = [] diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js index 11d4dad94ee..f8661e59e4b 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js @@ -172,11 +172,279 @@ frappe.ui.form.on("Subcontracting Order", { __("Status") ); } + + if (frm.doc.reserve_stock) { + if (frm.doc.status !== "Closed") { + if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) { + frm.add_custom_button( + __("Reserve"), + () => frm.events.create_stock_reservation_entries(frm), + __("Stock Reservation") + ); + } + } + + if ( + frm.doc.__onload && + frm.doc.__onload.has_reserved_stock && + frappe.model.can_cancel("Stock Reservation Entry") + ) { + frm.add_custom_button( + __("Unreserve"), + () => frm.events.cancel_stock_reservation_entries(frm), + __("Stock Reservation") + ); + } + + frm.doc.supplied_items.forEach((item) => { + if ( + flt(item.stock_reserved_qty) > 0 && + frappe.model.can_read("Stock Reservation Entry") + ) { + frm.add_custom_button( + __("Reserved Stock"), + () => frm.events.show_reserved_stock(frm), + __("Stock Reservation") + ); + return; + } + }); + } } frm.trigger("get_materials_from_supplier"); }, + create_stock_reservation_entries(frm) { + const dialog = new frappe.ui.Dialog({ + title: __("Stock Reservation"), + size: "extra-large", + fields: [ + { + fieldname: "items", + fieldtype: "Table", + label: __("Items to Reserve"), + allow_bulk_edit: false, + cannot_add_rows: true, + cannot_delete_rows: true, + data: [], + fields: [ + { + fieldname: "subcontracting_order_supplied_item", + fieldtype: "Link", + label: __("Subcontracting Order Supplied Item"), + options: "Subcontracting Order Supplied Item", + reqd: 1, + in_list_view: 1, + read_only: 1, + get_query: () => { + return { + query: "erpnext.controllers.queries.get_filtered_child_rows", + filters: { + parenttype: frm.doc.doctype, + parent: frm.doc.name, + }, + }; + }, + }, + { + fieldname: "rm_item_code", + fieldtype: "Link", + label: __("Item Code"), + options: "Item", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "warehouse", + fieldtype: "Link", + label: __("Warehouse"), + options: "Warehouse", + reqd: 1, + in_list_view: 1, + read_only: 1, + }, + { + fieldname: "qty_to_reserve", + fieldtype: "Float", + label: __("Qty"), + reqd: 1, + in_list_view: 1, + }, + ], + }, + ], + primary_action_label: __("Reserve Stock"), + primary_action: () => { + var data = { items: dialog.fields_dict.items.grid.get_selected_children() }; + + if (data.items && data.items.length > 0) { + frappe.call({ + doc: frm.doc, + method: "reserve_raw_materials", + args: { + items: data.items.map((item) => ({ + name: item.subcontracting_order_supplied_item, + qty_to_reserve: item.qty_to_reserve, + })), + }, + freeze: true, + freeze_message: __("Reserving Stock..."), + callback: (_) => { + frm.reload_doc(); + }, + }); + + dialog.hide(); + } else { + frappe.msgprint(__("Please select items to reserve.")); + } + }, + }); + + frm.doc.supplied_items.forEach((item) => { + let unreserved_qty = + flt(item.required_qty) - flt(item.supplied_qty) - flt(item.stock_reserved_qty); + + if (unreserved_qty > 0) { + dialog.fields_dict.items.df.data.push({ + __checked: 1, + subcontracting_order_supplied_item: item.name, + rm_item_code: item.rm_item_code, + warehouse: item.reserve_warehouse, + qty_to_reserve: unreserved_qty, + }); + } + }); + + dialog.fields_dict.items.grid.refresh(); + dialog.show(); + }, + + cancel_stock_reservation_entries(frm) { + const dialog = new frappe.ui.Dialog({ + title: __("Stock Unreservation"), + size: "extra-large", + fields: [ + { + fieldname: "sr_entries", + fieldtype: "Table", + label: __("Reserved Stock"), + allow_bulk_edit: false, + cannot_add_rows: true, + cannot_delete_rows: true, + in_place_edit: true, + data: [], + fields: [ + { + fieldname: "sre", + fieldtype: "Link", + label: __("Stock Reservation Entry"), + options: "Stock Reservation Entry", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "item_code", + fieldtype: "Link", + label: __("Item Code"), + options: "Item", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "warehouse", + fieldtype: "Link", + label: __("Warehouse"), + options: "Warehouse", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "qty", + fieldtype: "Float", + label: __("Qty"), + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + ], + }, + ], + primary_action_label: __("Unreserve Stock"), + primary_action: () => { + var data = { sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children() }; + + if (data.sr_entries && data.sr_entries.length > 0) { + frappe.call({ + doc: frm.doc, + method: "cancel_stock_reservation_entries", + args: { + sre_list: data.sr_entries.map((item) => item.sre), + }, + freeze: true, + freeze_message: __("Unreserving Stock..."), + callback: (_) => { + frm.doc.__onload.has_reserved_stock = false; + frm.reload_doc(); + }, + }); + + dialog.hide(); + } else { + frappe.msgprint(__("Please select items to unreserve.")); + } + }, + }); + + frappe + .call({ + method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.get_stock_reservation_entries_for_voucher", + args: { + voucher_type: frm.doctype, + voucher_no: frm.doc.name, + }, + callback: (r) => { + if (!r.exc && r.message) { + r.message.forEach((sre) => { + if (flt(sre.reserved_qty) > flt(sre.delivered_qty)) { + dialog.fields_dict.sr_entries.df.data.push({ + sre: sre.name, + item_code: sre.item_code, + warehouse: sre.warehouse, + qty: flt(sre.reserved_qty) - flt(sre.delivered_qty), + }); + } + }); + } + }, + }) + .then((r) => { + dialog.fields_dict.sr_entries.grid.refresh(); + dialog.show(); + }); + }, + + show_reserved_stock(frm) { + // Get the latest modified date from the items table. + var to_date = moment(new Date(Math.max(...frm.doc.items.map((e) => new Date(e.modified))))).format( + "YYYY-MM-DD" + ); + + frappe.route_options = { + company: frm.doc.company, + from_date: frm.doc.transaction_date, + to_date: to_date, + voucher_type: frm.doc.doctype, + voucher_no: frm.doc.name, + }; + frappe.set_route("query-report", "Reserved Stock"); + }, + update_subcontracting_order_status(frm, status) { frappe.call({ method: "erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order.update_subcontracting_order_status", diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json index 9d2888058a5..d0223a3acd2 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json @@ -36,6 +36,7 @@ "service_items", "raw_materials_supplied_section", "set_reserve_warehouse", + "reserve_stock", "supplied_items", "tab_address_and_contact", "supplier_address", @@ -62,7 +63,8 @@ "select_print_heading", "column_break_43", "letter_head", - "tab_connections" + "tab_connections", + "production_plan" ], "fields": [ { @@ -471,6 +473,22 @@ "no_copy": 1, "options": "Currency", "read_only": 1 + }, + { + "default": "0", + "fieldname": "reserve_stock", + "fieldtype": "Check", + "label": "Reserve Stock", + "no_copy": 1, + "show_on_timeline": 1 + }, + { + "fieldname": "production_plan", + "fieldtype": "Data", + "hidden": 1, + "label": "Production Plan", + "no_copy": 1, + "read_only": 1 } ], "icon": "fa fa-file-text", diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index be7b6ec2247..dc5f11953c4 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -8,6 +8,10 @@ from frappe.utils import flt from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.subcontracting_controller import SubcontractingController +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + StockReservation, + has_reserved_stock, +) from erpnext.stock.stock_balance import update_bin_qty from erpnext.stock.utils import get_bin @@ -50,8 +54,10 @@ class SubcontractingOrder(SubcontractingController): letter_head: DF.Link | None naming_series: DF.Literal["SC-ORD-.YYYY.-"] per_received: DF.Percent + production_plan: DF.Data | None project: DF.Link | None purchase_order: DF.Link + reserve_stock: DF.Check schedule_date: DF.Date | None select_print_heading: DF.Link | None service_items: DF.Table[SubcontractingOrderServiceItem] @@ -105,6 +111,13 @@ class SubcontractingOrder(SubcontractingController): frappe.db.get_single_value("Buying Settings", "over_transfer_allowance"), ) + if self.reserve_stock: + if self.has_unreserved_stock(): + self.set_onload("has_unreserved_stock", True) + + if has_reserved_stock(self.doctype, self.name): + self.set_onload("has_reserved_stock", True) + def before_validate(self): super().before_validate() @@ -121,6 +134,7 @@ class SubcontractingOrder(SubcontractingController): self.update_prevdoc_status() self.update_status() self.update_subcontracted_quantity_in_po() + self.reserve_raw_materials() def on_cancel(self): self.update_prevdoc_status() @@ -253,10 +267,10 @@ class SubcontractingOrder(SubcontractingController): if si.fg_item: item = frappe.get_doc("Item", si.fg_item) - qty, subcontracted_qty, fg_item_qty = frappe.db.get_value( + qty, subcontracted_qty, fg_item_qty, production_plan_sub_assembly_item = frappe.db.get_value( "Purchase Order Item", si.purchase_order_item, - ["qty", "subcontracted_qty", "fg_item_qty"], + ["qty", "subcontracted_qty", "fg_item_qty", "production_plan_sub_assembly_item"], ) available_qty = flt(qty) - flt(subcontracted_qty) @@ -292,6 +306,7 @@ class SubcontractingOrder(SubcontractingController): "purchase_order_item": si.purchase_order_item, "material_request": si.material_request, "material_request_item": si.material_request_item, + "production_plan_sub_assembly_item": production_plan_sub_assembly_item, } ) else: @@ -362,6 +377,90 @@ class SubcontractingOrder(SubcontractingController): subcontracted_qty, ) + @frappe.whitelist() + def reserve_raw_materials(self, items=None, stock_entry=None): + if self.reserve_stock: + item_dict = {} + + if items: + item_dict = {d["name"]: d for d in items} + items = [item for item in self.supplied_items if item.name in item_dict] + + reservation_items = [] + is_transfer = False + for item in items or self.supplied_items: + data = frappe._dict( + { + "voucher_no": self.name, + "voucher_type": self.doctype, + "voucher_detail_no": item.name, + "item_code": item.rm_item_code, + "warehouse": item_dict.get(item.name, {}).get("warehouse", item.reserve_warehouse), + "stock_qty": item_dict.get(item.name, {}).get("qty_to_reserve", item.required_qty), + } + ) + + if stock_entry: + data.update( + { + "from_voucher_no": stock_entry, + "from_voucher_type": "Stock Entry", + "from_voucher_detail_no": item_dict[item.name]["reference_voucher_detail_no"], + "serial_and_batch_bundles": item_dict[item.name]["serial_and_batch_bundles"], + } + ) + elif self.production_plan: + fg_item = next(i for i in self.items if i.name == item.reference_name) + if production_plan_sub_assembly_item := fg_item.production_plan_sub_assembly_item: + from_voucher_detail_no, reserved_qty = frappe.get_value( + "Material Request Plan Item", + { + "parent": self.production_plan, + "item_code": item.rm_item_code, + "warehouse": item.reserve_warehouse, + "sub_assembly_item_reference": production_plan_sub_assembly_item, + "docstatus": 1, + }, + ["name", "stock_reserved_qty"], + ) + if flt(item.stock_reserved_qty) < reserved_qty: + is_transfer = True + data.update( + { + "from_voucher_no": self.production_plan, + "from_voucher_type": "Production Plan", + "from_voucher_detail_no": from_voucher_detail_no, + } + ) + + reservation_items.append(data) + + sre = StockReservation(self, items=reservation_items, notify=True) + if is_transfer: + sre.transfer_reservation_entries_to( + self.production_plan, from_doctype="Production Plan", to_doctype="Subcontracting Order" + ) + else: + if sre.make_stock_reservation_entries(): + frappe.msgprint(_("Stock Reservation Entries created"), alert=True, indicator="blue") + + def has_unreserved_stock(self) -> bool: + for item in self.get("supplied_items"): + if item.required_qty - flt(item.supplied_qty) - flt(item.stock_reserved_qty) > 0: + return True + + return False + + @frappe.whitelist() + def cancel_stock_reservation_entries(self, sre_list=None, notify=True) -> None: + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + cancel_stock_reservation_entries, + ) + + cancel_stock_reservation_entries( + voucher_type=self.doctype, voucher_no=self.name, sre_list=sre_list, notify=notify + ) + @frappe.whitelist() def make_subcontracting_receipt(source_name, target_doc=None): diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order_dashboard.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order_dashboard.py index f17d8cd961c..b0615bf4a9e 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order_dashboard.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order_dashboard.py @@ -4,5 +4,15 @@ from frappe import _ def get_data(): return { "fieldname": "subcontracting_order", - "transactions": [{"label": _("Reference"), "items": ["Subcontracting Receipt", "Stock Entry"]}], + "non_standard_fieldnames": {"Stock Reservation Entry": "voucher_no"}, + "transactions": [ + { + "label": _("Reference"), + "items": ["Subcontracting Receipt", "Stock Entry"], + }, + { + "label": _("Stock Reservation"), + "items": ["Stock Reservation Entry"], + }, + ], } diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index 913a15d40c8..363a9cdd565 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -700,6 +700,126 @@ class TestSubcontractingOrder(IntegrationTestCase): self.assertEqual(sco.supplied_items[0].required_qty, 210.149) + def test_stock_reservation(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_details_for_voucher, + ) + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 4", + "qty": 10, + "rate": 100, + "fg_item": "Subcontracted Item SA4", + "fg_item_qty": 10, + } + ] + + sco = get_subcontracting_order(service_items=service_items, do_not_submit=1) + sco.reserve_stock = 1 + + rm_items = get_rm_items(sco.supplied_items) + make_stock_in_entry(rm_items=rm_items) + sco.submit() + + sre_list = get_sre_details_for_voucher("Subcontracting Order", sco.name) + self.assertTrue(len(sre_list) > 0) + + se_dict = make_rm_stock_entry(sco.name) + se = frappe.get_doc(se_dict) + se.items[-1].use_serial_batch_fields = 1 + se.save() + se.submit() + sco.reload() + + for sre in sre_list: + self.assertEqual(frappe.get_value("Stock Reservation Entry", sre.name, "status"), "Closed") + + make_subcontracting_receipt(sco.name).submit() + for status in frappe.get_all( + "Stock Reservation Entry", filters={"voucher_no": sco.name, "docstatus": 1}, pluck="status" + )[:3]: + self.assertEqual(status, "Delivered") + + def test_stock_reservation_transfer(self): + from erpnext.manufacturing.doctype.production_plan.production_plan import ( + get_items_for_material_requests, + ) + from erpnext.manufacturing.doctype.production_plan.test_production_plan import create_production_plan + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_serial_batch_entries_for_voucher, + get_sre_details_for_voucher, + ) + + parent_fg = make_item() + make_bom( + item=parent_fg.name, raw_materials=["Subcontracted Item SA10"], rate=100, rm_qty=1, currency="INR" + ) + + plan = create_production_plan( + item_code=parent_fg.name, + planned_qty=10, + do_not_submit=True, + reserve_stock=True, + skip_available_sub_assembly_item=True, + for_warehouse="_Test Warehouse - _TC", + sub_assembly_warehouse="_Test Warehouse - _TC", + skip_getting_mr_items=True, + ) + plan.get_sub_assembly_items() + plan.sub_assembly_items[0].supplier = "_Test Supplier" + mr_items = get_items_for_material_requests(plan.as_dict()) + for d in mr_items: + plan.append("mr_items", d) + + make_stock_entry( + target="_Test Warehouse - _TC", item_code="Subcontracted SRM Item 1", qty=10, basic_rate=100 + ) + make_stock_entry( + target="_Test Warehouse - _TC", item_code="Subcontracted SRM Item 2", qty=10, basic_rate=100 + ) + make_stock_entry( + target="_Test Warehouse - _TC", item_code="Subcontracted SRM Item 3", qty=10, basic_rate=100 + ) + plan.submit() + + sre_against_plan = get_sre_details_for_voucher("Production Plan", plan.name) + sbe_pp_list = [] + for sre in sre_against_plan: + sbe_pp_list.append( + sorted( + get_serial_batch_entries_for_voucher(sre.name), + key=lambda x: x.get("serial_no") or x.get("batch_no") or "", + ) + ) + + plan.make_work_order() + po = frappe.get_doc( + "Purchase Order", + frappe.get_value("Purchase Order Item", {"production_plan": plan.name}, "parent"), + ) + po.items[0].item_code = "Subcontracted Service Item 4" + po.items[0].qty = 10 + po.submit() + so = create_subcontracting_order(po_name=po.name, do_not_save=1) + so.supplier_warehouse = "_Test Warehouse 1 - _TC" + so.reserve_stock = True + so.submit() + so.reload() + + sre_against_so = get_sre_details_for_voucher("Subcontracting Order", so.name) + sbe_so_list = [] + for sre in sre_against_so: + sbe_so_list.append( + sorted( + get_serial_batch_entries_for_voucher(sre.name), + key=lambda x: x.get("serial_no") or x.get("batch_no") or "", + ) + ) + + self.assertEqual(sbe_pp_list, sbe_so_list) + def create_subcontracting_order(**args): args = frappe._dict(args) diff --git a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json index 98154b9f7f7..689b64492f5 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json @@ -55,7 +55,8 @@ "section_break_34", "purchase_order_item", "page_break", - "subcontracting_conversion_factor" + "subcontracting_conversion_factor", + "production_plan_sub_assembly_item" ], "fields": [ { @@ -407,6 +408,16 @@ "hidden": 1, "label": "Subcontracting Conversion Factor", "read_only": 1 + }, + { + "fieldname": "production_plan_sub_assembly_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Production Plan Sub Assembly Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1, + "report_hide": 1 } ], "grid_page_length": 50, @@ -414,7 +425,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-08-10 22:37:39.863628", + "modified": "2025-11-03 12:29:45.156101", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Order Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.py b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.py index af741b6637c..bb390717171 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.py @@ -35,6 +35,7 @@ class SubcontractingOrderItem(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + production_plan_sub_assembly_item: DF.Data | None project: DF.Link | None purchase_order_item: DF.Data | None qty: DF.Float diff --git a/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.json b/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.json index f4f8d540a85..acd6aae6220 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.json @@ -21,6 +21,7 @@ "section_break_13", "required_qty", "supplied_qty", + "stock_reserved_qty", "column_break_16", "consumed_qty", "returned_qty", @@ -52,7 +53,7 @@ { "fieldname": "stock_uom", "fieldtype": "Link", - "label": "Stock Uom", + "label": "Stock UOM", "options": "UOM", "read_only": 1 }, @@ -160,18 +161,29 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:parent.reserve_stock", + "fieldname": "stock_reserved_qty", + "fieldtype": "Float", + "label": "Reserved Qty", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 } ], "hide_toolbar": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:46.680164", + "modified": "2025-10-30 16:00:43.379828", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Order Supplied Item", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.py b/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.py index 4892601d082..cace603faff 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_order_supplied_item/subcontracting_order_supplied_item.py @@ -28,6 +28,7 @@ class SubcontractingOrderSuppliedItem(Document): reserve_warehouse: DF.Link | None returned_qty: DF.Float rm_item_code: DF.Link | None + stock_reserved_qty: DF.Float stock_uom: DF.Link | None supplied_qty: DF.Float total_supplied_qty: DF.Float diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 51520d401ba..a622b1fb9c4 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -164,6 +164,8 @@ class SubcontractingReceipt(SubcontractingController): for table_name in ["items", "supplied_items"]: self.make_bundle_using_old_serial_batch_fields(table_name) + + self.update_stock_reservation_entries() self.update_stock_ledger() self.make_gl_entries() self.repost_future_sle_and_gle() @@ -189,6 +191,7 @@ class SubcontractingReceipt(SubcontractingController): self.set_consumed_qty_in_subcontract_order() self.set_subcontracting_order_status(update_bin=False) self.update_stock_ledger() + self.update_stock_reservation_entries() self.make_gl_entries_on_cancel() self.repost_future_sle_and_gle() self.update_status() @@ -199,7 +202,7 @@ class SubcontractingReceipt(SubcontractingController): def reset_raw_materials(self): self.supplied_items = [] self.flags.reset_raw_materials = True - self.create_raw_materials_supplied() + self.create_raw_materials_supplied_or_received() def validate_closed_subcontracting_order(self): for item in self.items: @@ -853,6 +856,17 @@ class SubcontractingReceipt(SubcontractingController): if frappe.db.get_single_value("Buying Settings", "auto_create_purchase_receipt"): make_purchase_receipt(self, save=True, notify=True) + def has_reserved_stock(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_details_for_voucher, + ) + + for item in self.supplied_items: + if get_sre_details_for_voucher("Subcontracting Order", item.subcontracting_order): + return True + + return False + @frappe.whitelist() def make_subcontract_return_against_rejected_warehouse(source_name):