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