feat: demand planning, MPS and MRP

This commit is contained in:
Rohit Waghchaure
2025-08-08 21:15:42 +05:30
parent 993ba4cf45
commit f7a37d2812
49 changed files with 4356 additions and 17 deletions

View File

@@ -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) {
// },
// });

View File

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

View File

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

View 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 IntegrationTestDeliveryScheduleItem(IntegrationTestCase):
"""
Integration tests for DeliveryScheduleItem.
Use this class for testing interactions between multiple components.
"""
pass

View File

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

View File

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

View File

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

View File

@@ -83,6 +83,8 @@
"actual_qty",
"column_break_jpky",
"company_total_stock",
"sales_order_schedule_section",
"add_schedule",
"manufacturing_section_section",
"bom_no",
"planning_section",
@@ -965,20 +967,31 @@
"label": "Project",
"options": "Project",
"search_index": 1
},
{
"fieldname": "sales_order_schedule_section",
"fieldtype": "Section Break",
"label": "Sales Order Schedule"
},
{
"fieldname": "add_schedule",
"fieldtype": "Button",
"label": "Add Schedule"
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-02-28 09:45:43.934947",
"modified": "2025-08-21 17:01:54.269105",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}