diff --git a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json
index 088338b4f2d..d26ddbdae4d 100644
--- a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json
+++ b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json
@@ -9,6 +9,7 @@
"from_warehouse",
"warehouse",
"material_request_type",
+ "supplier",
"column_break_4",
"item_name",
"uom",
@@ -73,6 +74,15 @@
"label": "Type",
"options": "\nPurchase\nMaterial Transfer\nMaterial Issue\nManufacture\nSubcontracting\nCustomer Provided"
},
+ {
+ "columns": 2,
+ "depends_on": "eval:['Purchase', 'Subcontracting'].includes(doc.material_request_type)",
+ "fieldname": "supplier",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Supplier",
+ "options": "Supplier"
+ },
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
@@ -267,7 +277,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
- "modified": "2026-08-07 17:31:31.732720",
+ "modified": "2026-08-22 11:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Material Request Plan Item",
diff --git a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.py b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.py
index 44c706c10ab..63dbefb3047 100644
--- a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.py
+++ b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.py
@@ -46,6 +46,7 @@ class MaterialRequestPlanItem(Document):
schedule_date: DF.Date | None
stock_reserved_qty: DF.Float
sub_assembly_item_reference: DF.Data | None
+ supplier: DF.Link | None
uom: DF.Link | None
warehouse: DF.Link
# end: auto-generated types
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js
index 292a36866a4..bd7d838af43 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.js
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js
@@ -493,9 +493,12 @@ frappe.ui.form.on("Production Plan", {
${frappe.utils.escape_html(
row.item_code
)}`;
+ let procurement_label = row.supplier
+ ? __("Procurement ({0})", [frappe.utils.escape_html(row.supplier)])
+ : __("Procurement");
let detail = is_material
- ? `${__("Procurement")}`
- : frappe.utils.escape_html(workstations.join(", ") || "-");
+ ? `${procurement_label}`
+ : frappe.utils.escape_html(workstations.join(", ") || row.supplier || "-");
let starts_in = schedule_starts_in(row.start);
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 64c1f81447b..c7fe359f1cb 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -270,9 +270,21 @@ class ProductionPlan(Document):
def on_cancel(self):
self.db_set("status", "Cancelled")
self.delete_draft_work_order()
+ self.delete_production_plan_schedule()
self.update_bin_qty()
self.update_sales_order()
self.update_stock_reservation()
+ self.delete_sub_assembly_and_material_rows()
+
+ def delete_production_plan_schedule(self):
+ frappe.db.delete("Production Plan Schedule", {"production_plan": self.name})
+
+ def delete_sub_assembly_and_material_rows(self):
+ for doctype in ("Production Plan Sub Assembly Item", "Material Request Plan Item"):
+ frappe.db.delete(doctype, {"parent": self.name, "parenttype": "Production Plan"})
+
+ self.set("sub_assembly_items", [])
+ self.set("mr_items", [])
def update_stock_reservation(self):
if not self.reserve_stock:
diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py
index 7027c83af04..943effe96bf 100644
--- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py
+++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py
@@ -161,6 +161,7 @@ def get_items_for_material_requests(
mr_items = _apply_other_locations(
doc, mr_items, warehouses, ignore_ordered_qty, get_parent_warehouse_data
)
+ _set_default_suppliers(mr_items, doc.get("company"))
if not mr_items:
_warn_no_mr_items(doc)
@@ -464,6 +465,59 @@ def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_pa
return new_mr_items
+def _set_default_suppliers(mr_items, company):
+ procurement_types = ("Purchase", "Subcontracting")
+ items = {
+ row.get("item_code") for row in mr_items if row.get("material_request_type") in procurement_types
+ }
+ if not items:
+ return
+
+ lead_time_suppliers = _get_default_lead_time_suppliers(items)
+ item_default_suppliers = _get_item_default_suppliers(items, company)
+
+ for row in mr_items:
+ if row.get("material_request_type") not in procurement_types or row.get("supplier"):
+ continue
+
+ item_code = row.get("item_code")
+ supplier = lead_time_suppliers.get(item_code) or item_default_suppliers.get(item_code)
+ if supplier:
+ row["supplier"] = supplier
+
+
+def _get_default_lead_time_suppliers(items):
+ table = frappe.qb.DocType("Item Lead Time Supplier")
+ rows = (
+ frappe.qb.from_(table)
+ .select(table.parent, table.supplier)
+ .where(
+ table.parent.isin(list(items)) & (table.parenttype == "Item Lead Time") & (table.is_default == 1)
+ )
+ .run(as_dict=True)
+ )
+ return {row.parent: row.supplier for row in rows}
+
+
+def _get_item_default_suppliers(items, company):
+ if not company:
+ return {}
+
+ table = frappe.qb.DocType("Item Default")
+ rows = (
+ frappe.qb.from_(table)
+ .select(table.parent, table.default_supplier)
+ .where(
+ table.parent.isin(list(items))
+ & (table.parenttype == "Item")
+ & (table.company == company)
+ & table.default_supplier.isnotnull()
+ )
+ .run(as_dict=True)
+ )
+ return {row.parent: row.default_supplier for row in rows}
+
+
def _warn_no_mr_items(doc):
to_enable = frappe.bold(frappe.get_meta("Production Plan").get_field("ignore_existing_ordered_qty").label)
warehouse = frappe.bold(doc.get("for_warehouse"))
diff --git a/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.json b/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.json
index 26a8518a240..48f0084c2b3 100644
--- a/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.json
+++ b/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.json
@@ -19,6 +19,7 @@
"column_break_item",
"operation",
"workstation",
+ "supplier",
"schedule_section",
"from_time",
"column_break_time",
@@ -117,6 +118,14 @@
"options": "Workstation",
"read_only": 1
},
+ {
+ "depends_on": "eval:doc.row_type == 'Raw Material' || doc.supplier",
+ "fieldname": "supplier",
+ "fieldtype": "Link",
+ "label": "Supplier",
+ "options": "Supplier",
+ "read_only": 1
+ },
{
"fieldname": "schedule_section",
"fieldtype": "Section Break",
@@ -150,7 +159,7 @@
],
"index_web_pages_for_search": 0,
"links": [],
- "modified": "2026-08-13 11:00:00.000000",
+ "modified": "2026-08-22 11:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Schedule",
diff --git a/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.py b/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.py
index f49c5abe171..9f7a9b0e7df 100644
--- a/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.py
+++ b/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.py
@@ -29,8 +29,9 @@ class ProductionPlanSchedule(Document):
operation: DF.Link | None
plan_row: DF.Data | None
production_plan: DF.Link
- row_type: DF.Literal["Finished Good", "Sub Assembly"]
+ row_type: DF.Literal["Finished Good", "Sub Assembly", "Raw Material"]
subject: DF.Data | None
+ supplier: DF.Link | None
task_key: DF.Data | None
to_time: DF.Datetime
workstation: DF.Link | None
diff --git a/erpnext/manufacturing/scheduling/plan_adapter.py b/erpnext/manufacturing/scheduling/plan_adapter.py
index 7c8c06ece41..f757fec94b5 100644
--- a/erpnext/manufacturing/scheduling/plan_adapter.py
+++ b/erpnext/manufacturing/scheduling/plan_adapter.py
@@ -7,7 +7,7 @@ from collections import defaultdict
import frappe
from frappe import _
-from frappe.utils import cint, flt, get_datetime
+from frappe.utils import add_to_date, cint, flt, get_datetime, getdate
from erpnext.manufacturing.scheduling import loaders
from erpnext.manufacturing.scheduling.engine import SchedulingEngine
@@ -126,7 +126,7 @@ def build_plan_tasks(plan, use_item_dates=0, item_dates=None):
fg_deps = get_finished_good_dependencies(fg_row, sub_rows, row_bounds)
fg_tasks, first_keys, _terminal = build_row_tasks(
- plan, fg_row.bom_no, fg_row.item_code, flt(fg_row.planned_qty), False, fg_row.name, ctx.lead_times
+ plan, fg_row.bom_no, fg_row.item_code, flt(fg_row.planned_qty), False, fg_row.name, ctx
)
wire_dependencies(fg_tasks, fg_deps)
wire_material_dependencies(
@@ -151,6 +151,10 @@ def get_build_context(plan):
item for items in bom_materials.values() for item in items if item not in produced_items
}
lead_times = get_lead_time_details(plan, material_items)
+ supplier_lead_times = get_supplier_lead_times(material_items | produced_items)
+ material_lead_days, material_suppliers = get_material_lead_days(
+ material_items, lead_times, supplier_lead_times, get_chosen_suppliers(plan)
+ )
return frappe._dict(
bom_materials={
@@ -158,13 +162,41 @@ def get_build_context(plan):
for bom, items in bom_materials.items()
},
lead_times=lead_times,
- material_lead_days=get_material_lead_days(material_items, lead_times),
+ supplier_lead_times=supplier_lead_times,
+ material_lead_days=material_lead_days,
+ material_suppliers=material_suppliers,
material_tasks={},
tasks=[],
task_info={},
)
+def get_supplier_lead_times(item_codes):
+ if not item_codes:
+ return {}
+
+ table = frappe.qb.DocType("Item Lead Time Supplier")
+ rows = (
+ frappe.qb.from_(table)
+ .select(table.parent, table.supplier, table.purchase_time, table.buffer_time, table.is_default)
+ .where(table.parent.isin(list(item_codes)) & (table.parenttype == "Item Lead Time"))
+ .run(as_dict=True)
+ )
+
+ grouped = defaultdict(dict)
+ for row in rows:
+ grouped[row.parent][row.supplier] = row
+ return dict(grouped)
+
+
+def get_chosen_suppliers(plan):
+ chosen = defaultdict(set)
+ for row in plan.get("mr_items") or []:
+ if row.get("supplier"):
+ chosen[row.item_code].add(row.supplier)
+ return chosen
+
+
def get_bom_materials(plan):
boms = {row.bom_no for row in plan.po_items if row.bom_no}
boms.update(row.bom_no for row in plan.sub_assembly_items if row.bom_no)
@@ -182,8 +214,8 @@ def get_bom_materials(plan):
return materials
-def get_material_lead_days(material_items, lead_times):
- missing = [item for item in material_items if item not in lead_times]
+def get_material_lead_days(material_items, lead_times, supplier_lead_times, chosen_suppliers):
+ missing = [item for item in material_items if item not in lead_times and item not in supplier_lead_times]
item_master_days = {}
if missing:
item_master_days = dict(
@@ -192,15 +224,38 @@ def get_material_lead_days(material_items, lead_times):
)
)
- lead_days = {}
+ lead_days, suppliers = {}, {}
for item in material_items:
- lead_time = lead_times.get(item)
- if lead_time:
- lead_days[item] = cint(lead_time.purchase_time) + cint(lead_time.buffer_time)
- else:
- lead_days[item] = cint(item_master_days.get(item))
+ days, supplier = resolve_purchase_lead_time(
+ lead_times.get(item), supplier_lead_times.get(item), chosen_suppliers.get(item)
+ )
+ lead_days[item] = cint(item_master_days.get(item)) if days is None else days
+ if supplier:
+ suppliers[item] = supplier
- return lead_days
+ return lead_days, suppliers
+
+
+def resolve_purchase_lead_time(lead_time, supplier_rows, chosen_suppliers=None):
+ rows = supplier_rows or {}
+ item_days = cint(lead_time.purchase_time) + cint(lead_time.buffer_time) if lead_time else None
+
+ candidates = []
+ for supplier in chosen_suppliers or []:
+ if supplier in rows:
+ candidates.append(
+ (cint(rows[supplier].purchase_time) + cint(rows[supplier].buffer_time), supplier)
+ )
+ elif item_days is not None:
+ candidates.append((item_days, supplier))
+
+ if candidates:
+ return max(candidates)
+
+ default_row = next((r for r in rows.values() if r.is_default), None)
+ if default_row:
+ return cint(default_row.purchase_time) + cint(default_row.buffer_time), default_row.supplier
+ return (item_days, None) if lead_time else (None, None)
def wire_material_dependencies(bom_no, first_tasks, ctx, consumer_row=None):
@@ -229,6 +284,7 @@ def get_material_task(item_code, lead_days, ctx, consumer_row=None):
"item_code": item_code,
"operation": None,
"parent_row": None,
+ "supplier": ctx.material_suppliers.get(item_code),
"consumers": [],
}
@@ -250,10 +306,19 @@ def set_chain_earliest_start(row_date, sub_rows, row_bounds, fg_tasks):
def build_sub_assembly_tasks(plan, sub_rows, ctx):
row_bounds = {}
+ row_suppliers = {}
for row in sub_rows:
subcontracted = row.type_of_manufacturing == "Subcontract"
+ row_suppliers[row.name] = get_subcontract_supplier(row, ctx) if subcontracted else None
row_tasks, first_keys, terminal_keys = build_row_tasks(
- plan, row.bom_no, row.production_item, flt(row.qty), subcontracted, row.name, ctx.lead_times
+ plan,
+ row.bom_no,
+ row.production_item,
+ flt(row.qty),
+ subcontracted,
+ row.name,
+ ctx,
+ row_suppliers[row.name],
)
row_bounds[row.name] = (first_keys, terminal_keys, row_tasks)
if not subcontracted:
@@ -280,11 +345,21 @@ def build_sub_assembly_tasks(plan, sub_rows, ctx):
ctx.tasks,
ctx.task_info,
parent_row=row.production_plan_item,
+ supplier=row_suppliers[row.name],
)
return row_bounds
+def get_subcontract_supplier(row, ctx):
+ if row.supplier:
+ return row.supplier
+
+ rows = ctx.supplier_lead_times.get(row.production_item) or {}
+ default_row = next((r for r in rows.values() if r.is_default), None)
+ return default_row.supplier if default_row else None
+
+
def get_first_tasks(bounds):
first_keys, _terminal_keys, row_tasks = bounds
return [task for task in row_tasks if task.key in first_keys]
@@ -305,7 +380,9 @@ def wire_dependencies(first_tasks, dependency_keys):
task.depends_on = list(dict.fromkeys([*task.depends_on, *dependency_keys]))
-def register_tasks(row_tasks, row_name, row_type, item_code, tasks, task_info, parent_row=None):
+def register_tasks(
+ row_tasks, row_name, row_type, item_code, tasks, task_info, parent_row=None, supplier=None
+):
for task in row_tasks:
tasks.append(task)
task_info[task.key] = {
@@ -314,19 +391,26 @@ def register_tasks(row_tasks, row_name, row_type, item_code, tasks, task_info, p
"item_code": item_code,
"operation": task.label,
"parent_row": parent_row,
+ "supplier": supplier,
}
-def build_row_tasks(plan, bom_no, item_code, qty, subcontracted, prefix, lead_times):
+def build_row_tasks(plan, bom_no, item_code, qty, subcontracted, prefix, ctx, supplier=None):
if not subcontracted and bom_no and frappe.get_cached_value("BOM", bom_no, "with_operations"):
row_tasks, terminal_keys = loaders.build_bom_operation_tasks(bom_no, qty, prefix)
if row_tasks:
first_keys = [task.key for task in row_tasks if not task.depends_on]
return row_tasks, first_keys, terminal_keys
- duration = get_lead_time_duration_mins(
- lead_times.get(item_code), qty, subcontracted, cint(plan.get("no_of_shifts"))
- )
+ lead_time = ctx.lead_times.get(item_code)
+ if subcontracted:
+ supplier_row = (ctx.supplier_lead_times.get(item_code) or {}).get(supplier)
+ if supplier_row:
+ lead_time = frappe._dict(
+ purchase_time=supplier_row.purchase_time, buffer_time=supplier_row.buffer_time
+ )
+
+ duration = get_lead_time_duration_mins(lead_time, qty, subcontracted, cint(plan.get("no_of_shifts")))
task = Task(key=prefix, duration_mins=duration)
return [task], [task.key], [task.key]
@@ -338,20 +422,34 @@ def get_lead_time_duration_mins(lead_time, qty, subcontracted, no_of_shifts):
if subcontracted:
return max(cint(lead_time.purchase_time) + cint(lead_time.buffer_time), 1) * 1440.0
- days = 0
+ buffer_mins = cint(lead_time.buffer_time) * 1440.0
+ days_needed = get_days_needed(lead_time, qty, no_of_shifts)
+ if not days_needed:
+ return max(buffer_mins, 1440.0)
+
+ full_days = math.ceil(days_needed) - 1
+ partial_day_mins = (days_needed - full_days) * get_daily_working_mins(lead_time, no_of_shifts)
+ return full_days * 1440.0 + partial_day_mins + buffer_mins
+
+
+def get_days_needed(lead_time, qty, no_of_shifts):
capacity = get_daily_capacity(lead_time, no_of_shifts)
if capacity:
- days = math.ceil(qty / capacity)
- elif lead_time.manufacturing_time_in_mins:
- minutes_per_day = (
- (no_of_shifts or cint(lead_time.no_of_shift) or 1)
- * (cint(lead_time.shift_time_in_hours) or 8)
- * 60
- * (cint(lead_time.no_of_workstations) or 1)
- )
- days = math.ceil(cint(lead_time.manufacturing_time_in_mins) * qty / minutes_per_day)
+ return qty / capacity
- return max(days + cint(lead_time.buffer_time), 1) * 1440.0
+ if lead_time.manufacturing_time_in_mins:
+ minutes_per_day = get_daily_working_mins(lead_time, no_of_shifts) * (
+ cint(lead_time.no_of_workstations) or 1
+ )
+ return cint(lead_time.manufacturing_time_in_mins) * qty / minutes_per_day
+
+ return 0
+
+
+def get_daily_working_mins(lead_time, no_of_shifts):
+ return (
+ (no_of_shifts or cint(lead_time.no_of_shift) or 1) * (cint(lead_time.shift_time_in_hours) or 8) * 60.0
+ )
def get_daily_capacity(lead_time, no_of_shifts):
@@ -400,6 +498,7 @@ def build_proposal(plan, result, task_info):
"row_type": info["row_type"],
"item_code": info["item_code"],
"parent_row": info.get("parent_row"),
+ "supplier": info.get("supplier"),
"consumers": info.get("consumers") or [],
}
)
@@ -466,6 +565,7 @@ def make_schedule_entry(plan, row_name, row, block):
"item_code": row["item_code"],
"operation": block.get("operation") if block.get("workstation") else None,
"workstation": block.get("workstation"),
+ "supplier": row.get("supplier"),
"from_time": block["from_time"],
"to_time": block["to_time"],
"duration_mins": block["duration_mins"],
@@ -500,6 +600,57 @@ def update_plan_row_dates(plan, proposal, use_item_dates=0, item_dates=None):
row.db_set("schedule_date", rows[row.name]["start"], update_modified=False)
row.db_set("schedule_end_date", rows[row.name]["end"], update_modified=False)
+ update_material_request_row_dates(plan, rows)
+
+
+def update_material_request_row_dates(plan, rows):
+ material_rows = {row["item_code"]: row for row in rows.values() if row.get("row_type") == "Raw Material"}
+ supplier_lead_times = get_supplier_lead_times(set(material_rows))
+ fallback_lead_days = get_fallback_lead_days(plan, set(material_rows))
+ for row in plan.get("mr_items") or []:
+ material = material_rows.get(row.item_code)
+ if material:
+ row.db_set(
+ "schedule_date",
+ get_material_row_schedule_date(row, material, supplier_lead_times, fallback_lead_days),
+ update_modified=False,
+ )
+
+
+def get_fallback_lead_days(plan, item_codes):
+ lead_times = get_lead_time_details(plan, item_codes)
+ missing = [item for item in item_codes if not lead_times.get(item)]
+ master_days = {}
+ if missing:
+ master_days = dict(
+ frappe.get_all(
+ "Item", filters={"name": ("in", missing)}, fields=["name", "lead_time_days"], as_list=True
+ )
+ )
+
+ days = {}
+ for item in item_codes:
+ lead_time = lead_times.get(item)
+ if lead_time:
+ days[item] = cint(lead_time.purchase_time) + cint(lead_time.buffer_time)
+ else:
+ days[item] = cint(master_days.get(item)) or None
+ return days
+
+
+def get_material_row_schedule_date(row, material, supplier_lead_times, fallback_lead_days):
+ supplier = row.get("supplier")
+ lead_row = (supplier_lead_times.get(row.item_code) or {}).get(supplier)
+ days = None
+ if lead_row:
+ days = cint(lead_row.purchase_time) + cint(lead_row.buffer_time)
+ elif supplier:
+ days = fallback_lead_days.get(row.item_code)
+
+ if days is None:
+ return getdate(material["end"])
+ return getdate(add_to_date(get_datetime(material["start"]), days=days))
+
def set_finished_good_start_date(fg_row, rows, use_item_dates, item_dates):
if use_item_dates and (item_dates or {}).get(fg_row.name):
diff --git a/erpnext/manufacturing/scheduling/test_plan_adapter.py b/erpnext/manufacturing/scheduling/test_plan_adapter.py
index 1db3912e9ab..3e790fd3f38 100644
--- a/erpnext/manufacturing/scheduling/test_plan_adapter.py
+++ b/erpnext/manufacturing/scheduling/test_plan_adapter.py
@@ -3,7 +3,7 @@
import frappe
from frappe.tests.utils import change_settings
-from frappe.utils import add_to_date, get_datetime
+from frappe.utils import add_to_date, flt, get_datetime, getdate
from erpnext.manufacturing.doctype.job_card.job_card import OverlapError
from erpnext.manufacturing.doctype.production_plan.test_production_plan import (
@@ -12,7 +12,11 @@ from erpnext.manufacturing.doctype.production_plan.test_production_plan import (
)
from erpnext.manufacturing.doctype.work_order.test_work_order import make_operation, make_workstation
from erpnext.manufacturing.scheduling import loaders
-from erpnext.manufacturing.scheduling.plan_adapter import apply_schedule, get_schedule_preview
+from erpnext.manufacturing.scheduling.plan_adapter import (
+ apply_schedule,
+ build_plan_tasks,
+ get_schedule_preview,
+)
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.tests.utils import ERPNextTestSuite
@@ -441,6 +445,106 @@ class TestPlanAdapter(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, entry.insert)
+ def test_cancelled_plan_clears_computed_rows(self):
+ plan = self.make_plan()
+ plan.cancel()
+
+ self.assertFalse(frappe.get_all("Production Plan Sub Assembly Item", filters={"parent": plan.name}))
+ self.assertFalse(frappe.get_all("Material Request Plan Item", filters={"parent": plan.name}))
+
+ amended = frappe.copy_doc(frappe.get_doc("Production Plan", plan.name))
+ amended.amended_from = plan.name
+ amended.docstatus = 0
+ amended.insert()
+ self.assertFalse(amended.sub_assembly_items)
+
+ amended.get_sub_assembly_items()
+ amended.submit()
+
+ self.assertTrue(amended.sub_assembly_items)
+ for row in amended.sub_assembly_items:
+ self.assertEqual(row.production_plan_item, amended.po_items[0].name)
+
+ tasks, task_info = build_plan_tasks(amended)
+ scheduled_items = {info["item_code"] for info in task_info.values()}
+ self.assertIn(amended.sub_assembly_items[0].production_item, scheduled_items)
+
+ def test_material_row_schedule_date_fallbacks(self):
+ from erpnext.manufacturing.scheduling.plan_adapter import get_material_row_schedule_date
+
+ material = {
+ "start": get_datetime("2026-11-16 09:00:00"),
+ "end": get_datetime("2026-11-22 09:00:00"),
+ }
+ row = frappe._dict(item_code="Test PPS RM", supplier="Test PPS Supplier C")
+
+ self.assertEqual(
+ get_material_row_schedule_date(row, material, {}, {"Test PPS RM": 0}), getdate("2026-11-16")
+ )
+ self.assertEqual(
+ get_material_row_schedule_date(row, material, {}, {"Test PPS RM": None}), getdate("2026-11-22")
+ )
+ self.assertEqual(
+ get_material_row_schedule_date(
+ frappe._dict(item_code="Test PPS RM"), material, {}, {"Test PPS RM": 4}
+ ),
+ getdate("2026-11-22"),
+ )
+
+ @change_settings("Manufacturing Settings", {"mins_between_operations": 10, "allow_overtime": 0})
+ def test_split_supplier_rows_keep_own_schedule_dates(self):
+ suppliers = ["Test PPS Supplier A", "Test PPS Supplier B", "Test PPS Supplier C"]
+ self.make_supplier_lead_time("Test PPS RM", suppliers)
+
+ plan = create_production_plan(
+ item_code="Test PPS FG",
+ planned_qty=2,
+ use_multi_level_bom=1,
+ do_not_submit=True,
+ skip_getting_mr_items=True,
+ )
+ plan.get_sub_assembly_items()
+ for supplier in suppliers:
+ plan.append(
+ "mr_items",
+ {
+ "item_code": "Test PPS RM",
+ "warehouse": "_Test Warehouse - _TC",
+ "quantity": 2,
+ "material_request_type": "Purchase",
+ "supplier": supplier,
+ },
+ )
+ plan.submit()
+
+ start_date = get_datetime("2026-11-09 09:00:00")
+ preview = get_schedule_preview(plan.name, start_date)
+ material_row = preview["rows"]["material:Test PPS RM"]
+ self.assertEqual(material_row["supplier"], suppliers[1])
+ self.assertEqual(material_row["end"], add_to_date(start_date, days=6))
+
+ apply_schedule(plan.name, start_date)
+ schedule_dates = dict(
+ frappe.get_all(
+ "Material Request Plan Item",
+ filters={"parent": plan.name, "item_code": "Test PPS RM"},
+ fields=["supplier", "schedule_date"],
+ as_list=True,
+ )
+ )
+ self.assertEqual(schedule_dates[suppliers[0]], getdate(add_to_date(start_date, days=3)))
+ self.assertEqual(schedule_dates[suppliers[1]], getdate(add_to_date(start_date, days=6)))
+ self.assertEqual(schedule_dates[suppliers[2]], getdate(add_to_date(start_date, days=4)))
+
+ def test_plan_cancel_deletes_schedule_entries(self):
+ plan = self.make_plan()
+ apply_schedule(plan.name, "2026-11-02 09:00:00")
+ self.assertTrue(frappe.db.exists("Production Plan Schedule", {"production_plan": plan.name}))
+
+ plan.reload()
+ plan.cancel()
+ self.assertFalse(frappe.db.exists("Production Plan Schedule", {"production_plan": plan.name}))
+
def test_incomplete_proposal_is_not_applied(self):
from unittest.mock import patch
@@ -481,6 +585,131 @@ class TestPlanAdapter(ERPNextTestSuite):
)
self.assertEqual(sub_starts, [material_arrival, add_to_date(material_arrival, minutes=120)])
+ def test_purchase_lead_time_supplier_resolution(self):
+ from erpnext.manufacturing.scheduling.plan_adapter import resolve_purchase_lead_time
+
+ lead_time = frappe._dict(purchase_time=4, buffer_time=0)
+ supplier_rows = {
+ "Supplier A": frappe._dict(supplier="Supplier A", purchase_time=3, buffer_time=0, is_default=1),
+ "Supplier B": frappe._dict(supplier="Supplier B", purchase_time=6, buffer_time=1, is_default=0),
+ }
+
+ self.assertEqual(
+ resolve_purchase_lead_time(lead_time, supplier_rows, {"Supplier B"}), (7, "Supplier B")
+ )
+ self.assertEqual(
+ resolve_purchase_lead_time(lead_time, supplier_rows, {"Supplier A", "Supplier B"}),
+ (7, "Supplier B"),
+ )
+ self.assertEqual(resolve_purchase_lead_time(lead_time, supplier_rows), (3, "Supplier A"))
+ self.assertEqual(resolve_purchase_lead_time(lead_time, None), (4, None))
+ self.assertEqual(resolve_purchase_lead_time(None, None), (None, None))
+ self.assertEqual(
+ resolve_purchase_lead_time(lead_time, supplier_rows, {"Supplier C"}), (4, "Supplier C")
+ )
+ self.assertEqual(
+ resolve_purchase_lead_time(lead_time, supplier_rows, {"Supplier A", "Supplier C"}),
+ (4, "Supplier C"),
+ )
+ self.assertEqual(resolve_purchase_lead_time(None, supplier_rows, {"Supplier C"}), (3, "Supplier A"))
+
+ def make_supplier_lead_time(self, item_code, suppliers):
+ for supplier in suppliers:
+ if not frappe.db.exists("Supplier", supplier):
+ frappe.get_doc(
+ {
+ "doctype": "Supplier",
+ "supplier_name": supplier,
+ "supplier_group": "All Supplier Groups",
+ }
+ ).insert()
+
+ frappe.delete_doc("Item Lead Time", item_code, force=True)
+ frappe.get_doc(
+ {
+ "doctype": "Item Lead Time",
+ "item_code": item_code,
+ "purchase_time": 4,
+ "supplier_lead_times": [
+ {"supplier": suppliers[0], "purchase_time": 3, "is_default": 1},
+ {"supplier": suppliers[1], "purchase_time": 6},
+ ],
+ }
+ ).insert()
+ self.addCleanup(frappe.delete_doc, "Item Lead Time", item_code, force=True)
+
+ @change_settings("Manufacturing Settings", {"mins_between_operations": 10, "allow_overtime": 0})
+ def test_schedule_uses_supplier_wise_lead_time(self):
+ suppliers = ["Test PPS Supplier A", "Test PPS Supplier B"]
+ self.make_supplier_lead_time("Test PPS RM", suppliers)
+
+ plan = create_production_plan(
+ item_code="Test PPS FG",
+ planned_qty=2,
+ use_multi_level_bom=1,
+ do_not_submit=True,
+ skip_getting_mr_items=True,
+ )
+ plan.get_sub_assembly_items()
+ plan.save()
+ start_date = get_datetime("2026-11-02 09:00:00")
+
+ preview = get_schedule_preview(plan.name, start_date)
+ material_row = preview["rows"]["material:Test PPS RM"]
+ self.assertEqual(material_row["supplier"], suppliers[0])
+ self.assertEqual(material_row["end"], add_to_date(start_date, days=3))
+
+ plan.append(
+ "mr_items",
+ {
+ "item_code": "Test PPS RM",
+ "warehouse": "_Test Warehouse - _TC",
+ "quantity": 4,
+ "material_request_type": "Purchase",
+ "supplier": suppliers[1],
+ },
+ )
+ plan.save()
+
+ preview = get_schedule_preview(plan.name, start_date)
+ material_row = preview["rows"]["material:Test PPS RM"]
+ self.assertEqual(material_row["supplier"], suppliers[1])
+ self.assertEqual(material_row["end"], add_to_date(start_date, days=6))
+
+ plan.submit()
+ apply_schedule(plan.name, start_date)
+ entry = frappe.get_value(
+ "Production Plan Schedule",
+ {"production_plan": plan.name, "row_type": "Raw Material", "item_code": "Test PPS RM"},
+ ["supplier", "to_time"],
+ as_dict=True,
+ )
+ self.assertEqual(entry.supplier, suppliers[1])
+ self.assertEqual(
+ frappe.db.get_value(
+ "Material Request Plan Item",
+ {"parent": plan.name, "item_code": "Test PPS RM"},
+ "schedule_date",
+ ),
+ getdate(add_to_date(start_date, days=6)),
+ )
+
+ def test_lead_time_duration_counts_partial_day_as_working_hours(self):
+ from erpnext.manufacturing.scheduling.plan_adapter import get_lead_time_duration_mins
+
+ lead_time = frappe._dict(
+ capacity_per_day=8, daily_yield=100, no_of_shift=2, shift_time_in_hours=8, buffer_time=0
+ )
+
+ self.assertEqual(get_lead_time_duration_mins(lead_time, 10, False, 2), 1680.0)
+ self.assertEqual(get_lead_time_duration_mins(lead_time, 16, False, 2), 2400.0)
+ self.assertEqual(get_lead_time_duration_mins(lead_time, 4, False, 2), 480.0)
+
+ mfg_lead_time = frappe._dict(
+ manufacturing_time_in_mins=120, no_of_shift=2, shift_time_in_hours=8, buffer_time=0
+ )
+ self.assertEqual(get_lead_time_duration_mins(mfg_lead_time, 10, False, 2), 1680.0)
+
@change_settings("Manufacturing Settings", {"mins_between_operations": 10, "allow_overtime": 0})
def test_preview_and_apply_schedule(self):
plan = self.make_plan()
diff --git a/erpnext/stock/doctype/item_lead_time/item_lead_time.json b/erpnext/stock/doctype/item_lead_time/item_lead_time.json
index 54d9c845cee..1ff9c7d1da2 100644
--- a/erpnext/stock/doctype/item_lead_time/item_lead_time.json
+++ b/erpnext/stock/doctype/item_lead_time/item_lead_time.json
@@ -28,6 +28,8 @@
"purchase_time",
"column_break_lsfp",
"buffer_time",
+ "supplier_lead_time_section",
+ "supplier_lead_times",
"item_details_tab",
"item_name",
"stock_uom"
@@ -168,6 +170,18 @@
"fieldtype": "Tab Break",
"label": "Purchase Time"
},
+ {
+ "fieldname": "supplier_lead_time_section",
+ "fieldtype": "Section Break",
+ "label": "Supplier Wise Purchase Time"
+ },
+ {
+ "description": "Overrides the Purchase Time above for the selected supplier. The row marked as default is used when no supplier is selected.",
+ "fieldname": "supplier_lead_times",
+ "fieldtype": "Table",
+ "label": "Supplier Lead Times",
+ "options": "Item Lead Time Supplier"
+ },
{
"fieldname": "manufacturing_time_tab",
"fieldtype": "Tab Break",
@@ -177,7 +191,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2025-10-30 11:45:03.602345",
+ "modified": "2026-08-22 11:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item Lead Time",
diff --git a/erpnext/stock/doctype/item_lead_time/item_lead_time.py b/erpnext/stock/doctype/item_lead_time/item_lead_time.py
index 22ead7885a9..03292253980 100644
--- a/erpnext/stock/doctype/item_lead_time/item_lead_time.py
+++ b/erpnext/stock/doctype/item_lead_time/item_lead_time.py
@@ -1,8 +1,10 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-# import frappe
+import frappe
+from frappe import _
from frappe.model.document import Document
+from frappe.utils import cint
class ItemLeadTime(Document):
@@ -14,6 +16,10 @@ class ItemLeadTime(Document):
if TYPE_CHECKING:
from frappe.types import DF
+ from erpnext.stock.doctype.item_lead_time_supplier.item_lead_time_supplier import (
+ ItemLeadTimeSupplier,
+ )
+
buffer_time: DF.Int
capacity_per_day: DF.Int
daily_yield: DF.Percent
@@ -26,7 +32,25 @@ class ItemLeadTime(Document):
purchase_time: DF.Int
shift_time_in_hours: DF.Int
stock_uom: DF.Link | None
+ supplier_lead_times: DF.Table[ItemLeadTimeSupplier]
total_workstation_time: DF.Int
# end: auto-generated types
- pass
+ def validate(self):
+ self.validate_supplier_lead_times()
+
+ def validate_supplier_lead_times(self):
+ suppliers = set()
+ default_rows = 0
+ for row in self.supplier_lead_times:
+ if row.supplier in suppliers:
+ frappe.throw(
+ _("Row #{0}: Supplier {1} is already added in the Supplier Lead Times table").format(
+ row.idx, frappe.bold(row.supplier)
+ )
+ )
+ suppliers.add(row.supplier)
+ default_rows += cint(row.is_default)
+
+ if default_rows > 1:
+ frappe.throw(_("Only one supplier can be marked as default in the Supplier Lead Times table"))
diff --git a/erpnext/stock/doctype/item_lead_time_supplier/__init__.py b/erpnext/stock/doctype/item_lead_time_supplier/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/stock/doctype/item_lead_time_supplier/item_lead_time_supplier.json b/erpnext/stock/doctype/item_lead_time_supplier/item_lead_time_supplier.json
new file mode 100644
index 00000000000..06e46b941dc
--- /dev/null
+++ b/erpnext/stock/doctype/item_lead_time_supplier/item_lead_time_supplier.json
@@ -0,0 +1,58 @@
+{
+ "actions": [],
+ "creation": "2026-08-22 11:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "supplier",
+ "purchase_time",
+ "buffer_time",
+ "is_default"
+ ],
+ "fields": [
+ {
+ "columns": 4,
+ "fieldname": "supplier",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Supplier",
+ "options": "Supplier",
+ "reqd": 1
+ },
+ {
+ "columns": 2,
+ "fieldname": "purchase_time",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Purchase Time (Days)"
+ },
+ {
+ "columns": 2,
+ "fieldname": "buffer_time",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Buffer Time (Days)"
+ },
+ {
+ "columns": 2,
+ "default": "0",
+ "fieldname": "is_default",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Is Default"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-08-22 11:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Stock",
+ "name": "Item Lead Time Supplier",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/erpnext/stock/doctype/item_lead_time_supplier/item_lead_time_supplier.py b/erpnext/stock/doctype/item_lead_time_supplier/item_lead_time_supplier.py
new file mode 100644
index 00000000000..01b65e4887d
--- /dev/null
+++ b/erpnext/stock/doctype/item_lead_time_supplier/item_lead_time_supplier.py
@@ -0,0 +1,25 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from frappe.model.document import Document
+
+
+class ItemLeadTimeSupplier(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
+
+ buffer_time: DF.Int
+ is_default: DF.Check
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ purchase_time: DF.Int
+ supplier: DF.Link
+ # end: auto-generated types
+
+ pass