From 0bc3cfe29d5777c1b600db41ed109e600ffc6b3d Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 12 May 2025 12:19:23 +0530 Subject: [PATCH] feat: stock reservation for production plan (#47373) * feat: reservation for production plan * test: test case for serial/batch * feat: reserve & un-reserve options in the production plan --- .../material_request_plan_item.json | 49 +- .../material_request_plan_item.py | 1 + .../production_plan/production_plan.js | 86 +++- .../production_plan/production_plan.json | 38 +- .../production_plan/production_plan.py | 181 +++++--- .../production_plan_dashboard.py | 4 + .../production_plan/test_production_plan.py | 429 +++++++++++++++++- .../production_plan_item_reference.json | 8 +- .../production_plan_item_reference.py | 2 +- .../production_plan_sub_assembly_item.json | 66 ++- .../production_plan_sub_assembly_item.py | 2 + .../doctype/work_order/work_order.json | 18 +- .../doctype/work_order/work_order.py | 138 ++++-- erpnext/public/js/stock_reservation.js | 23 +- .../purchase_receipt/purchase_receipt.py | 67 ++- .../stock/doctype/stock_entry/stock_entry.py | 12 +- .../stock_reservation_entry.json | 10 +- .../stock_reservation_entry.py | 194 +++++++- erpnext/stock/utils.py | 2 + 19 files changed, 1153 insertions(+), 177 deletions(-) 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 db5e19a601f..f10db8d8a7c 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 @@ -10,13 +10,17 @@ "warehouse", "item_name", "material_request_type", - "quantity", - "required_bom_qty", "column_break_4", - "schedule_date", "uom", "conversion_factor", + "section_break_azee", + "required_bom_qty", + "projected_qty", + "column_break_wack", + "quantity", + "stock_reserved_qty", "item_details", + "schedule_date", "description", "min_order_qty", "section_break_8", @@ -27,7 +31,6 @@ "reserved_qty_for_production", "column_break_yhelv", "ordered_qty", - "projected_qty", "safety_stock" ], "fields": [ @@ -47,7 +50,7 @@ "label": "Item Name" }, { - "columns": 2, + "columns": 3, "fieldname": "warehouse", "fieldtype": "Link", "in_list_view": 1, @@ -58,7 +61,7 @@ "search_index": 1 }, { - "columns": 1, + "columns": 2, "fieldname": "material_request_type", "fieldtype": "Select", "in_list_view": 1, @@ -70,17 +73,19 @@ "fieldtype": "Column Break" }, { - "columns": 2, + "columns": 3, "fieldname": "quantity", "fieldtype": "Float", "in_list_view": 1, - "label": "Plan to Request Qty", + "label": "Required Qty", "no_copy": 1, "reqd": 1 }, { + "columns": 2, "fieldname": "projected_qty", "fieldtype": "Float", + "in_list_view": 1, "label": "Projected Qty", "read_only": 1 }, @@ -89,7 +94,6 @@ "default": "0", "fieldname": "actual_qty", "fieldtype": "Float", - "in_list_view": 1, "label": "Qty In Stock", "no_copy": 1, "read_only": 1 @@ -172,11 +176,11 @@ "read_only": 1 }, { - "columns": 2, + "columns": 3, "fieldname": "required_bom_qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Qty As Per BOM", + "label": "Reqd Qty (BOM)", "no_copy": 1, "read_only": 1 }, @@ -187,7 +191,7 @@ "read_only": 1 }, { - "columns": 1, + "columns": 2, "fieldname": "schedule_date", "fieldtype": "Date", "in_list_view": 1, @@ -201,19 +205,36 @@ { "fieldname": "column_break_yhelv", "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_azee", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_wack", + "fieldtype": "Column Break" + }, + { + "fieldname": "stock_reserved_qty", + "fieldtype": "Float", + "label": "Stock Reserved Qty", + "no_copy": 1, + "read_only": 1 } ], + "grid_page_length": 50, "istable": 1, "links": [], - "modified": "2024-12-30 18:06:22.288340", + "modified": "2025-05-01 14:50:55.805442", "modified_by": "Administrator", "module": "Manufacturing", "name": "Material Request Plan Item", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} 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 3d3130f77d8..2b6e0994f46 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 @@ -42,6 +42,7 @@ class MaterialRequestPlanItem(Document): safety_stock: DF.Float sales_order: DF.Link | None schedule_date: DF.Date | None + stock_reserved_qty: DF.Float uom: DF.Link | None warehouse: DF.Link # end: auto-generated types diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index ab09a0f02f9..5ab291bb56a 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -9,6 +9,13 @@ frappe.ui.form.on("Production Plan", { }); }, + hide_reserve_stock_button(frm) { + frm.toggle_display("reserve_stock", false); + if (frm.doc.__onload?.enable_stock_reservation) { + frm.toggle_display("reserve_stock", true); + } + }, + setup(frm) { frm.trigger("setup_queries"); @@ -16,6 +23,9 @@ frappe.ui.form.on("Production Plan", { "Work Order": "Work Order / Subcontract PO", "Material Request": "Material Request", }; + + frm.set_df_property("sub_assembly_items", "cannot_delete_rows", true); + frm.set_df_property("mr_items", "cannot_delete_rows", true); }, setup_queries(frm) { @@ -140,12 +150,16 @@ frappe.ui.form.on("Production Plan", { ); } } + + if (frm.doc.status !== "Closed") { + frm.page.set_inner_btn_group_as_primary(__("Create")); + } } - if (frm.doc.status !== "Closed") { - frm.page.set_inner_btn_group_as_primary(__("Create")); - } frm.trigger("material_requirement"); + frm.trigger("hide_reserve_stock_button"); + frm.trigger("setup_stock_reservation_for_sub_assembly"); + frm.trigger("setup_stock_reservation_for_raw_materials"); const projected_qty_formula = `
@@ -193,6 +207,72 @@ frappe.ui.form.on("Production Plan", { set_field_options("projected_qty_formula", projected_qty_formula); }, + has_unreserved_stock(frm, table) { + let has_unreserved_stock = frm.doc[table].some( + (item) => flt(item.qty) > flt(item.stock_reserved_qty) + ); + + return has_unreserved_stock; + }, + + has_reserved_stock(frm, table) { + let has_reserved_stock = frm.doc[table].some((item) => flt(item.stock_reserved_qty) > 0); + + return has_reserved_stock; + }, + + setup_stock_reservation_for_sub_assembly(frm) { + if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) { + if (frm.events.has_unreserved_stock(frm, "sub_assembly_items")) { + frm.add_custom_button( + __("Reserve for Sub-assembly"), + () => erpnext.stock_reservation.make_entries(frm, "sub_assembly_items"), + __("Stock Reservation") + ); + } + + if (frm.events.has_reserved_stock(frm, "sub_assembly_items")) { + frm.add_custom_button( + __("Unreserve for Sub-assembly"), + () => erpnext.stock_reservation.unreserve_stock(frm), + __("Stock Reservation") + ); + + frm.add_custom_button( + __("Reserved Stock for Sub-assembly"), + () => erpnext.stock_reservation.show_reserved_stock(frm, "sub_assembly_items"), + __("Stock Reservation") + ); + } + } + }, + + setup_stock_reservation_for_raw_materials(frm) { + if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) { + if (frm.events.has_unreserved_stock(frm, "mr_items")) { + frm.add_custom_button( + __("Reserve for Raw Materials"), + () => erpnext.stock_reservation.make_entries(frm, "mr_items"), + __("Stock Reservation") + ); + } + + if (frm.events.has_reserved_stock(frm, "mr_items")) { + frm.add_custom_button( + __("Unreserve for Raw Materials"), + () => erpnext.stock_reservation.unreserve_stock(frm), + __("Stock Reservation") + ); + + frm.add_custom_button( + __("Reserved Stock for Raw Materials"), + () => erpnext.stock_reservation.show_reserved_stock(frm, "mr_items"), + __("Stock Reservation") + ); + } + } + }, + close_open_production_plan(frm, close = false) { frappe.call({ method: "set_status", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 01ab5fc7301..d733cb27845 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -11,6 +11,7 @@ "get_items_from", "column_break1", "posting_date", + "reserve_stock", "filters", "item_code", "customer", @@ -23,6 +24,7 @@ "from_delivery_date", "to_delivery_date", "sales_orders_detail", + "combine_items", "get_sales_orders", "sales_orders", "material_request_detail", @@ -30,16 +32,14 @@ "material_requests", "select_items_to_manufacture_section", "get_items", - "combine_items", "po_items", "section_break_25", "prod_plan_references", "section_break_24", - "combine_sub_items", "sub_assembly_warehouse", - "section_break_ucc4", - "skip_available_sub_assembly_item", "column_break_igxl", + "skip_available_sub_assembly_item", + "combine_sub_items", "get_sub_assembly_items", "section_break_g4ip", "sub_assembly_items", @@ -215,7 +215,8 @@ { "fieldname": "material_request_planning", "fieldtype": "Section Break", - "label": "Material Request Planning" + "hide_border": 1, + "label": "For Raw Materials" }, { "default": "1", @@ -231,10 +232,10 @@ }, { "default": "1", - "description": "If enabled, the system will consider items with a shortfall in quantity. \n
\nQty = Reqd Qty (BOM) - Projected Qty", + "description": "If enabled, formula for Required Qty:
\nRequired Qty (BOM) - Projected Qty.
This helps avoid over-ordering.", "fieldname": "ignore_existing_ordered_qty", "fieldtype": "Check", - "label": "Skip Available Raw Materials" + "label": "Consider Projected Qty in Calculation (RM)" }, { "fieldname": "column_break_25", @@ -314,7 +315,7 @@ { "fieldname": "for_warehouse", "fieldtype": "Link", - "label": "Raw Materials Warehouse", + "label": "For Warehouse", "options": "Warehouse" }, { @@ -375,11 +376,13 @@ "label": "Get Sub Assembly Items" }, { + "depends_on": "eval:doc.get_items_from == 'Sales Order'", "fieldname": "from_delivery_date", "fieldtype": "Date", "label": "From Delivery Date" }, { + "depends_on": "eval:doc.get_items_from == 'Sales Order'", "fieldname": "to_delivery_date", "fieldtype": "Date", "label": "To Delivery Date" @@ -404,15 +407,10 @@ }, { "default": "1", - "description": "If enabled, the system will consider items with a shortfall in quantity. \n
\nQty = Reqd Qty (BOM) - Projected Qty", + "description": "If enabled, formula for Qty to Order:
\nRequired Qty (BOM) - Projected Qty.
This helps avoid over-ordering.", "fieldname": "skip_available_sub_assembly_item", "fieldtype": "Check", - "label": "Skip Available Sub Assembly Items" - }, - { - "fieldname": "section_break_ucc4", - "fieldtype": "Column Break", - "hide_border": 1 + "label": "Consider Projected Qty in Calculation" }, { "fieldname": "section_break_g4ip", @@ -423,7 +421,7 @@ "fieldtype": "Column Break" }, { - "description": "When a parent warehouse is chosen, the system conducts stock checks against the associated child warehouses", + "description": "When a parent warehouse is chosen, the system conducts Project Qty checks against the associated child warehouses", "fieldname": "sub_assembly_warehouse", "fieldtype": "Link", "label": "Sub Assembly Warehouse", @@ -435,6 +433,12 @@ "fieldname": "consider_minimum_order_qty", "fieldtype": "Check", "label": "Consider Minimum Order Qty" + }, + { + "default": "0", + "fieldname": "reserve_stock", + "fieldtype": "Check", + "label": "Reserve Stock" } ], "grid_page_length": 50, @@ -442,7 +446,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-04-08 17:24:09.394056", + "modified": "2025-05-09 18:55:45.500257", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index bddf43a9dcc..899e28e0a1e 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -9,6 +9,8 @@ from collections import defaultdict import frappe from frappe import _, msgprint from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.query_builder import Case from frappe.query_builder.functions import IfNull, Sum from frappe.utils import ( add_days, @@ -20,6 +22,7 @@ from frappe.utils import ( getdate, now_datetime, nowdate, + parse_json, ) from frappe.utils.csvutils import build_csv_response from pypika.terms import ExistsCriterion @@ -28,6 +31,7 @@ from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_childr from erpnext.manufacturing.doctype.bom.bom import validate_bom_no from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation from erpnext.stock.get_item_details import get_conversion_factor from erpnext.stock.utils import get_or_make_bin from erpnext.utilities.transaction_base import validate_uom_is_integer @@ -84,6 +88,7 @@ class ProductionPlan(Document): posting_date: DF.Date prod_plan_references: DF.Table[ProductionPlanItemReference] project: DF.Link | None + reserve_stock: DF.Check sales_order_status: DF.Literal["", "To Deliver and Bill", "To Bill", "To Deliver"] sales_orders: DF.Table[ProductionPlanSalesOrder] skip_available_sub_assembly_item: DF.Check @@ -108,6 +113,12 @@ class ProductionPlan(Document): warehouses: DF.TableMultiSelect[ProductionPlanMaterialRequestWarehouse] # end: auto-generated types + def onload(self): + self.set_onload( + "enable_stock_reservation", + frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"), + ) + def validate(self): self.set_pending_qty_in_row_without_reference() self.calculate_total_planned_qty() @@ -116,6 +127,11 @@ class ProductionPlan(Document): validate_uom_is_integer(self, "stock_uom", "planned_qty") self.validate_sales_orders() self.validate_material_request_type() + self.enable_auto_reserve_stock() + + def enable_auto_reserve_stock(self): + if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"): + self.reserve_stock = 1 def validate_material_request_type(self): for row in self.get("mr_items"): @@ -552,12 +568,20 @@ class ProductionPlan(Document): def on_submit(self): self.update_bin_qty() self.update_sales_order() + self.update_stock_reservation() def on_cancel(self): self.db_set("status", "Cancelled") self.delete_draft_work_order() self.update_bin_qty() self.update_sales_order() + self.update_stock_reservation() + + def update_stock_reservation(self): + if not self.reserve_stock: + return + + make_stock_reservation_entries(self) def update_sales_order(self): sales_orders = [row.sales_order for row in self.po_items if row.sales_order] @@ -851,6 +875,7 @@ class ProductionPlan(Document): wo = frappe.new_doc("Work Order") wo.update(item) + wo.reserve_stock = self.reserve_stock wo.planned_start_date = item.get("planned_start_date") or item.get("schedule_date") if item.get("warehouse"): @@ -1369,29 +1394,28 @@ def get_material_request_items( get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0 ) - if required_qty > 0: - return { - "item_code": row.item_code, - "item_name": row.item_name, - "quantity": required_qty / conversion_factor, - "conversion_factor": conversion_factor, - "required_bom_qty": total_qty, - "stock_uom": row.get("stock_uom"), - "warehouse": warehouse - or row.get("source_warehouse") - or row.get("default_warehouse") - or item_group_defaults.get("default_warehouse"), - "safety_stock": row.safety_stock, - "actual_qty": bin_dict.get("actual_qty", 0), - "projected_qty": bin_dict.get("projected_qty", 0), - "ordered_qty": bin_dict.get("ordered_qty", 0), - "reserved_qty_for_production": bin_dict.get("reserved_qty_for_production", 0), - "min_order_qty": row["min_order_qty"], - "material_request_type": row.get("default_material_request_type"), - "sales_order": sales_order, - "description": row.get("description"), - "uom": row.get("purchase_uom") or row.get("stock_uom"), - } + return { + "item_code": row.item_code, + "item_name": row.item_name, + "quantity": required_qty / conversion_factor, + "conversion_factor": conversion_factor, + "required_bom_qty": total_qty, + "stock_uom": row.get("stock_uom"), + "warehouse": warehouse + or row.get("source_warehouse") + or row.get("default_warehouse") + or item_group_defaults.get("default_warehouse"), + "safety_stock": row.safety_stock, + "actual_qty": bin_dict.get("actual_qty", 0), + "projected_qty": bin_dict.get("projected_qty", 0), + "ordered_qty": bin_dict.get("ordered_qty", 0), + "reserved_qty_for_production": bin_dict.get("reserved_qty_for_production", 0), + "min_order_qty": row["min_order_qty"], + "material_request_type": row.get("default_material_request_type"), + "sales_order": sales_order, + "description": row.get("description"), + "uom": row.get("purchase_uom") or row.get("stock_uom"), + } def get_sales_orders(self): @@ -1792,6 +1816,7 @@ def get_sub_assembly_items( if d.expandable: parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) + required_qty = stock_qty if skip_available_sub_assembly_item and d.item_code not in sub_assembly_items: bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse)) @@ -1808,40 +1833,43 @@ def get_sub_assembly_items( elif warehouse: bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse)) - if stock_qty > 0: - bom_data.append( - frappe._dict( - { - "actual_qty": bin_details[d.item_code][0].get("actual_qty", 0) - if bin_details.get(d.item_code) - else 0, - "parent_item_code": parent_item_code, - "description": d.description, - "production_item": d.item_code, - "item_name": d.item_name, - "stock_uom": d.stock_uom, - "uom": d.stock_uom, - "bom_no": d.value, - "is_sub_contracted_item": d.is_sub_contracted_item, - "bom_level": indent, - "indent": indent, - "stock_qty": stock_qty, - } - ) + bom_data.append( + frappe._dict( + { + "actual_qty": bin_details[d.item_code][0].get("actual_qty", 0) + if bin_details.get(d.item_code) + else 0, + "parent_item_code": parent_item_code, + "description": d.description, + "production_item": d.item_code, + "item_name": d.item_name, + "stock_uom": d.stock_uom, + "uom": d.stock_uom, + "bom_no": d.value, + "is_sub_contracted_item": d.is_sub_contracted_item, + "bom_level": indent, + "indent": indent, + "stock_qty": stock_qty, + "required_qty": required_qty, + "projected_qty": bin_details[d.item_code][0].get("projected_qty", 0) + if bin_details.get(d.item_code) + else 0, + } ) + ) - if d.value: - get_sub_assembly_items( - sub_assembly_items, - bin_details, - d.value, - bom_data, - stock_qty, - company, - warehouse, - indent=indent + 1, - skip_available_sub_assembly_item=skip_available_sub_assembly_item, - ) + if d.value: + get_sub_assembly_items( + sub_assembly_items, + bin_details, + d.value, + bom_data, + stock_qty, + company, + warehouse, + indent=indent + 1, + skip_available_sub_assembly_item=skip_available_sub_assembly_item, + ) def set_default_warehouses(row, default_warehouses): @@ -2034,7 +2062,12 @@ def get_reserved_qty_for_sub_assembly(item_code, warehouse): frappe.qb.from_(table) .inner_join(child) .on(table.name == child.parent) - .select(Sum(child.qty - IfNull(child.wo_produced_qty, 0))) + .select( + Sum( + Case().when(child.qty > 0, child.qty).else_(child.required_qty) + - IfNull(child.wo_produced_qty, 0) + ) + ) .where( (table.docstatus == 1) & (child.production_item == item_code) @@ -2050,3 +2083,39 @@ def get_reserved_qty_for_sub_assembly(item_code, warehouse): qty = flt(query[0][0]) return qty if qty > 0 else 0.0 + + +@frappe.whitelist() +def make_stock_reservation_entries(doc, items=None, table_name=None, notify=False): + if isinstance(doc, str): + doc = parse_json(doc) + doc = frappe.get_doc("Work Order", doc.get("name")) + + if items and isinstance(items, str): + items = parse_json(items) + + mapper = { + "sub_assembly_items": { + "table_name": "sub_assembly_items", + "qty_field": "required_qty", + "warehouse_field": "fg_warehouse", + }, + "mr_items": { + "table_name": "mr_items", + "qty_field": "required_bom_qty", + "warehouse_field": "warehouse", + }, + } + + for child_table_name in mapper: + if table_name and table_name != child_table_name: + continue + + sre = StockReservation(doc, items=items, kwargs=mapper[child_table_name], notify=notify) + if doc.docstatus == 1: + sre.make_stock_reservation_entries() + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True) + elif doc.docstatus == 2: + sre.cancel_stock_reservation_entries() + + doc.reload() diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py b/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py index 6fc28a30971..71ca6843e2b 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py @@ -4,8 +4,12 @@ from frappe import _ def get_data(): return { "fieldname": "production_plan", + "non_standard_fieldnames": { + "Stock Reservation Entry": "voucher_no", + }, "transactions": [ {"label": _("Transactions"), "items": ["Work Order", "Material Request"]}, {"label": _("Subcontract"), "items": ["Purchase Order"]}, + {"label": _("Reservation"), "items": ["Stock Reservation Entry"]}, ], } diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index ab0abb4d48c..15342f9d6be 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -16,10 +16,15 @@ from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item, make_item +from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_batch_from_bundle, + get_serial_nos_from_bundle, +) from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_stock_reconciliation, ) +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation class UnitTestProductionPlan(UnitTestCase): @@ -146,7 +151,7 @@ class TestProductionPlan(IntegrationTestCase): item_code="Raw Material Item 2", target="_Test Warehouse - _TC", qty=1, rate=120 ) - pln = create_production_plan(item_code="Test Production Item 1", ignore_existing_ordered_qty=1) + pln = create_production_plan(item_code="Test Production Item 1", ignore_existing_ordered_qty=0) self.assertTrue(len(pln.mr_items)) self.assertTrue(flt(pln.mr_items[0].quantity), 1.0) @@ -182,11 +187,17 @@ class TestProductionPlan(IntegrationTestCase): pln = create_production_plan( item_code="Test Production Item 1", use_multi_level_bom=0, ignore_existing_ordered_qty=1 ) - self.assertFalse(len(pln.mr_items)) + items = [] + for row in pln.mr_items: + if row.quantity > 0: + items.append(row.item_code) + + self.assertFalse(len(items)) + + pln.cancel() sr1.cancel() sr2.cancel() - pln.cancel() def test_production_plan_sales_orders(self): "Test if previously fulfilled SO (with WO) is pulled into Prod Plan." @@ -1910,6 +1921,417 @@ class TestProductionPlan(IntegrationTestCase): self.assertEqual(mr_items[0].get("quantity"), 80) self.assertEqual(mr_items[1].get("quantity"), 70) + def test_stock_reservation_against_production_plan(self): + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1) + + bom_tree = { + "Finished Good For SR": { + "Sub Assembly For SR 1": {"Raw Material For SR 1": {}}, + "Sub Assembly For SR 2": {"Raw Material For SR 2": {}}, + "Sub Assembly For SR 3": {"Raw Material For SR 3": {}}, + } + } + parent_bom = create_nested_bom(bom_tree, prefix="") + + warehouse = "_Test Warehouse - _TC" + + for item_code in [ + "Sub Assembly For SR 1", + "Sub Assembly For SR 2", + "Sub Assembly For SR 3", + "Raw Material For SR 1", + "Raw Material For SR 2", + "Raw Material For SR 3", + ]: + make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=15, + skip_available_sub_assembly_item=1, + ignore_existing_ordered_qty=1, + do_not_submit=1, + warehouse=warehouse, + sub_assembly_warehouse=warehouse, + for_warehouse=warehouse, + reserve_stock=1, + ) + + plan.get_sub_assembly_items() + plan.set("mr_items", []) + mr_items = get_items_for_material_requests(plan.as_dict()) + for d in mr_items: + plan.append("mr_items", d) + + plan.save() + + self.assertTrue(len(plan.sub_assembly_items) == 3) + for row in plan.sub_assembly_items: + self.assertEqual(row.required_qty, 15.0) + self.assertEqual(row.qty, 10.0) + + self.assertTrue(len(plan.mr_items) == 3) + for row in plan.mr_items: + self.assertEqual(row.required_bom_qty, 10.0) + self.assertEqual(row.quantity, 5.0) + + plan.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 6) + + for row in reserved_entries: + self.assertEqual(row.reserved_qty, 5.0) + + plan.submit_material_request = 1 + plan.make_material_request() + plan.make_work_order() + + material_requests = frappe.get_all( + "Material Request", filters={"production_plan": plan.name}, pluck="name" + ) + + self.assertTrue(len(material_requests) > 0) + for mr_name in list(set(material_requests)): + po = make_purchase_order(mr_name) + po.supplier = "_Test Supplier" + po.submit() + + pr = make_purchase_receipt(po.name) + pr.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 9) + + work_orders = frappe.get_all("Work Order", filters={"production_plan": plan.name}, pluck="name") + for wo_name in list(set(work_orders)): + wo_doc = frappe.get_doc("Work Order", wo_name) + self.assertEqual(wo_doc.reserve_stock, 1) + + wo_doc.source_warehouse = warehouse + wo_doc.wip_warehouse = warehouse + wo_doc.fg_warehouse = warehouse + wo_doc.submit() + + sre = StockReservation(wo_doc) + reserved_entries = sre.get_reserved_entries("Work Order", wo_doc.name) + if wo_doc.production_item == "Finished Good For SR": + self.assertEqual(len(reserved_entries), 3) + else: + # For raw materials 2 stock reservation entries + # 5 qty was present already in stock and 5 added from new PO + self.assertEqual(len(reserved_entries), 2) + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 0) + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) + + def test_stock_reservation_of_serial_nos_against_production_plan(self): + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1) + + bom_tree = { + "Finished Good For SR": { + "SN Sub Assembly For SR 1": {"SN Raw Material For SR 1": {}}, + "SN Sub Assembly For SR 2": {"SN Raw Material For SR 2": {}}, + "SN Sub Assembly For SR 3": {"SN Raw Material For SR 3": {}}, + } + } + parent_bom = create_nested_bom(bom_tree, prefix="") + + warehouse = "_Test Warehouse - _TC" + + for item_code in [ + "SN Sub Assembly For SR 1", + "SN Sub Assembly For SR 2", + "SN Sub Assembly For SR 3", + "SN Raw Material For SR 1", + "SN Raw Material For SR 2", + "SN Raw Material For SR 3", + ]: + doc = frappe.get_doc("Item", item_code) + doc.has_serial_no = 1 + doc.serial_no_series = f"SNN-{item_code}.-.#####" + doc.save() + + make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=15, + skip_available_sub_assembly_item=1, + ignore_existing_ordered_qty=1, + do_not_submit=1, + warehouse=warehouse, + sub_assembly_warehouse=warehouse, + for_warehouse=warehouse, + reserve_stock=1, + ) + + plan.get_sub_assembly_items() + plan.set("mr_items", []) + mr_items = get_items_for_material_requests(plan.as_dict()) + for d in mr_items: + plan.append("mr_items", d) + + plan.save() + + self.assertTrue(len(plan.sub_assembly_items) == 3) + for row in plan.sub_assembly_items: + self.assertEqual(row.required_qty, 15.0) + self.assertEqual(row.qty, 10.0) + + self.assertTrue(len(plan.mr_items) == 3) + for row in plan.mr_items: + self.assertEqual(row.required_bom_qty, 10.0) + self.assertEqual(row.quantity, 5.0) + + plan.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 6) + + for row in reserved_entries: + self.assertEqual(row.reserved_qty, 5.0) + + plan.submit_material_request = 1 + plan.make_material_request() + plan.make_work_order() + + material_requests = frappe.get_all( + "Material Request", filters={"production_plan": plan.name}, pluck="name" + ) + + additional_serial_nos = [] + + for item_code in [ + "SN Sub Assembly For SR 1", + "SN Sub Assembly For SR 2", + "SN Sub Assembly For SR 3", + "SN Raw Material For SR 1", + "SN Raw Material For SR 2", + "SN Raw Material For SR 3", + ]: + se = make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + additional_serial_nos.extend(get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)) + + self.assertTrue(additional_serial_nos) + + self.assertTrue(len(material_requests) > 0) + for mr_name in list(set(material_requests)): + po = make_purchase_order(mr_name) + po.supplier = "_Test Supplier" + po.submit() + + pr = make_purchase_receipt(po.name) + pr.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 9) + serial_nos_res_for_pp = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, + pluck="serial_no", + ) + + work_orders = frappe.get_all("Work Order", filters={"production_plan": plan.name}, pluck="name") + for wo_name in list(set(work_orders)): + wo_doc = frappe.get_doc("Work Order", wo_name) + self.assertEqual(wo_doc.reserve_stock, 1) + + wo_doc.source_warehouse = warehouse + wo_doc.wip_warehouse = warehouse + wo_doc.fg_warehouse = warehouse + wo_doc.submit() + + sre = StockReservation(wo_doc) + reserved_entries = sre.get_reserved_entries("Work Order", wo_doc.name) + serial_nos_res_for_wo = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, + pluck="serial_no", + ) + + for serial_no in serial_nos_res_for_wo: + self.assertTrue(serial_no in serial_nos_res_for_pp) + self.assertFalse(serial_no in additional_serial_nos) + + if wo_doc.production_item == "Finished Good For SR": + self.assertEqual(len(reserved_entries), 3) + else: + # For raw materials 2 stock reservation entries + # 5 qty was present already in stock and 5 added from new PO + self.assertEqual(len(reserved_entries), 2) + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 0) + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) + + def test_stock_reservation_of_batch_nos_against_production_plan(self): + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1) + + bom_tree = { + "Finished Good For SR": { + "Batch Sub Assembly For SR 1": {"Batch Raw Material For SR 1": {}}, + "Batch Sub Assembly For SR 2": {"Batch Raw Material For SR 2": {}}, + "Batch Sub Assembly For SR 3": {"Batch Raw Material For SR 3": {}}, + } + } + parent_bom = create_nested_bom(bom_tree, prefix="") + + warehouse = "_Test Warehouse - _TC" + + for item_code in [ + "Batch Sub Assembly For SR 1", + "Batch Sub Assembly For SR 2", + "Batch Sub Assembly For SR 3", + "Batch Raw Material For SR 1", + "Batch Raw Material For SR 2", + "Batch Raw Material For SR 3", + ]: + doc = frappe.get_doc("Item", item_code) + doc.has_batch_no = 1 + doc.create_new_batch = 1 + doc.batch_number_series = f"BCH-{item_code}.-.#####" + doc.save() + + make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=15, + skip_available_sub_assembly_item=1, + ignore_existing_ordered_qty=1, + do_not_submit=1, + warehouse=warehouse, + sub_assembly_warehouse=warehouse, + for_warehouse=warehouse, + reserve_stock=1, + ) + + plan.get_sub_assembly_items() + plan.set("mr_items", []) + mr_items = get_items_for_material_requests(plan.as_dict()) + for d in mr_items: + plan.append("mr_items", d) + + plan.save() + + self.assertTrue(len(plan.sub_assembly_items) == 3) + for row in plan.sub_assembly_items: + self.assertEqual(row.required_qty, 15.0) + self.assertEqual(row.qty, 10.0) + + self.assertTrue(len(plan.mr_items) == 3) + for row in plan.mr_items: + self.assertEqual(row.required_bom_qty, 10.0) + self.assertEqual(row.quantity, 5.0) + + plan.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 6) + + for row in reserved_entries: + self.assertEqual(row.reserved_qty, 5.0) + + plan.submit_material_request = 1 + plan.make_material_request() + plan.make_work_order() + + material_requests = frappe.get_all( + "Material Request", filters={"production_plan": plan.name}, pluck="name" + ) + + additional_batches = [] + + for item_code in [ + "Batch Sub Assembly For SR 1", + "Batch Sub Assembly For SR 2", + "Batch Sub Assembly For SR 3", + "Batch Raw Material For SR 1", + "Batch Raw Material For SR 2", + "Batch Raw Material For SR 3", + ]: + se = make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + additional_batches.append(batch_no) + + self.assertTrue(additional_batches) + + self.assertTrue(len(material_requests) > 0) + for mr_name in list(set(material_requests)): + po = make_purchase_order(mr_name) + po.supplier = "_Test Supplier" + po.submit() + + pr = make_purchase_receipt(po.name) + pr.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 9) + batches_reserved_for_pp = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, + pluck="batch_no", + ) + + work_orders = frappe.get_all("Work Order", filters={"production_plan": plan.name}, pluck="name") + for wo_name in list(set(work_orders)): + wo_doc = frappe.get_doc("Work Order", wo_name) + self.assertEqual(wo_doc.reserve_stock, 1) + + wo_doc.source_warehouse = warehouse + wo_doc.wip_warehouse = warehouse + wo_doc.fg_warehouse = warehouse + wo_doc.submit() + + sre = StockReservation(wo_doc) + reserved_entries = sre.get_reserved_entries("Work Order", wo_doc.name) + batches_reserved_for_wo = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, + pluck="batch_no", + ) + + for batch_no in batches_reserved_for_wo: + self.assertTrue(batch_no in batches_reserved_for_pp) + self.assertFalse(batch_no in additional_batches) + + if wo_doc.production_item == "Finished Good For SR": + self.assertEqual(len(reserved_entries), 3) + else: + # For raw materials 2 stock reservation entries + # 5 qty was present already in stock and 5 added from new PO + self.assertEqual(len(reserved_entries), 2) + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 0) + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) + def create_production_plan(**args): """ @@ -1931,6 +2353,7 @@ def create_production_plan(**args): "get_items_from": "Sales Order", "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, } ) diff --git a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json index c5330d74772..38fe30cc9de 100644 --- a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json +++ b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json @@ -26,7 +26,7 @@ }, { "fieldname": "qty", - "fieldtype": "Data", + "fieldtype": "Float", "in_list_view": 1, "label": "Qty" }, @@ -37,17 +37,19 @@ "label": "Item Reference" } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:20.410593", + "modified": "2025-05-07 17:47:36.244083", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Item Reference", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py index d95bf8c006f..d72402201d3 100644 --- a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py +++ b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py @@ -19,7 +19,7 @@ class ProductionPlanItemReference(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data - qty: DF.Data | None + qty: DF.Float sales_order: DF.Link | None sales_order_item: DF.Data | None # end: auto-generated types 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 5b87373f243..6a5d7dcb3d2 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 @@ -9,29 +9,32 @@ "item_name", "fg_warehouse", "parent_item_code", - "schedule_date", "column_break_3", - "qty", "bom_no", "bom_level", "type_of_manufacturing", + "section_break_4rxf", + "required_qty", + "column_break_xfhm", + "projected_qty", + "qty", + "subcontracting_section", "supplier", - "work_order_details_section", - "wo_produced_qty", "purchase_order", + "work_order_details_section", "production_plan_item", + "wo_produced_qty", + "stock_reserved_qty", "column_break_7", "received_qty", "indent", "section_break_19", + "schedule_date", "uom", "stock_uom", - "column_break_22", - "description", - "section_break_4rxf", "actual_qty", - "column_break_xfhm", - "projected_qty" + "column_break_22", + "description" ], "fields": [ { @@ -49,18 +52,18 @@ "depends_on": "eval:doc.type_of_manufacturing == \"In House\"", "fieldname": "work_order_details_section", "fieldtype": "Section Break", - "label": "Reference" + "label": "Manufacturing" }, { "fieldname": "column_break_7", "fieldtype": "Column Break" }, { - "columns": 1, + "columns": 2, "fieldname": "qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Required Qty", + "label": "Qty to Order", "read_only": 1 }, { @@ -152,7 +155,6 @@ "columns": 2, "fieldname": "fg_warehouse", "fieldtype": "Link", - "in_list_view": 1, "label": "Target Warehouse", "options": "Warehouse" }, @@ -167,22 +169,24 @@ { "fieldname": "supplier", "fieldtype": "Link", + "in_list_view": 1, "label": "Supplier", - "mandatory_depends_on": "eval:doc.type_of_manufacturing == 'Subcontract'", + "mandatory_depends_on": "eval:doc.type_of_manufacturing == 'Subcontract' && doc.qty > 0", "options": "Supplier" }, { - "columns": 1, + "columns": 2, "fieldname": "schedule_date", "fieldtype": "Datetime", - "in_list_view": 1, "label": "Schedule Date" }, { "fieldname": "section_break_4rxf", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Quantity" }, { + "columns": 2, "fieldname": "actual_qty", "fieldtype": "Float", "label": "Actual Qty", @@ -194,8 +198,10 @@ "fieldtype": "Column Break" }, { + "columns": 2, "fieldname": "projected_qty", "fieldtype": "Float", + "in_list_view": 1, "label": "Projected Qty", "no_copy": 1, "read_only": 1 @@ -205,20 +211,42 @@ "fieldtype": "Float", "label": "Produced Qty", "read_only": 1 + }, + { + "columns": 2, + "fieldname": "required_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Required Qty" + }, + { + "fieldname": "subcontracting_section", + "fieldtype": "Section Break", + "label": "Subcontracting" + }, + { + "fieldname": "stock_reserved_qty", + "fieldtype": "Float", + "label": "Stock Reserved Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-01-01 17:50:32.273610", + "modified": "2025-05-01 14:28:35.979941", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Sub Assembly Item", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} 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..41e5bf28b56 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 @@ -32,7 +32,9 @@ class ProductionPlanSubAssemblyItem(Document): purchase_order: DF.Link | None qty: DF.Float received_qty: DF.Float + required_qty: DF.Float schedule_date: DF.Datetime | None + stock_reserved_qty: DF.Float stock_uom: DF.Link | None supplier: DF.Link | None type_of_manufacturing: DF.Literal["In House", "Subcontract", "Material Request"] diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 3171bbd2741..7b7a670e6f0 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -43,11 +43,6 @@ "skip_transfer", "from_wip_warehouse", "update_consumed_material_cost_in_project", - "serial_no_and_batch_for_finished_good_section", - "has_serial_no", - "has_batch_no", - "column_break_18", - "batch_size", "time", "planned_start_date", "planned_end_date", @@ -63,6 +58,11 @@ "column_break_24", "corrective_operation_cost", "total_operating_cost", + "serial_no_and_batch_for_finished_good_section", + "has_serial_no", + "has_batch_no", + "column_break_18", + "batch_size", "more_info", "description", "stock_uom", @@ -505,7 +505,7 @@ "depends_on": "eval:!doc.__islocal", "fieldname": "serial_no_and_batch_for_finished_good_section", "fieldtype": "Section Break", - "label": "Serial No and Batch for Finished Good" + "label": "Finished Good Serial / Batch" }, { "fieldname": "column_break_17", @@ -594,12 +594,13 @@ "label": " Reserve Stock" } ], + "grid_page_length": 50, "icon": "fa fa-cogs", "idx": 1, "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2024-09-23 16:56:00.483027", + "modified": "2025-04-25 11:46:38.739588", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", @@ -629,10 +630,11 @@ "role": "Stock User" } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "ASC", "states": [], "title_field": "production_item", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 26318b10634..0aaaf78f50c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -10,7 +10,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Case -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import IfNull, Sum from frappe.utils import ( cint, date_diff, @@ -1399,7 +1399,9 @@ class WorkOrder(Document): items = frappe._dict() stock_entry.reload() - if stock_entry.purpose == "Manufacture" and self.sales_order: + if stock_entry.purpose == "Manufacture" and ( + self.sales_order or self.production_plan_sub_assembly_item + ): items = self.get_finished_goods_for_reservation(stock_entry) elif stock_entry.purpose == "Material Transfer for Manufacture": items = self.get_list_of_materials_for_reservation(stock_entry) @@ -1440,41 +1442,97 @@ class WorkOrder(Document): def get_finished_goods_for_reservation(self, stock_entry): items = frappe._dict() - so_details = self.get_so_details() - if not so_details: - return items + if self.production_plan_sub_assembly_item: + # Reserve the sub-assembly item for the final product for the work order. + item_details = self.get_wo_details() + else: + # Reserve the final product for the sales order. + item_details = self.get_so_details() - qty = so_details.stock_qty - (so_details.stock_reserved_qty + so_details.delivered_qty) - if not qty: - return items - - for row in stock_entry.items: - if not row.t_warehouse or not row.is_finished_item: + for item in item_details: + qty_to_reserve = flt(item.stock_qty) - flt(item.stock_reserved_qty + item.delivered_qty) + if qty_to_reserve <= 0: continue - if qty > row.transfer_qty: - qty = row.transfer_qty + warehouse = item.warehouse + if ( + item.get("parenttype") == "Work Order" + and item.get("skip_transfer") + and item.get("from_wip_warehouse") + ): + warehouse = item.wip_warehouse - if row.item_code not in items: - items[row.item_code] = frappe._dict( - { - "voucher_no": self.sales_order, - "voucher_type": "Sales Order", - "voucher_detail_no": so_details.name, - "item_code": row.item_code, - "warehouse": row.t_warehouse, - "stock_qty": qty, - "from_voucher_no": stock_entry.name, - "from_voucher_type": stock_entry.doctype, - "from_voucher_detail_no": row.name, - "serial_and_batch_bundles": [row.serial_and_batch_bundle], - } - ) - else: - items[row.item_code]["stock_qty"] += qty + for row in stock_entry.items: + if ( + not row.t_warehouse + or not row.is_finished_item + or row.t_warehouse != warehouse + or row.item_code != item.item_code + ): + continue + + reserved_qty = qty_to_reserve + if qty_to_reserve > row.transfer_qty: + reserved_qty = row.transfer_qty + qty_to_reserve -= row.transfer_qty + else: + qty_to_reserve = 0 + + if row.item_code not in items: + items[row.item_code] = frappe._dict( + { + "voucher_no": item.voucher_no, + "voucher_type": item.voucher_type, + "voucher_detail_no": item.name, + "item_code": row.item_code, + "warehouse": row.t_warehouse, + "stock_qty": reserved_qty, + "from_voucher_no": stock_entry.name, + "from_voucher_type": stock_entry.doctype, + "from_voucher_detail_no": row.name, + "serial_and_batch_bundles": [row.serial_and_batch_bundle], + } + ) + else: + items[row.item_code]["stock_qty"] += reserved_qty return items + def get_wo_details(self): + doctype = frappe.qb.DocType("Work Order") + child_doctype = frappe.qb.DocType("Work Order Item") + + query = ( + frappe.qb.from_(doctype) + .inner_join(child_doctype) + .on(doctype.name == child_doctype.parent) + .select( + child_doctype.name, + child_doctype.required_qty.as_("stock_qty"), + child_doctype.transferred_qty.as_("delivered_qty"), + child_doctype.stock_reserved_qty, + child_doctype.source_warehouse.as_("warehouse"), + doctype.wip_warehouse, + doctype.skip_transfer, + doctype.from_wip_warehouse, + child_doctype.parenttype, + child_doctype.item_code, + child_doctype.parent.as_("voucher_no"), + child_doctype.parenttype.as_("voucher_type"), + ) + .where( + (child_doctype.item_code == self.production_item) + & (doctype.docstatus == 1) + & (doctype.production_plan == self.production_plan) + & ( + IfNull(doctype.production_plan_sub_assembly_item, "") + != self.production_plan_sub_assembly_item + ) + ) + ) + + return query.run(as_dict=1) + def get_so_details(self): return frappe.db.get_value( "Sales Order Item", @@ -1483,7 +1541,15 @@ class WorkOrder(Document): "item_code": self.production_item, "docstatus": 1, }, - ["name", "stock_qty", "stock_reserved_qty", "delivered_qty"], + [ + "name", + "stock_qty", + "stock_reserved_qty", + "warehouse", + "parent as voucher_no", + "parenttype as voucher_type", + "delivered_qty", + ], as_dict=1, ) @@ -1526,7 +1592,7 @@ class WorkOrder(Document): @frappe.whitelist() -def make_stock_reservation_entries(doc, items=None, notify=False): +def make_stock_reservation_entries(doc, items=None, table_name=None, notify=False): if isinstance(doc, str): doc = parse_json(doc) doc = frappe.get_doc("Work Order", doc.get("name")) @@ -1536,7 +1602,13 @@ def make_stock_reservation_entries(doc, items=None, notify=False): sre = StockReservation(doc, items=items, notify=notify) if doc.docstatus == 1: - sre.make_stock_reservation_entries() + if doc.production_plan: + sre.transfer_reservation_entries_to( + doc.production_plan, from_doctype="Production Plan", to_doctype="Work Order" + ) + else: + sre.make_stock_reservation_entries() + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True) elif doc.docstatus == 2: sre.cancel_stock_reservation_entries() diff --git a/erpnext/public/js/stock_reservation.js b/erpnext/public/js/stock_reservation.js index 58d57aff499..854212ee5ae 100644 --- a/erpnext/public/js/stock_reservation.js +++ b/erpnext/public/js/stock_reservation.js @@ -14,7 +14,7 @@ $.extend(erpnext.stock_reservation, { fields: erpnext.stock_reservation.get_dialog_fields(frm, parms), primary_action_label: __("Reserve Stock"), primary_action: () => { - erpnext.stock_reservation.reserve_stock(frm, parms); + erpnext.stock_reservation.reserve_stock(frm, table_name, parms); }, }); @@ -32,9 +32,20 @@ $.extend(erpnext.stock_reservation, { "Work Order": "required_qty", }[frm.doc.doctype]; + if (frm.doc.doctype === "Production Plan") { + if (table_name === "sub_assembly_items") { + params["qty_field"] = "qty"; + params["item_code_field"] = "production_item"; + params["warehouse_field"] = "fg_warehouse"; + } else { + params["qty_field"] = "quantity"; + } + } + params["dispatch_qty_field"] = { "Sales Order": "delivered_qty", "Work Order": "transferred_qty", + "Production Plan": "delivered_qty", }[frm.doc.doctype]; params["method"] = { @@ -140,6 +151,9 @@ $.extend(erpnext.stock_reservation, { dispatch_qty_field = "consumed_qty"; } + let item_code_field = parms.item_code_field || "item_code"; + let warehouse_field = parms.warehouse_field || "warehouse"; + frm.doc[parms.table_name].forEach((item) => { if (frm.doc.reserve_stock) { let unreserved_qty = @@ -152,8 +166,8 @@ $.extend(erpnext.stock_reservation, { if (unreserved_qty > 0) { let args = { __checked: 1, - item_code: item.item_code, - warehouse: item.warehouse || item.source_warehouse, + item_code: item[item_code_field] || item.item_code, + warehouse: item[warehouse_field] || item.warehouse || item.source_warehouse, }; args[field] = item.name; @@ -167,7 +181,7 @@ $.extend(erpnext.stock_reservation, { dialog.show(); }, - reserve_stock(frm, parms) { + reserve_stock(frm, table_name, parms) { let dialog = erpnext.stock_reservation.dialog; var data = { items: dialog.fields_dict.items.grid.get_selected_children() }; @@ -177,6 +191,7 @@ $.extend(erpnext.stock_reservation, { args: { doc: frm.doc, items: data.items, + table_name: table_name, notify: true, }, freeze: true, diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index d499ffd219a..b6d87b7525c 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -17,6 +17,7 @@ from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.accounts_controller import merge_taxes from erpnext.controllers.buying_controller import BuyingController from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -383,7 +384,7 @@ class PurchaseReceipt(BuyingController): self.make_gl_entries() self.repost_future_sle_and_gle() self.set_consumed_qty_in_subcontract_order() - self.reserve_stock_for_sales_order() + self.reserve_stock() self.update_received_qty_if_from_pp() def update_received_qty_if_from_pp(self): @@ -913,6 +914,10 @@ class PurchaseReceipt(BuyingController): pr_doc = self if (pr == self.name) else frappe.get_doc("Purchase Receipt", pr) update_billing_percentage(pr_doc, update_modified=update_modified) + def reserve_stock(self): + self.reserve_stock_for_sales_order() + self.reserve_stock_for_production_plan() + def reserve_stock_for_sales_order(self): if ( self.is_return @@ -953,6 +958,66 @@ class PurchaseReceipt(BuyingController): notify=True, ) + def reserve_stock_for_production_plan(self): + if self.is_return or not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): + return + + production_plan_references = self.get_production_plan_references() + production_plan_items = [] + + docnames = [] + for row in self.items: + if row.material_request_item and row.material_request_item in production_plan_references: + _ref = production_plan_references[row.material_request_item] + docnames.append(_ref.production_plan) + row.update( + { + "voucher_type": "Production Plan", + "voucher_no": _ref.production_plan, + "voucher_detail_no": _ref.material_request_plan_item, + "from_voucher_no": self.name, + "from_voucher_detail_no": row.name, + "from_voucher_type": self.doctype, + } + ) + + production_plan_items.append(row) + + if not production_plan_items: + return + + sre = StockReservation(doc=self, items=production_plan_items) + sre.make_stock_reservation_entries() + if docnames: + sre.transfer_reservation_entries_to( + docnames, from_doctype="Production Plan", to_doctype="Work Order" + ) + + def get_production_plan_references(self): + production_plan_references = frappe._dict() + material_request_items = [] + + for row in self.items: + if row.material_request_item: + material_request_items.append(row.material_request_item) + + if not material_request_items: + return frappe._dict() + + items = frappe.get_all( + "Material Request Item", + fields=["material_request_plan_item", "production_plan", "name"], + filters={"name": ["in", material_request_items], "docstatus": 1}, + ) + + for item in items: + if not item.production_plan: + continue + + production_plan_references.setdefault(item.name, item) + + return production_plan_references + def enable_recalculate_rate_in_sles(self): rejected_warehouses = frappe.get_all( "Purchase Receipt Item", filters={"parent": self.name}, pluck="rejected_warehouse" diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index fd4b4eda6dc..b8f23be201e 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1646,7 +1646,11 @@ class StockEntry(StockController): def make_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) - if self.purpose == "Manufacture" and not pro_doc.sales_order: + if ( + self.purpose == "Manufacture" + and not pro_doc.sales_order + and not pro_doc.production_plan_sub_assembly_item + ): return pro_doc.set_reserved_qty_for_wip_and_fg(self) @@ -1654,7 +1658,11 @@ class StockEntry(StockController): 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) - if self.purpose == "Manufacture" and not pro_doc.sales_order: + if ( + self.purpose == "Manufacture" + and not pro_doc.sales_order + and not pro_doc.production_plan_sub_assembly_item + ): return pro_doc.cancel_reserved_qty_for_wip_and_fg(self) 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 daae6218fed..79837e5513f 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", + "options": "\nSales Order\nWork Order\nProduction Plan", "print_width": "150px", "read_only": 1, "width": "150px" @@ -289,7 +289,7 @@ "fieldtype": "Select", "label": "From Voucher Type", "no_copy": 1, - "options": "\nPick List\nPurchase Receipt\nStock Entry\nWork Order", + "options": "\nPick List\nPurchase Receipt\nStock Entry\nWork Order\nProduction Plan", "print_hide": 1, "read_only": 1, "report_hide": 1 @@ -339,12 +339,13 @@ "label": "Qty in WIP Warehouse" } ], + "grid_page_length": 50, "hide_toolbar": 1, "in_create": 1, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-09-19 15:28:24.726283", + "modified": "2025-04-30 22:15:22.998138", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", @@ -450,8 +451,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} 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 de1dc49aabf..b9a5593afc1 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -30,7 +30,9 @@ class StockReservationEntry(Document): delivered_qty: DF.Float from_voucher_detail_no: DF.Data | None from_voucher_no: DF.DynamicLink | None - from_voucher_type: DF.Literal["", "Pick List", "Purchase Receipt", "Stock Entry", "Work Order"] + from_voucher_type: DF.Literal[ + "", "Pick List", "Purchase Receipt", "Stock Entry", "Work Order", "Production Plan" + ] has_batch_no: DF.Check has_serial_no: DF.Check item_code: DF.Link | None @@ -46,7 +48,7 @@ class StockReservationEntry(Document): voucher_detail_no: DF.Data | None voucher_no: DF.DynamicLink | None voucher_qty: DF.Float - voucher_type: DF.Literal["", "Sales Order", "Work Order"] + voucher_type: DF.Literal["", "Sales Order", "Work Order", "Production Plan"] warehouse: DF.Link | None # end: auto-generated types @@ -335,6 +337,7 @@ class StockReservationEntry(Document): item_doctype = { "Sales Order": "Sales Order Item", "Work Order": "Work Order Item", + "Production Plan": "Production Plan Sub Assembly Item", }.get(self.voucher_type, None) if item_doctype: @@ -350,6 +353,11 @@ class StockReservationEntry(Document): ) ).run(as_list=True)[0][0] or 0 + if self.voucher_type == "Production Plan" and frappe.db.exists( + "Material Request Plan Item", self.voucher_detail_no + ): + item_doctype = "Material Request Plan Item" + frappe.db.set_value( item_doctype, self.voucher_detail_no, @@ -968,13 +976,14 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st class StockReservation: - def __init__(self, doc, items=None, notify=True): + def __init__(self, doc, items=None, kwargs=None, notify=True): if isinstance(doc, str): doc = parse_json(doc) doc = frappe.get_doc("Work Order", doc.get("name")) self.doc = doc self.items = items + self.kwargs = kwargs self.initialize_fields() def initialize_fields(self) -> None: @@ -989,6 +998,9 @@ class StockReservation: self.warehouse_field = "source_warehouse" if self.doc.skip_transfer and self.doc.from_wip_warehouse: self.warehouse = self.doc.wip_warehouse + elif self.doc.doctype == "Production Plan" and self.kwargs: + for key, value in self.kwargs.items(): + setattr(self, key, value) def cancel_stock_reservation_entries(self, names=None) -> None: """Cancels Stock Reservation Entries for the Voucher.""" @@ -1037,11 +1049,16 @@ class StockReservation: if isinstance(item, dict): item = frappe._dict(item) + item_code = item.get("item_code") or item.get("production_item") + item_details = frappe.get_cached_value( - "Item", item.item_code, ["has_serial_no", "has_batch_no", "stock_uom"], as_dict=True + "Item", item_code, ["has_serial_no", "has_batch_no", "stock_uom"], as_dict=True ) warehouse = self.warehouse or item.get(self.warehouse_field) or item.get("warehouse") + if self.doc.doctype == "Production Plan" and item.get("from_warehouse"): + warehouse = item.get("from_warehouse") + if ( not warehouse and self.doc.doctype == "Work Order" @@ -1052,10 +1069,12 @@ class StockReservation: ) qty = item.get(self.qty_field) or item.get("stock_qty") + if not qty: + continue - self.available_qty_to_reserve = self.get_available_qty_to_reserve(item.item_code, warehouse) + self.available_qty_to_reserve = self.get_available_qty_to_reserve(item_code, warehouse) if not self.available_qty_to_reserve: - self.throw_stock_not_exists_error(item, warehouse) + self.throw_stock_not_exists_error(item.idx, item_code, warehouse) self.qty_to_be_reserved = ( qty if self.available_qty_to_reserve >= qty else self.available_qty_to_reserve @@ -1064,7 +1083,7 @@ class StockReservation: if not self.qty_to_be_reserved: continue - sre.item_code = item.item_code + sre.item_code = item_code sre.warehouse = warehouse sre.has_serial_no = item_details.has_serial_no sre.has_batch_no = item_details.has_batch_no @@ -1107,10 +1126,10 @@ class StockReservation: }, ) - def throw_stock_not_exists_error(self, item, warehouse): + def throw_stock_not_exists_error(self, idx, item_code, warehouse): frappe.msgprint( _("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format( - item.idx, frappe.bold(item.item_code), frappe.bold(warehouse) + idx, frappe.bold(item_code), frappe.bold(warehouse) ), title=_("Stock Reservation"), indicator="orange", @@ -1143,6 +1162,163 @@ class StockReservation: return available_qty + def transfer_reservation_entries_to(self, docnames, from_doctype, to_doctype): + delivery_qty_to_update = frappe._dict() + if isinstance(docnames, str): + docnames = [docnames] + + items_to_reserve = self.get_items_to_reserve(docnames, from_doctype, to_doctype) + if not items_to_reserve: + return + + reservation_entries = self.get_reserved_entries(from_doctype, docnames) + if not reservation_entries: + return + + entries_to_reserve = [] + for row in reservation_entries: + for entry in items_to_reserve: + if not ( + row.item_code == entry.item_code and row.warehouse == entry.warehouse and entry.qty > 0 + ): + continue + + available_qty = row.reserved_qty - row.delivered_qty + if available_qty <= 0: + continue + + # transfer qty + if available_qty > entry.qty: + qty_to_reserve = entry.qty + row.delivered_qty += available_qty - entry.qty + delivery_qty_to_update.setdefault(row.name, row.delivered_qty) + else: + qty_to_reserve = available_qty + row.delivered_qty += qty_to_reserve + delivery_qty_to_update.setdefault(row.name, row.delivered_qty) + + entries_to_reserve.append([entry, row, qty_to_reserve]) + + entry.qty -= qty_to_reserve + + if delivery_qty_to_update: + self.update_delivered_qty(delivery_qty_to_update) + + for entry, row, qty_to_reserve in entries_to_reserve: + self.make_stock_reservation_entry(entry, row, qty_to_reserve) + + def update_delivered_qty(self, delivery_qty_to_update): + for name, delivered_qty in delivery_qty_to_update.items(): + doctype = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.update(doctype) + .set(doctype.delivered_qty, delivered_qty) + .set( + doctype.status, + "Delivered" if doctype.reserved_qty == doctype.delivered_qty else "Reserved", + ) + .where(doctype.name == name) + ) + + query.run() + + def make_stock_reservation_entry(self, row, against_row, reserved_qty): + fields = [ + "item_code", + "warehouse", + "voucher_type", + "voucher_no", + "voucher_detail_no", + "company", + "stock_uom", + ] + + sre = frappe.new_doc("Stock Reservation Entry") + for row_field in fields: + sre.set(row_field, row.get(row_field)) + + sre.available_qty = reserved_qty + sre.reserved_qty = reserved_qty + sre.voucher_qty = row.required_qty + sre.from_voucher_no = against_row.voucher_no + sre.from_voucher_detail_no = against_row.voucher_detail_no + sre.from_voucher_type = against_row.voucher_type + + bundles = [against_row.name] + if row.serial_and_batch_bundles: + bundles = row.serial_and_batch_bundles + + self.set_serial_batch(sre, bundles) + + sre.save() + sre.submit() + + def get_reserved_entries(self, doctype, docnames): + filters = { + "docstatus": 1, + "status": ("not in", ["Delivered", "Cancelled", "Draft"]), + "voucher_type": doctype, + "voucher_no": docnames, + } + + if isinstance(docnames, list): + filters["voucher_no"] = ("in", docnames) + + return frappe.get_all("Stock Reservation Entry", fields=["*"], filters=filters) + + def get_items_to_reserve(self, docnames, from_doctype, to_doctype): + field = frappe.scrub(from_doctype) + + doctype = frappe.qb.DocType(to_doctype) + child_doctype = frappe.qb.DocType(to_doctype + " Item") + + query = ( + frappe.qb.from_(doctype) + .inner_join(child_doctype) + .on(doctype.name == child_doctype.parent) + .select( + doctype.name.as_("voucher_no"), + child_doctype.name.as_("voucher_detail_no"), + child_doctype.item_code, + doctype.company, + child_doctype.stock_uom, + ) + .where((doctype.docstatus == 1) & (doctype[field].isin(docnames))) + ) + + if to_doctype == "Work Order": + query = query.select( + child_doctype.source_warehouse, + doctype.wip_warehouse, + doctype.skip_transfer, + doctype.from_wip_warehouse, + child_doctype.required_qty, + (child_doctype.required_qty - child_doctype.transferred_qty).as_("qty"), + child_doctype.stock_reserved_qty, + ) + + query = query.where( + (doctype.qty > doctype.material_transferred_for_manufacturing) + & (doctype.status != "Completed") + ) + + data = query.run(as_dict=True) + items = [] + + for row in data: + if row.qty > row.stock_reserved_qty: + row.qty -= flt(row.stock_reserved_qty) + row.warehouse = row.source_warehouse + if row.skip_transfer and row.from_wip_warehouse: + row.warehouse = row.wip_warehouse + + if to_doctype == "Work Order": + row.voucher_type = "Work Order" + + items.append(row) + + return items + def create_stock_reservation_entries_for_so_items( sales_order: object, diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 15c356dde3d..6b93e0883cf 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -109,6 +109,8 @@ def get_stock_balance( from erpnext.stock.stock_ledger import get_previous_sle + frappe.has_permission("Item", "read") + if posting_date is None: posting_date = nowdate() if posting_time is None: