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
This commit is contained in:
rohitwaghchaure
2025-05-12 12:19:23 +05:30
committed by GitHub
parent 2ad16673f0
commit 0bc3cfe29d
19 changed files with 1153 additions and 177 deletions

View File

@@ -10,13 +10,17 @@
"warehouse", "warehouse",
"item_name", "item_name",
"material_request_type", "material_request_type",
"quantity",
"required_bom_qty",
"column_break_4", "column_break_4",
"schedule_date",
"uom", "uom",
"conversion_factor", "conversion_factor",
"section_break_azee",
"required_bom_qty",
"projected_qty",
"column_break_wack",
"quantity",
"stock_reserved_qty",
"item_details", "item_details",
"schedule_date",
"description", "description",
"min_order_qty", "min_order_qty",
"section_break_8", "section_break_8",
@@ -27,7 +31,6 @@
"reserved_qty_for_production", "reserved_qty_for_production",
"column_break_yhelv", "column_break_yhelv",
"ordered_qty", "ordered_qty",
"projected_qty",
"safety_stock" "safety_stock"
], ],
"fields": [ "fields": [
@@ -47,7 +50,7 @@
"label": "Item Name" "label": "Item Name"
}, },
{ {
"columns": 2, "columns": 3,
"fieldname": "warehouse", "fieldname": "warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
@@ -58,7 +61,7 @@
"search_index": 1 "search_index": 1
}, },
{ {
"columns": 1, "columns": 2,
"fieldname": "material_request_type", "fieldname": "material_request_type",
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1, "in_list_view": 1,
@@ -70,17 +73,19 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"columns": 2, "columns": 3,
"fieldname": "quantity", "fieldname": "quantity",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Plan to Request Qty", "label": "Required Qty",
"no_copy": 1, "no_copy": 1,
"reqd": 1 "reqd": 1
}, },
{ {
"columns": 2,
"fieldname": "projected_qty", "fieldname": "projected_qty",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1,
"label": "Projected Qty", "label": "Projected Qty",
"read_only": 1 "read_only": 1
}, },
@@ -89,7 +94,6 @@
"default": "0", "default": "0",
"fieldname": "actual_qty", "fieldname": "actual_qty",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1,
"label": "Qty In Stock", "label": "Qty In Stock",
"no_copy": 1, "no_copy": 1,
"read_only": 1 "read_only": 1
@@ -172,11 +176,11 @@
"read_only": 1 "read_only": 1
}, },
{ {
"columns": 2, "columns": 3,
"fieldname": "required_bom_qty", "fieldname": "required_bom_qty",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Qty As Per BOM", "label": "Reqd Qty (BOM)",
"no_copy": 1, "no_copy": 1,
"read_only": 1 "read_only": 1
}, },
@@ -187,7 +191,7 @@
"read_only": 1 "read_only": 1
}, },
{ {
"columns": 1, "columns": 2,
"fieldname": "schedule_date", "fieldname": "schedule_date",
"fieldtype": "Date", "fieldtype": "Date",
"in_list_view": 1, "in_list_view": 1,
@@ -201,17 +205,34 @@
{ {
"fieldname": "column_break_yhelv", "fieldname": "column_break_yhelv",
"fieldtype": "Column Break" "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, "istable": 1,
"links": [], "links": [],
"modified": "2024-12-30 18:06:22.288340", "modified": "2025-05-01 14:50:55.805442",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Material Request Plan Item", "name": "Material Request Plan Item",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

@@ -42,6 +42,7 @@ class MaterialRequestPlanItem(Document):
safety_stock: DF.Float safety_stock: DF.Float
sales_order: DF.Link | None sales_order: DF.Link | None
schedule_date: DF.Date | None schedule_date: DF.Date | None
stock_reserved_qty: DF.Float
uom: DF.Link | None uom: DF.Link | None
warehouse: DF.Link warehouse: DF.Link
# end: auto-generated types # end: auto-generated types

View File

@@ -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) { setup(frm) {
frm.trigger("setup_queries"); frm.trigger("setup_queries");
@@ -16,6 +23,9 @@ frappe.ui.form.on("Production Plan", {
"Work Order": "Work Order / Subcontract PO", "Work Order": "Work Order / Subcontract PO",
"Material Request": "Material Request", "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) { 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("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 = ` <table class="table table-bordered" style="background-color: var(--scrollbar-track-color);"> const projected_qty_formula = ` <table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td style="padding-left:25px"> <tr><td style="padding-left:25px">
@@ -193,6 +207,72 @@ frappe.ui.form.on("Production Plan", {
set_field_options("projected_qty_formula", projected_qty_formula); 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) { close_open_production_plan(frm, close = false) {
frappe.call({ frappe.call({
method: "set_status", method: "set_status",

View File

@@ -11,6 +11,7 @@
"get_items_from", "get_items_from",
"column_break1", "column_break1",
"posting_date", "posting_date",
"reserve_stock",
"filters", "filters",
"item_code", "item_code",
"customer", "customer",
@@ -23,6 +24,7 @@
"from_delivery_date", "from_delivery_date",
"to_delivery_date", "to_delivery_date",
"sales_orders_detail", "sales_orders_detail",
"combine_items",
"get_sales_orders", "get_sales_orders",
"sales_orders", "sales_orders",
"material_request_detail", "material_request_detail",
@@ -30,16 +32,14 @@
"material_requests", "material_requests",
"select_items_to_manufacture_section", "select_items_to_manufacture_section",
"get_items", "get_items",
"combine_items",
"po_items", "po_items",
"section_break_25", "section_break_25",
"prod_plan_references", "prod_plan_references",
"section_break_24", "section_break_24",
"combine_sub_items",
"sub_assembly_warehouse", "sub_assembly_warehouse",
"section_break_ucc4",
"skip_available_sub_assembly_item",
"column_break_igxl", "column_break_igxl",
"skip_available_sub_assembly_item",
"combine_sub_items",
"get_sub_assembly_items", "get_sub_assembly_items",
"section_break_g4ip", "section_break_g4ip",
"sub_assembly_items", "sub_assembly_items",
@@ -215,7 +215,8 @@
{ {
"fieldname": "material_request_planning", "fieldname": "material_request_planning",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Material Request Planning" "hide_border": 1,
"label": "For Raw Materials"
}, },
{ {
"default": "1", "default": "1",
@@ -231,10 +232,10 @@
}, },
{ {
"default": "1", "default": "1",
"description": "If enabled, the system will consider items with a shortfall in quantity. \n<br>\nQty = Reqd Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\">Projected Qty</a>", "description": "If enabled, formula for <b>Required Qty</b>: <br>\nRequired Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\">Projected Qty</a>. <br> This helps avoid over-ordering.",
"fieldname": "ignore_existing_ordered_qty", "fieldname": "ignore_existing_ordered_qty",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Skip Available Raw Materials" "label": "Consider Projected Qty in Calculation (RM)"
}, },
{ {
"fieldname": "column_break_25", "fieldname": "column_break_25",
@@ -314,7 +315,7 @@
{ {
"fieldname": "for_warehouse", "fieldname": "for_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Raw Materials Warehouse", "label": "For Warehouse",
"options": "Warehouse" "options": "Warehouse"
}, },
{ {
@@ -375,11 +376,13 @@
"label": "Get Sub Assembly Items" "label": "Get Sub Assembly Items"
}, },
{ {
"depends_on": "eval:doc.get_items_from == 'Sales Order'",
"fieldname": "from_delivery_date", "fieldname": "from_delivery_date",
"fieldtype": "Date", "fieldtype": "Date",
"label": "From Delivery Date" "label": "From Delivery Date"
}, },
{ {
"depends_on": "eval:doc.get_items_from == 'Sales Order'",
"fieldname": "to_delivery_date", "fieldname": "to_delivery_date",
"fieldtype": "Date", "fieldtype": "Date",
"label": "To Delivery Date" "label": "To Delivery Date"
@@ -404,15 +407,10 @@
}, },
{ {
"default": "1", "default": "1",
"description": "If enabled, the system will consider items with a shortfall in quantity. \n<br>\nQty = Reqd Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\">Projected Qty</a>", "description": "If enabled, formula for <b>Qty to Order</b>: <br>\nRequired Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\">Projected Qty</a>. <br> This helps avoid over-ordering.",
"fieldname": "skip_available_sub_assembly_item", "fieldname": "skip_available_sub_assembly_item",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Skip Available Sub Assembly Items" "label": "Consider Projected Qty in Calculation"
},
{
"fieldname": "section_break_ucc4",
"fieldtype": "Column Break",
"hide_border": 1
}, },
{ {
"fieldname": "section_break_g4ip", "fieldname": "section_break_g4ip",
@@ -423,7 +421,7 @@
"fieldtype": "Column Break" "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", "fieldname": "sub_assembly_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Sub Assembly Warehouse", "label": "Sub Assembly Warehouse",
@@ -435,6 +433,12 @@
"fieldname": "consider_minimum_order_qty", "fieldname": "consider_minimum_order_qty",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Consider Minimum Order Qty" "label": "Consider Minimum Order Qty"
},
{
"default": "0",
"fieldname": "reserve_stock",
"fieldtype": "Check",
"label": "Reserve Stock"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -442,7 +446,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-04-08 17:24:09.394056", "modified": "2025-05-09 18:55:45.500257",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Production Plan", "name": "Production Plan",

View File

@@ -9,6 +9,8 @@ from collections import defaultdict
import frappe import frappe
from frappe import _, msgprint from frappe import _, msgprint
from frappe.model.document import Document 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.query_builder.functions import IfNull, Sum
from frappe.utils import ( from frappe.utils import (
add_days, add_days,
@@ -20,6 +22,7 @@ from frappe.utils import (
getdate, getdate,
now_datetime, now_datetime,
nowdate, nowdate,
parse_json,
) )
from frappe.utils.csvutils import build_csv_response from frappe.utils.csvutils import build_csv_response
from pypika.terms import ExistsCriterion 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.bom.bom import validate_bom_no
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details 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.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.get_item_details import get_conversion_factor
from erpnext.stock.utils import get_or_make_bin from erpnext.stock.utils import get_or_make_bin
from erpnext.utilities.transaction_base import validate_uom_is_integer from erpnext.utilities.transaction_base import validate_uom_is_integer
@@ -84,6 +88,7 @@ class ProductionPlan(Document):
posting_date: DF.Date posting_date: DF.Date
prod_plan_references: DF.Table[ProductionPlanItemReference] prod_plan_references: DF.Table[ProductionPlanItemReference]
project: DF.Link | None project: DF.Link | None
reserve_stock: DF.Check
sales_order_status: DF.Literal["", "To Deliver and Bill", "To Bill", "To Deliver"] sales_order_status: DF.Literal["", "To Deliver and Bill", "To Bill", "To Deliver"]
sales_orders: DF.Table[ProductionPlanSalesOrder] sales_orders: DF.Table[ProductionPlanSalesOrder]
skip_available_sub_assembly_item: DF.Check skip_available_sub_assembly_item: DF.Check
@@ -108,6 +113,12 @@ class ProductionPlan(Document):
warehouses: DF.TableMultiSelect[ProductionPlanMaterialRequestWarehouse] warehouses: DF.TableMultiSelect[ProductionPlanMaterialRequestWarehouse]
# end: auto-generated types # 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): def validate(self):
self.set_pending_qty_in_row_without_reference() self.set_pending_qty_in_row_without_reference()
self.calculate_total_planned_qty() self.calculate_total_planned_qty()
@@ -116,6 +127,11 @@ class ProductionPlan(Document):
validate_uom_is_integer(self, "stock_uom", "planned_qty") validate_uom_is_integer(self, "stock_uom", "planned_qty")
self.validate_sales_orders() self.validate_sales_orders()
self.validate_material_request_type() 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): def validate_material_request_type(self):
for row in self.get("mr_items"): for row in self.get("mr_items"):
@@ -552,12 +568,20 @@ class ProductionPlan(Document):
def on_submit(self): def on_submit(self):
self.update_bin_qty() self.update_bin_qty()
self.update_sales_order() self.update_sales_order()
self.update_stock_reservation()
def on_cancel(self): def on_cancel(self):
self.db_set("status", "Cancelled") self.db_set("status", "Cancelled")
self.delete_draft_work_order() self.delete_draft_work_order()
self.update_bin_qty() self.update_bin_qty()
self.update_sales_order() 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): def update_sales_order(self):
sales_orders = [row.sales_order for row in self.po_items if row.sales_order] 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 = frappe.new_doc("Work Order")
wo.update(item) wo.update(item)
wo.reserve_stock = self.reserve_stock
wo.planned_start_date = item.get("planned_start_date") or item.get("schedule_date") wo.planned_start_date = item.get("planned_start_date") or item.get("schedule_date")
if item.get("warehouse"): 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 get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
) )
if required_qty > 0: return {
return { "item_code": row.item_code,
"item_code": row.item_code, "item_name": row.item_name,
"item_name": row.item_name, "quantity": required_qty / conversion_factor,
"quantity": required_qty / conversion_factor, "conversion_factor": conversion_factor,
"conversion_factor": conversion_factor, "required_bom_qty": total_qty,
"required_bom_qty": total_qty, "stock_uom": row.get("stock_uom"),
"stock_uom": row.get("stock_uom"), "warehouse": warehouse
"warehouse": warehouse or row.get("source_warehouse")
or row.get("source_warehouse") or row.get("default_warehouse")
or row.get("default_warehouse") or item_group_defaults.get("default_warehouse"),
or item_group_defaults.get("default_warehouse"), "safety_stock": row.safety_stock,
"safety_stock": row.safety_stock, "actual_qty": bin_dict.get("actual_qty", 0),
"actual_qty": bin_dict.get("actual_qty", 0), "projected_qty": bin_dict.get("projected_qty", 0),
"projected_qty": bin_dict.get("projected_qty", 0), "ordered_qty": bin_dict.get("ordered_qty", 0),
"ordered_qty": bin_dict.get("ordered_qty", 0), "reserved_qty_for_production": bin_dict.get("reserved_qty_for_production", 0),
"reserved_qty_for_production": bin_dict.get("reserved_qty_for_production", 0), "min_order_qty": row["min_order_qty"],
"min_order_qty": row["min_order_qty"], "material_request_type": row.get("default_material_request_type"),
"material_request_type": row.get("default_material_request_type"), "sales_order": sales_order,
"sales_order": sales_order, "description": row.get("description"),
"description": row.get("description"), "uom": row.get("purchase_uom") or row.get("stock_uom"),
"uom": row.get("purchase_uom") or row.get("stock_uom"), }
}
def get_sales_orders(self): def get_sales_orders(self):
@@ -1792,6 +1816,7 @@ def get_sub_assembly_items(
if d.expandable: if d.expandable:
parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") parent_item_code = frappe.get_cached_value("BOM", bom_no, "item")
stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) 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: 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)) 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: elif warehouse:
bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse)) bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse))
if stock_qty > 0: bom_data.append(
bom_data.append( frappe._dict(
frappe._dict( {
{ "actual_qty": bin_details[d.item_code][0].get("actual_qty", 0)
"actual_qty": bin_details[d.item_code][0].get("actual_qty", 0) if bin_details.get(d.item_code)
if bin_details.get(d.item_code) else 0,
else 0, "parent_item_code": parent_item_code,
"parent_item_code": parent_item_code, "description": d.description,
"description": d.description, "production_item": d.item_code,
"production_item": d.item_code, "item_name": d.item_name,
"item_name": d.item_name, "stock_uom": d.stock_uom,
"stock_uom": d.stock_uom, "uom": d.stock_uom,
"uom": d.stock_uom, "bom_no": d.value,
"bom_no": d.value, "is_sub_contracted_item": d.is_sub_contracted_item,
"is_sub_contracted_item": d.is_sub_contracted_item, "bom_level": indent,
"bom_level": indent, "indent": indent,
"indent": indent, "stock_qty": stock_qty,
"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: if d.value:
get_sub_assembly_items( get_sub_assembly_items(
sub_assembly_items, sub_assembly_items,
bin_details, bin_details,
d.value, d.value,
bom_data, bom_data,
stock_qty, stock_qty,
company, company,
warehouse, warehouse,
indent=indent + 1, indent=indent + 1,
skip_available_sub_assembly_item=skip_available_sub_assembly_item, skip_available_sub_assembly_item=skip_available_sub_assembly_item,
) )
def set_default_warehouses(row, default_warehouses): 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) frappe.qb.from_(table)
.inner_join(child) .inner_join(child)
.on(table.name == child.parent) .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( .where(
(table.docstatus == 1) (table.docstatus == 1)
& (child.production_item == item_code) & (child.production_item == item_code)
@@ -2050,3 +2083,39 @@ def get_reserved_qty_for_sub_assembly(item_code, warehouse):
qty = flt(query[0][0]) qty = flt(query[0][0])
return qty if qty > 0 else 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()

View File

@@ -4,8 +4,12 @@ from frappe import _
def get_data(): def get_data():
return { return {
"fieldname": "production_plan", "fieldname": "production_plan",
"non_standard_fieldnames": {
"Stock Reservation Entry": "voucher_no",
},
"transactions": [ "transactions": [
{"label": _("Transactions"), "items": ["Work Order", "Material Request"]}, {"label": _("Transactions"), "items": ["Work Order", "Material Request"]},
{"label": _("Subcontract"), "items": ["Purchase Order"]}, {"label": _("Subcontract"), "items": ["Purchase Order"]},
{"label": _("Reservation"), "items": ["Stock Reservation Entry"]},
], ],
} }

View File

@@ -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.sales_order import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order 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.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_entry.test_stock_entry import make_stock_entry
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation, create_stock_reconciliation,
) )
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
class UnitTestProductionPlan(UnitTestCase): class UnitTestProductionPlan(UnitTestCase):
@@ -146,7 +151,7 @@ class TestProductionPlan(IntegrationTestCase):
item_code="Raw Material Item 2", target="_Test Warehouse - _TC", qty=1, rate=120 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(len(pln.mr_items))
self.assertTrue(flt(pln.mr_items[0].quantity), 1.0) self.assertTrue(flt(pln.mr_items[0].quantity), 1.0)
@@ -182,11 +187,17 @@ class TestProductionPlan(IntegrationTestCase):
pln = create_production_plan( pln = create_production_plan(
item_code="Test Production Item 1", use_multi_level_bom=0, ignore_existing_ordered_qty=1 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() sr1.cancel()
sr2.cancel() sr2.cancel()
pln.cancel()
def test_production_plan_sales_orders(self): def test_production_plan_sales_orders(self):
"Test if previously fulfilled SO (with WO) is pulled into Prod Plan." "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[0].get("quantity"), 80)
self.assertEqual(mr_items[1].get("quantity"), 70) 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): def create_production_plan(**args):
""" """
@@ -1931,6 +2353,7 @@ def create_production_plan(**args):
"get_items_from": "Sales Order", "get_items_from": "Sales Order",
"skip_available_sub_assembly_item": args.skip_available_sub_assembly_item or 0, "skip_available_sub_assembly_item": args.skip_available_sub_assembly_item or 0,
"sub_assembly_warehouse": args.sub_assembly_warehouse, "sub_assembly_warehouse": args.sub_assembly_warehouse,
"reserve_stock": args.reserve_stock or 0,
} }
) )

View File

@@ -26,7 +26,7 @@
}, },
{ {
"fieldname": "qty", "fieldname": "qty",
"fieldtype": "Data", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Qty" "label": "Qty"
}, },
@@ -37,15 +37,17 @@
"label": "Item Reference" "label": "Item Reference"
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:10:20.410593", "modified": "2025-05-07 17:47:36.244083",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Production Plan Item Reference", "name": "Production Plan Item Reference",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

@@ -19,7 +19,7 @@ class ProductionPlanItemReference(Document):
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
qty: DF.Data | None qty: DF.Float
sales_order: DF.Link | None sales_order: DF.Link | None
sales_order_item: DF.Data | None sales_order_item: DF.Data | None
# end: auto-generated types # end: auto-generated types

View File

@@ -9,29 +9,32 @@
"item_name", "item_name",
"fg_warehouse", "fg_warehouse",
"parent_item_code", "parent_item_code",
"schedule_date",
"column_break_3", "column_break_3",
"qty",
"bom_no", "bom_no",
"bom_level", "bom_level",
"type_of_manufacturing", "type_of_manufacturing",
"section_break_4rxf",
"required_qty",
"column_break_xfhm",
"projected_qty",
"qty",
"subcontracting_section",
"supplier", "supplier",
"work_order_details_section",
"wo_produced_qty",
"purchase_order", "purchase_order",
"work_order_details_section",
"production_plan_item", "production_plan_item",
"wo_produced_qty",
"stock_reserved_qty",
"column_break_7", "column_break_7",
"received_qty", "received_qty",
"indent", "indent",
"section_break_19", "section_break_19",
"schedule_date",
"uom", "uom",
"stock_uom", "stock_uom",
"column_break_22",
"description",
"section_break_4rxf",
"actual_qty", "actual_qty",
"column_break_xfhm", "column_break_22",
"projected_qty" "description"
], ],
"fields": [ "fields": [
{ {
@@ -49,18 +52,18 @@
"depends_on": "eval:doc.type_of_manufacturing == \"In House\"", "depends_on": "eval:doc.type_of_manufacturing == \"In House\"",
"fieldname": "work_order_details_section", "fieldname": "work_order_details_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Reference" "label": "Manufacturing"
}, },
{ {
"fieldname": "column_break_7", "fieldname": "column_break_7",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"columns": 1, "columns": 2,
"fieldname": "qty", "fieldname": "qty",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Required Qty", "label": "Qty to Order",
"read_only": 1 "read_only": 1
}, },
{ {
@@ -152,7 +155,6 @@
"columns": 2, "columns": 2,
"fieldname": "fg_warehouse", "fieldname": "fg_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1,
"label": "Target Warehouse", "label": "Target Warehouse",
"options": "Warehouse" "options": "Warehouse"
}, },
@@ -167,22 +169,24 @@
{ {
"fieldname": "supplier", "fieldname": "supplier",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1,
"label": "Supplier", "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" "options": "Supplier"
}, },
{ {
"columns": 1, "columns": 2,
"fieldname": "schedule_date", "fieldname": "schedule_date",
"fieldtype": "Datetime", "fieldtype": "Datetime",
"in_list_view": 1,
"label": "Schedule Date" "label": "Schedule Date"
}, },
{ {
"fieldname": "section_break_4rxf", "fieldname": "section_break_4rxf",
"fieldtype": "Section Break" "fieldtype": "Section Break",
"label": "Quantity"
}, },
{ {
"columns": 2,
"fieldname": "actual_qty", "fieldname": "actual_qty",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Actual Qty", "label": "Actual Qty",
@@ -194,8 +198,10 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"columns": 2,
"fieldname": "projected_qty", "fieldname": "projected_qty",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1,
"label": "Projected Qty", "label": "Projected Qty",
"no_copy": 1, "no_copy": 1,
"read_only": 1 "read_only": 1
@@ -205,18 +211,40 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Produced Qty", "label": "Produced Qty",
"read_only": 1 "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, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-01-01 17:50:32.273610", "modified": "2025-05-01 14:28:35.979941",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Production Plan Sub Assembly Item", "name": "Production Plan Sub Assembly Item",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

@@ -32,7 +32,9 @@ class ProductionPlanSubAssemblyItem(Document):
purchase_order: DF.Link | None purchase_order: DF.Link | None
qty: DF.Float qty: DF.Float
received_qty: DF.Float received_qty: DF.Float
required_qty: DF.Float
schedule_date: DF.Datetime | None schedule_date: DF.Datetime | None
stock_reserved_qty: DF.Float
stock_uom: DF.Link | None stock_uom: DF.Link | None
supplier: DF.Link | None supplier: DF.Link | None
type_of_manufacturing: DF.Literal["In House", "Subcontract", "Material Request"] type_of_manufacturing: DF.Literal["In House", "Subcontract", "Material Request"]

View File

@@ -43,11 +43,6 @@
"skip_transfer", "skip_transfer",
"from_wip_warehouse", "from_wip_warehouse",
"update_consumed_material_cost_in_project", "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", "time",
"planned_start_date", "planned_start_date",
"planned_end_date", "planned_end_date",
@@ -63,6 +58,11 @@
"column_break_24", "column_break_24",
"corrective_operation_cost", "corrective_operation_cost",
"total_operating_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", "more_info",
"description", "description",
"stock_uom", "stock_uom",
@@ -505,7 +505,7 @@
"depends_on": "eval:!doc.__islocal", "depends_on": "eval:!doc.__islocal",
"fieldname": "serial_no_and_batch_for_finished_good_section", "fieldname": "serial_no_and_batch_for_finished_good_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Serial No and Batch for Finished Good" "label": "Finished Good Serial / Batch"
}, },
{ {
"fieldname": "column_break_17", "fieldname": "column_break_17",
@@ -594,12 +594,13 @@
"label": " Reserve Stock" "label": " Reserve Stock"
} }
], ],
"grid_page_length": 50,
"icon": "fa fa-cogs", "icon": "fa fa-cogs",
"idx": 1, "idx": 1,
"image_field": "image", "image_field": "image",
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2024-09-23 16:56:00.483027", "modified": "2025-04-25 11:46:38.739588",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Work Order", "name": "Work Order",
@@ -629,6 +630,7 @@
"role": "Stock User" "role": "Stock User"
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "ASC", "sort_order": "ASC",
"states": [], "states": [],

View File

@@ -10,7 +10,7 @@ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Case 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 ( from frappe.utils import (
cint, cint,
date_diff, date_diff,
@@ -1399,7 +1399,9 @@ class WorkOrder(Document):
items = frappe._dict() items = frappe._dict()
stock_entry.reload() 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) items = self.get_finished_goods_for_reservation(stock_entry)
elif stock_entry.purpose == "Material Transfer for Manufacture": elif stock_entry.purpose == "Material Transfer for Manufacture":
items = self.get_list_of_materials_for_reservation(stock_entry) 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): def get_finished_goods_for_reservation(self, stock_entry):
items = frappe._dict() items = frappe._dict()
so_details = self.get_so_details() if self.production_plan_sub_assembly_item:
if not so_details: # Reserve the sub-assembly item for the final product for the work order.
return items 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) for item in item_details:
if not qty: qty_to_reserve = flt(item.stock_qty) - flt(item.stock_reserved_qty + item.delivered_qty)
return items if qty_to_reserve <= 0:
for row in stock_entry.items:
if not row.t_warehouse or not row.is_finished_item:
continue continue
if qty > row.transfer_qty: warehouse = item.warehouse
qty = row.transfer_qty 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: for row in stock_entry.items:
items[row.item_code] = frappe._dict( if (
{ not row.t_warehouse
"voucher_no": self.sales_order, or not row.is_finished_item
"voucher_type": "Sales Order", or row.t_warehouse != warehouse
"voucher_detail_no": so_details.name, or row.item_code != item.item_code
"item_code": row.item_code, ):
"warehouse": row.t_warehouse, continue
"stock_qty": qty,
"from_voucher_no": stock_entry.name, reserved_qty = qty_to_reserve
"from_voucher_type": stock_entry.doctype, if qty_to_reserve > row.transfer_qty:
"from_voucher_detail_no": row.name, reserved_qty = row.transfer_qty
"serial_and_batch_bundles": [row.serial_and_batch_bundle], qty_to_reserve -= row.transfer_qty
} else:
) qty_to_reserve = 0
else:
items[row.item_code]["stock_qty"] += qty 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 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): def get_so_details(self):
return frappe.db.get_value( return frappe.db.get_value(
"Sales Order Item", "Sales Order Item",
@@ -1483,7 +1541,15 @@ class WorkOrder(Document):
"item_code": self.production_item, "item_code": self.production_item,
"docstatus": 1, "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, as_dict=1,
) )
@@ -1526,7 +1592,7 @@ class WorkOrder(Document):
@frappe.whitelist() @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): if isinstance(doc, str):
doc = parse_json(doc) doc = parse_json(doc)
doc = frappe.get_doc("Work Order", doc.get("name")) 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) sre = StockReservation(doc, items=items, notify=notify)
if doc.docstatus == 1: 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) frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
elif doc.docstatus == 2: elif doc.docstatus == 2:
sre.cancel_stock_reservation_entries() sre.cancel_stock_reservation_entries()

View File

@@ -14,7 +14,7 @@ $.extend(erpnext.stock_reservation, {
fields: erpnext.stock_reservation.get_dialog_fields(frm, parms), fields: erpnext.stock_reservation.get_dialog_fields(frm, parms),
primary_action_label: __("Reserve Stock"), primary_action_label: __("Reserve Stock"),
primary_action: () => { 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", "Work Order": "required_qty",
}[frm.doc.doctype]; }[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"] = { params["dispatch_qty_field"] = {
"Sales Order": "delivered_qty", "Sales Order": "delivered_qty",
"Work Order": "transferred_qty", "Work Order": "transferred_qty",
"Production Plan": "delivered_qty",
}[frm.doc.doctype]; }[frm.doc.doctype];
params["method"] = { params["method"] = {
@@ -140,6 +151,9 @@ $.extend(erpnext.stock_reservation, {
dispatch_qty_field = "consumed_qty"; 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) => { frm.doc[parms.table_name].forEach((item) => {
if (frm.doc.reserve_stock) { if (frm.doc.reserve_stock) {
let unreserved_qty = let unreserved_qty =
@@ -152,8 +166,8 @@ $.extend(erpnext.stock_reservation, {
if (unreserved_qty > 0) { if (unreserved_qty > 0) {
let args = { let args = {
__checked: 1, __checked: 1,
item_code: item.item_code, item_code: item[item_code_field] || item.item_code,
warehouse: item.warehouse || item.source_warehouse, warehouse: item[warehouse_field] || item.warehouse || item.source_warehouse,
}; };
args[field] = item.name; args[field] = item.name;
@@ -167,7 +181,7 @@ $.extend(erpnext.stock_reservation, {
dialog.show(); dialog.show();
}, },
reserve_stock(frm, parms) { reserve_stock(frm, table_name, parms) {
let dialog = erpnext.stock_reservation.dialog; let dialog = erpnext.stock_reservation.dialog;
var data = { items: dialog.fields_dict.items.grid.get_selected_children() }; var data = { items: dialog.fields_dict.items.grid.get_selected_children() };
@@ -177,6 +191,7 @@ $.extend(erpnext.stock_reservation, {
args: { args: {
doc: frm.doc, doc: frm.doc,
items: data.items, items: data.items,
table_name: table_name,
notify: true, notify: true,
}, },
freeze: true, freeze: true,

View File

@@ -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.accounts_controller import merge_taxes
from erpnext.controllers.buying_controller import BuyingController from erpnext.controllers.buying_controller import BuyingController
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction 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"} form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -383,7 +384,7 @@ class PurchaseReceipt(BuyingController):
self.make_gl_entries() self.make_gl_entries()
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
self.set_consumed_qty_in_subcontract_order() self.set_consumed_qty_in_subcontract_order()
self.reserve_stock_for_sales_order() self.reserve_stock()
self.update_received_qty_if_from_pp() self.update_received_qty_if_from_pp()
def update_received_qty_if_from_pp(self): 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) pr_doc = self if (pr == self.name) else frappe.get_doc("Purchase Receipt", pr)
update_billing_percentage(pr_doc, update_modified=update_modified) 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): def reserve_stock_for_sales_order(self):
if ( if (
self.is_return self.is_return
@@ -953,6 +958,66 @@ class PurchaseReceipt(BuyingController):
notify=True, 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): def enable_recalculate_rate_in_sles(self):
rejected_warehouses = frappe.get_all( rejected_warehouses = frappe.get_all(
"Purchase Receipt Item", filters={"parent": self.name}, pluck="rejected_warehouse" "Purchase Receipt Item", filters={"parent": self.name}, pluck="rejected_warehouse"

View File

@@ -1646,7 +1646,11 @@ class StockEntry(StockController):
def make_stock_reserve_for_wip_and_fg(self): def make_stock_reserve_for_wip_and_fg(self):
if self.is_stock_reserve_for_work_order(): if self.is_stock_reserve_for_work_order():
pro_doc = frappe.get_doc("Work Order", self.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 return
pro_doc.set_reserved_qty_for_wip_and_fg(self) 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): def cancel_stock_reserve_for_wip_and_fg(self):
if self.is_stock_reserve_for_work_order(): if self.is_stock_reserve_for_work_order():
pro_doc = frappe.get_doc("Work Order", self.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 return
pro_doc.cancel_reserved_qty_for_wip_and_fg(self) pro_doc.cancel_reserved_qty_for_wip_and_fg(self)

View File

@@ -84,7 +84,7 @@
"no_copy": 1, "no_copy": 1,
"oldfieldname": "voucher_type", "oldfieldname": "voucher_type",
"oldfieldtype": "Data", "oldfieldtype": "Data",
"options": "\nSales Order\nWork Order", "options": "\nSales Order\nWork Order\nProduction Plan",
"print_width": "150px", "print_width": "150px",
"read_only": 1, "read_only": 1,
"width": "150px" "width": "150px"
@@ -289,7 +289,7 @@
"fieldtype": "Select", "fieldtype": "Select",
"label": "From Voucher Type", "label": "From Voucher Type",
"no_copy": 1, "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, "print_hide": 1,
"read_only": 1, "read_only": 1,
"report_hide": 1 "report_hide": 1
@@ -339,12 +339,13 @@
"label": "Qty in WIP Warehouse" "label": "Qty in WIP Warehouse"
} }
], ],
"grid_page_length": 50,
"hide_toolbar": 1, "hide_toolbar": 1,
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2024-09-19 15:28:24.726283", "modified": "2025-04-30 22:15:22.998138",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Reservation Entry", "name": "Stock Reservation Entry",
@@ -450,6 +451,7 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

@@ -30,7 +30,9 @@ class StockReservationEntry(Document):
delivered_qty: DF.Float delivered_qty: DF.Float
from_voucher_detail_no: DF.Data | None from_voucher_detail_no: DF.Data | None
from_voucher_no: DF.DynamicLink | 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_batch_no: DF.Check
has_serial_no: DF.Check has_serial_no: DF.Check
item_code: DF.Link | None item_code: DF.Link | None
@@ -46,7 +48,7 @@ class StockReservationEntry(Document):
voucher_detail_no: DF.Data | None voucher_detail_no: DF.Data | None
voucher_no: DF.DynamicLink | None voucher_no: DF.DynamicLink | None
voucher_qty: DF.Float 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 warehouse: DF.Link | None
# end: auto-generated types # end: auto-generated types
@@ -335,6 +337,7 @@ class StockReservationEntry(Document):
item_doctype = { item_doctype = {
"Sales Order": "Sales Order Item", "Sales Order": "Sales Order Item",
"Work Order": "Work Order Item", "Work Order": "Work Order Item",
"Production Plan": "Production Plan Sub Assembly Item",
}.get(self.voucher_type, None) }.get(self.voucher_type, None)
if item_doctype: if item_doctype:
@@ -350,6 +353,11 @@ class StockReservationEntry(Document):
) )
).run(as_list=True)[0][0] or 0 ).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( frappe.db.set_value(
item_doctype, item_doctype,
self.voucher_detail_no, self.voucher_detail_no,
@@ -968,13 +976,14 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st
class StockReservation: class StockReservation:
def __init__(self, doc, items=None, notify=True): def __init__(self, doc, items=None, kwargs=None, notify=True):
if isinstance(doc, str): if isinstance(doc, str):
doc = parse_json(doc) doc = parse_json(doc)
doc = frappe.get_doc("Work Order", doc.get("name")) doc = frappe.get_doc("Work Order", doc.get("name"))
self.doc = doc self.doc = doc
self.items = items self.items = items
self.kwargs = kwargs
self.initialize_fields() self.initialize_fields()
def initialize_fields(self) -> None: def initialize_fields(self) -> None:
@@ -989,6 +998,9 @@ class StockReservation:
self.warehouse_field = "source_warehouse" self.warehouse_field = "source_warehouse"
if self.doc.skip_transfer and self.doc.from_wip_warehouse: if self.doc.skip_transfer and self.doc.from_wip_warehouse:
self.warehouse = self.doc.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: def cancel_stock_reservation_entries(self, names=None) -> None:
"""Cancels Stock Reservation Entries for the Voucher.""" """Cancels Stock Reservation Entries for the Voucher."""
@@ -1037,11 +1049,16 @@ class StockReservation:
if isinstance(item, dict): if isinstance(item, dict):
item = frappe._dict(item) item = frappe._dict(item)
item_code = item.get("item_code") or item.get("production_item")
item_details = frappe.get_cached_value( 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") 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 ( if (
not warehouse not warehouse
and self.doc.doctype == "Work Order" and self.doc.doctype == "Work Order"
@@ -1052,10 +1069,12 @@ class StockReservation:
) )
qty = item.get(self.qty_field) or item.get("stock_qty") 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: 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 = ( self.qty_to_be_reserved = (
qty if self.available_qty_to_reserve >= qty else self.available_qty_to_reserve 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: if not self.qty_to_be_reserved:
continue continue
sre.item_code = item.item_code sre.item_code = item_code
sre.warehouse = warehouse sre.warehouse = warehouse
sre.has_serial_no = item_details.has_serial_no sre.has_serial_no = item_details.has_serial_no
sre.has_batch_no = item_details.has_batch_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( frappe.msgprint(
_("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format( _("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"), title=_("Stock Reservation"),
indicator="orange", indicator="orange",
@@ -1143,6 +1162,163 @@ class StockReservation:
return available_qty 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( def create_stock_reservation_entries_for_so_items(
sales_order: object, sales_order: object,

View File

@@ -109,6 +109,8 @@ def get_stock_balance(
from erpnext.stock.stock_ledger import get_previous_sle from erpnext.stock.stock_ledger import get_previous_sle
frappe.has_permission("Item", "read")
if posting_date is None: if posting_date is None:
posting_date = nowdate() posting_date = nowdate()
if posting_time is None: if posting_time is None: