mirror of
https://github.com/frappe/erpnext.git
synced 2025-12-03 18:35:36 +00:00
feat: demand planning, MPS and MRP
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -10,7 +10,8 @@
|
||||
"sales_order_date",
|
||||
"col_break1",
|
||||
"customer",
|
||||
"grand_total"
|
||||
"grand_total",
|
||||
"status"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -58,17 +59,25 @@
|
||||
"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": []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
252
erpnext/manufacturing/doctype/sales_forecast/sales_forecast.json
Normal file
252
erpnext/manufacturing/doctype/sales_forecast/sales_forecast.json
Normal file
@@ -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": []
|
||||
}
|
||||
228
erpnext/manufacturing/doctype/sales_forecast/sales_forecast.py
Normal file
228
erpnext/manufacturing/doctype/sales_forecast/sales_forecast.py
Normal file
@@ -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
|
||||
@@ -0,0 +1,13 @@
|
||||
from frappe import _
|
||||
|
||||
|
||||
def get_data():
|
||||
return {
|
||||
"fieldname": "demand_planning",
|
||||
"transactions": [
|
||||
{
|
||||
"label": _("MPS"),
|
||||
"items": ["Master Production Schedule"],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 `<span class="text-danger">${value}</span>`;
|
||||
// }
|
||||
// }
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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,18 +967,29 @@
|
||||
"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": [],
|
||||
|
||||
@@ -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,9 +98,28 @@
|
||||
"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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]},
|
||||
],
|
||||
}
|
||||
|
||||
0
erpnext/stock/doctype/item_lead_time/__init__.py
Normal file
0
erpnext/stock/doctype/item_lead_time/__init__.py
Normal file
65
erpnext/stock/doctype/item_lead_time/item_lead_time.js
Normal file
65
erpnext/stock/doctype/item_lead_time/item_lead_time.js
Normal file
@@ -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));
|
||||
},
|
||||
});
|
||||
216
erpnext/stock/doctype/item_lead_time/item_lead_time.json
Normal file
216
erpnext/stock/doctype/item_lead_time/item_lead_time.json
Normal file
@@ -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": []
|
||||
}
|
||||
34
erpnext/stock/doctype/item_lead_time/item_lead_time.py
Normal file
34
erpnext/stock/doctype/item_lead_time/item_lead_time.py
Normal file
@@ -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
|
||||
20
erpnext/stock/doctype/item_lead_time/test_item_lead_time.py
Normal file
20
erpnext/stock/doctype/item_lead_time/test_item_lead_time.py
Normal file
@@ -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
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user