diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 692492ac1a7..8900165ff7c 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -16,6 +16,7 @@ "order_confirmation_no", "order_confirmation_date", "get_items_from_open_material_requests", + "mps", "column_break_7", "transaction_date", "schedule_date", @@ -1315,6 +1316,13 @@ "fieldtype": "Data", "is_virtual": 1, "label": "Last Scanned Warehouse" + }, + { + "fieldname": "mps", + "fieldtype": "Link", + "label": "MPS", + "options": "Master Production Schedule", + "read_only": 1 } ], "grid_page_length": 50, @@ -1322,7 +1330,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2025-07-31 17:19:40.816883", + "modified": "2025-08-28 11:00:56.635116", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index b7da5fc8945..a097e4abecf 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -108,6 +108,7 @@ class PurchaseOrder(BuyingController): items: DF.Table[PurchaseOrderItem] language: DF.Data | None letter_head: DF.Link | None + mps: DF.Link | None named_place: DF.Data | None naming_series: DF.Literal["PUR-ORD-.YYYY.-"] net_total: DF.Currency diff --git a/erpnext/manufacturing/doctype/master_production_schedule/__init__.py b/erpnext/manufacturing/doctype/master_production_schedule/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.js b/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.js new file mode 100644 index 00000000000..020f2c6db72 --- /dev/null +++ b/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.js @@ -0,0 +1,186 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Master Production Schedule", { + refresh(frm) { + frm.trigger("set_query_filters"); + + frm.set_df_property("items", "cannot_add_rows", true); + frm.fields_dict.items.$wrapper.find("[data-action='duplicate_rows']").css("display", "none"); + + frm.trigger("set_custom_buttons"); + }, + + setup(frm) { + frm.trigger("set_indicator_for_item"); + }, + + set_indicator_for_item(frm) { + frm.set_indicator_formatter("item_code", function (doc) { + if (doc.order_release_date < frappe.datetime.get_today()) { + return "orange"; + } else if (doc.order_release_date > frappe.datetime.get_today()) { + return "blue"; + } else { + return "green"; + } + }); + }, + + set_query_filters(frm) { + frm.set_query("parent_warehouse", (doc) => { + return { + filters: { + is_group: 1, + company: doc.company, + }, + }; + }); + }, + + get_actual_demand(frm) { + frm.call({ + method: "get_actual_demand", + doc: frm.doc, + freeze: true, + freeze_message: __("Generating Master Production Schedule..."), + callback: (r) => { + frm.reload_doc(); + }, + }); + }, + + set_custom_buttons(frm) { + if (!frm.is_new()) { + frm.add_custom_button(__("View MRP"), () => { + frappe.set_route("query-report", "Material Requirements Planning Report", { + company: frm.doc.company, + from_date: frm.doc.from_date, + to_date: frm.doc.to_date, + mps: frm.doc.name, + warehouse: frm.doc.parent_warehouse, + sales_forecast: frm.doc.sales_forecast, + }); + }); + } + }, + + get_sales_orders(frm) { + frm.sales_order_dialog = new frappe.ui.Dialog({ + fields: [ + { + fieldtype: "Section Break", + label: __("Filters for Sales Orders"), + }, + { + fieldname: "customer", + fieldtype: "Link", + options: "Customer", + label: __("Customer"), + }, + { + fieldtype: "Section Break", + }, + { + fieldname: "from_date", + fieldtype: "Date", + label: __("From Date"), + }, + { + fieldname: "to_date", + fieldtype: "Date", + label: __("To Date"), + }, + { + fieldtype: "Column Break", + }, + { + fieldname: "delivery_from_date", + fieldtype: "Date", + label: __("Delivery From Date"), + default: frm.doc.from_date, + }, + { + fieldname: "delivery_to_date", + fieldtype: "Date", + label: __("Delivery To Date"), + }, + ], + title: __("Get Sales Orders"), + size: "large", + primary_action_label: __("Get Sales Orders"), + primary_action: (data) => { + frm.sales_order_dialog.hide(); + frm.events.fetch_sales_orders(frm, data); + }, + }); + + frm.sales_order_dialog.show(); + }, + + fetch_sales_orders(frm, data) { + frm.call({ + method: "fetch_sales_orders", + doc: frm.doc, + freeze: true, + freeze_message: __("Fetching Sales Orders..."), + args: data, + callback: (r) => { + frm.reload_doc(); + }, + }); + }, + + get_material_requests(frm) { + frm.sales_order_dialog = new frappe.ui.Dialog({ + fields: [ + { + fieldtype: "Section Break", + label: __("Filters for Material Requests"), + }, + { + fieldname: "material_request_type", + fieldtype: "Select", + label: __("Purpose"), + options: "\nPurchase\nManufacture", + default: "Manufacture", + }, + { + fieldtype: "Column Break", + }, + { + fieldname: "from_date", + fieldtype: "Date", + label: __("From Date"), + }, + { + fieldname: "to_date", + fieldtype: "Date", + label: __("To Date"), + }, + ], + title: __("Get Material Requests"), + size: "large", + primary_action_label: __("Get Material Requests"), + primary_action: (data) => { + frm.sales_order_dialog.hide(); + frm.events.fetch_materials_requests(frm, data); + }, + }); + + frm.sales_order_dialog.show(); + }, + + fetch_materials_requests(frm, data) { + frm.call({ + method: "fetch_materials_requests", + doc: frm.doc, + freeze: true, + freeze_message: __("Fetching Material Requests..."), + args: data, + callback: (r) => { + frm.reload_doc(); + }, + }); + }, +}); diff --git a/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.json b/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.json new file mode 100644 index 00000000000..3110c44bcfc --- /dev/null +++ b/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.json @@ -0,0 +1,264 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "naming_series:", + "creation": "2025-08-08 19:54:43.478386", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "mps_tab", + "naming_series", + "company", + "column_break_xdcy", + "posting_date", + "from_date", + "to_date", + "section_break_rrrx", + "column_break_klws", + "select_items", + "column_break_mtqw", + "parent_warehouse", + "sales_orders_and_material_requests_tab", + "open_orders_section", + "get_sales_orders", + "sales_orders", + "get_material_requests", + "material_requests", + "section_break_xtby", + "column_break_yhkr", + "column_break_vvys", + "get_actual_demand", + "section_break_cmgo", + "items", + "forecast_demand_section", + "sales_forecast", + "amended_from" + ], + "fields": [ + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "column_break_xdcy", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_rrrx", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_klws", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_mtqw", + "fieldtype": "Column Break" + }, + { + "allow_bulk_edit": 1, + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "Master Production Schedule Item" + }, + { + "default": "Today", + "fieldname": "posting_date", + "fieldtype": "Date", + "label": "Posting Date", + "reqd": 1 + }, + { + "fieldname": "section_break_cmgo", + "fieldtype": "Section Break", + "label": "Actual Demand" + }, + { + "default": "MPS.YY.-.######", + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "MPS.YY.-.######", + "reqd": 1 + }, + { + "description": "For projected and forecast quantities, the system will consider all child warehouses under the selected parent warehouse.", + "fieldname": "parent_warehouse", + "fieldtype": "Link", + "label": "Parent Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date", + "reqd": 1 + }, + { + "fieldname": "to_date", + "fieldtype": "Date", + "label": "To Date" + }, + { + "fieldname": "sales_orders_and_material_requests_tab", + "fieldtype": "Tab Break", + "label": "Demand" + }, + { + "fieldname": "sales_orders", + "fieldtype": "Table", + "label": "Sales Orders", + "options": "Production Plan Sales Order" + }, + { + "fieldname": "mps_tab", + "fieldtype": "Tab Break", + "label": "Planning" + }, + { + "fieldname": "material_requests", + "fieldtype": "Table", + "label": "Material Requests", + "options": "Production Plan Material Request" + }, + { + "fieldname": "open_orders_section", + "fieldtype": "Section Break", + "label": "Open Orders" + }, + { + "fieldname": "section_break_xtby", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_vvys", + "fieldtype": "Column Break" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Master Production Schedule", + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "sales_forecast", + "fieldtype": "Link", + "label": "Sales Forecast", + "options": "Sales Forecast" + }, + { + "fieldname": "get_sales_orders", + "fieldtype": "Button", + "label": "Get Sales Orders" + }, + { + "fieldname": "get_material_requests", + "fieldtype": "Button", + "label": "Get Material Requests" + }, + { + "fieldname": "select_items", + "fieldtype": "Table MultiSelect", + "label": "Select Items", + "options": "Master Production Schedule Item" + }, + { + "fieldname": "column_break_yhkr", + "fieldtype": "Column Break" + }, + { + "fieldname": "get_actual_demand", + "fieldtype": "Button", + "label": "Get Actual Demand" + }, + { + "fieldname": "forecast_demand_section", + "fieldtype": "Section Break", + "label": "Forecast Demand" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-09-02 19:33:28.244544", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Master Production Schedule", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Manufacturing User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Manufacturing Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.py b/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.py new file mode 100644 index 00000000000..906a329acca --- /dev/null +++ b/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.py @@ -0,0 +1,456 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import math + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.query_builder.functions import Sum +from frappe.utils import add_days, flt, getdate, parse_json, today +from frappe.utils.nestedset import get_descendants_of + + +class MasterProductionSchedule(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from erpnext.manufacturing.doctype.master_production_schedule_item.master_production_schedule_item import ( + MasterProductionScheduleItem, + ) + from erpnext.manufacturing.doctype.production_plan_material_request.production_plan_material_request import ( + ProductionPlanMaterialRequest, + ) + from erpnext.manufacturing.doctype.production_plan_sales_order.production_plan_sales_order import ( + ProductionPlanSalesOrder, + ) + + amended_from: DF.Link | None + company: DF.Link + from_date: DF.Date + items: DF.Table[MasterProductionScheduleItem] + material_requests: DF.Table[ProductionPlanMaterialRequest] + naming_series: DF.Literal["MPS.YY.-.######"] + parent_warehouse: DF.Link | None + posting_date: DF.Date + sales_forecast: DF.Link | None + sales_orders: DF.Table[ProductionPlanSalesOrder] + select_items: DF.TableMultiSelect[MasterProductionScheduleItem] + to_date: DF.Date | None + # end: auto-generated types + + @frappe.whitelist() + def get_actual_demand(self): + self.set("items", []) + + actual_demand_data = self.get_demand_data() + + item_wise_data = self.get_item_wise_mps_data(actual_demand_data) + + if not item_wise_data: + return [] + + self.update_item_details(item_wise_data) + self.add_mps_data(item_wise_data) + + if not self.is_new(): + self.save() + + def validate(self): + self.set_to_date() + + def set_to_date(self): + self.to_date = None + for row in self.items: + if not self.to_date or getdate(row.delivery_date) > getdate(self.to_date): + self.to_date = row.delivery_date + + forecast_delivery_dates = self.get_sales_forecast_data() + for date in forecast_delivery_dates: + if not self.to_date or getdate(date) > getdate(self.to_date): + self.to_date = date + + def get_sales_forecast_data(self): + if not self.sales_forecast: + return [] + + filters = {"parent": self.sales_forecast} + if self.select_items: + items = [d.item_code for d in self.select_items if d.item_code] + filters["item_code"] = ("in", items) + + return frappe.get_all( + "Sales Forecast Item", + filters=filters, + pluck="delivery_date", + order_by="delivery_date asc", + ) + + def update_item_details(self, data): + items = [item[0] for item in data if item[0]] + item_details = self.get_item_details(items) + + for key in data: + item_data = data[key] + item_code = key[0] + if item_code in item_details: + item_data.update(item_details[item_code]) + + def get_item_details(self, items): + doctype = frappe.qb.DocType("Item") + + query = ( + frappe.qb.from_(doctype) + .select( + doctype.name.as_("item_code"), + doctype.default_bom.as_("bom_no"), + doctype.item_name, + ) + .where(doctype.name.isin(items)) + ) + + item_details = query.run(as_dict=True) + item_wise_details = frappe._dict({}) + + if not item_details: + return item_wise_details + + for row in item_details: + row.cumulative_lead_time = self.get_cumulative_lead_time(row.item_code, row.bom_no) + + for row in item_details: + item_wise_details.setdefault(row.item_code, row) + + return item_wise_details + + def get_cumulative_lead_time(self, item_code, bom_no, time_in_days=0): + if not time_in_days: + time_in_days = get_item_lead_time(item_code) + + bom_materials = frappe.get_all( + "BOM Item", + filters={"parent": bom_no, "docstatus": 1}, + fields=["item_code", "bom_no"], + ) + + for row in bom_materials: + if row.bom_no: + time_in_days += self.get_cumulative_lead_time(row.item_code, row.bom_no) + else: + lead_time = get_item_lead_time(row.item_code) + time_in_days += lead_time + + return time_in_days + + def get_demand_data(self): + sales_order_data = self.get_sales_orders_data() + material_request_data = self.get_material_requests_data() + + return sales_order_data + material_request_data + + def get_material_requests_data(self): + if not self.material_requests: + return [] + + doctype = frappe.qb.DocType("Material Request Item") + + query = ( + frappe.qb.from_(doctype) + .select( + doctype.item_code, + doctype.warehouse, + doctype.stock_uom, + doctype.schedule_date.as_("delivery_date"), + doctype.parent.as_("material_request"), + doctype.stock_qty.as_("qty"), + ) + .orderby(doctype.schedule_date) + ) + + if self.material_requests: + material_requests = [m.material_request for m in self.material_requests if m.material_request] + query = query.where(doctype.parent.isin(material_requests)) + + if self.from_date: + query = query.where(doctype.schedule_date >= self.from_date) + + if self.to_date: + query = query.where(doctype.schedule_date <= self.to_date) + + return query.run(as_dict=True) + + def get_sales_orders_data(self): + sales_order_schedules = self.get_sales_order_schedules() + ignore_orders = [] + if sales_order_schedules: + for row in sales_order_schedules: + if row.sales_order not in ignore_orders: + ignore_orders.append(row.sales_order) + + sales_orders = self.get_items_from_sales_orders(ignore_orders) + + return sales_orders + sales_order_schedules + + def get_items_from_sales_orders(self, ignore_orders=None): + doctype = frappe.qb.DocType("Sales Order Item") + query = ( + frappe.qb.from_(doctype) + .select( + doctype.item_code, + doctype.warehouse, + doctype.stock_uom, + doctype.delivery_date, + doctype.name.as_("sales_order"), + doctype.stock_qty.as_("qty"), + ) + .where(doctype.docstatus == 1) + .orderby(doctype.delivery_date) + ) + + if self.from_date: + query = query.where(doctype.delivery_date >= self.from_date) + + if self.to_date: + query = query.where(doctype.delivery_date <= self.to_date) + + if self.sales_orders: + names = [s.sales_order for s in self.sales_orders if s.sales_order] + if ignore_orders: + names = [name for name in names if name not in ignore_orders] + + query = query.where(doctype.parent.isin(names)) + + return query.run(as_dict=True) + + def get_sales_order_schedules(self): + doctype = frappe.qb.DocType("Delivery Schedule Item") + query = frappe.qb.from_(doctype).select( + doctype.item_code, + doctype.warehouse, + doctype.stock_uom, + doctype.delivery_date, + doctype.sales_order, + doctype.stock_qty.as_("qty"), + ) + + if self.sales_orders: + names = [s.sales_order for s in self.sales_orders if s.sales_order] + query = query.where(doctype.sales_order.isin(names)) + + if self.from_date: + query = query.where(doctype.delivery_date >= self.from_date) + + if self.to_date: + query = query.where(doctype.delivery_date <= self.to_date) + + return query.run(as_dict=True) + + def get_item_wise_mps_data(self, data): + item_wise_data = frappe._dict({}) + + for item in data: + key = (item.item_code, item.delivery_date) + + if key not in item_wise_data: + item_wise_data[key] = frappe._dict( + { + "item_code": item.item_code, + "delivery_date": item.delivery_date, + "stock_uom": item.stock_uom, + "qty": 0.0, + "cumulative_lead_time": 0.0, + "order_release_date": item.delivery_date, + } + ) + + item_details = item_wise_data[key] + item_details.qty += item.qty + + return item_wise_data + + def add_mps_data(self, data): + data = frappe._dict(sorted(data.items(), key=lambda x: x[0][1])) + + for key in data: + row = data[key] + row.cumulative_lead_time = math.ceil(row.cumulative_lead_time) + row.order_release_date = add_days(row.delivery_date, -row.cumulative_lead_time) + if getdate(row.order_release_date) < getdate(today()): + continue + + row.planned_qty = row.qty + row.uom = row.stock_uom + row.warehouse = row.warehouse or self.parent_warehouse + self.append("items", row) + + def get_distinct_items(self, data): + items = [] + for item in data: + if item.item_code not in items: + items.append(item.item_code) + + return items + + @frappe.whitelist() + def fetch_materials_requests(self, **data): + if isinstance(data, str): + data = parse_json(data) + + self.set("material_requests", []) + materials_requests = self.get_material_requests(data) + if not materials_requests: + frappe.msgprint( + _("No open Material Requests found for the given criteria."), + alert=True, + ) + return + + for row in materials_requests: + self.append( + "material_requests", + { + "material_request": row.name, + "material_request_date": row.transaction_date, + }, + ) + + if not self.is_new(): + self.save() + + def get_material_requests(self, data): + doctype = frappe.qb.DocType("Material Request") + + query = ( + frappe.qb.from_(doctype) + .select( + doctype.name, + doctype.transaction_date, + ) + .where((doctype.docstatus == 1) & (doctype.status.notin(["Closed", "Completed"]))) + .orderby(doctype.schedule_date) + ) + + if data.get("material_request_type"): + query = query.where(doctype.material_request_type == data.get("material_request_type")) + + if data.get("from_date"): + query = query.where(doctype.transaction_date >= data.get("from_date")) + + if data.get("to_date"): + query = query.where(doctype.transaction_date <= data.get("to_date")) + + if self.from_date: + query = query.where(doctype.schedule_date >= self.from_date) + + if self.to_date: + query = query.where(doctype.schedule_date <= self.to_date) + + return query.run(as_dict=True) + + @frappe.whitelist() + def fetch_sales_orders(self, **data): + if isinstance(data, str): + data = parse_json(data) + + self.set("sales_orders", []) + sales_orders = self.get_sales_orders(data) + if not sales_orders: + return + + for row in sales_orders: + self.append( + "sales_orders", + { + "sales_order": row.name, + "sales_order_date": row.transaction_date, + "delivery_date": row.delivery_date, + "customer": row.customer, + "status": row.status, + "grand_total": row.grand_total, + }, + ) + + if not self.is_new(): + self.save() + + def get_sales_orders(self, kwargs): + doctype = frappe.qb.DocType("Sales Order") + + query = ( + frappe.qb.from_(doctype) + .select( + doctype.name, + doctype.transaction_date, + doctype.delivery_date, + doctype.customer, + doctype.status, + doctype.grand_total, + ) + .where((doctype.docstatus == 1) & (doctype.status.notin(["Closed", "Completed"]))) + .orderby(doctype.delivery_date) + ) + + if kwargs.get("customer"): + query = query.where(doctype.customer == kwargs.get("customer")) + + if kwargs.get("from_date"): + query = query.where(doctype.transaction_date >= kwargs.get("from_date")) + + if kwargs.get("to_date"): + query = query.where(doctype.transaction_date <= kwargs.get("to_date")) + + if kwargs.get("delivery_from_date"): + query = query.where(doctype.delivery_date >= kwargs.get("delivery_from_date")) + + if kwargs.get("delivery_to_date"): + query = query.where(doctype.delivery_date <= kwargs.get("to_delivery_date")) + + if items := self.get_items_for_mps(): + doctype_item = frappe.qb.DocType("Sales Order Item") + query = query.join(doctype_item).on(doctype_item.parent == doctype.name) + query = query.where(doctype_item.item_code.isin(items)) + + return query.run(as_dict=True) + + def get_items_for_mps(self): + if not self.select_items: + return + + return [d.item_code for d in self.select_items if d.item_code] + + def on_submit(self): + self.enqueue_mrp_creation() + + def enqueue_mrp_creation(self): + frappe.enqueue_doc("Master Production Schedule", self.name, "make_mrp", queue="long", timeout=1800) + + frappe.msgprint( + _("MRP Log documents are being created in the background."), + alert=True, + ) + + +def get_item_lead_time(item_code): + doctype = frappe.qb.DocType("Item Lead Time") + + query = ( + frappe.qb.from_(doctype) + .select( + ((doctype.manufacturing_time_in_mins / 1440) + doctype.purchase_time + doctype.buffer_time).as_( + "cumulative_lead_time" + ) + ) + .where(doctype.item_code == item_code) + ) + + result = query.run(as_dict=True) + if result: + return result[0].cumulative_lead_time or 0 + + return 0 diff --git a/erpnext/manufacturing/doctype/master_production_schedule/test_master_production_schedule.py b/erpnext/manufacturing/doctype/master_production_schedule/test_master_production_schedule.py new file mode 100644 index 00000000000..1263fc583b5 --- /dev/null +++ b/erpnext/manufacturing/doctype/master_production_schedule/test_master_production_schedule.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestMasterProductionSchedule(IntegrationTestCase): + """ + Integration tests for MasterProductionSchedule. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/erpnext/manufacturing/doctype/master_production_schedule_item/__init__.py b/erpnext/manufacturing/doctype/master_production_schedule_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/doctype/master_production_schedule_item/master_production_schedule_item.json b/erpnext/manufacturing/doctype/master_production_schedule_item/master_production_schedule_item.json new file mode 100644 index 00000000000..55f52765222 --- /dev/null +++ b/erpnext/manufacturing/doctype/master_production_schedule_item/master_production_schedule_item.json @@ -0,0 +1,101 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-08-12 17:09:08.171687", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "delivery_date", + "cumulative_lead_time", + "order_release_date", + "planned_qty", + "warehouse", + "item_name", + "bom_no", + "uom" + ], + "fields": [ + { + "columns": 2, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Warehouse", + "options": "Warehouse", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "delivery_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Delivery Date", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "planned_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Planned Qty" + }, + { + "columns": 2, + "fieldname": "order_release_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Start Date" + }, + { + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "bom_no", + "fieldtype": "Link", + "in_list_view": 1, + "label": "BOM No", + "options": "BOM" + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "label": "UOM", + "options": "UOM" + }, + { + "columns": 2, + "fieldname": "cumulative_lead_time", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Lead Time", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-09-02 19:41:27.167095", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Master Production Schedule Item", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/manufacturing/doctype/master_production_schedule_item/master_production_schedule_item.py b/erpnext/manufacturing/doctype/master_production_schedule_item/master_production_schedule_item.py new file mode 100644 index 00000000000..af248cdc020 --- /dev/null +++ b/erpnext/manufacturing/doctype/master_production_schedule_item/master_production_schedule_item.py @@ -0,0 +1,31 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class MasterProductionScheduleItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + bom_no: DF.Link | None + cumulative_lead_time: DF.Int + delivery_date: DF.Date | None + item_code: DF.Link | None + item_name: DF.Data | None + order_release_date: DF.Date | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + planned_qty: DF.Float + uom: DF.Link | None + warehouse: DF.Link | None + # end: auto-generated types + + pass diff --git a/erpnext/manufacturing/doctype/production_plan_sales_order/production_plan_sales_order.json b/erpnext/manufacturing/doctype/production_plan_sales_order/production_plan_sales_order.json index 9b573bd4d0d..c32e2057d57 100644 --- a/erpnext/manufacturing/doctype/production_plan_sales_order/production_plan_sales_order.json +++ b/erpnext/manufacturing/doctype/production_plan_sales_order/production_plan_sales_order.json @@ -10,7 +10,8 @@ "sales_order_date", "col_break1", "customer", - "grand_total" + "grand_total", + "status" ], "fields": [ { @@ -58,18 +59,26 @@ "print_width": "120px", "read_only": 1, "width": "120px" + }, + { + "fieldname": "status", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Status" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:20.746852", + "modified": "2025-08-21 15:16:13.828240", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Sales Order", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "ASC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/production_plan_sales_order/production_plan_sales_order.py b/erpnext/manufacturing/doctype/production_plan_sales_order/production_plan_sales_order.py index 7f793b58685..ac9a346b253 100644 --- a/erpnext/manufacturing/doctype/production_plan_sales_order/production_plan_sales_order.py +++ b/erpnext/manufacturing/doctype/production_plan_sales_order/production_plan_sales_order.py @@ -21,6 +21,7 @@ class ProductionPlanSalesOrder(Document): parenttype: DF.Data sales_order: DF.Link sales_order_date: DF.Date | None + status: DF.Data | None # end: auto-generated types pass diff --git a/erpnext/manufacturing/doctype/sales_forecast/__init__.py b/erpnext/manufacturing/doctype/sales_forecast/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.js b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.js new file mode 100644 index 00000000000..01623f06ee1 --- /dev/null +++ b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.js @@ -0,0 +1,56 @@ +frappe.ui.form.on("Sales Forecast", { + refresh(frm) { + frm.trigger("set_query_filters"); + frm.trigger("set_custom_buttons"); + }, + + set_query_filters(frm) { + frm.set_query("parent_warehouse", (doc) => { + return { + filters: { + is_group: 1, + company: doc.company, + }, + }; + }); + + frm.set_query("item_code", "items", () => { + return { + filters: { + disabled: 0, + is_stock_item: 1, + }, + }; + }); + }, + + generate_demand(frm) { + frm.call({ + method: "generate_demand", + doc: frm.doc, + freeze: true, + callback: function (r) { + frm.reload_doc(); + }, + }); + }, + + set_custom_buttons(frm) { + if (frm.doc.docstatus === 1 && frm.doc.status === "Planned") { + frm.add_custom_button(__("Create MPS"), () => { + frappe.model.open_mapped_doc({ + method: "erpnext.manufacturing.doctype.sales_forecast.sales_forecast.create_mps", + frm: frm, + }); + }).addClass("btn-primary"); + } + }, +}); + +frappe.ui.form.on("Sales Forecast Item", { + adjust_qty(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + row.demand_qty = row.forecast_qty + row.adjust_qty; + frappe.model.set_value(cdt, cdn, "demand_qty", row.demand_qty); + }, +}); diff --git a/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.json b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.json new file mode 100644 index 00000000000..ae752a5ff0f --- /dev/null +++ b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.json @@ -0,0 +1,252 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "naming_series:", + "creation": "2025-08-12 17:20:16.012501", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "naming_series", + "company", + "posting_date", + "forecasting_method", + "column_break_xdcy", + "from_date", + "frequency", + "demand_number", + "section_break_rrrx", + "column_break_klws", + "selected_items", + "column_break_mtqw", + "parent_warehouse", + "section_break_cmgo", + "generate_demand", + "items", + "section_break_kuzf", + "amended_from", + "column_break_laqr", + "status", + "connections_tab" + ], + "fields": [ + { + "default": "SF.YY.-.######", + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "SF.YY.-.######", + "reqd": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "column_break_xdcy", + "fieldtype": "Column Break" + }, + { + "default": "Today", + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Posting Date" + }, + { + "collapsible": 1, + "fieldname": "section_break_rrrx", + "fieldtype": "Section Break", + "label": "Item and Warehouse" + }, + { + "fieldname": "column_break_klws", + "fieldtype": "Column Break" + }, + { + "fieldname": "selected_items", + "fieldtype": "Table MultiSelect", + "label": "Select Items", + "options": "Sales Forecast Item", + "reqd": 1 + }, + { + "fieldname": "column_break_mtqw", + "fieldtype": "Column Break" + }, + { + "description": "For projected and forecast quantities, the system will consider all child warehouses under the selected parent warehouse.", + "fieldname": "parent_warehouse", + "fieldtype": "Link", + "label": "Parent Warehouse", + "options": "Warehouse", + "reqd": 1 + }, + { + "fieldname": "section_break_cmgo", + "fieldtype": "Section Break" + }, + { + "allow_bulk_edit": 1, + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "Sales Forecast Item" + }, + { + "default": "Today", + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date", + "reqd": 1 + }, + { + "fieldname": "generate_demand", + "fieldtype": "Button", + "label": "Generate Demand" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Sales Forecast", + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "default": "6", + "fieldname": "demand_number", + "fieldtype": "Int", + "label": "Number of Weeks / Months", + "reqd": 1 + }, + { + "fieldname": "connections_tab", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 + }, + { + "fieldname": "section_break_kuzf", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_laqr", + "fieldtype": "Column Break" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Planned\nMPS Generated", + "read_only": 1 + }, + { + "default": "Holt-Winters", + "fieldname": "forecasting_method", + "fieldtype": "Select", + "label": "Forecasting Method", + "options": "Holt-Winters\nManual" + }, + { + "default": "Monthly", + "fieldname": "frequency", + "fieldtype": "Select", + "label": "Frequency", + "options": "Weekly\nMonthly", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2025-09-21 13:24:34.720794", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Sales Forecast", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Manufacturing User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Manufacturing Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.py b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.py new file mode 100644 index 00000000000..8bd06abc46c --- /dev/null +++ b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.py @@ -0,0 +1,228 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +import pandas as pd +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.query_builder.functions import DateFormat, Sum, YearWeek +from frappe.utils import add_to_date, cint, date_diff, flt +from frappe.utils.nestedset import get_descendants_of + + +class SalesForecast(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from erpnext.manufacturing.doctype.sales_forecast_item.sales_forecast_item import SalesForecastItem + + amended_from: DF.Link | None + company: DF.Link + demand_number: DF.Int + forecasting_method: DF.Literal["Holt-Winters", "Manual"] + frequency: DF.Literal["Weekly", "Monthly"] + from_date: DF.Date + items: DF.Table[SalesForecastItem] + naming_series: DF.Literal["SF.YY.-.######"] + parent_warehouse: DF.Link + posting_date: DF.Date | None + selected_items: DF.TableMultiSelect[SalesForecastItem] + status: DF.Literal["Planned", "MPS Generated"] + # end: auto-generated types + + def validate(self): + self.validate_demand_qty() + + def validate_demand_qty(self): + if self.forecasting_method == "Manual": + return + + for row in self.items: + demand_qty = row.forecast_qty + flt(row.adjust_qty) + if row.demand_qty != demand_qty: + row.demand_qty = demand_qty + + def get_sales_data(self): + to_date = self.from_date + from_date = add_to_date(to_date, years=-3) + + doctype = frappe.qb.DocType("Sales Order") + child_doctype = frappe.qb.DocType("Sales Order Item") + + query = ( + frappe.qb.from_(doctype) + .inner_join(child_doctype) + .on(child_doctype.parent == doctype.name) + .select(child_doctype.item_code, Sum(child_doctype.qty).as_("qty"), doctype.transaction_date) + .where((doctype.docstatus == 1) & (doctype.transaction_date.between(from_date, to_date))) + .groupby(child_doctype.item_code) + ) + + if self.selected_items: + items = [item.item_code for item in self.selected_items] + query = query.where(child_doctype.item_code.isin(items)) + + if self.parent_warehouse: + warehouses = get_descendants_of("Warehouse", self.parent_warehouse) + query = query.where(child_doctype.warehouse.isin(warehouses)) + + query = query.groupby(doctype.transaction_date) + + return query.run(as_dict=True) + + def generate_manual_demand(self): + forecast_demand = [] + for row in self.selected_items: + item_details = frappe.db.get_value( + "Item", row.item_code, ["item_name", "stock_uom as uom"], as_dict=True + ) + + for index in range(self.demand_number): + if self.horizon_type == "Monthly": + delivery_date = add_to_date(self.from_date, months=index + 1) + else: + delivery_date = add_to_date(self.from_date, weeks=index + 1) + + forecast_demand.append( + { + "item_code": row.item_code, + "delivery_date": delivery_date, + "item_name": item_details.item_name, + "uom": item_details.uom, + "demand_qty": 0.0, + } + ) + + for demand in forecast_demand: + self.append("items", demand) + + self.save() + + @frappe.whitelist() + def generate_demand(self): + from statsmodels.tsa.holtwinters import ExponentialSmoothing + + self.set("items", []) + + if self.forecasting_method == "Manual": + self.generate_manual_demand() + return + + sales_data = self.get_sales_data() + if not sales_data: + frappe.throw(_("No sales data found for the selected items.")) + + itemwise_data = self.group_sales_data_by_item(sales_data) + + for item_code, data in itemwise_data.items(): + seasonal_periods = self.get_seasonal_periods(data) + pd_sales_data = pd.DataFrame({"item": data.item, "date": data.date, "qty": data.qty}) + + resample_val = "M" if self.horizon_type == "Monthly" else "W" + _sales_data = pd_sales_data.set_index("date").resample(resample_val).sum()["qty"] + + model = ExponentialSmoothing( + _sales_data, trend="add", seasonal="mul", seasonal_periods=seasonal_periods + ) + + fit = model.fit() + forecast = fit.forecast(self.demand_number) + + forecast_data = forecast.to_dict() + if forecast_data: + self.add_sales_forecast_item(item_code, forecast_data) + + self.save() + + def add_sales_forecast_item(self, item_code, forecast_data): + item_details = frappe.db.get_value( + "Item", item_code, ["item_name", "stock_uom as uom", "name as item_code"], as_dict=True + ) + + uom_whole_number = frappe.get_cached_value("UOM", item_details.uom, "must_be_whole_number") + + for date, qty in forecast_data.items(): + if uom_whole_number: + qty = round(qty) + + item_details.update( + { + "delivery_date": date, + "forecast_qty": qty, + "demand_qty": qty, + "warehouse": self.parent_warehouse, + } + ) + + self.append("items", item_details) + + def get_seasonal_periods(self, data): + days = date_diff(data["end_date"], data["start_date"]) + if self.horizon_type == "Monthly": + months = (days / 365) * 12 + seasonal_periods = cint(months / 2) + if seasonal_periods > 12: + seasonal_periods = 12 + else: + weeks = days / 7 + seasonal_periods = cint(weeks / 2) + if seasonal_periods > 52: + seasonal_periods = 52 + + return seasonal_periods + + def group_sales_data_by_item(self, sales_data): + """ + Group sales data by item code and calculate total quantity sold. + """ + itemwise_data = frappe._dict({}) + for row in sales_data: + if row.item_code not in itemwise_data: + itemwise_data[row.item_code] = frappe._dict( + { + "start_date": row.transaction_date, + "item": [], + "date": [], + "qty": [], + "end_date": "", + } + ) + + item_data = itemwise_data[row.item_code] + item_data["item"].append(row.item_code) + item_data["date"].append(pd.to_datetime(row.transaction_date)) + item_data["qty"].append(row.qty) + item_data["end_date"] = row.transaction_date + + return itemwise_data + + +@frappe.whitelist() +def create_mps(source_name, target_doc=None): + def postprocess(source, doc): + doc.naming_series = "MPS.YY.-.######" + + doc = get_mapped_doc( + "Sales Forecast", + source_name, + { + "Sales Forecast": { + "doctype": "Master Production Schedule", + "validation": {"docstatus": ["=", 1]}, + "field_map": { + "name": "sales_forecast", + "from_date": "from_date", + }, + }, + }, + target_doc, + postprocess, + ) + + return doc diff --git a/erpnext/manufacturing/doctype/sales_forecast/sales_forecast_dashboard.py b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast_dashboard.py new file mode 100644 index 00000000000..a2166be4339 --- /dev/null +++ b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast_dashboard.py @@ -0,0 +1,13 @@ +from frappe import _ + + +def get_data(): + return { + "fieldname": "demand_planning", + "transactions": [ + { + "label": _("MPS"), + "items": ["Master Production Schedule"], + }, + ], + } diff --git a/erpnext/manufacturing/doctype/sales_forecast/sales_forecast_list.js b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast_list.js new file mode 100644 index 00000000000..00748e007ed --- /dev/null +++ b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast_list.js @@ -0,0 +1,12 @@ +frappe.listview_settings["Sales Forecast"] = { + add_fields: ["status"], + get_indicator: function (doc) { + if (doc.status === "Planned") { + // Closed + return [__("Planned"), "orange", "status,=,Planned"]; + } else if (doc.status === "MPS Generated") { + // on hold + return [__("MPS Generated"), "green", "status,=,MPS Generated"]; + } + }, +}; diff --git a/erpnext/manufacturing/doctype/sales_forecast/test_sales_forecast.py b/erpnext/manufacturing/doctype/sales_forecast/test_sales_forecast.py new file mode 100644 index 00000000000..3f95a82d5f0 --- /dev/null +++ b/erpnext/manufacturing/doctype/sales_forecast/test_sales_forecast.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestSalesForecast(IntegrationTestCase): + """ + Integration tests for SalesForecast. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/erpnext/manufacturing/doctype/sales_forecast_item/__init__.py b/erpnext/manufacturing/doctype/sales_forecast_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.json b/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.json new file mode 100644 index 00000000000..16c5275c0b8 --- /dev/null +++ b/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.json @@ -0,0 +1,107 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-08-18 20:57:19.816490", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "uom", + "delivery_date", + "forecast_qty", + "adjust_qty", + "demand_qty", + "warehouse" + ], + "fields": [ + { + "columns": 2, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "read_only": 1, + "reqd": 1 + }, + { + "columns": 2, + "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, + "fieldname": "item_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Item Name", + "read_only": 1 + }, + { + "columns": 2, + "fetch_from": "item_code.sales_uom", + "fetch_if_empty": 1, + "fieldname": "uom", + "fieldtype": "Link", + "in_list_view": 1, + "label": "UOM", + "options": "UOM", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "delivery_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Delivery Date", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "forecast_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Forecast Qty", + "non_negative": 1, + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "adjust_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Adjust Qty" + }, + { + "columns": 3, + "fieldname": "demand_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Demand Qty", + "non_negative": 1, + "reqd": 1 + }, + { + "columns": 2, + "fieldname": "warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Warehouse", + "options": "Warehouse", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-08-18 21:59:38.859082", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Sales Forecast Item", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.py b/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.py new file mode 100644 index 00000000000..5fcb718fb50 --- /dev/null +++ b/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.py @@ -0,0 +1,30 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class SalesForecastItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + adjust_qty: DF.Float + delivery_date: DF.Date | None + demand_qty: DF.Float + forecast_qty: DF.Float + item_code: DF.Link + item_name: DF.Data | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + uom: DF.Link | None + warehouse: DF.Link | None + # end: auto-generated types + + pass diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 7927973289c..d1aa9278889 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -14,6 +14,7 @@ "item_name", "image", "bom_no", + "mps", "sales_order", "column_break1", "company", @@ -601,6 +602,13 @@ "label": "Disassembled Qty", "no_copy": 1, "read_only": 1 + }, + { + "fieldname": "mps", + "fieldtype": "Link", + "label": "MPS", + "options": "Master Production Schedule", + "read_only": 1 } ], "grid_page_length": 50, @@ -609,7 +617,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2025-06-21 00:55:45.916224", + "modified": "2025-08-28 11:01:48.719824", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 55ce1f4dc16..5aac8b2775c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -101,6 +101,7 @@ class WorkOrder(Document): material_request: DF.Link | None material_request_item: DF.Data | None material_transferred_for_manufacturing: DF.Float + mps: DF.Link | None naming_series: DF.Literal["MFG-WO-.YYYY.-"] operations: DF.Table[WorkOrderOperation] planned_end_date: DF.Datetime | None diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index a740c13a461..1f4621f1a34 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -390,10 +390,18 @@ def get_time_logs(job_cards): @frappe.whitelist() -def get_default_holiday_list(): - return frappe.get_cached_value( - "Company", frappe.defaults.get_user_default("Company"), "default_holiday_list" - ) +def get_default_holiday_list(company=None): + if company: + if not frappe.has_permission("Company", "read"): + return [] + + if not frappe.db.exists("Company", company): + return [] + + if not company: + company = frappe.defaults.get_user_default("Company") + + return frappe.get_cached_value("Company", company, "default_holiday_list") def check_if_within_operating_hours(workstation, operation, from_datetime, to_datetime): diff --git a/erpnext/manufacturing/report/material_requirements_planning_report/__init__.py b/erpnext/manufacturing/report/material_requirements_planning_report/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.js b/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.js new file mode 100644 index 00000000000..ad066bad582 --- /dev/null +++ b/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.js @@ -0,0 +1,170 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["Material Requirements Planning Report"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1, + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_days(frappe.datetime.get_today(), 7), + reqd: 1, + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.add_months(frappe.datetime.get_today(), 3), + reqd: 1, + }, + { + fieldname: "item_code", + label: __("Item Code"), + fieldtype: "Link", + options: "Item", + get_query: function () { + return { + filters: { + is_stock_item: 1, + }, + }; + }, + }, + { + fieldname: "warehouse", + label: __("Warehouse"), + fieldtype: "Link", + options: "Warehouse", + reqd: 1, + default: frappe.defaults.get_user_default("Warehouse"), + }, + { + fieldname: "mps", + label: __("MPS"), + fieldtype: "Link", + options: "Master Production Schedule", + reqd: 1, + }, + { + fieldname: "type_of_material", + label: __("Type of Material"), + fieldtype: "Select", + default: "All", + options: "\nFinished Goods\nRaw Materials\nAll", + }, + { + fieldname: "safety_stock_check_frequency", + label: __("Safety Stock Check Frequency"), + fieldtype: "Select", + default: "Weekly", + options: "\nDaily\nWeekly\nMonthly", + }, + { + fieldname: "add_safety_stock", + label: __("Add Safety Stock"), + fieldtype: "Check", + default: 1, + }, + { + fieldname: "show_in_bucket_view", + label: __("Show in Bucket View"), + fieldtype: "Check", + }, + { + fieldname: "bucket_view", + label: __("View Data Based on"), + fieldtype: "Select", + options: "Delivery Date\nRelease Date", + default: "Delivery Date", + depends_on: "eval:doc.show_in_bucket_view == 1", + }, + { + fieldname: "bucket_size", + label: __("Bucket Size"), + fieldtype: "Select", + default: "Monthly", + options: "Daily\nWeekly\nMonthly", + depends_on: "eval:doc.show_in_bucket_view == 1", + }, + ], + formatter: function (value, row, column, data, default_formatter) { + if (column.fieldtype === "Float" && !data.item_code) { + return ""; + } + + value = default_formatter(value, row, column, data); + // if (column.fieldname === "release_date") { + // if (frappe.datetime.get_day_diff(data.release_date, frappe.datetime.get_today()) < 0) { + // return `${value}`; + // } + // } + + return value; + }, + + get_datatable_options(options) { + return Object.assign(options, { + checkboxColumn: true, + }); + }, + + onload(report) { + report.page.add_inner_button(__("Make Purchase / Work Order"), () => { + let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows(); + let selected_rows = indexes.map((i) => frappe.query_report.data[i]); + + if (!selected_rows.length) { + frappe.throw(__("Please select a row to create a Reposting Entry")); + } else { + let show_in_bucket_view = frappe.query_report.get_filter_value("show_in_bucket_view"); + if (show_in_bucket_view) { + frappe.throw(__("Please uncheck 'Show in Bucket View' to create Orders")); + } + + frappe.prompt( + [ + { + fieldname: "use_default_warehouse", + label: __("Use Default Warehouse"), + fieldtype: "Check", + default: 1, + }, + { + fieldname: "warehouse", + label: __("Warehouse"), + fieldtype: "Link", + options: "Warehouse", + depends_on: "eval:!doc.use_default_warehouse", + mandatory_depends_on: "eval:!doc.use_default_warehouse", + }, + ], + (prompt_data) => { + frappe.call({ + method: "erpnext.manufacturing.report.material_requirements_planning_report.material_requirements_planning_report.make_order", + freeze: true, + args: { + selected_rows: selected_rows, + company: frappe.query_report.get_filter_value("company"), + warehouse: !prompt_data.use_default_warehouse ? prompt_data.warehouse : null, + mps: frappe.query_report.get_filter_value("mps"), + }, + callback: function (r) { + if (r.message) { + frappe.set_route("List", r.message); + } + }, + }); + } + ); + } + }); + }, +}; diff --git a/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.json b/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.json new file mode 100644 index 00000000000..499e4a5180f --- /dev/null +++ b/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.json @@ -0,0 +1,47 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2025-08-15 14:29:09.655112", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "{}", + "letterhead": null, + "modified": "2025-08-22 17:55:48.952251", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Material Requirements Planning Report", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Master Production Schedule", + "report_name": "Material Requirements Planning Report", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Manufacturing Manager" + }, + { + "role": "Manufacturing User" + }, + { + "role": "Purchase Manager" + }, + { + "role": "Purchase User" + }, + { + "role": "Stock User" + }, + { + "role": "Stock Manager" + } + ], + "timeout": 0 +} diff --git a/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.py b/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.py new file mode 100644 index 00000000000..a04ee0100e7 --- /dev/null +++ b/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.py @@ -0,0 +1,1362 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import math +from datetime import datetime, timedelta + +import frappe +from frappe import _ +from frappe.query_builder import Case +from frappe.query_builder.functions import Sum +from frappe.utils import ( + add_days, + add_months, + cint, + days_diff, + flt, + formatdate, + get_date_str, + get_first_day, + getdate, + parse_json, + today, +) +from frappe.utils.nestedset import get_descendants_of + + +def execute(filters: dict | None = None): + obj = MaterialRequirementsPlanningReport(filters) + data, chart = obj.generate_mrp() + columns = obj.get_columns() + + return columns, data, None, chart + + +class MaterialRequirementsPlanningReport: + def __init__(self, filters): + self.filters = filters + + def generate_mrp(self): + self.fg_items = [] + self.rm_items = [] + self.dates = self.get_dates() + self.mps_data = self.get_mps_data() + + items = self.get_items_from_mps(self.mps_data) + self.item_rm_details = self.get_raw_materials_data(items) + + self._bin_details = self.get_item_wise_bin_details() + self.add_non_planned_orders(items) + + self._wo_details = self.get_work_order_data() + self._po_details = self.get_purchase_order_data() + self._so_details = self.get_sales_order_data() + + self.update_sales_forecast_data() + data, chart = self.get_mrp_data() + + return data, chart + + def add_non_planned_orders(self, items): + _adhoc_so_details = frappe._dict({}) + + so = frappe.qb.DocType("Sales Order") + so_item = frappe.qb.DocType("Sales Order Item") + + query = ( + frappe.qb.from_(so) + .inner_join(so_item) + .on(so.name == so_item.parent) + .select( + so_item.item_code, + so_item.item_name, + so.delivery_date, + so_item.warehouse, + ((so_item.qty - so_item.delivered_qty) * so_item.conversion_factor).as_("adhoc_qty"), + ) + .where( + (so.docstatus == 1) + & (so.status.notin(["Closed", "Completed", "Stopped"])) + & (so_item.docstatus == 1) + & (so_item.item_code.isin(items)) + ) + ) + + if self.filters.get("warehouse"): + warehouses = [self.filters.get("warehouse")] + if frappe.db.get_value("Warehouse", self.filters.get("warehouse"), "is_group"): + warehouses = get_descendants_of("Warehouse", self.filters.get("warehouse")) + + query = query.where(so_item.warehouse.isin(warehouses)) + + if skip_orders := self.get_orders_to_skip(): + query = query.where(so.name.notin(skip_orders)) + + if self.filters.get("from_date"): + query = query.where(so.transaction_date >= self.filters.get("from_date")) + + if self.filters.get("to_date"): + query = query.where(so.transaction_date <= self.filters.get("to_date")) + + data = query.run(as_dict=True) + + for row in data: + self.mps_data.append( + frappe._dict( + { + "item_code": row.item_code, + "item_name": row.item_code, + "delivery_date": row.delivery_date, + "adhoc_qty": row.adhoc_qty, + "warehouse": row.warehouse, + "is_adhoc": 1, + } + ) + ) + + def get_orders_to_skip(self): + return frappe.get_all( + "Production Plan Sales Order", + filters={"parent": self.filters.mps}, + pluck="sales_order", + ) + + def get_item_wise_bin_details(self): + items = self.fg_items + self.rm_items + if not items: + return {} + + _bin_details = frappe._dict({}) + + doctype = frappe.qb.DocType("Bin") + query = ( + frappe.qb.from_(doctype) + .select( + doctype.item_code, + Sum(doctype.actual_qty).as_("actual_qty"), + Sum(doctype.reserved_stock).as_("reserved_stock"), + Sum(doctype.indented_qty).as_("indented_qty"), + Sum(doctype.reserved_qty_for_production).as_("reserved_qty_for_production"), + Sum(doctype.reserved_qty_for_sub_contract).as_("reserved_qty_for_sub_contract"), + Sum(doctype.reserved_qty_for_production_plan).as_("reserved_qty_for_production_plan"), + Sum(doctype.reserved_qty).as_("reserved_qty"), + Sum(doctype.projected_qty).as_("projected_qty"), + ) + .where(doctype.item_code.isin(items)) + .groupby(doctype.item_code) + ) + + if self.filters.get("warehouse"): + warehouses = [self.filters.get("warehouse")] + if frappe.db.get_value("Warehouse", self.filters.get("warehouse"), "is_group"): + warehouses = get_descendants_of("Warehouse", self.filters.get("warehouse")) + + query = query.where(doctype.warehouse.isin(warehouses)) + + bin_data = query.run(as_dict=True) + + for row in bin_data: + if row.item_code not in _bin_details: + _bin_details[row.item_code] = row + + self.update_mps_data_with_bin_details(_bin_details) + + return _bin_details + + def update_mps_data_with_bin_details(self, bin_details): + if not self.filters.mps: + return + + items = self.fg_items + self.rm_items + + if not items: + return + + sales_orders = frappe.get_all( + "Production Plan Sales Order", + filters={"parent": self.filters.mps}, + pluck="sales_order", + ) + + if not sales_orders: + return + + sales_order_items = frappe.get_all( + "Sales Order Item", + filters={"parent": ["in", sales_orders], "docstatus": 1, "item_code": ["in", items]}, + fields=["item_code", "qty", "delivered_qty", "conversion_factor"], + ) + + if not sales_order_items: + return + + for row in sales_order_items: + reserved_qty = flt(flt(row.qty) - flt(row.delivered_qty)) * flt(row.conversion_factor) + if reserved_qty <= 0: + continue + + if details := bin_details.get(row.item_code, {}): + details.reserved_qty -= reserved_qty + details.projected_qty += reserved_qty + + def update_sales_forecast_data(self): + sales_forecast_data = self.get_sales_forecast_data() + + if not sales_forecast_data: + return + + for row in sales_forecast_data: + record_exists = False + for d in self.mps_data: + if not d.sales_forecast_qty: + d.sales_forecast_qty = 0 + + if row.item_code == d.item_code and getdate(row.delivery_date) == getdate(d.delivery_date): + d.sales_forecast_qty += row.qty + record_exists = True + + if not record_exists: + self.mps_data.append( + frappe._dict( + { + "item_code": row.item_code, + "item_name": row.item_code, + "delivery_date": row.delivery_date, + "projected_qty": 0, + "sales_forecast_qty": row.qty, + "warehouse": self.filters.get("warehouse"), + } + ) + ) + + def get_mrp_data(self): + data = self.get_detailed_view_data() + data = self.filter_based_on_type_of_materials(data) + chart = self.get_chart_data(data) or {} + + if self.filters.show_in_bucket_view: + return self.get_bucket_view_data(data), chart + + return data, chart + + def filter_based_on_type_of_materials(self, data): + new_data = [] + if self.filters.type_of_material == "All": + return data + + mapper = { + "Finished Goods": "Manufacture", + "Raw Materials": "Purchase", + }.get(self.filters.type_of_material) + + for row in data: + if row.type_of_material == mapper: + row.indent = 0 + new_data.append(row) + + return new_data + + def get_chart_data(self, data): + # Prepare chart for demand vs supply + + if self.filters.get("show_in_bucket_view"): + return self.get_bucket_view_chart_data(data) + else: + return self.get_detailed_view_chart_data(data) + + def get_detailed_view_chart_data(self, data): + chart_data = frappe._dict({}) + i = 0 + + sorted_data = sorted(data, key=lambda x: getdate(x.get("delivery_date"))) + for row in sorted_data: + if getdate(row.deliver_date) < getdate(today()): + continue + + if not row.delivery_date: + continue + + if i == 10: + break + + delivery_date = formatdate(row.delivery_date, "dd MMM") + if delivery_date not in chart_data: + i += 1 + chart_data[delivery_date] = frappe._dict( + { + "demand": 0.0, + "supply": 0.0, + } + ) + + demand_supply_data = chart_data[delivery_date] + demand_supply_data.demand += math.ceil(flt(row.planned_qty)) + demand_supply_data.supply += ( + flt(row.in_hand_qty) + flt(row.po_ordered_qty) + flt(row.wo_ordered_qty) + ) + + demand_data = [] + supply_data = [] + for row in chart_data: + value = chart_data[row] + + demand_data.append(math.ceil(flt(value.demand))) + supply_data.append(math.ceil(flt(value.supply))) + + return { + "data": { + "labels": list(chart_data.keys()), + "datasets": [ + { + "name": _("Demand"), + "values": demand_data, + }, + { + "name": _("Supply"), + "values": supply_data, + }, + ], + }, + "type": "bar", + "height": 350, + "colors": ["#7cd6fd", "green"], + "title": _("Demand vs Supply"), + } + + def get_bucket_view_chart_data(self, data): + chart_data = frappe._dict({}) + labels = [] + i = 0 + for row in self.dates: + if self.filters.bucket_size == "Daily" and i == 15: + break + + if self.filters.bucket_size == "Weekly" and i == 12: + break + + row = frappe._dict(row) + for d in data: + if getdate(d.delivery_date) >= getdate(row.from_date) and getdate(d.delivery_date) <= getdate( + row.to_date + ): + if row.from_date not in chart_data: + i += 1 + label = row.label + if self.filters.bucket_size == "Weekly": + label = formatdate(row.from_date, "dd") + "-" + formatdate(row.to_date, "dd MMM") + + labels.append(label) + chart_data[row.from_date] = frappe._dict( + { + "demand": 0.0, + "supply": 0.0, + } + ) + + demand_supply_data = chart_data[row.from_date] + demand_supply_data.demand += math.ceil(flt(d.planned_qty)) + demand_supply_data.supply += ( + flt(d.in_hand_qty) + flt(d.po_ordered_qty) + flt(d.wo_ordered_qty) + ) + + demand_data = [] + supply_data = [] + + for row in chart_data: + value = chart_data[row] + + demand_data.append(math.ceil(flt(value.demand))) + supply_data.append(math.ceil(flt(value.supply))) + + return { + "data": { + "labels": labels, + "datasets": [ + { + "name": _("Demand"), + "values": demand_data, + }, + { + "name": _("Supply"), + "values": supply_data, + }, + ], + }, + "type": "bar", + "height": 350, + "colors": ["#7cd6fd", "green"], + "title": _("Demand vs Supply"), + } + + def get_bucket_view_data(self, data): + new_data = [] + + item_wise_data = frappe._dict({}) + for item in data: + if item.item_code not in item_wise_data: + item_wise_data[item.item_code] = frappe._dict( + { + "item_code": item.item_code, + "item_name": item.item_name, + "lead_time": item.lead_time, + "parents_bom": item.parent_bom, + "bom_no": item.bom_no, + "indent": item.indent, + "capacity": item.capacity, + } + ) + + item_data = item_wise_data[item.item_code] + + for date in self.dates: + date = frappe._dict(date) + if date["from_date"] not in item_data: + item_data[date["from_date"]] = 0.0 + + if self.filters.bucket_view == "Delivery Date": + if getdate(item.delivery_date) >= getdate(date.from_date) and getdate( + item.delivery_date + ) <= getdate(date.to_date): + item_data[date["from_date"]] += flt(item.required_qty) + else: + if getdate(item.release_date) >= getdate(date.from_date) and getdate( + item.release_date + ) <= getdate(date.to_date): + item_data[date["from_date"]] += flt(item.required_qty) + + for row in item_wise_data: + new_data.append(frappe._dict(item_wise_data[row])) + + return new_data + + def get_detailed_view_data(self): + data = [] + i = 0 + + sorted_data = sorted(self.mps_data, key=lambda x: getdate(x["delivery_date"])) + + for row in sorted_data: + rm_details = self.item_rm_details.get(row.item_code) or frappe._dict({}) + + row.indent = 0 + row.bom_no = rm_details.get("bom_no") + row.lead_time = math.ceil(rm_details.get("lead_time", 0)) + if not row.sales_forecast_qty: + row.sales_forecast_qty = 0 + + row.demand_qty = max(flt(row.planned_qty), flt(row.sales_forecast_qty)) + row.planned_qty = max(flt(row.planned_qty), flt(row.sales_forecast_qty)) + if row.get("is_adhoc"): + row.planned_qty += row.adhoc_qty + + for field in ["min_order_qty", "purchase_uom", "safety_stock"]: + if rm_details.get(field): + row[field] = rm_details.get(field) + + self.update_required_qty(row) + row.release_date = add_days(row.delivery_date, row.lead_time * -1) + if i != 0: + data.append(frappe._dict({})) + + i += 1 + row.capacity = 0 + if rm_details.raw_materials: + row.capacity = get_item_capacity(row.item_code, self.filters.bucket_size) + row.type_of_material = "Manufacture" + + data.append(row) + if rm_details.raw_materials: + self.update_rm_details( + rm_details.raw_materials, row.release_date, row.required_qty, rm_details.bom_no, data + ) + + return data + + def add_non_planned_so(self, row): + if so_details := self._so_details.get((row.item_code, row.delivery_date)): + row.adhoc_qty = so_details.qty + row.planned_qty += so_details.qty + del self._so_details[(row.item_code, row.delivery_date)] + + def add_bin_details(self, row): + if bin_details := self._bin_details.get(row.item_code): + current_qty = bin_details.get("actual_qty", 0.0) - flt(bin_details.get("reserved_stock", 0.0)) + if current_qty > 0: + if row.required_qty > current_qty: + row.in_hand_qty = current_qty + row.required_qty -= current_qty + bin_details["actual_qty"] = 0.0 + else: + row.in_hand_qty = row.required_qty + bin_details["actual_qty"] -= row.required_qty + row.required_qty = 0.0 + + def add_po_details(self, row): + if row.required_qty > 0 and self._po_details: + dict_update = {} + for (item_code, delivery_date), po_data in self._po_details.items(): + if row.item_code == item_code and getdate(delivery_date) <= getdate(row.delivery_date): + po_ordered_qty = po_data.qty + if row.required_qty > po_ordered_qty: + row.po_ordered_qty = po_ordered_qty + row.required_qty -= po_ordered_qty + dict_update[(item_code, delivery_date)] = 0.0 + else: + row.po_ordered_qty = row.required_qty + dict_update[(item_code, delivery_date)] = flt(po_data.qty) - flt(row.required_qty) + row.required_qty = 0.0 + + if row.required_qty <= 0.0: + break + + for key, qty in dict_update.items(): + if qty <= 0.0 and key in self._po_details: + del self._po_details[key] + elif key in self._po_details: + self._po_details[key].qty = qty + + def add_wo_details(self, row): + if row.required_qty > 0 and self._wo_details: + dict_update = {} + for (item_code, delivery_date), wo_data in self._wo_details.items(): + if row.item_code == item_code and getdate(delivery_date) <= getdate(row.delivery_date): + wo_ordered_qty = wo_data.qty + if row.required_qty > wo_ordered_qty: + row.wo_ordered_qty = wo_ordered_qty + row.required_qty -= wo_ordered_qty + dict_update[(item_code, delivery_date)] = 0.0 + else: + row.wo_ordered_qty = row.required_qty + dict_update[(item_code, delivery_date)] = flt(wo_data.qty) - flt(row.required_qty) + row.required_qty = 0.0 + + if row.required_qty <= 0.0: + break + + if dict_update: + for key, qty in dict_update.items(): + if qty <= 0.0 and key in self._wo_details: + del self._wo_details[key] + elif key in self._wo_details: + self._wo_details[key].qty = qty + + def update_required_qty(self, row): + row.required_qty = flt(row.planned_qty) + row.in_hand_qty = 0.0 + + self.add_non_planned_so(row) + self.add_bin_details(row) + self.add_po_details(row) + self.add_wo_details(row) + self.add_safety_stock(row) + + def add_safety_stock(self, row): + if self.filters.add_safety_stock: + row.required_qty += flt(row.safety_stock) + + def get_work_order_data(self): + wo_details = frappe._dict({}) + + doctype = frappe.qb.DocType("Work Order") + query = ( + frappe.qb.from_(doctype) + .select( + (doctype.qty - doctype.produced_qty).as_("qty"), + doctype.production_item.as_("item_code"), + doctype.planned_end_date.as_("delivery_date"), + ) + .where((doctype.docstatus == 1) & (doctype.status.notin(["Stopped", "Closed", "Completed"]))) + ) + + items = self.fg_items + self.rm_items + + if items: + query = query.where(doctype.production_item.isin(items)) + + if self.filters.get("warehouse"): + warehouses = [self.filters.get("warehouse")] + if frappe.db.get_value("Warehouse", self.filters.get("warehouse"), "is_group"): + warehouses = get_descendants_of("Warehouse", self.filters.get("warehouse")) + + query = query.where(doctype.fg_warehouse.isin(warehouses)) + + data = query.run(as_dict=True) + + for row in data: + key = (row.item_code, row.delivery_date) + if key not in wo_details: + wo_details[key] = row + else: + wo_details[key].qty += row.qty + + return wo_details + + def get_purchase_order_data(self): + po_details = frappe._dict({}) + + parent_doctype = frappe.qb.DocType("Purchase Order") + doctype = frappe.qb.DocType("Purchase Order Item") + + query = ( + frappe.qb.from_(parent_doctype) + .inner_join(doctype) + .on(parent_doctype.name == doctype.parent) + .select( + ((doctype.qty - doctype.received_qty) * doctype.conversion_factor).as_("qty"), + doctype.item_code.as_("item_code"), + doctype.schedule_date.as_("delivery_date"), + ) + .where( + (doctype.docstatus == 1) + & (parent_doctype.status.notin(["Stopped", "Closed", "Completed", "Received"])) + ) + ) + + items = self.fg_items + self.rm_items + if items: + query = query.where(doctype.item_code.isin(items)) + + if self.filters.get("warehouse"): + warehouses = [self.filters.get("warehouse")] + if frappe.db.get_value("Warehouse", self.filters.get("warehouse"), "is_group"): + warehouses = get_descendants_of("Warehouse", self.filters.get("warehouse")) + + query = query.where(doctype.warehouse.isin(warehouses)) + + data = query.run(as_dict=True) + + for row in data: + key = (row.item_code, row.delivery_date) + if key not in po_details: + po_details[key] = row + else: + po_details[key].qty += row.qty + + if sco := self.get_subcontracted_data(): + for row in sco: + key = (row.item_code, row.delivery_date) + if key not in po_details: + po_details[key] = row + else: + po_details[key].qty += row.qty + + return po_details + + def get_sales_order_data(self): + if not self.rm_items: + return frappe._dict({}) + + so_details = frappe._dict({}) + + parent_doctype = frappe.qb.DocType("Sales Order") + doctype = frappe.qb.DocType("Sales Order Item") + + query = ( + frappe.qb.from_(parent_doctype) + .inner_join(doctype) + .on(parent_doctype.name == doctype.parent) + .select( + ((doctype.qty - doctype.delivered_qty) * doctype.conversion_factor).as_("qty"), + doctype.item_code.as_("item_code"), + doctype.delivery_date, + ) + .where( + (doctype.docstatus == 1) + & (parent_doctype.status.notin(["Stopped", "Closed", "Completed", "Received"])) + ) + ) + + if self.rm_items: + query = query.where(doctype.item_code.isin(self.rm_items)) + + if self.filters.get("warehouse"): + warehouses = [self.filters.get("warehouse")] + if frappe.db.get_value("Warehouse", self.filters.get("warehouse"), "is_group"): + warehouses = get_descendants_of("Warehouse", self.filters.get("warehouse")) + + query = query.where(doctype.warehouse.isin(warehouses)) + + data = query.run(as_dict=True) + + for row in data: + key = (row.item_code, row.delivery_date) + if key not in so_details: + so_details[key] = row + else: + so_details[key].qty += row.qty + + if packed_items := self.get_packed_items_sales_order(): + for row in packed_items: + key = (row.item_code, row.delivery_date) + if key not in so_details: + so_details[key] = row + else: + so_details[key].qty += row.qty + + return so_details + + def get_packed_items_sales_order(self): + parent_doctype = frappe.qb.DocType("Sales Order") + so_item = frappe.qb.DocType("Sales Order Item") + doctype = frappe.qb.DocType("Packed Item") + + query = ( + frappe.qb.from_(parent_doctype) + .inner_join(so_item) + .on(parent_doctype.name == so_item.parent) + .inner_join(doctype) + .on(so_item.name == doctype.parent_detail_docname) + .select( + ((doctype.qty) * doctype.conversion_factor).as_("qty"), + doctype.item_code, + doctype.item_name, + so_item.delivery_date, + ) + .where( + (doctype.docstatus == 1) + & (parent_doctype.status.notin(["Stopped", "Closed", "Completed", "Received"])) + & ((so_item.qty - so_item.delivered_qty) > 0) + ) + ) + + if self.rm_items: + query = query.where(doctype.item_code.isin(self.rm_items)) + + if self.filters.get("warehouse"): + warehouses = [self.filters.get("warehouse")] + if frappe.db.get_value("Warehouse", self.filters.get("warehouse"), "is_group"): + warehouses = get_descendants_of("Warehouse", self.filters.get("warehouse")) + + query = query.where(doctype.warehouse.isin(warehouses)) + + return query.run(as_dict=True) + + def get_subcontracted_data(self): + parent_doctype = frappe.qb.DocType("Subcontracting Order") + doctype = frappe.qb.DocType("Subcontracting Order Item") + + query = ( + frappe.qb.from_(parent_doctype) + .inner_join(doctype) + .on(parent_doctype.name == doctype.parent) + .select( + (doctype.qty - doctype.received_qty).as_("qty"), + doctype.item_code.as_("item_code"), + doctype.schedule_date.as_("delivery_date"), + ) + .where( + (doctype.docstatus == 1) & (parent_doctype.status.notin(["Stopped", "Closed", "Completed"])) + ) + ) + + items = self.fg_items + self.rm_items + if items: + query = query.where(doctype.item_code.isin(items)) + + if self.filters.get("warehouse"): + warehouses = [self.filters.get("warehouse")] + if frappe.db.get_value("Warehouse", self.filters.get("warehouse"), "is_group"): + warehouses = get_descendants_of("Warehouse", self.filters.get("warehouse")) + + query = query.where(doctype.warehouse.isin(warehouses)) + + return query.run(as_dict=True) + + def update_rm_details(self, raw_materials, delivery_date, planned_qty, bom_no, data): + for material in raw_materials: + lead_time = math.ceil(material.lead_time) + row = frappe._dict( + { + "item_code": material.item_code, + "item_name": material.item_name, + "default_warehouse": material.default_warehouse, + "default_supplier": material.default_supplier, + "planned_qty": material.stock_qty * planned_qty, + "projected_qty": 0, + "delivery_date": delivery_date, + "lead_time": lead_time, + "release_date": add_days(delivery_date, lead_time * -1), + "indent": material.indent + 1, + "parent_bom": bom_no, + "bom_no": material.bom_no, + "warehouse": self.filters.get("warehouse"), + "min_order_qty": flt(material.get("min_order_qty", 0)), + "purchase_uom": material.get("purchase_uom"), + "safety_stock": flt(material.get("safety_stock", 0)), + } + ) + + row.capacity = 0 + if material.raw_materials: + row.capacity = get_item_capacity(material.item_code, self.filters.bucket_size) + row.type_of_material = "Manufacture" + else: + row.type_of_material = "Purchase" + + self.update_required_qty(row) + + data.append(row) + + if material.raw_materials: + self.update_rm_details( + material.raw_materials, row.release_date, row.required_qty, material.bom_no, data + ) + + def get_mps_data(self): + doctype = frappe.qb.DocType("Master Production Schedule") + child_doctype = frappe.qb.DocType("Master Production Schedule Item") + + query = ( + frappe.qb.from_(doctype) + .inner_join(child_doctype) + .on(doctype.name == child_doctype.parent) + .select( + child_doctype.item_code, + child_doctype.delivery_date, + doctype.parent_warehouse, + child_doctype.name, + child_doctype.item_name, + child_doctype.planned_qty, + child_doctype.uom, + ) + .where( + (doctype.from_date >= self.filters.from_date) + & (doctype.to_date <= self.filters.to_date) + & (child_doctype.parentfield == "items") + ) + .orderby(child_doctype.delivery_date) + ) + + fields = { + "parent_warehouse": self.filters.get("warehouse"), + "name": self.filters.get("mps"), + "item_code": self.filters.get("item_code"), + } + + for field, value in fields.items(): + if not value: + continue + + if field == "item_code": + query = query.where(child_doctype[field] == value) + else: + query = query.where(doctype[field] == value) + + return query.run(as_dict=True) + + def get_items_from_mps(self, mps_data): + items = [] + for row in mps_data: + if row.item_code not in items: + items.append(row.item_code) + + return items + + def get_raw_materials_data(self, items): + item_wise_rm_details = frappe._dict() + for item_code in items: + if item_code not in item_wise_rm_details: + item_wise_rm_details[item_code] = frappe.db.get_value( + "Item", + item_code, + ["default_bom as bom_no", "safety_stock", "min_order_qty", "purchase_uom"], + as_dict=True, + ) + + item_data = item_wise_rm_details[item_code] + item_data.lead_time = get_item_lead_time( + item_code, "Manufacture" if item_data.bom_no else "Purchase" + ) + + if item_code not in self.fg_items: + self.fg_items.append(item_code) + + if item_data.bom_no: + item_data.raw_materials = self.get_raw_materials(item_data.bom_no, indent=0) + + return item_wise_rm_details + + def get_raw_materials(self, bom_no, indent=0): + company = self.filters.get("company") + raw_materials = frappe.get_all( + "BOM", + filters=[["BOM Item", "parent", "=", bom_no], ["BOM", "docstatus", "=", 1]], + fields=[ + "`tabBOM Item`.`item_code`", + "`tabBOM Item`.`stock_qty`", + "`tabBOM`.quantity as parent_qty", + "`tabBOM Item`.`bom_no`", + "`tabBOM`.`name` as parent_bom", + "`tabBOM Item`.`item_name`", + "`tabBOM Item`.`stock_uom` as uom", + ], + ) + + for material in raw_materials: + material.indent = indent + material.qty = flt(material.stock_qty / material.parent_qty) + + if details := get_item_details(material.item_code, company): + material.update(details) + + if material.item_code not in self.rm_items: + self.rm_items.append(material.item_code) + + if material.bom_no: + material.raw_materials = self.get_raw_materials(material.bom_no, indent + 1) + material.lead_time = get_item_lead_time(material.item_code, "Manufacture") + else: + material.lead_time = get_item_lead_time(material.item_code, "Purchase") + + return raw_materials + + def get_columns(self): + if self.filters.show_in_bucket_view: + columns = [ + { + "fieldname": "item_code", + "label": _("Item Code"), + "fieldtype": "Link", + "options": "Item", + "width": 150, + }, + { + "fieldname": "item_name", + "label": _("Item Name"), + "fieldtype": "Data", + }, + ] + + label = _("Capacity") + " (" + _(self.filters.bucket_size) + ")" + columns.append( + { + "fieldname": "capacity", + "label": _(label), + "fieldtype": "Int", + } + ) + + for date in self.dates: + columns.append( + { + "fieldname": date["from_date"], + "label": date["label"], + "fieldtype": "Float", + "width": 135, + } + ) + + return columns + + columns = [ + { + "fieldname": "item_code", + "label": _("Item Code"), + "fieldtype": "Link", + "options": "Item", + "width": 150, + }, + { + "fieldname": "item_name", + "label": _("Item Name"), + "fieldtype": "Data", + }, + { + "fieldname": "type_of_material", + "label": _("Type"), + "fieldtype": "Data", + "width": 100, + }, + { + "fieldname": "warehouse", + "label": _("Warehouse"), + "fieldtype": "Link", + "options": "Warehouse", + "hidden": True, + }, + ] + + columns += [ + { + "fieldname": "demand_qty", + "label": _("Demand Qty"), + "fieldtype": "Float", + "width": 120, + }, + { + "fieldname": "adhoc_qty", + "label": _("Ad-hoc Qty"), + "fieldtype": "Float", + "width": 120, + }, + ] + + columns += [ + { + "fieldname": "planned_qty", + "label": _("Planned Qty"), + "fieldtype": "Float", + "width": 120, + }, + { + "fieldname": "in_hand_qty", + "label": _("On Hand"), + "fieldtype": "Float", + "width": 90, + }, + { + "fieldname": "po_ordered_qty", + "label": _("Planned Purchase Order"), + "fieldtype": "Float", + }, + { + "fieldname": "wo_ordered_qty", + "label": _("Planned Work Order"), + "fieldtype": "Float", + }, + { + "fieldname": "safety_stock", + "label": _("Safety Stock"), + "fieldtype": "Float", + }, + { + "fieldname": "required_qty", + "label": _("Required Qty"), + "fieldtype": "Float", + }, + { + "fieldname": "min_order_qty", + "label": _("Min Order Qty"), + "fieldtype": "Float", + }, + { + "fieldname": "delivery_date", + "label": _("Delivery Date"), + "fieldtype": "Date", + }, + { + "fieldname": "lead_time", + "label": _("Lead Time"), + "fieldtype": "Int", + }, + { + "fieldname": "release_date", + "label": _("Release Date"), + "fieldtype": "Date", + }, + { + "fieldname": "bom_no", + "label": _("BOM No"), + "fieldtype": "Link", + "options": "BOM", + "width": 150, + }, + ] + + return columns + + def get_dates(self): + bucket_size = self.filters.bucket_size + + from_date = self.filters.from_date + if bucket_size == "Weekly": + from_date = self.get_first_date_of_week(from_date) + elif bucket_size == "Monthly": + from_date = get_first_day(from_date) + + dates_list = [] + while getdate(self.filters.to_date) > getdate(from_date): + args = {"from_date": from_date} + + days = 1 if bucket_size == "Daily" else 7 + if bucket_size == "Monthly": + from_date = add_months(from_date, 1) + else: + from_date = add_days(from_date, days) + + args["to_date"] = add_days(from_date, -1) + + if bucket_size == "Monthly": + args["label"] = formatdate(from_date, "MMM YYYY") + else: + if bucket_size == "Weekly": + args["label"] = ( + formatdate(args["from_date"], "dd MMM") + + " - " + + formatdate(args["to_date"], "dd MMM") + ) + else: + args["label"] = formatdate(args["to_date"], "dd MMM") + + dates_list.append(args) + + return dates_list + + def get_first_date_of_week(self, input_date): + # convert string to datetime + if isinstance(input_date, str): + input_date = datetime.strptime(input_date, "%Y-%m-%d") + + start_of_week = input_date - timedelta(days=input_date.weekday()) + return start_of_week.strftime("%Y-%m-%d") + + def get_last_date_of_week(self, input_date): + # convert string to datetime + if isinstance(input_date, str): + input_date = datetime.strptime(input_date, "%Y-%m-%d") + + end_of_week = input_date + timedelta(days=(6 - input_date.weekday())) + return end_of_week.strftime("%Y-%m-%d") + + def get_sales_forecast_data(self): + forecast_doc = frappe.qb.DocType("Sales Forecast") + doctype = frappe.qb.DocType("Sales Forecast Item") + + query = ( + frappe.qb.from_(forecast_doc) + .inner_join(doctype) + .on(forecast_doc.name == doctype.parent) + .select( + forecast_doc.frequency, + doctype.item_code, + doctype.delivery_date, + doctype.parent.as_("sales_forecast"), + doctype.uom.as_("stock_uom"), + doctype.demand_qty.as_("qty"), + ) + .where((forecast_doc.docstatus == 1) & (doctype.parentfield == "items")) + .orderby(doctype.idx) + ) + + if self.filters.mps: + forecast = frappe.db.get_value("Master Production Schedule", self.filters.mps, "sales_forecast") + query = query.where(forecast_doc.name == forecast) + + if self.filters.from_date: + query = query.where(doctype.delivery_date >= self.filters.from_date) + + if self.filters.to_date: + query = query.where(doctype.delivery_date <= self.filters.to_date) + + if self.filters.warehouse: + query = query.where(doctype.warehouse == self.filters.warehouse) + + if self.filters.item_code: + query = query.where(doctype.item_code == self.filters.item_code) + + sales_data = query.run(as_dict=True) + + return convert_to_daily_bucket_data(sales_data) + + +@frappe.request_cache +def get_item_details(item_code, company): + data = frappe.db.get_value( + "Item", item_code, ["safety_stock", "min_order_qty", "purchase_uom"], as_dict=True + ) or frappe._dict({"safety_stock": 0}) + + default_data = frappe.db.get_value( + "Item Default", + {"parent": item_code, "company": company}, + ["default_warehouse", "default_supplier"], + as_dict=True, + ) + + if default_data: + data.update(default_data) + + return data + + +@frappe.request_cache +def get_item_lead_time(item_code, type_of_material): + doctype = frappe.qb.DocType("Item Lead Time") + + query = frappe.qb.from_(doctype).where(doctype.item_code == item_code) + + if type_of_material == "Manufacture": + query = query.select( + Case() + .when(doctype.manufacturing_time_in_mins.isnull(), 0) + .else_(doctype.manufacturing_time_in_mins / 1440 + doctype.buffer_time) + .as_("lead_time") + ) + else: + query = query.select( + Case() + .when(doctype.purchase_time.isnull(), 0) + .else_(doctype.purchase_time + doctype.buffer_time) + .as_("lead_time") + ) + + time = query.run(pluck="lead_time") + + return time[0] if time else 0 + + +def convert_to_daily_bucket_data(data): + bucketed_data = [] + + for row in data: + if row.frequency == "Monthly": + # Convert monthly data to daily buckets + start_date = get_first_day(row.delivery_date) + no_of_days = days_diff(add_months(start_date, 1), start_date) + + for i in range(no_of_days): # Assuming 30 days in a month + bucketed_data.append( + frappe._dict( + { + "frequency": "Daily", + "item_code": row.item_code, + "delivery_date": add_days(start_date, i), + "sales_forecast": row.sales_forecast, + "stock_uom": row.stock_uom, + "qty": row.qty / no_of_days, # Assuming equal distribution across days + } + ) + ) + + elif row.frequency == "Weekly": + # Convert weekly data to daily buckets + start_date = getdate(row.delivery_date) + for i in range(7): + bucketed_data.append( + frappe._dict( + { + "frequency": "Daily", + "item_code": row.item_code, + "delivery_date": add_days(start_date, i), + "sales_forecast": row.sales_forecast, + "stock_uom": row.stock_uom, + "qty": row.qty / 7, # Assuming equal distribution across days + } + ) + ) + + return bucketed_data + + +@frappe.request_cache +def get_item_capacity(item_code, bucket_size): + capacity = frappe.db.get_value( + "Item Lead Time", + item_code, + "capacity_per_day", + ) + + if not capacity: + return 0.0 + + no_of_days = 7 if bucket_size == "Weekly" else 30 + if bucket_size == "Daily": + no_of_days = 1 + + return math.ceil(cint(capacity) * no_of_days) + + +@frappe.whitelist() +def make_order(selected_rows, company, warehouse=None, mps=None): + if not frappe.has_permission("Purchase Order", "create"): + frappe.throw(_("Not permitted to make Purchase Orders"), frappe.PermissionError) + + if isinstance(selected_rows, str): + selected_rows = parse_json(selected_rows) + + if not frappe.db.exists("Company", company): + frappe.throw(_("Company {0} does not exist").format(company)) + + purchase_orders = {} + work_orders = [] + for row in selected_rows: + row = frappe._dict(row) + if row.type_of_material == "Purchase": + purchase_orders.setdefault((row.default_supplier, row.release_date), []).append(row) + + if row.type_of_material == "Manufacture" and row.bom_no: + work_orders.append(row) + + if purchase_orders: + make_purchase_orders(purchase_orders, company, warehouse=warehouse, mps=mps) + + if work_orders: + make_work_orders(work_orders, company, warehouse=warehouse, mps=mps) + + +def make_purchase_orders(purchase_orders, company, warehouse=None, mps=None): + for (supplier, release_date), items in purchase_orders.items(): + po = frappe.new_doc("Purchase Order") + po.supplier = supplier + po.company = company + po.mps = mps + po.transaction_date = release_date + po.set("items", []) + + for item in items: + uom = item.purchase_uom or item.uom + if not uom: + uom_details = get_item_uom(item.item_code) + uom = uom_details.purchase_uom or uom_details.stock_uom + + if is_whole_number(uom): + item.required_qty = math.ceil(item.required_qty) + + if flt(item.required_qty) < flt(item.min_order_qty): + item.required_qty = item.min_order_qty + + po.append( + "items", + { + "item_code": item.item_code, + "qty": item.required_qty, + "uom": uom, + "schedule_date": item.delivery_date if item.delivery_date else today(), + "warehouse": warehouse or item.default_warehouse, + }, + ) + + if len(po.items) > 0: + po.insert() + frappe.msgprint( + _("Purchase Order {0} created").format(frappe.bold(po.name)), + alert=True, + ) + + +def make_work_orders(work_orders, company, warehouse=None, mps=None): + for item in work_orders: + uom = item.uom + if not uom: + uom_details = get_item_uom(item.item_code) + uom = uom_details.purchase_uom or uom_details.stock_uom + + if is_whole_number(uom): + item.required_qty = math.ceil(item.required_qty) + + wo = frappe.new_doc("Work Order") + wo.production_item = item.item_code + wo.bom_no = item.bom_no + wo.company = company + wo.qty = item.required_qty + wo.mps = mps + wo.stock_uom = uom + wo.wip_warehouse = item.default_warehouse + wo.fg_warehouse = warehouse or item.default_warehouse + wo.planned_start_date = item.release_date if item.release_date else today() + wo.planned_end_date = item.delivery_date if item.delivery_date else today() + wo.flags.ignore_mandatory = True + wo.insert() + frappe.msgprint( + _("Work Order {0} created").format(frappe.bold(wo.name)), + alert=True, + ) + + +@frappe.request_cache +def get_item_uom(item_code): + return frappe.get_cached_value("Item", item_code, ["stock_uom", "purchase_uom"], as_dict=True) + + +@frappe.request_cache +def is_whole_number(uom): + return frappe.get_cached_value("UOM", uom, "must_be_whole_number") diff --git a/erpnext/selling/doctype/delivery_schedule_item/__init__.py b/erpnext/selling/doctype/delivery_schedule_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.js b/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.js new file mode 100644 index 00000000000..8f36a868674 --- /dev/null +++ b/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Delivery Schedule Item", { +// refresh(frm) { + +// }, +// }); diff --git a/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.json b/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.json new file mode 100644 index 00000000000..908251ea343 --- /dev/null +++ b/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.json @@ -0,0 +1,157 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-08-21 16:55:39.222786", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "item_code", + "warehouse", + "column_break_sfve", + "delivery_date", + "sales_order", + "sales_order_item", + "section_break_gttb", + "qty", + "uom", + "conversion_factor", + "column_break_gsks", + "stock_qty", + "stock_uom" + ], + "fields": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item Code", + "options": "Item", + "read_only": 1 + }, + { + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Qty", + "read_only": 1 + }, + { + "fieldname": "conversion_factor", + "fieldtype": "Float", + "label": "Conversion Factor", + "read_only": 1 + }, + { + "fieldname": "column_break_sfve", + "fieldtype": "Column Break" + }, + { + "fieldname": "stock_qty", + "fieldtype": "Float", + "label": "Stock Qty", + "read_only": 1 + }, + { + "fieldname": "delivery_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Delivery Date", + "read_only": 1 + }, + { + "fieldname": "sales_order", + "fieldtype": "Link", + "label": "Sales Order", + "options": "Sales Order", + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "sales_order_item", + "fieldtype": "Data", + "label": "Sales Order Item", + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Warehouse", + "options": "Warehouse", + "read_only": 1 + }, + { + "fieldname": "section_break_gttb", + "fieldtype": "Section Break" + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "in_list_view": 1, + "label": "UOM", + "options": "UOM", + "read_only": 1 + }, + { + "fieldname": "column_break_gsks", + "fieldtype": "Column Break" + }, + { + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-08-21 18:11:30.134073", + "modified_by": "Administrator", + "module": "Selling", + "name": "Delivery Schedule Item", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "item_code" +} diff --git a/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.py b/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.py new file mode 100644 index 00000000000..91f240b7f02 --- /dev/null +++ b/erpnext/selling/doctype/delivery_schedule_item/delivery_schedule_item.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class DeliveryScheduleItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + conversion_factor: DF.Float + delivery_date: DF.Date | None + item_code: DF.Link | None + qty: DF.Float + sales_order: DF.Link | None + sales_order_item: DF.Data | None + stock_qty: DF.Float + stock_uom: DF.Link | None + uom: DF.Link | None + warehouse: DF.Link | None + # end: auto-generated types + + pass diff --git a/erpnext/selling/doctype/delivery_schedule_item/test_delivery_schedule_item.py b/erpnext/selling/doctype/delivery_schedule_item/test_delivery_schedule_item.py new file mode 100644 index 00000000000..d941e415c57 --- /dev/null +++ b/erpnext/selling/doctype/delivery_schedule_item/test_delivery_schedule_item.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestDeliveryScheduleItem(IntegrationTestCase): + """ + Integration tests for DeliveryScheduleItem. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index f8166b6e8c5..133205f251e 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -212,6 +212,7 @@ frappe.ui.form.on("Sales Order", { "Purchase Order", "Unreconcile Payment", "Unreconcile Payment Entries", + "Delivery Schedule Item", ]; }, @@ -545,6 +546,209 @@ frappe.ui.form.on("Sales Order", { }; frappe.set_route("query-report", "Reserved Stock"); }, + + prepare_delivery_schedule(frm, row, data) { + let fields = [ + { + fieldtype: "Date", + fieldname: "delivery_date", + label: __("First Delivery Date"), + reqd: 1, + default: row.delivery_date || frm.doc.delivery_date || frappe.datetime.get_today(), + }, + { + fieldtype: "Float", + fieldname: "qty", + label: __("Qty"), + read_only: 1, + default: row.qty || 0, + }, + { + fieldtype: "Column Break", + }, + { + fieldtype: "Select", + fieldname: "frequency", + label: __("Frequency"), + options: "\nWeekly\nMonthly\nQuarterly\nHalf Yearly\nYearly", + }, + { + fieldtype: "Int", + fieldname: "no_of_deliveries", + label: __("No of Deliveries"), + }, + { + fieldtype: "Section Break", + }, + { + fieldtype: "Button", + fieldname: "get_delivery_schedule", + label: __("Get Delivery Schedule"), + click: () => { + frappe.db.get_value("UOM", row.uom, "must_be_whole_number", (r) => { + frm.events.add_delivery_schedule(frm, row, r.must_be_whole_number); + }); + }, + }, + { + fieldtype: "Table", + data: [], + fieldname: "delivery_schedule", + label: __("Delivery Schedule"), + fields: [ + { + fieldtype: "Date", + fieldname: "delivery_date", + label: __("Delivery Date"), + reqd: 1, + in_list_view: 1, + }, + { + fieldtype: "Float", + fieldname: "qty", + label: __("Qty"), + reqd: 1, + in_list_view: 1, + }, + { + fieldtype: "Data", + fieldname: "Name", + label: __("name"), + read_only: 1, + }, + ], + }, + ]; + + frm.schedule_dialog = new frappe.ui.Dialog({ + title: __("Delivery Schedule"), + fields: fields, + size: "large", + primary_action_label: __("Add Schedule"), + primary_action: (data) => { + if (!data.delivery_schedule || !data.delivery_schedule.length) { + frappe.throw(__("Please enter at least one delivery date and quantity")); + } + + let total_qty = 0; + data.delivery_schedule.forEach((d) => { + if (!d.qty) { + frappe.throw(__("Please enter a valid quantity")); + } + total_qty += flt(d.qty); + }); + + if (total_qty > flt(row.qty)) { + frappe.throw( + __("Total quantity in delivery schedule cannot be greater than the item quantity") + ); + } + + frappe.call({ + doc: frm.doc, + method: "create_delivery_schedule", + args: { + child_row: row, + schedules: data.delivery_schedule, + }, + freeze: true, + freeze_message: __("Creating Delivery Schedule..."), + callback: function () { + frm.refresh_field("items"); + frm.schedule_dialog.hide(); + }, + }); + }, + }); + + frm.schedule_dialog.show(); + + if (data?.length) { + data.forEach((d) => { + if (d.delivery_date && d.qty) { + frm.schedule_dialog.fields_dict.delivery_schedule.df.data.push({ + delivery_date: d.delivery_date, + qty: d.qty, + name: d.name, + }); + } + }); + + frm.schedule_dialog.fields_dict.delivery_schedule.refresh(); + } + }, + + add_delivery_schedule(frm, row, must_be_whole_number) { + let first_delivery_date = frm.schedule_dialog.get_value("delivery_date"); + let frequency = frm.schedule_dialog.get_value("frequency"); + let no_of_deliveries = cint(frm.schedule_dialog.get_value("no_of_deliveries")); + + if (!frequency) { + frappe.throw(__("Please select a frequency for delivery schedule")); + } + + if (!first_delivery_date) { + frappe.throw(__("Please enter the first delivery date")); + } + + if (no_of_deliveries <= 0) { + frappe.throw(__("Please enter a valid number of deliveries")); + } + + frm.schedule_dialog.fields_dict.delivery_schedule.df.data = []; + let qty_to_deliver = row.qty; + let qty_per_delivery = qty_to_deliver / no_of_deliveries; + for (let i = 0; i < no_of_deliveries; i++) { + let qty = qty_per_delivery; + if (must_be_whole_number) { + qty = cint(qty); + } + + if (i === no_of_deliveries - 1) { + // Last delivery, adjust the quantity to deliver the remaining amount + qty = qty_to_deliver; + qty_to_deliver = 0; + } else { + qty_to_deliver -= qty; + } + + frm.schedule_dialog.fields_dict.delivery_schedule.df.data.push({ + delivery_date: first_delivery_date, + qty: qty, + }); + + if (frequency === "Weekly") { + first_delivery_date = frappe.datetime.add_days(first_delivery_date, i + 1 * 7); + } else { + let month_mapper = { + Monthly: 1, + Quarterly: 3, + Half_Yearly: 6, + Yearly: 12, + }; + + first_delivery_date = frappe.datetime.add_months( + first_delivery_date, + month_mapper[frequency] * i + 1 + ); + } + } + + frm.schedule_dialog.fields_dict.delivery_schedule.refresh(); + }, + + set_delivery_schedule(frm, row, data) { + data.forEach((d) => { + if (d.delivery_date && d.qty) { + frm.schedule_dialog.fields_dict.delivery_schedule.df.data.push({ + delivery_date: d.delivery_date, + qty: d.qty, + }); + } + }); + + frm.schedule_dialog.fields_dict.delivery_schedule.refresh(); + }, }); frappe.ui.form.on("Sales Order Item", { @@ -557,11 +761,27 @@ frappe.ui.form.on("Sales Order Item", { frm.script_manager.copy_from_first_row("items", row, ["delivery_date"]); } }, + delivery_date: function (frm, cdt, cdn) { if (!frm.doc.delivery_date) { erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "delivery_date"); } }, + + add_schedule(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + + frappe.call({ + method: "get_delivery_schedule", + doc: frm.doc, + args: { + sales_order_item: row.name, + }, + callback: function (r) { + frm.events.prepare_delivery_schedule(frm, row, r.message); + }, + }); + }, }); erpnext.selling.SalesOrderController = class SalesOrderController extends erpnext.selling.SellingController { diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index f18b8762c7f..48b36227c58 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -13,7 +13,7 @@ from frappe.desk.notifications import clear_doctype_notifications from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import Sum -from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html +from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, parse_json, strip_html from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( unlink_inter_company_doc, @@ -467,6 +467,7 @@ class SalesOrder(SellingController): if self.status == "Closed": frappe.throw(_("Closed order cannot be cancelled. Unclose to cancel.")) + self.delete_delivery_schedule_items() self.check_nextdoc_docstatus() self.update_reserved_qty() self.update_project() @@ -791,6 +792,79 @@ class SalesOrder(SellingController): if not item.delivery_date: item.delivery_date = self.delivery_date + @frappe.whitelist() + def get_delivery_schedule(self, sales_order_item): + return frappe.get_all( + "Delivery Schedule Item", + filters={"sales_order_item": sales_order_item, "sales_order": self.name}, + fields=["delivery_date", "qty", "name"], + order_by="delivery_date asc", + ) + + @frappe.whitelist() + def create_delivery_schedule(self, child_row, schedules): + if isinstance(child_row, dict): + child_row = frappe._dict(child_row) + + if isinstance(schedules, str): + schedules = parse_json(schedules) + + names = [] + first_delivery_date = None + for row in schedules: + row = frappe._dict(row) + + if not first_delivery_date: + first_delivery_date = row.delivery_date + + data = { + "delivery_date": row.delivery_date, + "qty": row.qty, + "uom": child_row.uom, + "stock_uom": child_row.stock_uom, + "item_code": child_row.item_code, + "conversion_factor": child_row.conversion_factor or 1.0, + "warehouse": child_row.warehouse, + "sales_order_item": child_row.name, + "sales_order": self.name, + "stock_qty": row.qty * (child_row.conversion_factor or 1.0), + } + + if frappe.db.exists("Delivery Schedule Item", row.name): + doc = frappe.get_doc("Delivery Schedule Item", row.name) + else: + doc = frappe.new_doc("Delivery Schedule Item") + + doc.update(data) + doc.save(ignore_permissions=True) + names.append(doc.name) + + if names: + self.delete_delivery_schedule_items(names) + + if first_delivery_date: + self.update_delivery_date_based_on_schedule(child_row, first_delivery_date) + + def update_delivery_date_based_on_schedule(self, child_row, first_delivery_date): + for row in self.items: + if row.name == child_row.name: + if first_delivery_date: + row.delivery_date = first_delivery_date + break + + self.save() + + def delete_delivery_schedule_items(self, ignore_names=None): + """Delete delivery schedule items.""" + doctype = frappe.qb.DocType("Delivery Schedule Item") + + query = frappe.qb.from_(doctype).delete().where(doctype.sales_order == self.name) + + if ignore_names: + query = query.where(doctype.name.notin(ignore_names)) + + query.run() + def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float: """Returns the unreserved quantity for the Sales Order Item.""" diff --git a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py index 55dba73934b..f3fae44330d 100644 --- a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py +++ b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py @@ -29,5 +29,6 @@ def get_data(): {"label": _("Manufacturing"), "items": ["Work Order", "BOM", "Blanket Order"]}, {"label": _("Reference"), "items": ["Quotation", "Auto Repeat", "Stock Reservation Entry"]}, {"label": _("Payment"), "items": ["Payment Entry", "Payment Request", "Journal Entry"]}, + {"label": _("Schedule"), "items": ["Delivery Schedule Item"]}, ], } diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 83dd6411b87..bfb839b2343 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -83,6 +83,8 @@ "actual_qty", "column_break_jpky", "company_total_stock", + "sales_order_schedule_section", + "add_schedule", "manufacturing_section_section", "bom_no", "planning_section", @@ -965,20 +967,31 @@ "label": "Project", "options": "Project", "search_index": 1 + }, + { + "fieldname": "sales_order_schedule_section", + "fieldtype": "Section Break", + "label": "Sales Order Schedule" + }, + { + "fieldname": "add_schedule", + "fieldtype": "Button", + "label": "Add Schedule" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2025-02-28 09:45:43.934947", + "modified": "2025-08-21 17:01:54.269105", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", "naming_rule": "Random", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/setup/doctype/uom/uom.json b/erpnext/setup/doctype/uom/uom.json index feb1b41327c..73c69643c89 100644 --- a/erpnext/setup/doctype/uom/uom.json +++ b/erpnext/setup/doctype/uom/uom.json @@ -65,7 +65,7 @@ "icon": "fa fa-compass", "idx": 1, "links": [], - "modified": "2024-03-27 13:10:57.375141", + "modified": "2025-08-21 18:59:27.900209", "modified_by": "Administrator", "module": "Setup", "name": "UOM", @@ -98,12 +98,31 @@ "read": 1, "report": 1, "role": "Stock User" + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1 } ], "quick_entry": 1, + "row_format": "Dynamic", "show_name_in_global_search": 1, "sort_field": "creation", "sort_order": "ASC", "states": [], "translated_doctype": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 68be8499ad2..15ca9230d42 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -517,6 +517,16 @@ $.extend(erpnext.item, { }, __("Actions") ); + + frm.add_custom_button( + __("Make Lead Time"), + function () { + frm.make_new("Item Lead Time", { + item_code: frm.doc.name, + }); + }, + __("Actions") + ); }, weight_to_validate: function (frm) { diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 5ddef1c24f6..5a17a7e399c 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -116,7 +116,15 @@ "customer_code", "default_item_manufacturer", "default_manufacturer_part_no", - "total_projected_qty" + "total_projected_qty", + "lead_time_in_days_section", + "procurement_time", + "manufacturing_time", + "column_break_whvr", + "planning_buffer", + "cumulative_time", + "capacity_in_days_section", + "production_capacity" ], "fields": [ { @@ -888,6 +896,46 @@ "fieldname": "deferred_accounting_section", "fieldtype": "Section Break", "label": "Deferred Accounting" + }, + { + "fieldname": "lead_time_in_days_section", + "fieldtype": "Section Break", + "label": "Lead Time (In Days)" + }, + { + "fieldname": "procurement_time", + "fieldtype": "Int", + "label": "Procurement Time" + }, + { + "fieldname": "planning_buffer", + "fieldtype": "Int", + "label": "Planning Buffer" + }, + { + "fieldname": "column_break_whvr", + "fieldtype": "Column Break" + }, + { + "fieldname": "manufacturing_time", + "fieldtype": "Int", + "label": "Manufacturing Time" + }, + { + "fieldname": "cumulative_time", + "fieldtype": "Int", + "label": "Cumulative Time", + "read_only": 1 + }, + { + "fieldname": "capacity_in_days_section", + "fieldtype": "Section Break", + "label": "Capacity (In Days)" + }, + { + "fieldname": "production_capacity", + "fieldtype": "Int", + "label": "Production Capacity" } ], "icon": "fa fa-tag", @@ -895,7 +943,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2025-08-08 14:58:48.674193", + "modified": "2025-08-14 23:35:56.293048", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 4b93ccae724..04310bdf188 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -81,6 +81,7 @@ class Item(Document): brand: DF.Link | None country_of_origin: DF.Link | None create_new_batch: DF.Check + cumulative_time: DF.Int customer: DF.Link | None customer_code: DF.SmallText | None customer_items: DF.Table[ItemCustomerDetail] @@ -119,6 +120,7 @@ class Item(Document): item_name: DF.Data | None last_purchase_rate: DF.Float lead_time_days: DF.Int + manufacturing_time: DF.Int max_discount: DF.Float min_order_qty: DF.Float naming_series: DF.Literal["STO-ITEM-.YYYY.-"] @@ -127,6 +129,9 @@ class Item(Document): opening_stock: DF.Float over_billing_allowance: DF.Float over_delivery_receipt_allowance: DF.Float + planning_buffer: DF.Int + procurement_time: DF.Int + production_capacity: DF.Int purchase_uom: DF.Link | None quality_inspection_template: DF.Link | None reorder_levels: DF.Table[ItemReorder] @@ -216,10 +221,16 @@ class Item(Document): self.validate_auto_reorder_enabled_in_stock_settings() self.cant_change() self.validate_item_tax_net_rate_range() + self.set_cumulative_time() if not self.is_new(): self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group") + def set_cumulative_time(self): + self.cumulative_time = ( + cint(self.procurement_time) + cint(self.manufacturing_time) + cint(self.planning_buffer) + ) + def on_update(self): self.update_variants() self.update_item_price() diff --git a/erpnext/stock/doctype/item/item_dashboard.py b/erpnext/stock/doctype/item/item_dashboard.py index 88ae34f228c..147b3bcd683 100644 --- a/erpnext/stock/doctype/item/item_dashboard.py +++ b/erpnext/stock/doctype/item/item_dashboard.py @@ -32,5 +32,6 @@ def get_data(): {"label": _("Manufacture"), "items": ["Production Plan", "Work Order", "Item Manufacturer"]}, {"label": _("Traceability"), "items": ["Serial No", "Batch"]}, {"label": _("Stock Movement"), "items": ["Stock Entry", "Stock Reconciliation"]}, + {"label": _("Lead Time"), "items": ["Item Lead Time"]}, ], } diff --git a/erpnext/stock/doctype/item_lead_time/__init__.py b/erpnext/stock/doctype/item_lead_time/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/item_lead_time/item_lead_time.js b/erpnext/stock/doctype/item_lead_time/item_lead_time.js new file mode 100644 index 00000000000..872cc646b80 --- /dev/null +++ b/erpnext/stock/doctype/item_lead_time/item_lead_time.js @@ -0,0 +1,65 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Item Lead Time", { + refresh(frm) { + frm.trigger("setup_queries"); + }, + + setup_queries(frm) { + frm.set_query("bom_no", () => { + return { + filters: { + item: frm.doc.item_code, + docstatus: 1, + with_operations: 1, + }, + }; + }); + }, + + shift_time_in_hours(frm) { + frm.trigger("calculate_total_workstation_time"); + }, + + no_of_workstations(frm) { + frm.trigger("calculate_total_workstation_time"); + }, + + no_of_shift(frm) { + frm.trigger("calculate_total_workstation_time"); + }, + + calculate_total_workstation_time(frm) { + let total_workstation_time = + frm.doc.shift_time_in_hours * frm.doc.no_of_workstations * frm.doc.no_of_shift; + frm.set_value("total_workstation_time", total_workstation_time); + }, + + total_workstation_time(frm) { + frm.trigger("calculate_no_of_units_produced"); + }, + + manufacturing_time_in_mins(frm) { + frm.trigger("calculate_no_of_units_produced"); + }, + + calculate_no_of_units_produced(frm) { + let no_of_units_produced = + Math.ceil(frm.doc.total_workstation_time / frm.doc.manufacturing_time_in_mins) * 60; + frm.set_value("no_of_units_produced", no_of_units_produced); + }, + + no_of_units_produced(frm) { + frm.trigger("calculate_capacity_per_day"); + }, + + daily_yield(frm) { + frm.trigger("calculate_capacity_per_day"); + }, + + calculate_capacity_per_day(frm) { + let capacity_per_day = (frm.doc.daily_yield * frm.doc.no_of_units_produced) / 100; + frm.set_value("capacity_per_day", Math.ceil(capacity_per_day)); + }, +}); diff --git a/erpnext/stock/doctype/item_lead_time/item_lead_time.json b/erpnext/stock/doctype/item_lead_time/item_lead_time.json new file mode 100644 index 00000000000..c8909e49ad7 --- /dev/null +++ b/erpnext/stock/doctype/item_lead_time/item_lead_time.json @@ -0,0 +1,216 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:item_code", + "creation": "2025-08-21 12:46:54.727571", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "manufacturing_time_tab", + "item_code", + "column_break_qwyo", + "manufacturing_section", + "bom_no", + "shift_time_in_hours", + "no_of_workstations", + "column_break_cdqv", + "no_of_shift", + "total_workstation_time", + "section_break_wuqi", + "column_break_yilv", + "manufacturing_time_in_mins", + "no_of_days", + "no_of_units_produced", + "column_break_bbsv", + "daily_yield", + "capacity_per_day", + "purchase_lead_time_tab", + "section_break_fwyn", + "purchase_and_other_column", + "purchase_time", + "column_break_lsfp", + "buffer_time", + "item_details_tab", + "item_name", + "stock_uom" + ], + "fields": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item Code", + "options": "Item", + "unique": 1 + }, + { + "fieldname": "column_break_qwyo", + "fieldtype": "Column Break" + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "section_break_fwyn", + "fieldtype": "Section Break", + "label": "Purchase" + }, + { + "fieldname": "purchase_and_other_column", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_lsfp", + "fieldtype": "Column Break" + }, + { + "description": "In Days", + "fieldname": "buffer_time", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Buffer Time" + }, + { + "fieldname": "manufacturing_section", + "fieldtype": "Section Break", + "label": "Workstation" + }, + { + "fieldname": "bom_no", + "fieldtype": "Link", + "label": "Default BOM", + "options": "BOM" + }, + { + "default": "1", + "fieldname": "no_of_shift", + "fieldtype": "Int", + "label": "No of Shift" + }, + { + "fieldname": "column_break_bbsv", + "fieldtype": "Column Break" + }, + { + "description": "Per Unit Time in Mins", + "fieldname": "manufacturing_time_in_mins", + "fieldtype": "Int", + "label": "Manufacturing Time" + }, + { + "description": "Per Day", + "fieldname": "total_workstation_time", + "fieldtype": "Int", + "label": "Total Workstation Time (In Hours)" + }, + { + "default": "90", + "description": "(Good Units Produced / Total Units Produced) \u00d7 100", + "fieldname": "daily_yield", + "fieldtype": "Percent", + "label": "Daily Yield" + }, + { + "fieldname": "capacity_per_day", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Capacity" + }, + { + "fieldname": "no_of_units_produced", + "fieldtype": "Int", + "label": "No of Units Produced" + }, + { + "description": "In Days", + "fieldname": "purchase_time", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Purchase Time" + }, + { + "description": "Per Day", + "fieldname": "shift_time_in_hours", + "fieldtype": "Int", + "label": "Shift Time (In Hours)" + }, + { + "fieldname": "column_break_yilv", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_wuqi", + "fieldtype": "Section Break", + "label": "Manufacturing" + }, + { + "fieldname": "column_break_cdqv", + "fieldtype": "Column Break" + }, + { + "description": "Similar types of workstations where the same operations run in parallel.", + "fieldname": "no_of_workstations", + "fieldtype": "Int", + "label": "No of Workstations" + }, + { + "fieldname": "item_details_tab", + "fieldtype": "Tab Break", + "label": "Item Details" + }, + { + "fetch_from": "item_code.stock_uom", + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + }, + { + "fieldname": "purchase_lead_time_tab", + "fieldtype": "Tab Break", + "label": "Purchase Time" + }, + { + "default": "1", + "fieldname": "no_of_days", + "fieldtype": "Int", + "label": "No of Days" + }, + { + "fieldname": "manufacturing_time_tab", + "fieldtype": "Tab Break", + "label": "Manufacturing Time" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-08-31 13:12:38.458052", + "modified_by": "Administrator", + "module": "Stock", + "name": "Item Lead Time", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/stock/doctype/item_lead_time/item_lead_time.py b/erpnext/stock/doctype/item_lead_time/item_lead_time.py new file mode 100644 index 00000000000..c00fa6faf11 --- /dev/null +++ b/erpnext/stock/doctype/item_lead_time/item_lead_time.py @@ -0,0 +1,34 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class ItemLeadTime(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + bom_no: DF.Link | None + buffer_time: DF.Int + capacity_per_day: DF.Int + daily_yield: DF.Percent + item_code: DF.Link | None + item_name: DF.Data | None + manufacturing_time_in_mins: DF.Int + no_of_days: DF.Int + no_of_shift: DF.Int + no_of_units_produced: DF.Int + no_of_workstations: DF.Int + purchase_time: DF.Int + shift_time_in_hours: DF.Int + stock_uom: DF.Link | None + total_workstation_time: DF.Int + # end: auto-generated types + + pass diff --git a/erpnext/stock/doctype/item_lead_time/test_item_lead_time.py b/erpnext/stock/doctype/item_lead_time/test_item_lead_time.py new file mode 100644 index 00000000000..49601b5faad --- /dev/null +++ b/erpnext/stock/doctype/item_lead_time/test_item_lead_time.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestItemLeadTime(IntegrationTestCase): + """ + Integration tests for ItemLeadTime. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/pyproject.toml b/pyproject.toml index 489682cdb75..468efa67a32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,9 @@ dependencies = [ "pypng~=0.20220715.0", # MT940 parser for bank statements - "mt-940>=4.26.0" + "mt-940>=4.26.0", + "pandas~=2.2.2", + "statsmodels~=0.14.5", ] [build-system]