From 97efd51fb897f23eefbcd51e9595b1c86e50aa03 Mon Sep 17 00:00:00 2001 From: Venkatesh <47534423+venkat102@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:49:28 +0530 Subject: [PATCH] feat: add option to create production plan from sales order (#53662) Co-authored-by: sudarsan2001 --- .../doctype/sales_order/sales_order.js | 16 ++++++++++ .../doctype/sales_order/sales_order.py | 32 +++++++++++++++++++ .../doctype/sales_order/test_sales_order.py | 24 ++++++++++++-- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 82926bd3855..bd41932aac4 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -18,6 +18,7 @@ frappe.ui.form.on("Sales Order", { Project: "Project", "Payment Entry": "Payment", "Work Order": "Work Order", + "Production Plan": "Production Plan", }; frm.add_fetch("customer", "tax_id", "tax_id"); @@ -1059,6 +1060,14 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex __("Create") ); } + + if (frappe.model.can_create("Production Plan") && !doc.is_subcontracted) { + this.frm.add_custom_button( + __("Production Plan"), + () => this.make_production_plan(), + __("Create") + ); + } } // sales invoice @@ -1339,6 +1348,13 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex }); } + make_production_plan() { + frappe.model.open_mapped_doc({ + method: "erpnext.selling.doctype.sales_order.sales_order.make_production_plan", + frm: this.frm, + }); + } + order_type() { this.toggle_delivery_date(); } diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 744b50a3d44..c0441126da0 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -28,6 +28,7 @@ from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( ) from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_items_for_material_requests, + get_sales_orders, ) from erpnext.selling.doctype.customer.customer import check_credit_limit from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults @@ -1814,6 +1815,37 @@ def make_work_orders(items: str, sales_order: str, company: str, project: str | return [p.name for p in out] +@frappe.whitelist() +def make_production_plan(source_name: str, target_doc: str | Document | None = None): + sales_order = frappe.get_doc("Sales Order", source_name) + + production_plan = frappe.new_doc( + "Production Plan", + company=sales_order.company, + get_items_from="Sales Order", + posting_date=nowdate(), + ) + + open_so = [data.name for data in get_sales_orders(production_plan)] + if sales_order.name not in open_so: + frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name)) + + production_plan.append( + "sales_orders", + { + "sales_order": sales_order.name, + "sales_order_date": sales_order.transaction_date, + "customer": sales_order.customer, + "grand_total": sales_order.base_grand_total, + }, + ) + production_plan.get_items() + if not production_plan.get("po_items"): + frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name)) + + return production_plan + + @frappe.whitelist() def update_status(status: str, name: str): so = frappe.get_doc("Sales Order", name, check_permission="submit") diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 6616c52b720..6a8abea1543 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -8,7 +8,7 @@ import frappe import frappe.permissions from frappe.core.doctype.user_permission.test_user_permission import create_user from frappe.tests import change_settings -from frappe.utils import add_days, flt, nowdate, today +from frappe.utils import add_days, flt, getdate, nowdate, today from erpnext.controllers.accounts_controller import InvalidQtyError, get_due_date, update_child_qty_rate from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import ( @@ -24,6 +24,7 @@ from erpnext.selling.doctype.sales_order.sales_order import ( create_pick_list, make_delivery_note, make_material_request, + make_production_plan, make_raw_material_request, make_sales_invoice, make_work_orders, @@ -213,6 +214,26 @@ class TestSalesOrder(ERPNextTestSuite): self.assertEqual(dn.doctype, "Delivery Note") self.assertEqual(len(dn.get("items")), len(so.get("items"))) + def test_make_production_plan(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + fg_item = make_item("Test PP FG Item", {"is_stock_item": 1}).name + make_bom(item=fg_item, rate=100, raw_materials=["_Test Item"]) + + so = make_sales_order(item_code=fg_item, do_not_submit=True) + self.assertRaises(frappe.ValidationError, make_production_plan, so.name) + + so.submit() + pp = make_production_plan(so.name) + + self.assertEqual(pp.doctype, "Production Plan") + self.assertGreater(len(pp.get("po_items")), 0) + self.assertEqual(pp.get("po_items")[0].sales_order, so.name) + self.assertEqual(pp.get("sales_orders")[0].sales_order, so.name) + self.assertEqual(getdate(pp.get("sales_orders")[0].sales_order_date), getdate(so.transaction_date)) + self.assertEqual(pp.get("sales_orders")[0].customer, so.customer) + self.assertEqual(pp.get("sales_orders")[0].grand_total, so.base_grand_total) + def test_make_sales_invoice(self): so = make_sales_order(do_not_submit=True) @@ -2787,7 +2808,6 @@ def make_sales_order(**args): ) so.delivery_date = add_days(so.transaction_date, 10) - if not args.do_not_save: so.insert() if not args.do_not_submit: