mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-15 11:09:17 +00:00
Merge pull request #49184 from rohitwaghchaure/feat-mrp
feat: MPS (SO Schedules) and MRP
This commit is contained in:
@@ -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,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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user