mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 06:31:48 +00:00
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:
@@ -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)) || "-";
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
187
erpnext/manufacturing/scheduling/DESIGN.md
Normal file
187
erpnext/manufacturing/scheduling/DESIGN.md
Normal file
@@ -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?
|
||||
0
erpnext/manufacturing/scheduling/__init__.py
Normal file
0
erpnext/manufacturing/scheduling/__init__.py
Normal file
296
erpnext/manufacturing/scheduling/engine.py
Normal file
296
erpnext/manufacturing/scheduling/engine.py
Normal 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
|
||||
133
erpnext/manufacturing/scheduling/loaders.py
Normal file
133
erpnext/manufacturing/scheduling/loaders.py
Normal 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,
|
||||
)
|
||||
100
erpnext/manufacturing/scheduling/models.py
Normal file
100
erpnext/manufacturing/scheduling/models.py
Normal 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
|
||||
488
erpnext/manufacturing/scheduling/plan_adapter.py
Normal file
488
erpnext/manufacturing/scheduling/plan_adapter.py
Normal 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)
|
||||
264
erpnext/manufacturing/scheduling/test_plan_adapter.py
Normal file
264
erpnext/manufacturing/scheduling/test_plan_adapter.py
Normal 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)
|
||||
184
erpnext/manufacturing/scheduling/test_scheduling_engine.py
Normal file
184
erpnext/manufacturing/scheduling/test_scheduling_engine.py
Normal 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()
|
||||
Reference in New Issue
Block a user