feat: capacity aware scheduling for production plan (#58094)

* feat: capacity aware scheduling for production plan

* fix: do not apply incomplete schedule proposals

* fix: lock plan re-scheduling once work orders exist

* test: concurrent jobs across multiple machines with job capacity

* chore: fix linter and semgrep issues

* fix: readable subject for production plan schedule entries

* fix: persist computed start for item rows without explicit dates

* fix: block manual creation of production plan schedule entries

* chore: replace em-dashes with hyphens in design doc

* fix: cleared item-wise dates no longer constrain the schedule

* chore: format test file
This commit is contained in:
rohitwaghchaure
2026-08-12 18:10:03 +05:30
committed by GitHub
parent 43de54b907
commit c6d08979d3
19 changed files with 2373 additions and 11 deletions

View File

@@ -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
? `<div class="schedule-preview-warning">${__(
"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(", ")}</div>`
: "";
let locked_note = proposal.orders_exist
? `<div class="schedule-preview-warning">${__(
"Work Orders / Purchase Orders already exist against this plan, so the schedule is locked. Cancel them to re-schedule."
)}</div>`
: "";
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}
<div class="schedule-preview-table-wrapper">
<table class="table schedule-preview-table">
<thead><tr>
<th>${__("Item")}</th>
<th>${__("Workstations")}</th>
<th>${__("Start")}</th>
<th>${__("End")}</th>
<th>${__("Starts In")}</th>
</tr></thead>
<tbody>${rows_html}</tbody>
</table>
</div>
`);
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
? `<span class="sub-indent" style="margin-left: ${(row.indent - 1) * 20}px">↳</span>`
: "";
let item = `${indent_html}
<span class="${is_fg ? "item-fg" : ""}${is_material ? " text-muted" : ""}">${frappe.utils.escape_html(
row.item_code
)}</span>`;
let detail = is_material
? `<span class="text-muted">${__("Procurement")}</span>`
: frappe.utils.escape_html(workstations.join(", ") || "-");
let starts_in = schedule_starts_in(row.start);
return `<tr>
<td class="item-cell">${item}</td>
<td class="text-muted">${detail}</td>
<td>${format_schedule_date(row.start)}</td>
<td>${format_schedule_date(row.end)}</td>
<td class="starts-in">
<span class="starts-in-pill ${starts_in.color}">${starts_in.label}</span>
</td>
</tr>`;
},
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 `<div class="schedule-preview-summary">
<div class="summary-block">
<div class="summary-label">${__("Expected Completion")}</div>
<div class="summary-value">${format_schedule_date(proposal.completion_date)}</div>
</div>
<div class="summary-block">
<div class="summary-label">${__("Total Duration")}</div>
<div class="summary-value">${total}</div>
</div>
<div class="summary-block">
<div class="summary-label">${__("Items")}</div>
<div class="summary-value">${Object.keys(proposal.rows || {}).length}</div>
</div>
</div>`;
},
get_preview_styles() {
return `<style>
.schedule-preview-summary { display: flex; gap: 12px; margin-bottom: 12px; }
.schedule-preview-summary .summary-block {
flex: 1; background-color: var(--bg-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-md); padding: 8px 12px;
}
.schedule-preview-summary .summary-label { font-size: var(--text-sm); color: var(--text-muted); }
.schedule-preview-summary .summary-value { font-weight: 600; margin-top: 2px; }
.schedule-preview-warning {
background-color: var(--bg-red); color: var(--text-on-red);
border-radius: var(--border-radius-md); padding: 8px 12px; margin-bottom: 12px;
font-size: var(--text-sm);
}
.schedule-preview-table-wrapper { max-height: 55vh; overflow-y: auto; }
.schedule-preview-table th { position: sticky; top: 0; background-color: var(--fg-color); }
.schedule-preview-table td, .schedule-preview-table th { padding: 8px 10px; }
.schedule-preview-table .item-cell .item-fg { font-weight: 600; }
.schedule-preview-table .sub-indent { color: var(--text-muted); margin: 0 4px 0 12px; }
.schedule-preview-table .indicator-pill { margin-left: 6px; }
.schedule-preview-table .starts-in { white-space: nowrap; }
.starts-in-pill {
display: inline-block; padding: 2px 10px; border-radius: 999px;
font-size: var(--text-sm); font-weight: 500;
}
.starts-in-pill.green { background-color: var(--bg-green); color: var(--text-on-green); }
.starts-in-pill.orange { background-color: var(--bg-orange); color: var(--text-on-orange); }
.starts-in-pill.gray { background-color: var(--bg-gray); color: var(--text-on-gray); }
</style>`;
},
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)) || "-";
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"])

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,187 @@
# Production Scheduling Engine - Design
Status: **Phases 12 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 12 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?

View File

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

View File

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

View File

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

View File

@@ -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 = "<br>".join(f"{key}: {reason}" for key, reason in unscheduled.items())
frappe.throw(
_("Cannot apply an incomplete schedule. {0} task(s) could not be placed:<br>{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)

View File

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

View File

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