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",
"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
}
}

View File

@@ -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

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) {
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 = ` <table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<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);
},
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",

View File

@@ -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<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",
"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<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",
"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",

View File

@@ -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()

View File

@@ -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"]},
],
}

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.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,
}
)

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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"]

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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,

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.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"

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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: