diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js
index 5cba4e50eb4..292a36866a4 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.js
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js
@@ -142,6 +142,21 @@ frappe.ui.form.on("Production Plan", {
__("View")
);
+ frm.add_custom_button(
+ __("Production Schedule"),
+ () => {
+ frappe.route_options = { production_plan: frm.doc.name };
+ frappe.set_route("List", "Production Plan Schedule", "Calendar");
+ },
+ __("View")
+ );
+
+ if (!["Completed", "Closed"].includes(frm.doc.status)) {
+ frm.add_custom_button(__("Schedule Items"), () => {
+ frm.events.show_schedule_dialog(frm);
+ });
+ }
+
let has_create_buttons = false;
if (frm.doc.status !== "Completed") {
@@ -248,6 +263,324 @@ frappe.ui.form.on("Production Plan", {
set_field_options("projected_qty_formula", projected_qty_formula);
},
+ show_schedule_dialog(frm) {
+ let items_data = frm.doc.po_items.map((row) => ({
+ plan_row: row.name,
+ item_code: row.item_code,
+ planned_qty: row.planned_qty,
+ start_date: row.planned_start_date,
+ }));
+
+ let dialog = new frappe.ui.Dialog({
+ title: __("Schedule Production Plan"),
+ size: "large",
+ fields: [
+ {
+ label: __("Start Date"),
+ fieldname: "start_date",
+ fieldtype: "Datetime",
+ reqd: 1,
+ default: frappe.datetime.now_datetime(),
+ },
+ {
+ label: __("Use Item Wise Start Dates"),
+ fieldname: "use_item_dates",
+ fieldtype: "Check",
+ default: 0,
+ description: __(
+ "Set a start date per assembly item below; its sub-assemblies are scheduled from the same date. The Start Date above is the earliest limit. Rows with a date here keep it as entered; clear a date to let the system schedule that item freely and write back the computed start."
+ ),
+ },
+ {
+ label: __("Item Wise Start Dates"),
+ fieldname: "items",
+ fieldtype: "Table",
+ depends_on: "eval:doc.use_item_dates",
+ cannot_add_rows: true,
+ cannot_delete_rows: true,
+ in_place_edit: true,
+ data: items_data,
+ get_data: () => items_data,
+ fields: [
+ {
+ fieldname: "plan_row",
+ fieldtype: "Data",
+ hidden: 1,
+ },
+ {
+ label: __("Item"),
+ fieldname: "item_code",
+ fieldtype: "Link",
+ options: "Item",
+ in_list_view: 1,
+ read_only: 1,
+ columns: 3,
+ },
+ {
+ label: __("Planned Qty"),
+ fieldname: "planned_qty",
+ fieldtype: "Float",
+ in_list_view: 1,
+ read_only: 1,
+ columns: 2,
+ },
+ {
+ label: __("Start Date"),
+ fieldname: "start_date",
+ fieldtype: "Datetime",
+ in_list_view: 1,
+ columns: 4,
+ },
+ ],
+ },
+ ],
+ primary_action_label: __("Preview"),
+ primary_action: (values) => {
+ dialog.hide();
+ frm.events.fetch_schedule_preview(frm, frm.events.get_schedule_args(frm, values));
+ },
+ });
+
+ dialog.show();
+ },
+
+ get_schedule_args(frm, values) {
+ let item_dates = {};
+ if (values.use_item_dates) {
+ (values.items || []).forEach((row) => {
+ if (row.plan_row && row.start_date) {
+ item_dates[row.plan_row] = row.start_date;
+ }
+ });
+ }
+
+ return {
+ production_plan: frm.doc.name,
+ start_date: values.start_date,
+ use_item_dates: values.use_item_dates,
+ item_dates: item_dates,
+ };
+ },
+
+ fetch_schedule_preview(frm, args) {
+ frappe.call({
+ method: "erpnext.manufacturing.scheduling.plan_adapter.get_schedule_preview",
+ type: "GET",
+ args: args,
+ freeze: true,
+ freeze_message: __("Calculating Schedule..."),
+ callback: (r) => {
+ if (!r.exc) {
+ frm.events.show_schedule_preview(frm, args, r.message);
+ }
+ },
+ });
+ },
+
+ show_schedule_preview(frm, values, proposal) {
+ let ordered = frm.events.get_ordered_preview_rows(proposal);
+ let rows_html = ordered.map((row) => frm.events.get_preview_row_html(row)).join("");
+
+ let unscheduled = Object.entries(proposal.unscheduled || {});
+ let warning = unscheduled.length
+ ? `
${__(
+ "Could not schedule {0} task(s), so this proposal cannot be applied",
+ [unscheduled.length]
+ )}: ${unscheduled
+ .map(([key, reason]) => `${frappe.utils.escape_html(key)} (${reason})`)
+ .join(", ")}
`
+ : "";
+
+ let locked_note = proposal.orders_exist
+ ? `${__(
+ "Work Orders / Purchase Orders already exist against this plan, so the schedule is locked. Cancel them to re-schedule."
+ )}
`
+ : "";
+
+ let dialog_options = {
+ title: __("Schedule Preview"),
+ size: "extra-large",
+ };
+
+ if (!unscheduled.length && !proposal.orders_exist) {
+ dialog_options.primary_action_label = __("Apply Schedule");
+ dialog_options.primary_action = () => {
+ dialog.hide();
+ frm.events.apply_schedule(frm, values);
+ };
+ }
+
+ let dialog = new frappe.ui.Dialog(dialog_options);
+
+ dialog.$body.html(`
+ ${frm.events.get_preview_styles()}
+ ${frm.events.get_preview_summary_html(proposal, ordered)}
+ ${locked_note}
+ ${warning}
+
+
+
+ | ${__("Item")} |
+ ${__("Workstations")} |
+ ${__("Start")} |
+ ${__("End")} |
+ ${__("Starts In")} |
+
+ ${rows_html}
+
+
+ `);
+ dialog.show();
+ },
+
+ get_ordered_preview_rows(proposal) {
+ let entries = Object.entries(proposal.rows || {}).map(([name, row]) => ({ name, ...row }));
+ let by_start = (a, b) => (a.start < b.start ? -1 : 1);
+
+ let materials = entries.filter((row) => row.row_type === "Raw Material").sort(by_start);
+ let finished_goods = entries.filter((row) => row.row_type === "Finished Good").sort(by_start);
+ let sub_assemblies = entries
+ .filter((row) => !["Finished Good", "Raw Material"].includes(row.row_type))
+ .sort(by_start);
+
+ let used_materials = new Set();
+ let materials_for = (consumer, indent) =>
+ materials
+ .filter((material) => (material.consumers || []).includes(consumer))
+ .map((material) => {
+ used_materials.add(material.name);
+ return { ...material, indent };
+ });
+
+ let ordered = [];
+ finished_goods.forEach((fg) => {
+ ordered.push(fg);
+ let children = [
+ ...sub_assemblies
+ .filter((sub) => sub.parent_row === fg.name)
+ .map((sub) => ({ ...sub, indent: 1 })),
+ ...materials_for(fg.name, 1),
+ ].sort(by_start);
+
+ children.forEach((child) => {
+ ordered.push(child);
+ if (child.row_type !== "Raw Material") {
+ ordered.push(...materials_for(child.name, 2));
+ }
+ });
+ });
+
+ sub_assemblies
+ .filter((sub) => !finished_goods.some((fg) => fg.name === sub.parent_row))
+ .forEach((sub) => {
+ ordered.push(sub);
+ ordered.push(...materials_for(sub.name, 1));
+ });
+
+ ordered.push(...materials.filter((material) => !used_materials.has(material.name)));
+
+ return ordered;
+ },
+
+ get_preview_row_html(row) {
+ let workstations = [...new Set(row.blocks.map((block) => block.workstation).filter(Boolean))];
+ let is_fg = row.row_type === "Finished Good";
+ let is_material = row.row_type === "Raw Material";
+ let indent_html = row.indent
+ ? `↳`
+ : "";
+ let item = `${indent_html}
+ ${frappe.utils.escape_html(
+ row.item_code
+ )}`;
+ let detail = is_material
+ ? `${__("Procurement")}`
+ : frappe.utils.escape_html(workstations.join(", ") || "-");
+
+ let starts_in = schedule_starts_in(row.start);
+
+ return `
+ | ${item} |
+ ${detail} |
+ ${format_schedule_date(row.start)} |
+ ${format_schedule_date(row.end)} |
+
+ ${starts_in.label}
+ |
+
`;
+ },
+
+ get_preview_summary_html(proposal, ordered) {
+ let starts = ordered.map((row) => row.start).sort();
+ let total = starts.length ? schedule_duration_label(starts[0], proposal.completion_date) : "-";
+
+ return `
+
+
${__("Expected Completion")}
+
${format_schedule_date(proposal.completion_date)}
+
+
+
${__("Total Duration")}
+
${total}
+
+
+
${__("Items")}
+
${Object.keys(proposal.rows || {}).length}
+
+
`;
+ },
+
+ get_preview_styles() {
+ return ``;
+ },
+
+ apply_schedule(frm, args) {
+ frappe.call({
+ method: "erpnext.manufacturing.scheduling.plan_adapter.apply_schedule",
+ args: args,
+ freeze: true,
+ freeze_message: __("Applying Schedule..."),
+ callback: (r) => {
+ if (!r.exc) {
+ frappe.show_alert({
+ message: __("Schedule applied. Expected completion on {0}", [
+ frappe.datetime.str_to_user(r.message.completion_date),
+ ]),
+ indicator: "green",
+ });
+ frm.reload_doc();
+ }
+ },
+ });
+ },
+
get_items_for_work_order(frm) {
let items = frm.doc.po_items;
if (frm.doc.sub_assembly_items?.length) {
@@ -768,3 +1101,41 @@ frappe.tour["Production Plan"] = [
description: __("To add subcontracted Item's raw materials if include exploded items is disabled."),
},
];
+
+function format_schedule_date(value) {
+ if (!value) {
+ return "-";
+ }
+
+ return moment(value).format("Do MMMM YYYY, h:mm A");
+}
+
+function schedule_duration_parts(total_mins) {
+ let days = Math.floor(total_mins / 1440);
+ let hours = Math.floor((total_mins % 1440) / 60);
+ let minutes = Math.round(total_mins % 60);
+
+ let parts = [];
+ if (days) parts.push(__("{0}d", [days]));
+ if (hours) parts.push(__("{0}h", [hours]));
+ if (!days && minutes) parts.push(__("{0}m", [minutes]));
+
+ return parts.join(" ");
+}
+
+function schedule_starts_in(start) {
+ let mins = moment(start).diff(moment(), "minutes");
+ if (mins <= 0) {
+ return { label: moment(start).fromNow(), color: "gray" };
+ }
+
+ return {
+ label: __("in {0}", [schedule_duration_parts(mins) || __("a moment")]),
+ color: mins >= 1440 ? "green" : "orange",
+ };
+}
+
+function schedule_duration_label(from_time, to_time) {
+ let mins = moment(to_time).diff(moment(from_time), "minutes");
+ return schedule_duration_parts(Math.max(mins, 0)) || "-";
+}
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json
index d7e5c48de1c..c780ba2f3be 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.json
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json
@@ -40,6 +40,7 @@
"column_break_igxl",
"skip_available_sub_assembly_item",
"combine_sub_items",
+ "no_of_shifts",
"get_sub_assembly_items",
"section_break_g4ip",
"sub_assembly_items",
@@ -446,6 +447,12 @@
"fieldname": "reserve_stock",
"fieldtype": "Check",
"label": "Reserve Stock"
+ },
+ {
+ "description": "Used by scheduling when an item has no BOM operations: scales the Item Lead Time daily capacity to this many shifts.",
+ "fieldname": "no_of_shifts",
+ "fieldtype": "Int",
+ "label": "No of Shifts"
}
],
"grid_page_length": 50,
@@ -453,7 +460,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2026-07-07 00:00:00.000000",
+ "modified": "2026-08-12 00:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan",
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py b/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py
index 71ca6843e2b..f875a0f9abf 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py
@@ -11,5 +11,6 @@ def get_data():
{"label": _("Transactions"), "items": ["Work Order", "Material Request"]},
{"label": _("Subcontract"), "items": ["Purchase Order"]},
{"label": _("Reservation"), "items": ["Stock Reservation Entry"]},
+ {"label": _("Schedule"), "items": ["Production Plan Schedule"]},
],
}
diff --git a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json
index 4ac68aa9133..7ea2e4961a8 100644
--- a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json
+++ b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json
@@ -14,6 +14,7 @@
"stock_uom",
"warehouse",
"planned_start_date",
+ "planned_end_date",
"section_break_9",
"pending_qty",
"ordered_qty",
@@ -89,6 +90,7 @@
"options": "Warehouse"
},
{
+ "allow_on_submit": 1,
"default": "Today",
"fieldname": "planned_start_date",
"fieldtype": "Datetime",
@@ -215,12 +217,20 @@
"fieldtype": "Data",
"hidden": 1,
"label": "temporary name"
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "planned_end_date",
+ "fieldtype": "Datetime",
+ "label": "Planned End Date",
+ "no_copy": 1,
+ "read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2024-06-03 13:10:20.252166",
+ "modified": "2026-08-12 00:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Item",
@@ -230,4 +240,4 @@
"sort_field": "creation",
"sort_order": "ASC",
"states": []
-}
\ No newline at end of file
+}
diff --git a/erpnext/manufacturing/doctype/production_plan_schedule/__init__.py b/erpnext/manufacturing/doctype/production_plan_schedule/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.json b/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.json
new file mode 100644
index 00000000000..8e0ce97a1bb
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.json
@@ -0,0 +1,183 @@
+{
+ "actions": [],
+ "autoname": "hash",
+ "creation": "2026-08-12 00:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 0,
+ "engine": "InnoDB",
+ "field_order": [
+ "subject",
+ "production_plan",
+ "company",
+ "column_break_main",
+ "row_type",
+ "plan_row",
+ "task_key",
+ "item_section",
+ "item_code",
+ "item_name",
+ "column_break_item",
+ "operation",
+ "workstation",
+ "schedule_section",
+ "from_time",
+ "column_break_time",
+ "to_time",
+ "duration_mins"
+ ],
+ "fields": [
+ {
+ "fieldname": "subject",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Subject",
+ "read_only": 1
+ },
+ {
+ "fieldname": "production_plan",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Production Plan",
+ "options": "Production Plan",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fetch_from": "production_plan.company",
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "label": "Company",
+ "options": "Company",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_main",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "row_type",
+ "fieldtype": "Select",
+ "label": "Row Type",
+ "options": "Finished Good\nSub Assembly\nRaw Material",
+ "read_only": 1
+ },
+ {
+ "fieldname": "plan_row",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Plan Row",
+ "read_only": 1
+ },
+ {
+ "fieldname": "task_key",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Task Key",
+ "read_only": 1
+ },
+ {
+ "fieldname": "item_section",
+ "fieldtype": "Section Break",
+ "label": "Item & Operation"
+ },
+ {
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Item Code",
+ "options": "Item",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "item_code.item_name",
+ "fieldname": "item_name",
+ "fieldtype": "Data",
+ "label": "Item Name",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_item",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "operation",
+ "fieldtype": "Link",
+ "label": "Operation",
+ "options": "Operation",
+ "read_only": 1
+ },
+ {
+ "fieldname": "workstation",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Workstation",
+ "options": "Workstation",
+ "read_only": 1
+ },
+ {
+ "fieldname": "schedule_section",
+ "fieldtype": "Section Break",
+ "label": "Schedule"
+ },
+ {
+ "fieldname": "from_time",
+ "fieldtype": "Datetime",
+ "in_list_view": 1,
+ "label": "From Time",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_time",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "to_time",
+ "fieldtype": "Datetime",
+ "label": "To Time",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fieldname": "duration_mins",
+ "fieldtype": "Float",
+ "label": "Duration (Mins)",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 0,
+ "links": [],
+ "modified": "2026-08-12 18:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Manufacturing",
+ "name": "Production Plan Schedule",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Manufacturing User",
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Manufacturing Manager",
+ "share": 1
+ }
+ ],
+ "sort_field": "from_time",
+ "sort_order": "ASC",
+ "states": [],
+ "title_field": "subject",
+ "track_changes": 0
+}
diff --git a/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.py b/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.py
new file mode 100644
index 00000000000..da9e6e0efbc
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule.py
@@ -0,0 +1,44 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class ProductionPlanSchedule(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
+
+ company: DF.Link | None
+ duration_mins: DF.Float
+ from_time: DF.Datetime
+ item_code: DF.Link | None
+ item_name: DF.Data | None
+ operation: DF.Link | None
+ plan_row: DF.Data | None
+ production_plan: DF.Link
+ row_type: DF.Literal["Finished Good", "Sub Assembly"]
+ subject: DF.Data | None
+ task_key: DF.Data | None
+ to_time: DF.Datetime
+ workstation: DF.Link | None
+ # end: auto-generated types
+
+ def before_insert(self):
+ if not self.flags.from_scheduler:
+ frappe.throw(
+ _(
+ "Production Plan Schedule entries cannot be created manually. Use the Schedule Items action on the Production Plan."
+ )
+ )
+
+
+def on_doctype_update():
+ frappe.db.add_index("Production Plan Schedule", ["production_plan"])
+ frappe.db.add_index("Production Plan Schedule", ["workstation", "from_time"])
diff --git a/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule_calendar.js b/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule_calendar.js
new file mode 100644
index 00000000000..d5001b84eec
--- /dev/null
+++ b/erpnext/manufacturing/doctype/production_plan_schedule/production_plan_schedule_calendar.js
@@ -0,0 +1,34 @@
+// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.views.calendar["Production Plan Schedule"] = {
+ field_map: {
+ start: "from_time",
+ end: "to_time",
+ id: "name",
+ title: "subject",
+ allDay: "allDay",
+ },
+ order_by: "from_time",
+ filters: [
+ {
+ fieldtype: "Link",
+ fieldname: "production_plan",
+ options: "Production Plan",
+ label: __("Production Plan"),
+ },
+ {
+ fieldtype: "Link",
+ fieldname: "workstation",
+ options: "Workstation",
+ label: __("Workstation"),
+ },
+ {
+ fieldtype: "Link",
+ fieldname: "item_code",
+ options: "Item",
+ label: __("Item"),
+ },
+ ],
+ get_events_method: "frappe.desk.calendar.get_events",
+};
diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json
index 35f7bc39799..1fb11283848 100644
--- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json
+++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json
@@ -34,6 +34,7 @@
"indent",
"section_break_19",
"schedule_date",
+ "schedule_end_date",
"uom",
"stock_uom",
"actual_qty",
@@ -180,6 +181,7 @@
"options": "Supplier"
},
{
+ "allow_on_submit": 1,
"columns": 2,
"fieldname": "schedule_date",
"fieldtype": "Datetime",
@@ -262,13 +264,21 @@
"label": "Sales Order Item",
"no_copy": 1,
"print_hide": 1
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "schedule_end_date",
+ "fieldtype": "Datetime",
+ "label": "Schedule End Date",
+ "no_copy": 1,
+ "read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2026-03-16 10:28:41.879801",
+ "modified": "2026-08-12 00:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Sub Assembly Item",
diff --git a/erpnext/manufacturing/doctype/work_order/mapper.py b/erpnext/manufacturing/doctype/work_order/mapper.py
index 0a38c402155..90ab3c8189e 100644
--- a/erpnext/manufacturing/doctype/work_order/mapper.py
+++ b/erpnext/manufacturing/doctype/work_order/mapper.py
@@ -390,7 +390,7 @@ def validate_operation_data(row):
)
-def create_job_card(work_order, row, enable_capacity_planning=False, auto_create=False):
+def create_job_card(work_order, row, enable_capacity_planning=False, auto_create=False, schedule_blocks=None):
doc = frappe.new_doc("Job Card")
doc.update(_job_card_values(work_order, row))
@@ -403,9 +403,9 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create
doc.set_secondary_items()
if auto_create:
- _auto_create_job_card(doc, row, enable_capacity_planning)
+ _auto_create_job_card(doc, row, enable_capacity_planning, schedule_blocks)
- if enable_capacity_planning:
+ if enable_capacity_planning or schedule_blocks:
# automatically added scheduling rows shouldn't change status to WIP
doc.db_set("status", "Open")
@@ -457,15 +457,32 @@ def _job_card_warehouse_values(work_order, row, qty):
}
-def _auto_create_job_card(doc, row, enable_capacity_planning):
+def _auto_create_job_card(doc, row, enable_capacity_planning, schedule_blocks=None):
doc.flags.ignore_mandatory = True
- if enable_capacity_planning:
+ if schedule_blocks:
+ _apply_schedule_blocks(doc, schedule_blocks)
+ elif enable_capacity_planning:
doc.schedule_time_logs(row)
doc.insert()
frappe.msgprint(_("Job card {0} created").format(get_link_to_form("Job Card", doc.name)), alert=True)
+def _apply_schedule_blocks(doc, schedule_blocks):
+ if schedule_blocks[0].workstation:
+ doc.workstation = schedule_blocks[0].workstation
+
+ for block in schedule_blocks:
+ doc.append(
+ "scheduled_time_logs",
+ {
+ "from_time": block.from_time,
+ "to_time": block.to_time,
+ "time_in_mins": flt(block.duration_mins),
+ },
+ )
+
+
def get_work_order_operation_data(work_order, operation, workstation):
for d in work_order.operations:
if d.operation == operation and d.workstation == workstation:
diff --git a/erpnext/manufacturing/doctype/work_order/services/operations.py b/erpnext/manufacturing/doctype/work_order/services/operations.py
index 153c9be06d0..905664608f8 100644
--- a/erpnext/manufacturing/doctype/work_order/services/operations.py
+++ b/erpnext/manufacturing/doctype/work_order/services/operations.py
@@ -104,17 +104,50 @@ class OperationsService:
def prepare_data_for_job_card(self, row, idx, plan_days, enable_capacity_planning):
self.set_operation_start_end_time(row, idx)
+ schedule_blocks = self.get_plan_schedule_blocks(row)
+
+ if schedule_blocks:
+ row.planned_start_time = schedule_blocks[0].from_time
+ row.planned_end_time = schedule_blocks[-1].to_time
job_card_doc = create_job_card(
- self.doc, row, auto_create=True, enable_capacity_planning=enable_capacity_planning
+ self.doc,
+ row,
+ auto_create=True,
+ enable_capacity_planning=enable_capacity_planning and not schedule_blocks,
+ schedule_blocks=schedule_blocks,
)
- if enable_capacity_planning and job_card_doc:
+ if schedule_blocks:
+ row.db_update()
+ elif enable_capacity_planning and job_card_doc:
row.planned_start_time = job_card_doc.scheduled_time_logs[-1].from_time
row.planned_end_time = job_card_doc.scheduled_time_logs[-1].to_time
self._validate_capacity_window(row, plan_days)
row.db_update()
+ def get_plan_schedule_blocks(self, row):
+ plan_row = self.doc.production_plan_item or self.doc.production_plan_sub_assembly_item
+ if not (self.doc.production_plan and plan_row):
+ return []
+
+ if flt(row.job_card_qty) != flt(self.doc.qty):
+ return []
+
+ if sum(1 for d in self.doc.operations if d.operation == row.operation) > 1:
+ return []
+
+ return frappe.get_all(
+ "Production Plan Schedule",
+ filters={
+ "production_plan": self.doc.production_plan,
+ "plan_row": plan_row,
+ "operation": row.operation,
+ },
+ fields=["from_time", "to_time", "duration_mins", "workstation"],
+ order_by="from_time",
+ )
+
def _validate_capacity_window(self, row, plan_days):
from erpnext.manufacturing.doctype.work_order.work_order import CapacityError
diff --git a/erpnext/manufacturing/scheduling/DESIGN.md b/erpnext/manufacturing/scheduling/DESIGN.md
new file mode 100644
index 00000000000..9c705a7a40a
--- /dev/null
+++ b/erpnext/manufacturing/scheduling/DESIGN.md
@@ -0,0 +1,187 @@
+# Production Scheduling Engine - Design
+
+Status: **Phases 1–2 implemented**, plus the Work Order/Job Card date-sync slice of
+Phase 3. `plan_adapter.py` builds the plan task graph and backs the "Schedule Items"
+button with a what-if preview dialog on Production Plan; dates are written and
+Production Plan Schedule entries created only when the user applies the proposal.
+
+## 1. Problem
+
+Production Plan today carries dates (`po_items.planned_start_date`,
+`sub_assembly_items.schedule_date`) but nothing computes them - users type them in.
+The only real scheduling in ERPNext happens at the very end of the chain: when a Work
+Order is submitted, Job Cards are placed one at a time (first-fit, forward-only) against
+workstation working hours and existing bookings. Consequences:
+
+- A plan gives no answer to "when will this be done?" or "can we promise this date?"
+- MRP/MPS computes release dates from a static `Item Lead Time` number, blind to shop load.
+- There is no backward ("we must ship on X, when must we start?") scheduling anywhere.
+- Rescheduling after a disruption means resubmitting work orders one by one.
+
+## 2. Reference model - Epicor Kinetic
+
+Concepts worth adopting, and their fate in this design:
+
+| Epicor concept | What it does | This design |
+|---|---|---|
+| Capacity / Load / Scheduling Blocks | Resource supply vs demand; operations placed as time blocks on resources | Core model: `Resource` (calendar + capacity), load intervals, `Assignment.blocks` |
+| Finite vs Infinite scheduling | Finite respects load and never overloads; infinite ignores load to show demand vs capacity | `mode = FINITE / INFINITE` per run |
+| Forward / Backward scheduling | Earliest-completion from a start date, or latest-start from a due date (JIT), with forward fallback when backward lands in the past | `direction = FORWARD / BACKWARD`, automatic per-task forward fallback |
+| What-if scheduling | Propose a schedule without committing it | Engine is **dry-run by default**: it returns a proposal, callers persist |
+| Global scheduling | Batch re-schedule everything by priority after disruptions | Same engine fed with *all* open tasks; priority is a first-class task field |
+| Capability-based scheduling | Operation demands a capability, engine picks a concrete resource | `Task.resource_type` (maps to existing `workstation_type`), engine picks the earliest-available machine |
+| Resource Groups & calendars | Shifts, holidays per resource | `ResourceCalendar` built from Workstation working hours + holiday list |
+| Scheduling boards | Gantt UIs for jobs/resources | Phase 5 (UI reads engine output; not part of the core) |
+| Setup / queue / move times | Per-operation overheads | Partially: inter-task gap (mins between operations) now; explicit setup/queue fields are a Phase 5 schema addition |
+
+Not adopted (out of scope): sequence-optimization to minimize changeovers, multi-plant
+transfer scheduling, capable-to-promise quoting. Epicor itself ships those only in the
+APS add-on.
+
+## 3. ERPNext today - inventory and gaps
+
+What exists and is reused as-is:
+
+- **Workstation**: `working_hours` (daily shift slots), `holiday_list`,
+ `production_capacity` (parallel jobs), `workstation_type` (capability),
+ `plant_floor`, status. → becomes the engine's `Resource`.
+- **BOM Operation**: `time_in_mins`, `fixed_time`, `batch_size`, `sequence_id`
+ (parallel groups), `workstation` / `workstation_type`. → becomes `Task`s.
+- **Job Card**: `Job Card Scheduled Time` + `Job Card Time Log` rows are the booked
+ load; `schedule_time_logs` is today's first-fit placer. → load source; later a client.
+- **Manufacturing Settings**: `mins_between_operations`, `allow_overtime`,
+ `allow_production_on_holidays`, `capacity_planning_for_days`,
+ `disable_capacity_planning`. → engine options.
+- **Item Lead Time**: per-item `capacity_per_day`, `daily_yield`, shift/workstation
+ counts, `purchase_time`, `buffer_time`. → duration source for rows *without* BOM
+ operations, and for purchased/subcontracted tasks.
+- **MPS / MRP**: `Master Production Schedule`, `MPS Planned Order`, MRP report with
+ `cumulative_lead_time` and `release_date = delivery_date - lead_time`. → Phase 4 client.
+
+Gaps this design closes:
+
+| Gap | Today | Target |
+|---|---|---|
+| Plan-level scheduling | none (manual dates) | engine schedules the whole BOM-level task graph |
+| Backward scheduling | none | `BACKWARD` direction with forward fallback |
+| Infinite mode (RCCP) | none | `INFINITE` mode + overload report from the same output |
+| What-if | none | dry-run result object, persisted only on demand |
+| Cross-document view | job cards placed one WO at a time | one run can hold every open task; earlier placements constrain later ones |
+| MRP dates | static lead-time arithmetic | same engine, same calendars, load-aware |
+| Priority | none | `Task.priority` orders placement under contention |
+
+## 4. Architecture
+
+**Pure core, thin adapters.** The engine (`engine.py`, `models.py`, `calendars.py`)
+imports nothing from Frappe - it consumes plain tasks/resources/intervals and returns a
+proposal. All DB access lives in `loaders.py` (build inputs from doctypes) and, later,
+in per-document adapters (persist outputs). This is what makes the engine reusable by
+Production Plan, Work Order, and MRP alike, and testable without a site.
+
+```
+ ┌────────────────────────────────────────────┐
+ │ scheduling engine │
+ loaders.py ─▶ │ tasks + resources + load ─▶ assignments │ ─▶ adapters persist
+ (Workstation, │ direction: FORWARD | BACKWARD │ (Phase ≥ 2)
+ Job Cards, │ mode: FINITE | INFINITE │
+ BOM, ILT) │ dry-run: always │
+ └────────────────────────────────────────────┘
+ clients: Production Plan ─ Work Order/Job Card ─ MRP/MPS ─ boards/reports
+```
+
+### Core model (`models.py`)
+
+- `Interval(start, end)` - half-open time block.
+- `Resource(name, capacity, calendar)` - a workstation or any capacity-bearing thing
+ (a supplier lane, a subcontractor) with a `ResourceCalendar`.
+- `ResourceCalendar(daily_windows, holidays)` - daily shift windows (empty = 24×7,
+ i.e. overtime allowed or no working hours maintained) plus holiday dates.
+- `Task(key, duration_mins, resource, resource_type, depends_on, earliest_start,
+ due_date, priority)` - one operation, or one whole row when no operations exist
+ (duration then comes from Item Lead Time).
+- `Assignment(task_key, resource, blocks)` - where a task landed; `blocks` are the
+ scheduling blocks (a task may split across shifts/days).
+- `ScheduleResult(assignments, unscheduled)` - the proposal; `unscheduled` carries a
+ reason (no resource, beyond horizon) instead of throwing.
+
+### Algorithm (`engine.py`)
+
+Forward, finite (the default):
+
+1. Topologically sort tasks by `depends_on`; `priority` breaks ties, so under
+ contention the important job grabs the slot first (Epicor's global-scheduling
+ priority behavior).
+2. A task's ready time = max(anchor, its `earliest_start`, dependency ends + gap).
+3. Resolve the resource: explicit `resource` wins; otherwise every resource matching
+ `resource_type` is tried and the one giving the earliest finish wins
+ (capability-based scheduling).
+4. Walk the resource's calendar windows from the ready time; in FINITE mode a
+ sub-window only counts when concurrent load < capacity (segment sweep over interval
+ boundaries). Consume windows into blocks until the duration is exhausted.
+5. Placed blocks join the in-run load immediately, so later tasks - same plan or
+ another document in the same run - see them. This is what fixed the "two plans
+ scheduled back-to-back don't see each other" caveat of the earlier prototype.
+
+Backward: reverse topological order; latest end = min(due date, successor starts − gap);
+blocks are consumed walking the calendar backward. If the computed start lands before
+the anchor (today), the task - and transitively its successors - are re-placed forward
+from the anchor: Epicor's "backward with forward fallback", so an impossible due date
+degrades into "earliest possible" rather than an error.
+
+Infinite mode: identical walks, but existing load is ignored; only calendars constrain.
+Comparing FINITE vs INFINITE end dates for the same tasks *is* the overload report.
+
+### Task-graph construction (`loaders.py`)
+
+- `build_bom_operation_tasks(bom_no, qty, prefix)` - one task per BOM Operation,
+ time scaled `time_in_mins × qty / bom.quantity` (`fixed_time` unscaled). Operations
+ sharing a `sequence_id` become parallel siblings; each sequence group depends on the
+ previous group - same semantics Work Order applies today.
+- Production Plan graph (Phase 2): per FG row, each sub-assembly row expands to its BOM
+ operation chain (or a single lead-time task when the BOM has no operations /
+ subcontracted); FG tasks depend on the terminal tasks of its sub-assemblies. This
+ replaces the level-wave approximation of the earlier prototype with true
+ per-parent dependency edges.
+- `get_workstation_resources(...)` - Resource per Workstation; 24×7 calendar when
+ `allow_overtime` is on or no working hours are maintained; holidays dropped when
+ `allow_production_on_holidays` is on.
+- `get_booked_load(resources, from_date)` - intervals from open Job Cards
+ (Scheduled Time rows of untouched drafts + Time Logs), the same sources today's
+ overlap checks read.
+
+## 5. Phases
+
+| Phase | Deliverable | Persists to |
+|---|---|---|
+| **1 (this package)** | Engine core + loaders + unit tests. Dry-run only, nothing wired. | - |
+| **2 (built)** | Production Plan adapter (`plan_adapter.py`): "Schedule Items" runs the engine over the plan's task graph; what-if preview dialog shows the proposal, and only Apply writes `schedule_date`/`schedule_end_date` + `planned_start_date`/`planned_end_date` and materializes **Production Plan Schedule** rows (new non-submittable doctype, one row per scheduling block) - viewed shift-wise through Frappe's Calendar view with plan/workstation/item filters. Rows without BOM operations use Item Lead Time durations, scalable by the new `no_of_shifts` field on Production Plan. "Use Item Wise Start Dates" anchors each assembly item's chain to its own row `planned_start_date` (dialog date = global floor); in that mode row start dates are inputs and stay as entered, only end dates are written. Backward-from-delivery-date remains pending | plan child rows + Production Plan Schedule |
+| 3 | Work Order / Job Card unification: WO submission asks the engine for placement instead of the recursive first-fit in `schedule_time_logs`; job cards become the persisted form of engine blocks. Global reschedule = one engine run over all open job cards. **First slice built:** when a WO originates from a scheduled plan row, its auto-created Job Cards are seeded from the Production Plan Schedule blocks (same times, same workstation, one scheduled row per block) instead of re-running first-fit - plan calendar and job cards match exactly. Fallback to first-fit when no schedule exists, qty is batch-split, or an operation repeats in the routing | Job Card Scheduled Time |
+| 4 | MRP/MPS integration: planned-order release/due dates come from a backward engine run (load-aware when finite is chosen) instead of `delivery_date − cumulative_lead_time`; purchased items keep Item Lead Time durations on infinite supplier lanes | MPS Planned Order |
+| 5 | Boards & schema extras: resource Gantt board (drag = pin `earliest_start`), overload report (finite vs infinite), per-operation setup/queue time fields, `priority` field on Production Plan / Work Order | UI + new fields |
+
+Phase 2 is the sign-off gate for everything downstream; 3 and 4 are independent of each
+other once 1–2 land.
+
+## 6. Decisions taken (flag disagreement before Phase 2)
+
+1. **Dry-run by default, callers persist.** What-if and global re-scheduling fall out
+ for free; no hidden writes from the engine.
+2. **Pure-python core, Frappe only in loaders/adapters.** Unit-testable without a site;
+ reusable verbatim for MRP.
+3. **Duration hierarchy:** BOM Operations when present → Item Lead Time capacity/time
+ fields → 1-day floor. Purchased/subcontracted rows always use Item Lead Time
+ (`purchase_time + buffer_time`) on an infinite lane.
+4. **Backward falls back forward per-task** instead of failing the run.
+5. **No changeover/sequence optimization.** First-fit by priority order, like Kinetic's
+ base scheduler; APS-class optimization is explicitly out of scope.
+6. **Job Cards stay the persistence format for shop-floor bookings** (Phase 3 replaces
+ their placement logic, not their role).
+
+## 7. Open questions
+
+- Should FINITE be the default for Production Plan scheduling, with INFINITE only in
+ reports? (Epicor defaults resources to finite; proposal: yes.)
+- Material-constrained scheduling (don't start before raw material PO arrival) - Phase 4
+ via MRP pegging, or earlier as a simple `earliest_start` from Material Request dates?
+- Multi-company/multi-plant: scope resources per company now (proposal) or add a
+ plant dimension to `Resource` immediately?
diff --git a/erpnext/manufacturing/scheduling/__init__.py b/erpnext/manufacturing/scheduling/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/manufacturing/scheduling/engine.py b/erpnext/manufacturing/scheduling/engine.py
new file mode 100644
index 00000000000..569ced3c227
--- /dev/null
+++ b/erpnext/manufacturing/scheduling/engine.py
@@ -0,0 +1,296 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from __future__ import annotations
+
+import datetime
+import itertools
+from collections import defaultdict
+
+from erpnext.manufacturing.scheduling.models import (
+ BACKWARD,
+ FINITE,
+ FORWARD,
+ INFINITE,
+ Assignment,
+ Interval,
+ Resource,
+ ScheduleResult,
+ Task,
+)
+
+
+class SchedulingEngine:
+ def __init__(
+ self,
+ resources: list[Resource],
+ existing_load: dict[str, list[Interval]] | None = None,
+ mode: str = FINITE,
+ gap_mins: float = 0,
+ horizon_days: int = 365,
+ ):
+ self.resources = {resource.name: resource for resource in resources}
+ self.mode = mode
+ self.gap_mins = gap_mins
+ self.horizon_days = horizon_days
+ self.load: dict[str, list[Interval]] = defaultdict(list)
+ for name, intervals in (existing_load or {}).items():
+ self.load[name].extend(intervals)
+
+ def schedule(
+ self,
+ tasks: list[Task],
+ anchor: datetime.datetime,
+ direction: str = FORWARD,
+ not_before: datetime.datetime | None = None,
+ ) -> ScheduleResult:
+ if direction == BACKWARD:
+ return self.schedule_backward(tasks, anchor, not_before)
+
+ return self.schedule_forward(tasks, anchor)
+
+ def schedule_forward(self, tasks: list[Task], anchor: datetime.datetime) -> ScheduleResult:
+ result = ScheduleResult(direction_used=FORWARD)
+ ordered, cyclic = topological_order(tasks)
+ for key in cyclic:
+ result.unscheduled[key] = "cycle in dependencies"
+
+ for task in ordered:
+ ready_time = self.get_ready_time(task, anchor, result)
+ if ready_time is None:
+ result.unscheduled[task.key] = "dependency not scheduled"
+ continue
+
+ self.place_task(task, ready_time, result)
+
+ return result
+
+ def get_ready_time(self, task, anchor, result):
+ ready_time = max(anchor, task.earliest_start or anchor)
+ for dependency in task.depends_on:
+ assignment = result.assignments.get(dependency)
+ if not assignment:
+ return None
+
+ dependency_end = assignment.end + datetime.timedelta(minutes=self.gap_mins)
+ ready_time = max(ready_time, dependency_end)
+
+ return ready_time
+
+ def place_task(self, task, ready_time, result):
+ best = None
+ for resource in self.get_candidate_resources(task):
+ blocks = self.allocate(resource, ready_time, task.duration_mins, forward=True)
+ if blocks and (best is None or blocks[-1].end < best[1][-1].end):
+ best = (resource, blocks)
+
+ if best is None:
+ result.unscheduled[task.key] = "no capacity within horizon"
+ return
+
+ resource, blocks = best
+ self.record_blocks(resource, blocks)
+ result.assignments[task.key] = Assignment(
+ task_key=task.key, resource=resource.name if resource else None, blocks=blocks
+ )
+
+ def schedule_backward(
+ self,
+ tasks: list[Task],
+ due_anchor: datetime.datetime,
+ not_before: datetime.datetime | None = None,
+ ) -> ScheduleResult:
+ snapshot = {name: list(intervals) for name, intervals in self.load.items()}
+ result = self.try_backward(tasks, due_anchor, not_before)
+ if result is not None:
+ return result
+
+ self.load = defaultdict(list)
+ for name, intervals in snapshot.items():
+ self.load[name].extend(intervals)
+
+ return self.schedule_forward(tasks, not_before)
+
+ def try_backward(self, tasks, due_anchor, not_before):
+ result = ScheduleResult(direction_used=BACKWARD)
+ ordered, cyclic = topological_order(tasks)
+ for key in cyclic:
+ result.unscheduled[key] = "cycle in dependencies"
+
+ successors = get_successor_map(tasks)
+ for task in reversed(ordered):
+ latest_end = self.get_latest_end(task, due_anchor, successors, result)
+ if latest_end is None:
+ result.unscheduled[task.key] = "dependent not scheduled"
+ continue
+
+ if not self.place_task_backward(task, latest_end, not_before, result):
+ if not_before is None:
+ result.unscheduled[task.key] = "no capacity before due date"
+ continue
+
+ return None
+
+ return result
+
+ def get_latest_end(self, task, due_anchor, successors, result):
+ latest_end = min(due_anchor, task.due_date or due_anchor)
+ for successor in successors.get(task.key, []):
+ assignment = result.assignments.get(successor)
+ if not assignment:
+ return None
+
+ successor_start = assignment.start - datetime.timedelta(minutes=self.gap_mins)
+ latest_end = min(latest_end, successor_start)
+
+ return latest_end
+
+ def place_task_backward(self, task, latest_end, not_before, result):
+ best = None
+ for resource in self.get_candidate_resources(task):
+ blocks = self.allocate(resource, latest_end, task.duration_mins, forward=False)
+ if blocks and (best is None or blocks[0].start > best[1][0].start):
+ best = (resource, blocks)
+
+ bounds = [bound for bound in (task.earliest_start, not_before) if bound]
+ earliest_bound = max(bounds, default=None)
+ if best is None or (earliest_bound and best[1][0].start < earliest_bound):
+ return False
+
+ resource, blocks = best
+ self.record_blocks(resource, blocks)
+ result.assignments[task.key] = Assignment(
+ task_key=task.key, resource=resource.name if resource else None, blocks=blocks
+ )
+ return True
+
+ def get_candidate_resources(self, task):
+ if task.resource:
+ return [self.resources[task.resource]] if task.resource in self.resources else []
+
+ if task.resource_type:
+ return [r for r in self.resources.values() if r.resource_type == task.resource_type]
+
+ return [None]
+
+ def allocate(self, resource, anchor, duration_mins, forward=True):
+ if resource is None:
+ return [continuous_block(anchor, duration_mins, forward)]
+
+ blocks = []
+ remaining = duration_mins
+ day, boundary = anchor.date(), anchor
+ for _ in range(self.horizon_days):
+ windows = resource.calendar.windows_for_day(day)
+ for window in windows if forward else reversed(windows):
+ taken, remaining = self.consume_window(resource, window, boundary, remaining, forward)
+ blocks.extend(taken)
+ if remaining <= 0:
+ blocks.sort(key=lambda block: block.start)
+ return blocks
+
+ day += datetime.timedelta(days=1 if forward else -1)
+
+ return None
+
+ def consume_window(self, resource, window, boundary, remaining, forward):
+ start = max(window.start, boundary) if forward else window.start
+ end = window.end if forward else min(window.end, boundary)
+ if end <= start:
+ return [], remaining
+
+ blocks = []
+ segments = self.free_segments(resource, start, end)
+ for segment_start, segment_end in segments if forward else reversed(segments):
+ take_mins = min(remaining, (segment_end - segment_start).total_seconds() / 60)
+ if take_mins <= 0:
+ continue
+
+ blocks.append(cut_segment(segment_start, segment_end, take_mins, forward))
+ remaining -= take_mins
+ if remaining <= 0:
+ break
+
+ return blocks, remaining
+
+ def free_segments(self, resource, start, end):
+ if self.mode == INFINITE:
+ return [(start, end)]
+
+ overlapping = [iv for iv in self.load.get(resource.name, []) if iv.start < end and iv.end > start]
+ points = sorted(
+ {start, end}
+ | {max(iv.start, start) for iv in overlapping}
+ | {min(iv.end, end) for iv in overlapping}
+ )
+
+ segments = []
+ for segment_start, segment_end in itertools.pairwise(points):
+ concurrent = sum(1 for iv in overlapping if iv.start < segment_end and iv.end > segment_start)
+ if concurrent < resource.capacity:
+ if segments and segments[-1][1] == segment_start:
+ segments[-1] = (segments[-1][0], segment_end)
+ else:
+ segments.append((segment_start, segment_end))
+
+ return segments
+
+ def record_blocks(self, resource, blocks):
+ if resource is not None:
+ self.load[resource.name].extend(blocks)
+
+
+def continuous_block(anchor, duration_mins, forward):
+ delta = datetime.timedelta(minutes=duration_mins)
+ return Interval(anchor, anchor + delta) if forward else Interval(anchor - delta, anchor)
+
+
+def cut_segment(segment_start, segment_end, take_mins, forward):
+ delta = datetime.timedelta(minutes=take_mins)
+ if forward:
+ return Interval(segment_start, segment_start + delta)
+
+ return Interval(segment_end - delta, segment_end)
+
+
+def topological_order(tasks: list[Task]) -> tuple[list[Task], list[str]]:
+ by_key = {task.key: task for task in tasks}
+ pending_deps = {task.key: {dep for dep in task.depends_on if dep in by_key} for task in tasks}
+ dependents = defaultdict(list)
+ for task in tasks:
+ for dep in pending_deps[task.key]:
+ dependents[dep].append(task.key)
+
+ ready = sorted(
+ (key for key, deps in pending_deps.items() if not deps),
+ key=lambda key: (-by_key[key].priority, key),
+ )
+ ordered = []
+ while ready:
+ key = ready.pop(0)
+ ordered.append(by_key[key])
+ for dependent in dependents[key]:
+ pending_deps[dependent].discard(key)
+ if not pending_deps[dependent]:
+ insert_by_priority(ready, dependent, by_key)
+
+ cyclic = [key for key, deps in pending_deps.items() if deps]
+ return ordered, cyclic
+
+
+def insert_by_priority(ready: list[str], key: str, by_key: dict[str, Task]):
+ rank = (-by_key[key].priority, key)
+ index = 0
+ while index < len(ready) and (-by_key[ready[index]].priority, ready[index]) < rank:
+ index += 1
+
+ ready.insert(index, key)
+
+
+def get_successor_map(tasks: list[Task]) -> dict[str, list[str]]:
+ successors = defaultdict(list)
+ for task in tasks:
+ for dependency in task.depends_on:
+ successors[dependency].append(task.key)
+
+ return successors
diff --git a/erpnext/manufacturing/scheduling/loaders.py b/erpnext/manufacturing/scheduling/loaders.py
new file mode 100644
index 00000000000..2063879b2a1
--- /dev/null
+++ b/erpnext/manufacturing/scheduling/loaders.py
@@ -0,0 +1,133 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from collections import defaultdict
+
+import frappe
+from frappe.utils import cint, flt, get_datetime, get_time
+
+from erpnext.manufacturing.scheduling.models import Interval, Resource, ResourceCalendar, Task
+
+
+def get_workstation_resources(workstations=None, workstation_type=None):
+ filters = {"disabled": 0}
+ if workstations:
+ filters["name"] = ("in", workstations)
+ if workstation_type:
+ filters["workstation_type"] = workstation_type
+
+ rows = frappe.get_all(
+ "Workstation",
+ filters=filters,
+ fields=["name", "production_capacity", "workstation_type", "holiday_list"],
+ )
+
+ settings = frappe.get_cached_doc("Manufacturing Settings")
+ return [get_resource(row, settings) for row in rows]
+
+
+def get_resource(row, settings):
+ return Resource(
+ name=row.name,
+ capacity=cint(row.production_capacity) or 1,
+ resource_type=row.workstation_type,
+ calendar=get_workstation_calendar(row, settings),
+ )
+
+
+def get_workstation_calendar(row, settings):
+ daily_windows = []
+ if not cint(settings.allow_overtime):
+ daily_windows = [
+ (get_time(slot.start_time), get_time(slot.end_time))
+ for slot in frappe.get_all(
+ "Workstation Working Hour",
+ filters={"parent": row.name, "enabled": 1},
+ fields=["start_time", "end_time"],
+ order_by="idx",
+ )
+ ]
+
+ holidays = set()
+ if row.holiday_list and not cint(settings.allow_production_on_holidays):
+ holidays = set(frappe.get_all("Holiday", filters={"parent": row.holiday_list}, pluck="holiday_date"))
+
+ return ResourceCalendar(daily_windows=daily_windows, holidays=holidays)
+
+
+def get_booked_load(resource_names, from_date):
+ load = defaultdict(list)
+ add_booked_intervals(load, "Job Card Scheduled Time", resource_names, from_date, drafts_only=True)
+ add_booked_intervals(load, "Job Card Time Log", resource_names, from_date, drafts_only=False)
+ return load
+
+
+def add_booked_intervals(load, doctype, resource_names, from_date, drafts_only):
+ child = frappe.qb.DocType(doctype)
+ job_card = frappe.qb.DocType("Job Card")
+
+ query = (
+ frappe.qb.from_(child)
+ .join(job_card)
+ .on(child.parent == job_card.name)
+ .select(job_card.workstation, child.from_time, child.to_time)
+ .where(
+ job_card.workstation.isin(resource_names) & child.to_time.notnull() & (child.to_time > from_date)
+ )
+ )
+
+ if drafts_only:
+ query = query.where((job_card.docstatus == 0) & (job_card.total_time_in_mins == 0))
+ else:
+ query = query.where(job_card.docstatus < 2)
+
+ for row in query.run(as_dict=True):
+ load[row.workstation].append(Interval(get_datetime(row.from_time), get_datetime(row.to_time)))
+
+
+def build_bom_operation_tasks(bom_no, qty, prefix, earliest_start=None, priority=0):
+ bom_qty = flt(frappe.get_cached_value("BOM", bom_no, "quantity")) or 1
+ rows = frappe.get_all(
+ "BOM Operation",
+ filters={"parent": bom_no},
+ fields=[
+ "name",
+ "operation",
+ "workstation",
+ "workstation_type",
+ "time_in_mins",
+ "fixed_time",
+ "sequence_id",
+ ],
+ order_by="idx",
+ )
+
+ tasks = []
+ previous_group, current_group, current_sequence = [], [], None
+ for row in rows:
+ if current_group and (not row.sequence_id or row.sequence_id != current_sequence):
+ previous_group, current_group = current_group, []
+
+ task = build_operation_task(row, qty, bom_qty, prefix, previous_group, earliest_start, priority)
+ tasks.append(task)
+ current_group.append(task.key)
+ current_sequence = row.sequence_id
+
+ return tasks, current_group
+
+
+def build_operation_task(row, qty, bom_qty, prefix, previous_group, earliest_start, priority):
+ duration = flt(row.time_in_mins)
+ if not row.fixed_time:
+ duration = duration * qty / bom_qty
+
+ return Task(
+ key=f"{prefix}:{row.name}",
+ duration_mins=duration,
+ resource=row.workstation,
+ resource_type=None if row.workstation else row.workstation_type,
+ depends_on=list(previous_group),
+ earliest_start=earliest_start,
+ priority=priority,
+ label=row.operation,
+ )
diff --git a/erpnext/manufacturing/scheduling/models.py b/erpnext/manufacturing/scheduling/models.py
new file mode 100644
index 00000000000..4552814f2e0
--- /dev/null
+++ b/erpnext/manufacturing/scheduling/models.py
@@ -0,0 +1,100 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from __future__ import annotations
+
+import datetime
+from dataclasses import dataclass, field
+
+FORWARD = "Forward"
+BACKWARD = "Backward"
+FINITE = "Finite"
+INFINITE = "Infinite"
+
+
+@dataclass(frozen=True)
+class Interval:
+ start: datetime.datetime
+ end: datetime.datetime
+
+ def overlaps(self, other: Interval) -> bool:
+ return self.start < other.end and self.end > other.start
+
+ def duration_mins(self) -> float:
+ return (self.end - self.start).total_seconds() / 60
+
+
+@dataclass
+class ResourceCalendar:
+ daily_windows: list[tuple[datetime.time, datetime.time]] = field(default_factory=list)
+ holidays: set[datetime.date] = field(default_factory=set)
+
+ def is_always_open(self) -> bool:
+ return not self.daily_windows
+
+ def windows_for_day(self, day: datetime.date) -> list[Interval]:
+ if day in self.holidays:
+ return []
+
+ if self.is_always_open():
+ start = datetime.datetime.combine(day, datetime.time.min)
+ return [Interval(start, start + datetime.timedelta(days=1))]
+
+ return [
+ Interval(datetime.datetime.combine(day, start), datetime.datetime.combine(day, end))
+ for start, end in self.daily_windows
+ if end > start
+ ]
+
+
+@dataclass
+class Resource:
+ name: str
+ calendar: ResourceCalendar = field(default_factory=ResourceCalendar)
+ capacity: int = 1
+ resource_type: str | None = None
+
+
+@dataclass
+class Task:
+ key: str
+ duration_mins: float
+ resource: str | None = None
+ resource_type: str | None = None
+ depends_on: list[str] = field(default_factory=list)
+ earliest_start: datetime.datetime | None = None
+ due_date: datetime.datetime | None = None
+ priority: int = 0
+ label: str | None = None
+
+
+@dataclass
+class Assignment:
+ task_key: str
+ resource: str | None
+ blocks: list[Interval]
+
+ @property
+ def start(self) -> datetime.datetime:
+ return self.blocks[0].start
+
+ @property
+ def end(self) -> datetime.datetime:
+ return self.blocks[-1].end
+
+
+@dataclass
+class ScheduleResult:
+ assignments: dict[str, Assignment] = field(default_factory=dict)
+ unscheduled: dict[str, str] = field(default_factory=dict)
+ direction_used: str = FORWARD
+
+ @property
+ def start_date(self) -> datetime.datetime | None:
+ starts = [a.start for a in self.assignments.values()]
+ return min(starts) if starts else None
+
+ @property
+ def end_date(self) -> datetime.datetime | None:
+ ends = [a.end for a in self.assignments.values()]
+ return max(ends) if ends else None
diff --git a/erpnext/manufacturing/scheduling/plan_adapter.py b/erpnext/manufacturing/scheduling/plan_adapter.py
new file mode 100644
index 00000000000..5f200bc071e
--- /dev/null
+++ b/erpnext/manufacturing/scheduling/plan_adapter.py
@@ -0,0 +1,488 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import datetime
+import math
+from collections import defaultdict
+
+import frappe
+from frappe import _
+from frappe.utils import cint, flt, get_datetime
+
+from erpnext.manufacturing.scheduling import loaders
+from erpnext.manufacturing.scheduling.engine import SchedulingEngine
+from erpnext.manufacturing.scheduling.models import Task
+
+
+@frappe.whitelist(methods=["GET"])
+def get_schedule_preview(
+ production_plan: str,
+ start_date: str | datetime.datetime,
+ use_item_dates: int | str = 0,
+ item_dates: str | dict | None = None,
+):
+ plan = frappe.get_doc("Production Plan", production_plan)
+ plan.check_permission("read")
+
+ proposal = run_engine(plan, get_datetime(start_date), cint(use_item_dates), parse_item_dates(item_dates))
+ proposal["orders_exist"] = has_orders_against_plan(plan)
+ return proposal
+
+
+@frappe.whitelist(methods=["POST"])
+def apply_schedule(
+ production_plan: str,
+ start_date: str | datetime.datetime,
+ use_item_dates: int | str = 0,
+ item_dates: str | dict | None = None,
+):
+ plan = frappe.get_doc("Production Plan", production_plan)
+ plan.check_permission("write")
+ validate_plan_for_scheduling(plan)
+
+ use_item_dates = cint(use_item_dates)
+ item_dates = parse_item_dates(item_dates)
+ proposal = run_engine(plan, get_datetime(start_date), use_item_dates, item_dates)
+ validate_complete_proposal(proposal)
+ replace_schedule_entries(plan, proposal)
+ update_plan_row_dates(plan, proposal, use_item_dates, item_dates)
+ plan.notify_update()
+
+ return proposal
+
+
+def parse_item_dates(item_dates):
+ if not item_dates:
+ return {}
+
+ return {row: get_datetime(date) for row, date in frappe.parse_json(item_dates).items() if date}
+
+
+def validate_complete_proposal(proposal):
+ unscheduled = proposal.get("unscheduled") or {}
+ if not unscheduled:
+ return
+
+ reasons = "
".join(f"{key}: {reason}" for key, reason in unscheduled.items())
+ frappe.throw(
+ _("Cannot apply an incomplete schedule. {0} task(s) could not be placed:
{1}").format(
+ len(unscheduled), reasons
+ )
+ )
+
+
+def validate_plan_for_scheduling(plan):
+ if plan.docstatus == 2:
+ frappe.throw(_("Cannot schedule a cancelled Production Plan"))
+
+ if plan.status in ("Completed", "Closed"):
+ frappe.throw(_("Cannot schedule a Production Plan with status {0}").format(_(plan.status)))
+
+ if has_orders_against_plan(plan):
+ frappe.throw(
+ _(
+ "Work Orders / Purchase Orders have already been created against this Production Plan. Cancel them before re-scheduling."
+ )
+ )
+
+
+def has_orders_against_plan(plan):
+ if frappe.db.exists("Work Order", {"production_plan": plan.name, "docstatus": ("<", 2)}):
+ return True
+
+ return bool(
+ frappe.db.exists("Purchase Order Item", {"production_plan": plan.name, "docstatus": ("<", 2)})
+ )
+
+
+def run_engine(plan, start_date, use_item_dates=0, item_dates=None):
+ tasks, task_info = build_plan_tasks(plan, use_item_dates, item_dates)
+ settings = frappe.get_cached_doc("Manufacturing Settings")
+
+ resources = loaders.get_workstation_resources()
+ load = loaders.get_booked_load([r.name for r in resources], start_date) if resources else {}
+ engine = SchedulingEngine(
+ resources,
+ existing_load=load,
+ gap_mins=cint(settings.mins_between_operations) or 10,
+ horizon_days=cint(settings.capacity_planning_for_days) or 365,
+ )
+
+ result = engine.schedule(tasks, anchor=start_date)
+ return build_proposal(plan, result, task_info)
+
+
+def build_plan_tasks(plan, use_item_dates=0, item_dates=None):
+ ctx = get_build_context(plan)
+ tasks, task_info = ctx.tasks, ctx.task_info
+
+ for fg_row in plan.po_items:
+ sub_rows = [row for row in plan.sub_assembly_items if row.production_plan_item == fg_row.name]
+ row_bounds = build_sub_assembly_tasks(plan, sub_rows, ctx)
+ 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
+ )
+ wire_dependencies(fg_tasks, fg_deps)
+ wire_material_dependencies(
+ fg_row.bom_no, get_first_tasks((first_keys, None, fg_tasks)), ctx, consumer_row=fg_row.name
+ )
+ register_tasks(fg_tasks, fg_row.name, "Finished Good", fg_row.item_code, tasks, task_info)
+
+ if use_item_dates:
+ row_date = (item_dates or {}).get(fg_row.name)
+ if row_date:
+ set_chain_earliest_start(row_date, sub_rows, row_bounds, fg_tasks)
+
+ return tasks, task_info
+
+
+def get_build_context(plan):
+ bom_materials = get_bom_materials(plan)
+ produced_items = {row.item_code for row in plan.po_items}
+ produced_items.update(row.production_item for row in plan.sub_assembly_items)
+
+ material_items = {
+ 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)
+
+ return frappe._dict(
+ bom_materials={
+ bom: [item for item in items if item not in produced_items]
+ for bom, items in bom_materials.items()
+ },
+ lead_times=lead_times,
+ material_lead_days=get_material_lead_days(material_items, lead_times),
+ material_tasks={},
+ tasks=[],
+ task_info={},
+ )
+
+
+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)
+ if not boms:
+ return {}
+
+ materials = defaultdict(list)
+ for row in frappe.get_all(
+ "BOM Item",
+ filters={"parent": ("in", list(boms)), "parenttype": "BOM"},
+ fields=["parent", "item_code"],
+ ):
+ materials[row.parent].append(row.item_code)
+
+ return materials
+
+
+def get_material_lead_days(material_items, lead_times):
+ missing = [item for item in material_items if item not in lead_times]
+ item_master_days = {}
+ if missing:
+ item_master_days = dict(
+ frappe.get_all(
+ "Item", filters={"name": ("in", missing)}, fields=["name", "lead_time_days"], as_list=True
+ )
+ )
+
+ lead_days = {}
+ 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))
+
+ return lead_days
+
+
+def wire_material_dependencies(bom_no, first_tasks, ctx, consumer_row=None):
+ if not bom_no or not first_tasks:
+ return
+
+ dependency_keys = []
+ for item_code in ctx.bom_materials.get(bom_no, []):
+ lead_days = ctx.material_lead_days.get(item_code)
+ if lead_days:
+ dependency_keys.append(get_material_task(item_code, lead_days, ctx, consumer_row))
+
+ if dependency_keys:
+ wire_dependencies(first_tasks, dependency_keys)
+
+
+def get_material_task(item_code, lead_days, ctx, consumer_row=None):
+ key = f"material:{item_code}"
+ if key not in ctx.material_tasks:
+ task = Task(key=key, duration_mins=lead_days * 1440.0)
+ ctx.material_tasks[key] = task
+ ctx.tasks.append(task)
+ ctx.task_info[key] = {
+ "plan_row": key,
+ "row_type": "Raw Material",
+ "item_code": item_code,
+ "operation": None,
+ "parent_row": None,
+ "consumers": [],
+ }
+
+ if consumer_row and consumer_row not in ctx.task_info[key]["consumers"]:
+ ctx.task_info[key]["consumers"].append(consumer_row)
+
+ return key
+
+
+def set_chain_earliest_start(row_date, sub_rows, row_bounds, fg_tasks):
+ earliest_start = get_datetime(row_date)
+ chain_tasks = list(fg_tasks)
+ for row in sub_rows:
+ chain_tasks.extend(row_bounds[row.name][2])
+
+ for task in chain_tasks:
+ task.earliest_start = earliest_start
+
+
+def build_sub_assembly_tasks(plan, sub_rows, ctx):
+ row_bounds = {}
+ for row in sub_rows:
+ subcontracted = row.type_of_manufacturing == "Subcontract"
+ 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
+ )
+ row_bounds[row.name] = (first_keys, terminal_keys, row_tasks)
+ if not subcontracted:
+ wire_material_dependencies(
+ row.bom_no, get_first_tasks(row_bounds[row.name]), ctx, consumer_row=row.name
+ )
+
+ rows_by_item = defaultdict(list)
+ for row in sub_rows:
+ rows_by_item[row.production_item].append(row)
+
+ for row in sub_rows:
+ parents = rows_by_item.get(row.parent_item_code) or []
+ if parents:
+ parent_first_tasks = get_first_tasks(row_bounds[parents[0].name])
+ wire_dependencies(parent_first_tasks, row_bounds[row.name][1])
+
+ for row in sub_rows:
+ register_tasks(
+ row_bounds[row.name][2],
+ row.name,
+ "Sub Assembly",
+ row.production_item,
+ ctx.tasks,
+ ctx.task_info,
+ parent_row=row.production_plan_item,
+ )
+
+ return row_bounds
+
+
+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]
+
+
+def get_finished_good_dependencies(fg_row, sub_rows, row_bounds):
+ produced_items = {row.production_item for row in sub_rows}
+ dependencies = []
+ for row in sub_rows:
+ if row.parent_item_code == fg_row.item_code or row.parent_item_code not in produced_items:
+ dependencies.extend(row_bounds[row.name][1])
+
+ return dependencies
+
+
+def wire_dependencies(first_tasks, dependency_keys):
+ for task in first_tasks:
+ 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):
+ for task in row_tasks:
+ tasks.append(task)
+ task_info[task.key] = {
+ "plan_row": row_name,
+ "row_type": row_type,
+ "item_code": item_code,
+ "operation": task.label,
+ "parent_row": parent_row,
+ }
+
+
+def build_row_tasks(plan, bom_no, item_code, qty, subcontracted, prefix, lead_times):
+ 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"))
+ )
+ task = Task(key=prefix, duration_mins=duration)
+ return [task], [task.key], [task.key]
+
+
+def get_lead_time_duration_mins(lead_time, qty, subcontracted, no_of_shifts):
+ if not lead_time:
+ return 1440.0
+
+ if subcontracted:
+ return max(cint(lead_time.purchase_time) + cint(lead_time.buffer_time), 1) * 1440.0
+
+ days = 0
+ 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 max(days + cint(lead_time.buffer_time), 1) * 1440.0
+
+
+def get_daily_capacity(lead_time, no_of_shifts):
+ capacity = flt(lead_time.capacity_per_day)
+ if capacity and lead_time.daily_yield:
+ capacity = capacity * flt(lead_time.daily_yield) / 100
+
+ if capacity and no_of_shifts:
+ capacity = capacity * no_of_shifts / (cint(lead_time.no_of_shift) or 1)
+
+ return capacity
+
+
+def get_lead_time_details(plan, extra_items=None):
+ item_codes = {row.item_code for row in plan.po_items}
+ item_codes.update(row.production_item for row in plan.sub_assembly_items)
+ item_codes.update(extra_items or [])
+
+ return {
+ row.item_code: row
+ for row in frappe.get_all(
+ "Item Lead Time",
+ filters={"item_code": ("in", list(item_codes))},
+ fields=[
+ "item_code",
+ "capacity_per_day",
+ "daily_yield",
+ "manufacturing_time_in_mins",
+ "no_of_shift",
+ "shift_time_in_hours",
+ "no_of_workstations",
+ "purchase_time",
+ "buffer_time",
+ ],
+ )
+ }
+
+
+def build_proposal(plan, result, task_info):
+ rows = defaultdict(lambda: {"blocks": []})
+ for key, assignment in result.assignments.items():
+ info = task_info[key]
+ row = rows[info["plan_row"]]
+ row.update(
+ {
+ "row_type": info["row_type"],
+ "item_code": info["item_code"],
+ "parent_row": info.get("parent_row"),
+ "consumers": info.get("consumers") or [],
+ }
+ )
+ for block in assignment.blocks:
+ row["blocks"].append(
+ {
+ "task_key": key,
+ "operation": info["operation"],
+ "workstation": assignment.resource,
+ "from_time": block.start,
+ "to_time": block.end,
+ "duration_mins": block.duration_mins(),
+ }
+ )
+
+ for row in rows.values():
+ row["blocks"].sort(key=lambda block: block["from_time"])
+ row["start"] = row["blocks"][0]["from_time"]
+ row["end"] = row["blocks"][-1]["to_time"]
+
+ return {
+ "production_plan": plan.name,
+ "direction_used": result.direction_used,
+ "completion_date": result.end_date,
+ "rows": dict(rows),
+ "unscheduled": {key: reason for key, reason in result.unscheduled.items()},
+ }
+
+
+def replace_schedule_entries(plan, proposal):
+ frappe.db.delete("Production Plan Schedule", {"production_plan": plan.name})
+
+ for row_name, row in proposal["rows"].items():
+ for block in row["blocks"]:
+ entry = make_schedule_entry(plan, row_name, row, block)
+ entry.flags.from_scheduler = True
+ entry.insert(ignore_permissions=True)
+
+
+def make_schedule_entry(plan, row_name, row, block):
+ return frappe.get_doc(
+ {
+ "doctype": "Production Plan Schedule",
+ "production_plan": plan.name,
+ "company": plan.company,
+ "plan_row": row_name,
+ "row_type": row["row_type"],
+ "item_code": row["item_code"],
+ "operation": block.get("operation") if block.get("workstation") else None,
+ "workstation": block.get("workstation"),
+ "from_time": block["from_time"],
+ "to_time": block["to_time"],
+ "duration_mins": block["duration_mins"],
+ "task_key": block["task_key"],
+ "subject": get_entry_subject(row, block),
+ }
+ )
+
+
+def get_entry_subject(row, block):
+ item_name = frappe.get_cached_value("Item", row["item_code"], "item_name") or row["item_code"]
+
+ if row["row_type"] == "Raw Material":
+ activity = _("Procurement")
+ elif block.get("workstation") and block.get("operation"):
+ activity = block["operation"]
+ else:
+ activity = _("Production")
+
+ return f"{item_name} · {activity}"
+
+
+def update_plan_row_dates(plan, proposal, use_item_dates=0, item_dates=None):
+ rows = proposal["rows"]
+ for fg_row in plan.po_items:
+ if fg_row.name in rows:
+ set_finished_good_start_date(fg_row, rows, use_item_dates, item_dates)
+ fg_row.db_set("planned_end_date", rows[fg_row.name]["end"], update_modified=False)
+
+ for row in plan.sub_assembly_items:
+ if row.name in rows:
+ 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)
+
+
+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):
+ fg_row.db_set("planned_start_date", item_dates[fg_row.name], update_modified=False)
+ else:
+ fg_row.db_set("planned_start_date", rows[fg_row.name]["start"], update_modified=False)
diff --git a/erpnext/manufacturing/scheduling/test_plan_adapter.py b/erpnext/manufacturing/scheduling/test_plan_adapter.py
new file mode 100644
index 00000000000..6c18d1637c3
--- /dev/null
+++ b/erpnext/manufacturing/scheduling/test_plan_adapter.py
@@ -0,0 +1,264 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import change_settings
+from frappe.utils import add_to_date, get_datetime
+
+from erpnext.manufacturing.doctype.production_plan.test_production_plan import (
+ create_production_plan,
+ make_bom,
+)
+from erpnext.manufacturing.doctype.work_order.test_work_order import make_operation, make_workstation
+from erpnext.manufacturing.scheduling.plan_adapter import apply_schedule, get_schedule_preview
+from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.tests.utils import ERPNextTestSuite
+
+
+class TestPlanAdapter(ERPNextTestSuite):
+ def setUp(self):
+ super().setUp()
+ self.workstation = "Test PPS WS"
+ if not frappe.db.exists("Workstation", self.workstation):
+ make_workstation(workstation=self.workstation, production_capacity=1)
+
+ self.operation = "Test PPS Op"
+ if not frappe.db.exists("Operation", self.operation):
+ make_operation(operation=self.operation, workstation=self.workstation)
+
+ for item in ["Test PPS FG", "Test PPS FG 2", "Test PPS SA 1", "Test PPS SA 2", "Test PPS RM"]:
+ create_item(item, valuation_rate=100)
+
+ for sub_assembly in ["Test PPS SA 1", "Test PPS SA 2"]:
+ self.make_bom_with_operation(sub_assembly, ["Test PPS RM"], time_in_mins=60)
+
+ self.make_bom_with_operation("Test PPS FG", ["Test PPS SA 1", "Test PPS SA 2"], time_in_mins=30)
+ self.make_bom_with_operation("Test PPS FG 2", ["Test PPS RM"], time_in_mins=30)
+
+ def make_bom_with_operation(self, item, raw_materials, time_in_mins):
+ if frappe.db.exists("BOM", {"item": item, "docstatus": 1}):
+ return
+
+ bom = make_bom(item=item, raw_materials=raw_materials, with_operations=1, do_not_save=True)
+ bom.append(
+ "operations",
+ {
+ "operation": self.operation,
+ "workstation": self.workstation,
+ "time_in_mins": time_in_mins,
+ "hour_rate": 100,
+ },
+ )
+ bom.insert(ignore_permissions=True)
+ bom.submit()
+
+ def make_plan(self):
+ 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.submit()
+ return plan
+
+ @change_settings(
+ "Manufacturing Settings",
+ {"mins_between_operations": 10, "allow_overtime": 0, "disable_capacity_planning": 0},
+ )
+ def test_job_cards_match_plan_schedule(self):
+ plan = self.make_plan()
+ start_date = get_datetime("2026-10-01 09:00:00")
+ apply_schedule(plan.name, start_date)
+
+ plan.reload()
+ plan.make_work_order()
+ work_orders = frappe.get_all("Work Order", filters={"production_plan": plan.name}, pluck="name")
+ self.assertEqual(len(work_orders), 3)
+
+ for name in work_orders:
+ work_order = frappe.get_doc("Work Order", name)
+ work_order.wip_warehouse = "Work In Progress - _TC"
+ work_order.fg_warehouse = work_order.fg_warehouse or "Finished Goods - _TC"
+ work_order.submit()
+ self.assert_job_card_matches_schedule(plan, work_order)
+
+ self.assertRaises(frappe.ValidationError, apply_schedule, plan.name, start_date)
+
+ def assert_job_card_matches_schedule(self, plan, work_order):
+ plan_row = work_order.production_plan_item or work_order.production_plan_sub_assembly_item
+ entries = frappe.get_all(
+ "Production Plan Schedule",
+ filters={"production_plan": plan.name, "plan_row": plan_row},
+ fields=["from_time", "to_time", "workstation"],
+ order_by="from_time",
+ )
+ self.assertTrue(entries)
+
+ job_card = frappe.get_doc("Job Card", {"work_order": work_order.name})
+ self.assertEqual(len(job_card.scheduled_time_logs), len(entries))
+ for log, entry in zip(job_card.scheduled_time_logs, entries, strict=True):
+ self.assertEqual(get_datetime(log.from_time), get_datetime(entry.from_time))
+ self.assertEqual(get_datetime(log.to_time), get_datetime(entry.to_time))
+
+ self.assertEqual(job_card.workstation, entries[0].workstation)
+ self.assertEqual(get_datetime(job_card.expected_start_date), get_datetime(entries[0].from_time))
+ self.assertEqual(get_datetime(job_card.expected_end_date), get_datetime(entries[-1].to_time))
+
+ operation_row = work_order.operations[0]
+ self.assertEqual(get_datetime(operation_row.planned_start_time), get_datetime(entries[0].from_time))
+ self.assertEqual(get_datetime(operation_row.planned_end_time), get_datetime(entries[-1].to_time))
+
+ @change_settings("Manufacturing Settings", {"mins_between_operations": 10, "allow_overtime": 0})
+ def test_item_wise_start_dates(self):
+ day_one = get_datetime("2026-09-01 09:00:00")
+ day_two = get_datetime("2026-09-02 09:00:00")
+
+ plan = create_production_plan(
+ item_code="Test PPS FG",
+ planned_qty=2,
+ planned_start_date=day_one,
+ use_multi_level_bom=1,
+ do_not_submit=True,
+ skip_getting_mr_items=True,
+ )
+ plan.append(
+ "po_items",
+ {
+ "use_multi_level_bom": 1,
+ "item_code": "Test PPS FG 2",
+ "bom_no": frappe.db.get_value("Item", "Test PPS FG 2", "default_bom"),
+ "planned_qty": 2,
+ "planned_start_date": day_one,
+ "stock_uom": "Nos",
+ "warehouse": plan.po_items[0].warehouse,
+ },
+ )
+ plan.get_sub_assembly_items()
+ plan.submit()
+
+ apply_schedule(plan.name, day_one, use_item_dates=1, item_dates={plan.po_items[1].name: str(day_two)})
+ plan.reload()
+
+ fg_one, fg_two = plan.po_items
+ self.assertEqual(get_datetime(fg_two.planned_start_date), day_two)
+ self.assertEqual(get_datetime(fg_two.planned_end_date), add_to_date(day_two, minutes=60))
+ # no dialog date for the first row, so its computed start (after both
+ # sub-assemblies and the operation gap) is persisted to match the calendar
+ self.assertEqual(get_datetime(fg_one.planned_start_date), add_to_date(day_one, minutes=250))
+ self.assertEqual(get_datetime(fg_one.planned_end_date), add_to_date(day_one, minutes=310))
+
+ for row in plan.sub_assembly_items:
+ self.assertGreaterEqual(get_datetime(row.schedule_date), day_one)
+
+ @change_settings("Manufacturing Settings", {"mins_between_operations": 10, "allow_overtime": 0})
+ def test_cleared_item_date_frees_the_chain(self):
+ day_one = get_datetime("2026-12-01 09:00:00")
+ day_two = get_datetime("2026-12-02 09:00:00")
+
+ plan = self.make_plan()
+ fg_row = plan.po_items[0].name
+
+ apply_schedule(plan.name, day_one, use_item_dates=1, item_dates={fg_row: str(day_two)})
+ plan.reload()
+ self.assertEqual(get_datetime(plan.po_items[0].planned_start_date), day_two)
+
+ apply_schedule(plan.name, day_one, use_item_dates=1, item_dates={})
+ plan.reload()
+
+ # the cleared date no longer constrains the chain, so the item schedules
+ # freely from the dialog start date instead of the stale persisted one
+ self.assertEqual(get_datetime(plan.po_items[0].planned_start_date), add_to_date(day_one, minutes=250))
+
+ def test_manual_schedule_entry_creation_is_blocked(self):
+ plan = self.make_plan()
+ entry = frappe.get_doc(
+ {
+ "doctype": "Production Plan Schedule",
+ "production_plan": plan.name,
+ "item_code": "Test PPS FG",
+ "from_time": "2026-11-02 09:00:00",
+ "to_time": "2026-11-02 10:00:00",
+ }
+ )
+
+ self.assertRaises(frappe.ValidationError, entry.insert)
+
+ def test_incomplete_proposal_is_not_applied(self):
+ from unittest.mock import patch
+
+ from erpnext.manufacturing.scheduling import plan_adapter
+
+ plan = self.make_plan()
+ incomplete = {
+ "rows": {},
+ "unscheduled": {"task": "no capacity within horizon"},
+ "completion_date": None,
+ }
+
+ with patch.object(plan_adapter, "run_engine", return_value=incomplete):
+ self.assertRaises(frappe.ValidationError, apply_schedule, plan.name, "2026-11-02 09:00:00")
+
+ @change_settings("Manufacturing Settings", {"mins_between_operations": 10, "allow_overtime": 0})
+ def test_schedule_considers_raw_material_lead_time(self):
+ if frappe.db.exists("Item Lead Time", "Test PPS RM"):
+ frappe.db.set_value("Item Lead Time", "Test PPS RM", {"purchase_time": 2, "buffer_time": 0})
+ else:
+ frappe.get_doc(
+ {"doctype": "Item Lead Time", "item_code": "Test PPS RM", "purchase_time": 2}
+ ).insert()
+ self.addCleanup(frappe.delete_doc, "Item Lead Time", "Test PPS RM", force=True)
+
+ plan = self.make_plan()
+ start_date = get_datetime("2026-11-02 09:00:00")
+ preview = get_schedule_preview(plan.name, start_date)
+
+ material_row = preview["rows"].get("material:Test PPS RM")
+ self.assertIsNotNone(material_row)
+ self.assertEqual(material_row["start"], start_date)
+ self.assertEqual(material_row["end"], add_to_date(start_date, days=2))
+
+ material_arrival = add_to_date(start_date, days=2, minutes=10)
+ sub_starts = sorted(
+ row["start"] for row in preview["rows"].values() if row["row_type"] == "Sub Assembly"
+ )
+ self.assertEqual(sub_starts, [material_arrival, add_to_date(material_arrival, minutes=120)])
+
+ @change_settings("Manufacturing Settings", {"mins_between_operations": 10, "allow_overtime": 0})
+ def test_preview_and_apply_schedule(self):
+ plan = self.make_plan()
+ start_date = get_datetime("2026-08-13 09:00:00")
+
+ preview = get_schedule_preview(plan.name, start_date)
+ self.assertEqual(len(preview["rows"]), 3)
+ self.assertFalse(preview["unscheduled"])
+
+ sub_starts = sorted(
+ row["start"] for row in preview["rows"].values() if row["row_type"] == "Sub Assembly"
+ )
+ self.assertEqual(sub_starts, [start_date, add_to_date(start_date, minutes=120)])
+
+ fg_row = next(row for row in preview["rows"].values() if row["row_type"] == "Finished Good")
+ self.assertEqual(fg_row["start"], add_to_date(start_date, minutes=250))
+ self.assertEqual(preview["completion_date"], add_to_date(start_date, minutes=310))
+
+ self.assertFalse(frappe.db.exists("Production Plan Schedule", {"production_plan": plan.name}))
+
+ apply_schedule(plan.name, start_date)
+
+ entries = frappe.get_all(
+ "Production Plan Schedule",
+ filters={"production_plan": plan.name},
+ fields=["workstation", "from_time", "to_time", "row_type", "operation"],
+ )
+ self.assertEqual(len(entries), 3)
+ self.assertTrue(all(entry.workstation == self.workstation for entry in entries))
+
+ plan.reload()
+ self.assertEqual(get_datetime(plan.po_items[0].planned_start_date), fg_row["start"])
+ self.assertEqual(get_datetime(plan.po_items[0].planned_end_date), preview["completion_date"])
+ for row in plan.sub_assembly_items:
+ self.assertIn(get_datetime(row.schedule_date), sub_starts)
+ self.assertIsNotNone(row.schedule_end_date)
diff --git a/erpnext/manufacturing/scheduling/test_scheduling_engine.py b/erpnext/manufacturing/scheduling/test_scheduling_engine.py
new file mode 100644
index 00000000000..63cc805fa0e
--- /dev/null
+++ b/erpnext/manufacturing/scheduling/test_scheduling_engine.py
@@ -0,0 +1,184 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import datetime
+import unittest
+
+from erpnext.manufacturing.scheduling.engine import SchedulingEngine
+from erpnext.manufacturing.scheduling.models import (
+ BACKWARD,
+ FORWARD,
+ INFINITE,
+ Interval,
+ Resource,
+ ResourceCalendar,
+ Task,
+)
+
+
+def dt(day, hour, minute=0):
+ return datetime.datetime(2026, 8, day, hour, minute)
+
+
+def day_shift_calendar():
+ return ResourceCalendar(daily_windows=[(datetime.time(9, 0), datetime.time(17, 0))])
+
+
+class TestSchedulingEngine(unittest.TestCase):
+ def test_forward_places_task_within_working_window(self):
+ engine = SchedulingEngine([Resource("WS-A", calendar=day_shift_calendar())])
+ result = engine.schedule([Task("t1", duration_mins=120, resource="WS-A")], anchor=dt(12, 10))
+
+ assignment = result.assignments["t1"]
+ self.assertEqual(assignment.start, dt(12, 10))
+ self.assertEqual(assignment.end, dt(12, 12))
+
+ def test_task_splits_across_days_and_skips_holiday(self):
+ calendar = day_shift_calendar()
+ calendar.holidays.add(datetime.date(2026, 8, 13))
+ engine = SchedulingEngine([Resource("WS-A", calendar=calendar)])
+
+ result = engine.schedule([Task("t1", duration_mins=600, resource="WS-A")], anchor=dt(12, 15))
+
+ blocks = result.assignments["t1"].blocks
+ self.assertEqual(blocks[0], Interval(dt(12, 15), dt(12, 17)))
+ self.assertEqual(blocks[1], Interval(dt(14, 9), dt(14, 17)))
+ self.assertEqual(result.assignments["t1"].end, dt(14, 17))
+
+ def test_finite_capacity_serializes_and_capacity_two_runs_parallel(self):
+ tasks = [
+ Task("t1", duration_mins=120, resource="WS-A"),
+ Task("t2", duration_mins=120, resource="WS-A"),
+ ]
+
+ engine = SchedulingEngine([Resource("WS-A", calendar=day_shift_calendar(), capacity=1)])
+ result = engine.schedule([*tasks], anchor=dt(12, 9))
+ self.assertEqual(result.assignments["t1"].start, dt(12, 9))
+ self.assertEqual(result.assignments["t2"].start, dt(12, 11))
+
+ engine = SchedulingEngine([Resource("WS-A", calendar=day_shift_calendar(), capacity=2)])
+ result = engine.schedule([*tasks], anchor=dt(12, 9))
+ self.assertEqual(result.assignments["t2"].start, dt(12, 9))
+
+ def test_existing_load_pushes_task_and_infinite_mode_ignores_it(self):
+ booked = {"WS-A": [Interval(dt(12, 9), dt(12, 12))]}
+
+ engine = SchedulingEngine([Resource("WS-A", calendar=day_shift_calendar())], existing_load=booked)
+ result = engine.schedule([Task("t1", duration_mins=60, resource="WS-A")], anchor=dt(12, 9))
+ self.assertEqual(result.assignments["t1"].start, dt(12, 12))
+
+ engine = SchedulingEngine(
+ [Resource("WS-A", calendar=day_shift_calendar())], existing_load=booked, mode=INFINITE
+ )
+ result = engine.schedule([Task("t1", duration_mins=60, resource="WS-A")], anchor=dt(12, 9))
+ self.assertEqual(result.assignments["t1"].start, dt(12, 9))
+
+ def test_dependency_chain_applies_gap(self):
+ engine = SchedulingEngine([Resource("WS-A", calendar=day_shift_calendar())], gap_mins=10)
+ tasks = [
+ Task("t1", duration_mins=60, resource="WS-A"),
+ Task("t2", duration_mins=60, resource="WS-A", depends_on=["t1"]),
+ ]
+
+ result = engine.schedule(tasks, anchor=dt(12, 9))
+ self.assertEqual(result.assignments["t2"].start, dt(12, 10, 10))
+
+ def test_capability_selection_picks_free_machine(self):
+ resources = [
+ Resource("WS-A", calendar=day_shift_calendar(), resource_type="CNC"),
+ Resource("WS-B", calendar=day_shift_calendar(), resource_type="CNC"),
+ ]
+ engine = SchedulingEngine(resources, existing_load={"WS-A": [Interval(dt(12, 9), dt(12, 13))]})
+
+ result = engine.schedule([Task("t1", duration_mins=60, resource_type="CNC")], anchor=dt(12, 9))
+ self.assertEqual(result.assignments["t1"].resource, "WS-B")
+ self.assertEqual(result.assignments["t1"].start, dt(12, 9))
+
+ def test_four_jobs_run_concurrently_on_two_capacity_two_machines(self):
+ resources = [
+ Resource("Mold-A", calendar=day_shift_calendar(), capacity=2, resource_type="Molding"),
+ Resource("Mold-B", calendar=day_shift_calendar(), capacity=2, resource_type="Molding"),
+ ]
+ engine = SchedulingEngine(resources)
+ tasks = [Task(f"t{i}", duration_mins=120, resource_type="Molding") for i in range(4)]
+
+ result = engine.schedule(tasks, anchor=dt(12, 9))
+
+ self.assertEqual([a.start for a in result.assignments.values()], [dt(12, 9)] * 4)
+ machines = sorted(a.resource for a in result.assignments.values())
+ self.assertEqual(machines, ["Mold-A", "Mold-A", "Mold-B", "Mold-B"])
+
+ overflow = engine.schedule([Task("t5", duration_mins=120, resource_type="Molding")], anchor=dt(12, 9))
+ self.assertEqual(overflow.assignments["t5"].start, dt(12, 11))
+
+ def test_priority_wins_contention(self):
+ tasks = [
+ Task("low", duration_mins=120, resource="WS-A", priority=1),
+ Task("high", duration_mins=120, resource="WS-A", priority=10),
+ ]
+ engine = SchedulingEngine([Resource("WS-A", calendar=day_shift_calendar())])
+
+ result = engine.schedule(tasks, anchor=dt(12, 9))
+ self.assertEqual(result.assignments["high"].start, dt(12, 9))
+ self.assertEqual(result.assignments["low"].start, dt(12, 11))
+
+ def test_backward_scheduling_meets_due_date(self):
+ engine = SchedulingEngine([Resource("WS-A", calendar=day_shift_calendar())], gap_mins=10)
+ tasks = [
+ Task("t1", duration_mins=60, resource="WS-A"),
+ Task("t2", duration_mins=120, resource="WS-A", depends_on=["t1"]),
+ ]
+
+ result = engine.schedule(tasks, anchor=dt(14, 17), direction=BACKWARD, not_before=dt(12, 9))
+
+ self.assertEqual(result.direction_used, BACKWARD)
+ self.assertEqual(result.assignments["t2"].end, dt(14, 17))
+ self.assertEqual(result.assignments["t2"].start, dt(14, 15))
+ self.assertEqual(result.assignments["t1"].end, dt(14, 14, 50))
+ self.assertEqual(result.assignments["t1"].start, dt(14, 13, 50))
+
+ def test_backward_falls_back_forward_when_due_date_infeasible(self):
+ engine = SchedulingEngine([Resource("WS-A", calendar=day_shift_calendar())])
+ tasks = [Task("t1", duration_mins=480, resource="WS-A")]
+
+ result = engine.schedule(tasks, anchor=dt(12, 11), direction=BACKWARD, not_before=dt(12, 9))
+
+ self.assertEqual(result.direction_used, FORWARD)
+ self.assertEqual(result.assignments["t1"].start, dt(12, 9))
+ self.assertEqual(result.assignments["t1"].end, dt(12, 17))
+
+ def test_calendarless_task_runs_continuously(self):
+ engine = SchedulingEngine([])
+ result = engine.schedule([Task("buy", duration_mins=2880)], anchor=dt(12, 8))
+
+ self.assertIsNone(result.assignments["buy"].resource)
+ self.assertEqual(result.assignments["buy"].end, dt(14, 8))
+
+ def test_cycle_and_missing_capacity_reported_not_raised(self):
+ engine = SchedulingEngine([Resource("WS-A", calendar=day_shift_calendar())], horizon_days=1)
+ tasks = [
+ Task("a", duration_mins=60, resource="WS-A", depends_on=["b"]),
+ Task("b", duration_mins=60, resource="WS-A", depends_on=["a"]),
+ Task("c", duration_mins=600, resource="WS-A"),
+ ]
+
+ result = engine.schedule(tasks, anchor=dt(12, 9))
+
+ self.assertIn("a", result.unscheduled)
+ self.assertIn("b", result.unscheduled)
+ self.assertEqual(result.unscheduled["c"], "no capacity within horizon")
+
+ def test_run_holds_multiple_documents_without_blind_spots(self):
+ engine = SchedulingEngine([Resource("WS-A", calendar=day_shift_calendar())])
+ plan_a = [Task("planA:op", duration_mins=240, resource="WS-A")]
+ plan_b = [Task("planB:op", duration_mins=240, resource="WS-A")]
+
+ result_a = engine.schedule(plan_a, anchor=dt(12, 9))
+ result_b = engine.schedule(plan_b, anchor=dt(12, 9))
+
+ self.assertEqual(result_a.assignments["planA:op"].start, dt(12, 9))
+ self.assertEqual(result_b.assignments["planB:op"].start, dt(12, 13))
+
+
+if __name__ == "__main__":
+ unittest.main()