mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-17 16:45:02 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user