diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js
index bd7d838af43..0d0eb8f1a40 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.js
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js
@@ -151,6 +151,15 @@ frappe.ui.form.on("Production Plan", {
__("View")
);
+ frm.add_custom_button(
+ __("Plan Visualizer"),
+ () => {
+ frappe.route_options = { production_plan: frm.doc.name };
+ frappe.set_route("production-plan-visualizer");
+ },
+ __("View")
+ );
+
if (!["Completed", "Closed"].includes(frm.doc.status)) {
frm.add_custom_button(__("Schedule Items"), () => {
frm.events.show_schedule_dialog(frm);
diff --git a/erpnext/manufacturing/page/production_plan_visualizer/__init__.py b/erpnext/manufacturing/page/production_plan_visualizer/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/manufacturing/page/production_plan_visualizer/production_plan_visualizer.js b/erpnext/manufacturing/page/production_plan_visualizer/production_plan_visualizer.js
new file mode 100644
index 00000000000..9ad3a0d5da1
--- /dev/null
+++ b/erpnext/manufacturing/page/production_plan_visualizer/production_plan_visualizer.js
@@ -0,0 +1,1773 @@
+frappe.pages["production-plan-visualizer"].on_page_load = function (wrapper) {
+ const page = frappe.ui.make_app_page({
+ parent: wrapper,
+ title: __("Production Plan Visualizer"),
+ single_column: true,
+ });
+
+ frappe.production_plan_visualizer = new erpnext.ProductionPlanVisualizer(page);
+};
+
+frappe.pages["production-plan-visualizer"].on_page_show = function () {
+ const visualizer = frappe.production_plan_visualizer;
+ if (!visualizer) return;
+ if (frappe.route_options && frappe.route_options.production_plan) {
+ const plan = frappe.route_options.production_plan;
+ frappe.route_options = null;
+ visualizer.plan_field.set_value(plan);
+ }
+ visualizer.fit_viewport();
+};
+
+erpnext.ProductionPlanVisualizer = class ProductionPlanVisualizer {
+ constructor(page) {
+ this.page = page;
+ this.data = null;
+ this.focus = "all";
+ this.active_tab = "manufacture";
+ this.schedule_group = "item";
+ this.schedule_scale = "day";
+ this.today_offset = null;
+ this.body = $(this.page.body);
+ this.make();
+ }
+
+ make() {
+ $(this.page.wrapper).addClass("ppv-page").find(".page-head").css("border-bottom", "none");
+ this.body.html(`${this.styles()}
`);
+ this.container = this.body.find(".ppv");
+ this.make_plan_field();
+ $(window).on(
+ "resize.ppv",
+ frappe.utils.debounce(() => this.fit_viewport(), 150)
+ );
+ $(document).on("keydown.ppv", (e) => {
+ if (e.key === "Escape") this.close_document();
+ });
+ this.render_blank_state();
+ }
+
+ make_plan_field() {
+ this.plan_field = this.page.add_field({
+ fieldname: "production_plan",
+ label: __("Production Plan"),
+ fieldtype: "Link",
+ options: "Production Plan",
+ get_query: () => ({ filters: { docstatus: ["<", 2] } }),
+ change: () => {
+ const value = this.plan_field.get_value();
+ if (value && value !== this.current_plan) {
+ this.load(value);
+ } else if (!value) {
+ this.current_plan = null;
+ this.render_blank_state();
+ }
+ },
+ });
+ }
+
+ fit_viewport() {
+ if (!this.container || !this.container.is(":visible")) return;
+ const top = this.container[0].getBoundingClientRect().top;
+ const height = Math.max(window.innerHeight - top - 20, 460);
+ this.container.css("height", `${height}px`);
+ }
+
+ render_blank_state() {
+ this.container.empty().append(
+ $('').append(
+ frappe.ui.empty_state({
+ icon: "layout-dashboard",
+ title: __("Pick a Production Plan"),
+ description: __(
+ "Track readiness, shortages, work orders and the shop floor schedule on one screen."
+ ),
+ })
+ )
+ );
+ this.fit_viewport();
+ }
+
+ load(plan) {
+ this.current_plan = plan;
+ this.render_skeleton();
+ frappe
+ .call({
+ method: "erpnext.manufacturing.page.production_plan_visualizer.production_plan_visualizer.get_plan_overview",
+ args: { production_plan: plan },
+ })
+ .then((r) => {
+ if (this.current_plan !== plan) return;
+ this.data = r.message;
+ this.focus = "all";
+ this.active_tab = "manufacture";
+ this.render();
+ });
+ }
+
+ render_skeleton() {
+ const line = (w, h) => frappe.ui.skeleton.html({ width: w, height: h });
+ this.container.html(`
+
+ ${[1, 2, 3, 4, 5].map(() => `
${line("100%", "44px")}
`).join("")}
+
+
+ `);
+ this.fit_viewport();
+ }
+
+ render() {
+ this.build_index();
+ this.container.empty();
+ this.render_kpis();
+ this.render_workspace();
+ this.render_drawer();
+ this.fit_viewport();
+ }
+
+ render_drawer() {
+ this.backdrop = $('').appendTo(this.container);
+ this.drawer = $(`
+
+ `).appendTo(this.container);
+ this.backdrop.on("click", () => this.close_document());
+ }
+
+ build_index() {
+ this.data.schedule = (this.data.schedule || []).filter((d) => d.from_time && d.to_time);
+ const owners_of_row = {};
+ for (const fg of this.data.finished_goods) owners_of_row[fg.row_name] = [fg.row_name];
+ for (const sub of this.data.sub_assemblies) {
+ const owners = owners_of_row[sub.production_plan_item];
+ if (owners) owners_of_row[sub.row_name] = [...owners];
+ }
+ this.resolve_nested_owners(owners_of_row);
+
+ const bom_consumers = {};
+ for (const [row_name, items] of Object.entries(this.data.row_materials || {})) {
+ for (const item of items) (bom_consumers[item] = bom_consumers[item] || []).push(row_name);
+ }
+ if (this.data.plan.combine_sub_items) {
+ this.expand_combined_owners(owners_of_row, bom_consumers);
+ }
+
+ const subs_by_parent = {};
+ for (const sub of this.data.sub_assemblies) {
+ for (const owner of owners_of_row[sub.row_name] || []) {
+ (subs_by_parent[owner] = subs_by_parent[owner] || []).push(sub);
+ }
+ }
+
+ const subs_by_signature = {};
+ for (const sub of this.data.sub_assemblies) {
+ const key = `${sub.item_code}::${sub.bom_no || ""}`;
+ (subs_by_signature[key] = subs_by_signature[key] || []).push(sub);
+ }
+
+ const row_by_name = {};
+ for (const fg of this.data.finished_goods) row_by_name[fg.row_name] = fg;
+ for (const sub of this.data.sub_assemblies) row_by_name[sub.row_name] = sub;
+
+ const fg_label = {};
+ for (const fg of this.data.finished_goods) fg_label[fg.row_name] = fg.item_name || fg.item_code;
+
+ this.index = {
+ subs_by_parent,
+ owners_of_row,
+ bom_consumers,
+ subs_by_signature,
+ row_by_name,
+ fg_label,
+ };
+ for (const material of this.data.materials || []) {
+ material.owners = this.material_owners(material);
+ }
+ this.compute_stats();
+ }
+
+ expand_combined_owners(owners_of_row, bom_consumers) {
+ let changed = true;
+ let passes = 0;
+ while (changed && passes++ <= this.data.sub_assemblies.length) {
+ changed = false;
+ for (const sub of this.data.sub_assemblies) {
+ const owners = new Set(owners_of_row[sub.row_name] || []);
+ const before = owners.size;
+ for (const row_name of bom_consumers[sub.item_code] || []) {
+ for (const owner of owners_of_row[row_name] || []) owners.add(owner);
+ }
+ if (owners.size !== before) {
+ owners_of_row[sub.row_name] = [...owners];
+ changed = true;
+ }
+ }
+ }
+ }
+
+ resolve_nested_owners(owners_of_row) {
+ const fg_by_item = {};
+ for (const fg of this.data.finished_goods) {
+ (fg_by_item[fg.item_code] = fg_by_item[fg.item_code] || []).push(fg);
+ }
+ const subs_by_item = {};
+ for (const sub of this.data.sub_assemblies) {
+ (subs_by_item[sub.item_code] = subs_by_item[sub.item_code] || []).push(sub);
+ }
+
+ for (const sub of this.data.sub_assemblies) {
+ if (owners_of_row[sub.row_name]) continue;
+ const owners = this.walk_to_finished_goods(sub, fg_by_item, subs_by_item);
+ if (owners.length) owners_of_row[sub.row_name] = owners;
+ }
+ }
+
+ walk_to_finished_goods(sub, fg_by_item, subs_by_item) {
+ const seen = new Set();
+ const queue = [sub];
+ const owners = new Set();
+ while (queue.length) {
+ const node = queue.shift();
+ if (!node || seen.has(node.row_name)) continue;
+ seen.add(node.row_name);
+ const parent = node.parent_item_code;
+ for (const fg of this.same_demand(sub, fg_by_item[parent] || [])) owners.add(fg.row_name);
+ queue.push(...this.same_demand(sub, subs_by_item[parent] || []));
+ }
+ return [...owners];
+ }
+
+ same_demand(sub, candidates) {
+ if (!sub.sales_order || candidates.length < 2) return candidates;
+ const scoped = candidates.filter((d) => d.sales_order === sub.sales_order);
+ return scoped.length ? scoped : candidates;
+ }
+
+ material_owners(material) {
+ const key = `${material.main_item_code || ""}::${material.from_bom || ""}`;
+ const rows = (this.index.subs_by_signature[key] || []).map((d) => d.row_name);
+ if (material.consumer) rows.push(material.consumer);
+ rows.push(...(this.index.bom_consumers[material.item_code] || []));
+ return this.owners_of(this.same_sales_order(material, rows));
+ }
+
+ same_sales_order(material, row_names) {
+ if (!material.sales_order || row_names.length < 2) return row_names;
+ const scoped = row_names.filter(
+ (row_name) => (this.index.row_by_name[row_name] || {}).sales_order === material.sales_order
+ );
+ return scoped.length ? scoped : row_names;
+ }
+
+ owners_of(row_names) {
+ const owners = new Set();
+ for (const row_name of row_names) {
+ for (const owner of this.index.owners_of_row[row_name] || []) owners.add(owner);
+ }
+ return [...owners];
+ }
+
+ compute_stats() {
+ const documents = this.all_documents();
+ const materials = this.data.materials || [];
+ const rows = [...this.data.finished_goods, ...this.data.sub_assemblies];
+ this.stats = {
+ work_orders: documents.filter((d) => d.doctype === "Work Order"),
+ purchase_orders: documents.filter((d) => d.doctype === "Purchase Order"),
+ material_requests: this.data.material_requests || [],
+ short_materials: materials.filter((d) => this.open_qty(d) > 0),
+ unstarted: rows.filter((d) => !(d.documents || []).length),
+ coverage: this.material_coverage(materials),
+ schedule: this.data.schedule || [],
+ };
+ }
+
+ open_qty(material) {
+ return flt(flt(material.to_procure_qty) - flt(material.requested_qty), 6);
+ }
+
+ material_coverage(materials) {
+ const to_procure = materials.reduce((sum, d) => sum + flt(d.to_procure_qty), 0);
+ if (!to_procure) return 100;
+ const open = materials.reduce((sum, d) => sum + Math.max(this.open_qty(d), 0), 0);
+ return ((to_procure - open) / to_procure) * 100;
+ }
+
+ all_documents() {
+ const rows = [...this.data.finished_goods, ...this.data.sub_assemblies];
+ return rows.flatMap((row) => row.documents || []);
+ }
+
+ group_rows(rows, key_fn) {
+ return (rows || []).reduce((groups, row) => {
+ const key = key_fn(row);
+ (groups[key] = groups[key] || []).push(row);
+ return groups;
+ }, {});
+ }
+
+ render_kpis() {
+ const stats = this.stats;
+ const rail = $('').appendTo(this.container);
+ rail.append(this.hero_tile());
+ rail.append(
+ this.kpi_tile({
+ label: __("Material Readiness"),
+ value: `${Math.round(stats.coverage)}%`,
+ tone: stats.short_materials.length ? "red" : "green",
+ hint: stats.short_materials.length
+ ? __("{0} materials still to request", [stats.short_materials.length])
+ : __("Everything requested or in stock"),
+ tab: "materials",
+ })
+ );
+ rail.append(
+ this.kpi_tile({
+ label: __("Work Orders"),
+ value: stats.work_orders.length,
+ tone: stats.unstarted.length ? "amber" : null,
+ hint: stats.unstarted.length
+ ? __("{0} rows not started", [stats.unstarted.length])
+ : __("Every row has a document"),
+ dots: stats.work_orders,
+ tab: "manufacture",
+ })
+ );
+ rail.append(this.procurement_tile());
+ rail.append(this.schedule_tile());
+ }
+
+ hero_tile() {
+ const plan = this.data.plan;
+ const tile = $(`
+
+
${this.completion_ring(plan.completion)}
+
+
+
+ ${this.format_float(plan.total_produced_qty)}
+ / ${this.format_float(plan.total_planned_qty)} ${__(
+ "produced"
+ )}
+
+
${this.esc(plan.company)} · ${frappe.datetime.str_to_user(
+ plan.posting_date
+ )}
+
+
+ `);
+ tile.find(".ppv-hero-status").append(this.status_badge(plan.status, "sm"));
+ return tile;
+ }
+
+ kpi_tile({ label, value, hint, tone, dots, tab }) {
+ const tile = $(`
+
+
${this.esc(label)}
+
${this.esc(value)}
+
${this.esc(hint)}
+
+ `);
+ if (dots && dots.length) tile.find(".ppv-kpi-hint").prepend(this.status_dots(dots));
+ if (tab) tile.on("click", () => this.set_tab(tab));
+ return tile;
+ }
+
+ status_dots(rows) {
+ return Object.entries(this.group_rows(rows, (d) => d.status || __("Draft")))
+ .map(
+ ([status, group]) =>
+ `
+ ${group.length}
+ `
+ )
+ .join("");
+ }
+
+ procurement_tile() {
+ const orders = this.stats.purchase_orders;
+ const requests = this.stats.material_requests;
+ return this.kpi_tile({
+ label: __("Procurement"),
+ value: orders.length + requests.length,
+ hint: __("{0} requests · {1} orders", [requests.length, orders.length]),
+ dots: [...orders, ...requests],
+ tab: "materials",
+ });
+ }
+
+ schedule_tile() {
+ const blocks = this.stats.schedule;
+ if (!blocks.length) {
+ return this.kpi_tile({
+ label: __("Schedule"),
+ value: "—",
+ hint: __("Not scheduled yet"),
+ });
+ }
+ const workstations = new Set(blocks.map((d) => d.workstation).filter(Boolean));
+ const start = frappe.datetime.str_to_user(blocks[0].from_time.split(" ")[0]);
+ const end = frappe.datetime.str_to_user(
+ blocks.reduce((max, d) => (d.to_time > max ? d.to_time : max), blocks[0].to_time).split(" ")[0]
+ );
+ return this.kpi_tile({
+ label: __("Schedule"),
+ value: blocks.length,
+ hint: `${start} → ${end} · ${__("{0} workstations", [workstations.size])}`,
+ tab: "schedule",
+ });
+ }
+
+ completion_ring(completion) {
+ const radius = 26;
+ const circumference = 2 * Math.PI * radius;
+ const offset = circumference * (1 - Math.min(completion, 100) / 100);
+ return `
+
+ `;
+ }
+
+ render_workspace() {
+ const workspace = $('').appendTo(this.container);
+ this.rail = $('').appendTo(workspace);
+ this.detail = $('').appendTo(workspace);
+ this.render_rail();
+ this.render_detail();
+ }
+
+ render_rail() {
+ this.rail.empty().append(`
+
+ ${__("Finished Goods")}
+ ${this.data.finished_goods.length}
+
+ `);
+ this.rail.append(this.rail_search());
+ this.rail_body = $('').appendTo(this.rail);
+ this.rail_body.append(this.rail_row_all());
+ for (const fg of this.data.finished_goods) this.rail_body.append(this.rail_row(fg));
+ this.apply_rail_filter();
+ }
+
+ rail_search() {
+ const bar = $(`
+
+ ${frappe.utils.icon("search", "sm", "", "", "", true)}
+
+
+ `);
+ this.rail_query = "";
+ bar.find("input").on("input", (e) => {
+ this.rail_query = (e.target.value || "").trim().toLowerCase();
+ this.apply_rail_filter();
+ });
+ return bar;
+ }
+
+ apply_rail_filter() {
+ let visible = 0;
+ this.rail_body.find(".ppv-rail-row[data-search]").each((_, el) => {
+ const show = !this.rail_query || ($(el).attr("data-search") || "").includes(this.rail_query);
+ $(el).toggle(show);
+ if (show) visible += 1;
+ });
+ this.rail_body.find(".ppv-rail-none").toggle(!visible);
+ this.highlight_focus();
+ }
+
+ rail_row_all() {
+ const plan = this.data.plan;
+ const row = $(`
+
+
+ ${__("All Items")}
+ ${Math.round(plan.completion)}%
+
+
${__("{0} finished goods · {1} sub assemblies", [
+ this.data.finished_goods.length,
+ this.data.sub_assemblies.length,
+ ])}
+
+ `);
+ row.on("click", () => this.set_focus("all"));
+ return row;
+ }
+
+ rail_row(fg) {
+ const completion = fg.qty ? (fg.produced_qty / fg.qty) * 100 : 0;
+ const risk = this.risk_of(fg);
+ const row = $(`
+
+
+ ${this.esc(
+ fg.item_name || fg.item_code
+ )}
+ ${Math.round(completion)}%
+
+
${this.esc(fg.item_code)} · ${this.format_float(fg.qty)} ${this.esc(
+ fg.stock_uom || ""
+ )}
+
+
${this.esc(risk.label)}
+
+ `);
+ row.on("click", () => this.set_focus(fg.row_name));
+ return row;
+ }
+
+ risk_of(fg) {
+ const short = (this.data.materials || []).filter(
+ (d) => this.open_qty(d) > 0 && d.owners.includes(fg.row_name)
+ );
+ if (short.length) {
+ return { level: "short", label: __("{0} materials short", [short.length]) };
+ }
+ if (fg.qty && fg.produced_qty >= fg.qty) return { level: "done", label: __("Completed") };
+ const rows = [fg, ...(this.index.subs_by_parent[fg.row_name] || [])];
+ if (rows.every((d) => !(d.documents || []).length)) {
+ return { level: "idle", label: __("Not started") };
+ }
+ return { level: "running", label: __("In progress") };
+ }
+
+ set_focus(row_name) {
+ this.focus = row_name;
+ this.highlight_focus();
+ this.render_detail_body();
+ }
+
+ highlight_focus() {
+ this.rail_body.find(".ppv-rail-row").each((_, el) => {
+ $(el).toggleClass("is-active", $(el).attr("data-focus") === this.focus);
+ });
+ }
+
+ set_tab(tab) {
+ if (this.active_tab === tab) return;
+ this.active_tab = tab;
+ this.render_detail();
+ }
+
+ focused_goods() {
+ if (this.focus === "all") return this.data.finished_goods;
+ return this.data.finished_goods.filter((d) => d.row_name === this.focus);
+ }
+
+ render_detail() {
+ this.detail.empty();
+ const head = $('').appendTo(this.detail);
+ head.append(
+ frappe.ui.tab_buttons({
+ type: "subtle",
+ size: "sm",
+ value: this.active_tab,
+ options: [
+ { label: __("Items to Manufacture"), value: "manufacture" },
+ { label: __("Raw Materials"), value: "materials" },
+ { label: __("Schedule"), value: "schedule" },
+ ],
+ on_change: (value) => {
+ this.active_tab = value;
+ this.render_detail_body();
+ },
+ })
+ );
+ head.append(this.detail_search());
+ this.detail_body = $('').appendTo(this.detail);
+ this.render_detail_body();
+ }
+
+ detail_search() {
+ const bar = $(`
+
+ ${frappe.utils.icon("search", "sm", "", "", "", true)}
+
+
+ `);
+ bar.find("input").on("input", (e) => {
+ this.detail_query = (e.target.value || "").trim().toLowerCase();
+ this.apply_detail_filter();
+ });
+ this.detail_query = "";
+ return bar;
+ }
+
+ apply_detail_filter() {
+ const query = this.detail_query;
+ this.detail_body.find("tr[data-search]").each((_, el) => {
+ $(el).toggle(!query || ($(el).attr("data-search") || "").includes(query));
+ });
+ }
+
+ render_detail_body() {
+ this.detail_body.empty();
+ if (this.active_tab === "manufacture") this.render_manufacture_items();
+ else if (this.active_tab === "materials") this.render_materials();
+ else this.render_schedule();
+ this.apply_detail_filter();
+ }
+
+ make_table(columns) {
+ const head = columns
+ .map(
+ (col) =>
+ `${this.esc(col.label)} | `
+ )
+ .join("");
+ const table = $(``);
+ return { table, body: table.find("tbody") };
+ }
+
+ render_manufacture_items() {
+ const goods = this.focused_goods();
+ if (!goods.length) {
+ this.render_empty(__("No items to manufacture in this plan"));
+ return;
+ }
+ const { table, body } = this.make_table([
+ { label: __("Item") },
+ { label: __("Planned Qty"), class: "ppv-num" },
+ { label: __("Qty In Stock"), class: "ppv-num" },
+ { label: __("Produced Qty"), class: "ppv-num" },
+ { label: __("Pending Qty"), class: "ppv-num" },
+ { label: __("Progress"), class: "ppv-col-progress" },
+ { label: __("Documents"), class: "ppv-col-docs" },
+ ]);
+
+ for (const fg of goods) {
+ body.append(this.manufacture_row(fg, "fg"));
+ for (const sub of this.index.subs_by_parent[fg.row_name] || []) {
+ body.append(this.manufacture_row(sub, "sub"));
+ }
+ }
+ this.detail_body.append(table);
+ this.append_orphan_subs(body);
+ }
+
+ append_orphan_subs(body) {
+ if (this.focus !== "all") return;
+ const orphans = this.data.sub_assemblies.filter(
+ (d) => !(this.index.owners_of_row[d.row_name] || []).length
+ );
+ if (!orphans.length) return;
+ body.append(this.group_row(__("Unlinked Sub Assemblies"), 7));
+ for (const sub of orphans) body.append(this.manufacture_row(sub, "sub"));
+ }
+
+ group_row(label, span) {
+ return $(`| ${this.esc(label)} |
`);
+ }
+
+ manufacture_row(row, kind) {
+ const completion = row.qty ? (row.produced_qty / row.qty) * 100 : 0;
+ const uom = row.stock_uom || row.uom || "";
+ const tr = $(`
+ d.name)
+ .join(" ")}`.toLowerCase()
+ )}">
+ |
+
+ ${this.esc(row.item_code)}${uom ? ` · ${this.esc(uom)}` : ""}
+ |
+ ${this.format_float(row.qty)} |
+ ${kind === "sub" ? this.stock_value(row) : "—"} |
+ ${this.format_float(row.produced_qty)} |
+ ${this.format_float(row.pending_qty)} |
+ |
+ |
+
+ `);
+ tr.find(".ppv-item-tag").append(this.manufacture_tag(row, kind));
+ tr.find(".ppv-col-progress").append(this.progress_cell(completion));
+ this.append_document_chips(tr.find(".ppv-col-docs"), row.documents);
+ return tr;
+ }
+
+ manufacture_tag(row, kind) {
+ if (kind === "fg") {
+ if (!row.sales_order) return frappe.ui.badge({ label: __("Finished Good"), size: "sm" });
+ return frappe.ui.badge({
+ label: row.sales_order,
+ theme: "violet",
+ variant: "outline",
+ size: "sm",
+ });
+ }
+ return frappe.ui.badge({
+ label: __(row.type_of_manufacturing || "In House"),
+ size: "sm",
+ theme: row.type_of_manufacturing === "Subcontract" ? "amber" : "blue",
+ variant: "outline",
+ });
+ }
+
+ render_materials() {
+ const { owned, unassigned } = this.focused_materials();
+ if (!owned.length && !unassigned.length) {
+ this.render_empty(__("No raw materials planned for this plan yet"));
+ return;
+ }
+ const { table, body } = this.make_table([
+ { label: __("Material") },
+ { label: __("Reqd Qty (BOM)"), class: "ppv-num" },
+ { label: __("Qty In Stock"), class: "ppv-num" },
+ { label: __("Required Qty"), class: "ppv-num" },
+ { label: __("Requested Qty"), class: "ppv-num" },
+ { label: __("Ordered Qty"), class: "ppv-num" },
+ { label: __("Received Qty"), class: "ppv-num" },
+ { label: __("Status"), class: "ppv-col-status" },
+ { label: __("Requests"), class: "ppv-col-docs" },
+ ]);
+
+ for (const material of owned) body.append(this.material_row(material));
+ if (unassigned.length) {
+ body.append(this.group_row(__("Not linked to a finished good"), 9));
+ for (const material of unassigned) body.append(this.material_row(material));
+ }
+ this.detail_body.append(table);
+ }
+
+ focused_materials() {
+ const materials = [...(this.data.materials || [])].sort(
+ (a, b) => this.open_qty(b) - this.open_qty(a)
+ );
+ const owned =
+ this.focus === "all"
+ ? materials.filter((d) => d.owners.length)
+ : materials.filter((d) => d.owners.includes(this.focus));
+
+ return { owned, unassigned: materials.filter((d) => !d.owners.length) };
+ }
+
+ material_row(material) {
+ const open = this.open_qty(material);
+ const tr = $(`
+ d.name)
+ .join(" ")}`.toLowerCase()
+ )}">
+ |
+
+ ${this.esc(material.item_code)}${
+ material.warehouse ? ` · ${this.esc(material.warehouse)}` : ""
+ }${material.uom ? ` · ${this.esc(material.uom)}` : ""}
+ |
+ ${this.format_float(material.required_qty)} |
+ ${this.stock_value(material)} |
+ ${this.format_float(material.to_procure_qty)} |
+ ${this.format_float(material.requested_qty)} |
+ ${this.format_float(material.ordered_qty)} |
+ ${this.format_float(material.received_qty)} |
+ |
+ |
+
+ `);
+ const tag = tr.find(".ppv-item-tag");
+ tag.append(
+ frappe.ui.badge({
+ label: __(material.material_request_type || "Material"),
+ size: "sm",
+ variant: "ghost",
+ })
+ );
+ if (material.owners.length > 1) tag.append(this.shared_badge(material));
+ tr.find(".ppv-col-status").append(this.material_status(material, open));
+ this.append_document_chips(tr.find(".ppv-col-docs"), material.documents);
+ return tr;
+ }
+
+ material_status(material, open) {
+ const documents = material.documents || [];
+ if (open > 0) {
+ return this.pill(__("Request {0}", [this.format_float(open)]), "red");
+ }
+ if (!flt(material.to_procure_qty) && !documents.length) {
+ return this.pill(__("In Stock"), "green");
+ }
+
+ const statuses = [...new Set(documents.map((d) => d.status).filter(Boolean))];
+ if (statuses.length === 1) return this.pill(__(statuses[0]), this.status_theme(statuses[0]));
+ if (flt(material.received_qty) >= flt(material.to_procure_qty)) {
+ return this.pill(__("Received"), "green");
+ }
+ if (flt(material.ordered_qty) >= flt(material.to_procure_qty)) {
+ return this.pill(__("Ordered"), "blue");
+ }
+ return this.pill(__("Requested"), "amber");
+ }
+
+ shared_badge(material) {
+ const names = material.owners.map((row_name) => this.index.fg_label[row_name]).filter(Boolean);
+ return frappe.ui.badge({
+ label: __("Shared"),
+ size: "sm",
+ theme: "violet",
+ variant: "outline",
+ title: __("Needed by {0}. Quantities are the plan totals, as on the Production Plan.", [
+ names.join(", "),
+ ]),
+ });
+ }
+
+ pill(label, theme) {
+ return frappe.ui.badge({ label, theme, size: "sm" });
+ }
+
+ append_document_chips(target, documents) {
+ if (!documents || !documents.length) {
+ target.append(`${__("None")}`);
+ return;
+ }
+ const icons = { "Purchase Order": "shopping-cart", "Material Request": "clipboard-list" };
+ for (const doc of documents) {
+ $(``)
+ .append(
+ frappe.ui.badge({
+ label: doc.name,
+ size: "sm",
+ theme: this.status_theme(doc.status),
+ icon: icons[doc.doctype] || "factory",
+ title: __(doc.status || "Draft"),
+ })
+ )
+ .on("click", (e) => this.on_chip_click(e, doc))
+ .appendTo(target);
+ }
+ }
+
+ form_route(doctype, name) {
+ return `/app/${frappe.router.slug(doctype)}/${encodeURIComponent(name)}`;
+ }
+
+ on_chip_click(event, doc) {
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.which === 2) return;
+ event.preventDefault();
+ this.show_document(doc.doctype, doc.name);
+ }
+
+ show_document(doctype, name) {
+ this.drawer_key = `${doctype}/${name}`;
+ this.drawer.addClass("is-open");
+ this.backdrop.addClass("is-open");
+ this.drawer.find(".ppv-drawer-name").text(name);
+ this.drawer.find(".ppv-drawer-sub").text(__(doctype));
+ this.drawer.find(".ppv-drawer-actions").empty().append(this.drawer_actions(doctype, name));
+ this.drawer
+ .find(".ppv-drawer-body")
+ .html(frappe.ui.skeleton.html({ width: "100%", height: "200px" }));
+
+ frappe.db.get_doc(doctype, name).then((doc) => {
+ if (this.drawer_key === `${doctype}/${name}`) this.render_document(doc, doctype);
+ });
+ }
+
+ drawer_actions(doctype, name) {
+ const open = frappe.ui.button({
+ label: __("Open"),
+ icon_right: "external-link",
+ variant: "subtle",
+ size: "sm",
+ onclick: () => frappe.set_route("Form", doctype, name),
+ });
+ const close = frappe.ui.button({
+ icon: "x",
+ variant: "ghost",
+ size: "sm",
+ title: __("Close"),
+ onclick: () => this.close_document(),
+ });
+ return [open, close];
+ }
+
+ close_document() {
+ if (!this.drawer) return;
+ this.drawer_key = null;
+ this.drawer.removeClass("is-open");
+ this.backdrop.removeClass("is-open");
+ }
+
+ render_document(doc, doctype) {
+ const body = this.drawer.find(".ppv-drawer-body").empty();
+ this.drawer
+ .find(".ppv-drawer-sub")
+ .empty()
+ .append($(`${this.esc(__(doctype))}`), this.status_badge(doc.status, "sm"));
+
+ const grid = $('').appendTo(body);
+ for (const [fieldname, label, type] of this.document_fields(doctype)) {
+ grid.append(`
+
+
${this.esc(label)}
+
${this.esc(this.format_value(doc[fieldname], type))}
+
+ `);
+ }
+ this.render_document_items(body, doc, doctype);
+ }
+
+ render_document_items(body, doc, doctype) {
+ const { field, columns } = this.document_items(doctype);
+ const rows = doc[field] || [];
+ if (!rows.length) return;
+
+ body.append(`${__("Items")}
`);
+ const { table, body: tbody } = this.make_table(
+ columns.map(([, label, type]) => ({ label, class: type ? "ppv-num" : "" }))
+ );
+ for (const row of rows) {
+ const cells = columns
+ .map(
+ ([fieldname, , type]) =>
+ `${this.esc(
+ this.format_value(row[fieldname], type)
+ )} | `
+ )
+ .join("");
+ tbody.append(`${cells}
`);
+ }
+ body.append(table);
+ }
+
+ document_fields(doctype) {
+ const fields = {
+ "Work Order": [
+ ["production_item", __("Item")],
+ ["bom_no", __("BOM")],
+ ["qty", __("Qty to Manufacture"), "float"],
+ ["material_transferred_for_manufacturing", __("Transferred"), "float"],
+ ["produced_qty", __("Produced"), "float"],
+ ["planned_start_date", __("Planned Start"), "datetime"],
+ ["planned_end_date", __("Planned End"), "datetime"],
+ ["source_warehouse", __("Source Warehouse")],
+ ["fg_warehouse", __("Target Warehouse")],
+ ],
+ "Purchase Order": [
+ ["supplier", __("Supplier")],
+ ["transaction_date", __("Date"), "date"],
+ ["schedule_date", __("Required By"), "date"],
+ ["total_qty", __("Total Qty"), "float"],
+ ["per_received", __("Received"), "percent"],
+ ["per_billed", __("Billed"), "percent"],
+ ],
+ "Material Request": [
+ ["material_request_type", __("Type")],
+ ["transaction_date", __("Date"), "date"],
+ ["schedule_date", __("Required By"), "date"],
+ ["per_ordered", __("Ordered"), "percent"],
+ ["per_received", __("Received"), "percent"],
+ ],
+ };
+ return fields[doctype] || [];
+ }
+
+ document_items(doctype) {
+ if (doctype === "Work Order") {
+ return {
+ field: "required_items",
+ columns: [
+ ["item_code", __("Item")],
+ ["required_qty", __("Required"), "float"],
+ ["transferred_qty", __("Transferred"), "float"],
+ ["consumed_qty", __("Consumed"), "float"],
+ ],
+ };
+ }
+ const received = doctype === "Purchase Order" ? __("Received") : __("Ordered");
+ const received_field = doctype === "Purchase Order" ? "received_qty" : "ordered_qty";
+ return {
+ field: "items",
+ columns: [
+ ["item_code", __("Item")],
+ ["qty", __("Qty"), "float"],
+ [received_field, received, "float"],
+ ],
+ };
+ }
+
+ format_value(value, type) {
+ if (value === null || value === undefined || value === "") return "—";
+ if (type === "float") return this.format_float(value);
+ if (type === "percent") return `${Math.round(flt(value))}%`;
+ if (type === "date" || type === "datetime") return frappe.datetime.str_to_user(value);
+ return String(value);
+ }
+
+ render_empty(title) {
+ this.detail_body.append(
+ $('').append(frappe.ui.empty_state({ icon: "inbox", title }))
+ );
+ }
+
+ render_schedule() {
+ const blocks = this.focused_schedule();
+ if (!blocks.length) {
+ this.render_empty(__("No schedule yet — use Schedule Items on the Production Plan to build one"));
+ return;
+ }
+ this.detail_body.append(this.schedule_toolbar());
+ this.detail_body.append(this.schedule_timeline(blocks));
+ }
+
+ focused_schedule() {
+ const blocks = this.data.schedule || [];
+ if (this.focus === "all") return blocks;
+ const rows = new Set([this.focus]);
+ for (const sub of this.index.subs_by_parent[this.focus] || []) rows.add(sub.row_name);
+ const items = new Set(
+ (this.data.materials || []).filter((d) => d.owners.includes(this.focus)).map((d) => d.item_code)
+ );
+ return blocks.filter((d) =>
+ d.row_type === "Raw Material" ? items.has(d.item_code) : rows.has(d.plan_row)
+ );
+ }
+
+ schedule_toolbar() {
+ const toggle = (value, options, on_change) =>
+ frappe.ui.tab_buttons({ type: "ghost", size: "sm", value, options, on_change });
+ return $('')
+ .append(
+ toggle(
+ this.schedule_group,
+ [
+ { label: __("By Item"), value: "item" },
+ { label: __("By Workstation"), value: "workstation" },
+ ],
+ (value) => {
+ this.schedule_group = value;
+ this.render_detail_body();
+ }
+ )
+ )
+ .append(
+ toggle(
+ this.schedule_scale,
+ [
+ { label: __("Day"), value: "day" },
+ { label: __("Hour"), value: "hour" },
+ ],
+ (value) => {
+ this.schedule_scale = value;
+ this.render_detail_body();
+ }
+ )
+ ).append(`
+ ${__("Finished Good")}
+ ${__("Sub Assembly")}
+ ${__("Raw Material")}
+ `);
+ }
+
+ schedule_timeline(blocks) {
+ const raw_start = Math.min(...blocks.map((d) => frappe.datetime.str_to_obj(d.from_time).getTime()));
+ const raw_end = Math.max(...blocks.map((d) => frappe.datetime.str_to_obj(d.to_time).getTime()));
+ const axis = this.timeline_ticks(raw_start, raw_end);
+ const span = Math.max(axis.end - axis.start, 1);
+ const now = new Date().getTime();
+ this.today_offset = now > axis.start && now < axis.end ? ((now - axis.start) / span) * 100 : null;
+
+ const timeline = $(
+ ``
+ );
+ timeline.append(`
+
+ `);
+ for (const descriptor of this.schedule_rows(blocks)) {
+ timeline.append(this.timeline_row(descriptor, axis.start, span));
+ }
+ return $('').append(timeline);
+ }
+
+ timeline_ticks(start, end) {
+ const hour_ms = 3600000;
+ if (this.schedule_scale === "hour") return this.hour_ticks(start, end, hour_ms);
+
+ const first = new Date(start);
+ first.setHours(0, 0, 0, 0);
+ const ticks = [];
+ for (let time = first.getTime(); time < end; time += 24 * hour_ms) {
+ ticks.push(frappe.datetime.obj_to_user(new Date(time)).slice(0, 5));
+ }
+ return {
+ ticks,
+ start: first.getTime(),
+ end: first.getTime() + ticks.length * 24 * hour_ms,
+ tick_width: "84px",
+ };
+ }
+
+ hour_ticks(start, end, hour_ms) {
+ const span_hours = Math.max((end - start) / hour_ms, 1);
+ const step = span_hours <= 24 ? 1 : span_hours <= 72 ? 3 : span_hours <= 240 ? 6 : 12;
+ const first = new Date(start);
+ first.setMinutes(0, 0, 0);
+ first.setHours(Math.floor(first.getHours() / step) * step);
+ const ticks = [];
+ for (let time = first.getTime(); time < end; time += step * hour_ms) {
+ const date = new Date(time);
+ ticks.push(
+ date.getHours() === 0
+ ? frappe.datetime.obj_to_user(date).slice(0, 5)
+ : `${String(date.getHours()).padStart(2, "0")}:00`
+ );
+ }
+ return {
+ ticks,
+ start: first.getTime(),
+ end: first.getTime() + ticks.length * step * hour_ms,
+ tick_width: "64px",
+ };
+ }
+
+ schedule_rows(blocks) {
+ if (this.schedule_group === "workstation") {
+ const groups = this.group_rows(blocks, (d) => d.workstation || d.supplier || __("Unassigned"));
+ return Object.entries(groups).map(([label, rows]) => ({ label, indent: 0, blocks: rows }));
+ }
+ return this.schedule_tree(blocks);
+ }
+
+ schedule_tree(blocks) {
+ const by_row = this.group_rows(blocks, (d) => d.plan_row || "");
+ const material_blocks = this.group_rows(
+ blocks.filter((d) => d.row_type === "Raw Material"),
+ (d) => d.item_code
+ );
+ const row_materials = this.data.row_materials || {};
+ const used_materials = new Set();
+ const used_rows = new Set();
+
+ const material_rows = (row_name, indent) =>
+ (row_materials[row_name] || []).flatMap((item) => {
+ if (used_materials.has(item) || !material_blocks[item]) return [];
+ used_materials.add(item);
+ const rows = material_blocks[item];
+ return [{ label: rows[0].item_name || item, indent, blocks: rows }];
+ });
+
+ const out = [];
+ for (const fg of this.focused_goods()) {
+ used_rows.add(fg.row_name);
+ const branch = [];
+ for (const sub of this.index.subs_by_parent[fg.row_name] || []) {
+ used_rows.add(sub.row_name);
+ const indent = 1 + (sub.indent || 0);
+ const sub_blocks = by_row[sub.row_name] || [];
+ const children = material_rows(sub.row_name, indent + 1);
+ if (sub_blocks.length || children.length) {
+ branch.push(
+ { label: sub.item_name || sub.item_code, indent, blocks: sub_blocks },
+ ...children
+ );
+ }
+ }
+ branch.push(...material_rows(fg.row_name, 1));
+ const fg_blocks = by_row[fg.row_name] || [];
+ if (fg_blocks.length || branch.length) {
+ out.push({ label: fg.item_name || fg.item_code, indent: 0, blocks: fg_blocks }, ...branch);
+ }
+ }
+
+ return out.concat(this.leftover_rows(blocks, used_rows, used_materials));
+ }
+
+ leftover_rows(blocks, used_rows, used_materials) {
+ const leftover = blocks.filter((d) =>
+ d.row_type === "Raw Material" ? !used_materials.has(d.item_code) : !used_rows.has(d.plan_row)
+ );
+ const groups = this.group_rows(leftover, (d) => d.item_name || d.item_code || d.subject);
+ return Object.entries(groups).map(([label, rows]) => ({ label, indent: 0, blocks: rows }));
+ }
+
+ timeline_row(descriptor, start, span) {
+ const { label, indent, blocks } = descriptor;
+ const row = $(`
+
+
+ ${this.esc(label)}
+
+
+ `);
+ const track = row.find(".ppv-track");
+ if (this.today_offset !== null) {
+ track.append(``);
+ }
+ for (const block of blocks) track.append(this.timeline_block(block, start, span));
+ return row;
+ }
+
+ timeline_block(block, start, span) {
+ const from = frappe.datetime.str_to_obj(block.from_time).getTime();
+ const to = frappe.datetime.str_to_obj(block.to_time).getTime();
+ const left = ((from - start) / span) * 100;
+ const width = Math.max(((to - from) / span) * 100, 0.6);
+ const title = [
+ block.subject,
+ `${frappe.datetime.str_to_user(block.from_time)} → ${frappe.datetime.str_to_user(block.to_time)}`,
+ block.workstation || block.supplier || "",
+ ]
+ .filter(Boolean)
+ .join("\n");
+ return `${this.esc(
+ block.operation || block.item_name || block.subject || ""
+ )}`;
+ }
+
+ esc(value) {
+ return frappe.utils.escape_html(value == null ? "" : String(value));
+ }
+
+ progress_cell(completion) {
+ const value = Math.min(Math.max(completion, 0), 100);
+ return $('')
+ .append(frappe.ui.progress({ value }))
+ .append(`${Math.round(value)}%`);
+ }
+
+ stock_value(row) {
+ if (!row.stock_known) {
+ return `—`;
+ }
+ return this.format_float(row.available_qty);
+ }
+
+ format_float(value) {
+ return format_number(flt(value));
+ }
+
+ status_badge(status, size) {
+ return frappe.ui.badge({
+ label: __(status || "Draft"),
+ theme: this.status_theme(status),
+ size: size || "md",
+ });
+ }
+
+ status_theme(status) {
+ const themes = {
+ Completed: "green",
+ Transferred: "green",
+ Received: "green",
+ Ordered: "green",
+ Issued: "blue",
+ "In Process": "blue",
+ Submitted: "blue",
+ "In Progress": "blue",
+ Pending: "amber",
+ "Not Started": "amber",
+ "Partially Ordered": "amber",
+ "Partially Received": "amber",
+ "To Receive and Bill": "amber",
+ "To Receive": "amber",
+ "To Bill": "amber",
+ Stopped: "red",
+ Cancelled: "red",
+ Draft: "gray",
+ Closed: "gray",
+ "On Hold": "gray",
+ };
+ return themes[status] || "gray";
+ }
+
+ styles() {
+ return ``;
+ }
+};
diff --git a/erpnext/manufacturing/page/production_plan_visualizer/production_plan_visualizer.json b/erpnext/manufacturing/page/production_plan_visualizer/production_plan_visualizer.json
new file mode 100644
index 00000000000..dd5bd9f10e4
--- /dev/null
+++ b/erpnext/manufacturing/page/production_plan_visualizer/production_plan_visualizer.json
@@ -0,0 +1,29 @@
+{
+ "content": null,
+ "creation": "2026-08-28 10:00:00",
+ "docstatus": 0,
+ "doctype": "Page",
+ "idx": 0,
+ "modified": "2026-08-28 10:00:00",
+ "modified_by": "Administrator",
+ "module": "Manufacturing",
+ "name": "production-plan-visualizer",
+ "owner": "Administrator",
+ "page_name": "production-plan-visualizer",
+ "roles": [
+ {
+ "role": "Manufacturing User"
+ },
+ {
+ "role": "Manufacturing Manager"
+ },
+ {
+ "role": "System Manager"
+ }
+ ],
+ "script": null,
+ "standard": "Yes",
+ "style": null,
+ "system_page": 0,
+ "title": "Production Plan Visualizer"
+}
diff --git a/erpnext/manufacturing/page/production_plan_visualizer/production_plan_visualizer.py b/erpnext/manufacturing/page/production_plan_visualizer/production_plan_visualizer.py
new file mode 100644
index 00000000000..64c53eb490d
--- /dev/null
+++ b/erpnext/manufacturing/page/production_plan_visualizer/production_plan_visualizer.py
@@ -0,0 +1,385 @@
+import frappe
+from frappe.query_builder.functions import Sum
+from frappe.utils import flt
+
+
+@frappe.whitelist()
+def get_plan_overview(production_plan: str):
+ plan = frappe.get_doc("Production Plan", production_plan)
+ plan.check_permission("read")
+
+ work_orders = get_work_orders(production_plan)
+ purchase_orders = get_purchase_orders(production_plan)
+ schedule = get_schedule(production_plan)
+ stock, warehouses = get_stock_levels(plan)
+
+ return {
+ "plan": get_plan_details(plan),
+ "finished_goods": get_finished_goods(plan, work_orders),
+ "sub_assemblies": get_sub_assemblies(plan, work_orders, purchase_orders, stock, warehouses),
+ "material_requests": get_material_requests(production_plan),
+ "materials": get_materials(plan, production_plan, stock, warehouses),
+ "schedule": schedule,
+ "row_materials": get_row_materials(plan, schedule),
+ }
+
+
+def get_materials(plan, production_plan, stock, warehouses):
+ raised = {}
+ for row in get_raised_material_request_items(production_plan):
+ raised.setdefault(row.material_request_plan_item, []).append(row)
+
+ return [build_material(row, raised.get(row.name) or [], stock, warehouses) for row in plan.mr_items]
+
+
+def get_permitted_names(doctype, child_doctype, production_plan):
+ if not frappe.has_permission(doctype):
+ return []
+
+ return frappe.get_list(
+ doctype,
+ filters=[
+ [child_doctype, "production_plan", "=", production_plan],
+ [child_doctype, "docstatus", "<", 2],
+ ],
+ pluck="name",
+ distinct=True,
+ limit_page_length=0,
+ )
+
+
+def get_stock_levels(plan):
+ pairs = [(row.item_code, row.warehouse) for row in plan.mr_items if row.warehouse]
+ pairs += [(row.production_item, row.fg_warehouse) for row in plan.sub_assembly_items if row.fg_warehouse]
+ items = {item for item, _ in pairs}
+ warehouses = {warehouse for _, warehouse in pairs}
+ if not items or not warehouses or not frappe.has_permission("Bin"):
+ return {}, set()
+
+ permitted = set(
+ frappe.get_list(
+ "Warehouse",
+ filters={"name": ("in", warehouses)},
+ pluck="name",
+ limit_page_length=0,
+ )
+ )
+ if not permitted:
+ return {}, permitted
+
+ bins = frappe.get_list(
+ "Bin",
+ filters={"item_code": ("in", items), "warehouse": ("in", permitted)},
+ fields=["item_code", "warehouse", "actual_qty", "projected_qty"],
+ limit_page_length=0,
+ )
+
+ return {(d.item_code, d.warehouse): d for d in bins}, permitted
+
+
+def build_material(row, raised, stock, warehouses):
+ documents = {
+ entry.name: {"doctype": "Material Request", "name": entry.name, "status": entry.status}
+ for entry in raised
+ }
+ stock_known = row.warehouse in warehouses
+ level = stock.get((row.item_code, row.warehouse)) or frappe._dict()
+
+ return {
+ "row_name": row.name,
+ "item_code": row.item_code,
+ "item_name": row.item_name,
+ "uom": row.uom,
+ "warehouse": row.warehouse,
+ "material_request_type": row.material_request_type,
+ "required_qty": flt(row.required_bom_qty) or flt(row.quantity),
+ "to_procure_qty": flt(row.quantity),
+ "available_qty": flt(level.actual_qty) if stock_known else 0.0,
+ "projected_qty": flt(level.projected_qty) if stock_known else 0.0,
+ "stock_known": stock_known,
+ "requested_qty": flt(row.requested_qty),
+ "ordered_qty": sum(flt(entry.ordered_qty) for entry in raised),
+ "received_qty": sum(flt(entry.received_qty) for entry in raised),
+ "schedule_date": row.schedule_date,
+ "sales_order": row.get("sales_order"),
+ "consumer": row.get("sub_assembly_item_reference"),
+ "main_item_code": row.get("main_item_code"),
+ "from_bom": row.get("from_bom"),
+ "documents": list(documents.values()),
+ }
+
+
+def get_raised_material_request_items(production_plan):
+ names = get_permitted_names("Material Request", "Material Request Item", production_plan)
+ if not names:
+ return []
+
+ mr_item = frappe.qb.DocType("Material Request Item")
+ material_request = frappe.qb.DocType("Material Request")
+
+ return (
+ frappe.qb.from_(mr_item)
+ .inner_join(material_request)
+ .on(mr_item.parent == material_request.name)
+ .select(
+ mr_item.parent.as_("name"),
+ mr_item.material_request_plan_item,
+ mr_item.item_code,
+ mr_item.qty,
+ mr_item.ordered_qty,
+ mr_item.received_qty,
+ material_request.status,
+ )
+ .where(
+ (mr_item.production_plan == production_plan)
+ & (mr_item.docstatus < 2)
+ & mr_item.parent.isin(names)
+ )
+ .orderby(material_request.transaction_date)
+ .run(as_dict=True)
+ )
+
+
+def get_row_materials(plan, schedule):
+ material_items = {d.item_code for d in schedule if d.row_type == "Raw Material"}
+ material_items.update(row.item_code for row in plan.mr_items)
+ material_items.update(row.production_item for row in plan.sub_assembly_items)
+ rows = [(d.name, d.bom_no) for d in plan.po_items + plan.sub_assembly_items if d.bom_no]
+ if not material_items or not rows:
+ return {}
+
+ boms = frappe.get_list(
+ "BOM",
+ filters={"name": ("in", {bom_no for _, bom_no in rows})},
+ pluck="name",
+ limit_page_length=0,
+ )
+ if not boms:
+ return {}
+
+ bom_items = frappe.get_all(
+ "BOM Item",
+ filters={"parent": ("in", boms), "parenttype": "BOM", "item_code": ("in", material_items)},
+ fields=["parent", "item_code"],
+ )
+
+ by_bom = {}
+ for d in bom_items:
+ by_bom.setdefault(d.parent, []).append(d.item_code)
+
+ return {name: by_bom[bom_no] for name, bom_no in rows if by_bom.get(bom_no)}
+
+
+def get_plan_details(plan):
+ total_planned = flt(plan.total_planned_qty)
+ total_produced = flt(plan.total_produced_qty)
+ return {
+ "name": plan.name,
+ "status": plan.status,
+ "docstatus": plan.docstatus,
+ "company": plan.company,
+ "posting_date": plan.posting_date,
+ "combine_sub_items": plan.combine_sub_items,
+ "total_planned_qty": total_planned,
+ "total_produced_qty": total_produced,
+ "completion": flt(total_produced / total_planned * 100 if total_planned else 0, 1),
+ }
+
+
+def get_finished_goods(plan, work_orders):
+ rows = []
+ for row in plan.po_items:
+ documents = [d for d in work_orders if d.production_plan_item == row.name]
+ produced_qty = sum(flt(d.produced_qty) for d in documents)
+ rows.append(
+ {
+ "row_name": row.name,
+ "item_code": row.item_code,
+ "item_name": frappe.get_cached_value("Item", row.item_code, "item_name"),
+ "sales_order": row.get("sales_order"),
+ "warehouse": row.warehouse,
+ "planned_start_date": row.planned_start_date,
+ "planned_end_date": row.get("planned_end_date"),
+ "qty": flt(row.planned_qty),
+ "produced_qty": produced_qty,
+ "pending_qty": flt(row.planned_qty) - produced_qty,
+ "stock_uom": row.stock_uom,
+ "documents": documents,
+ }
+ )
+
+ return rows
+
+
+def get_sub_assemblies(plan, work_orders, purchase_orders, stock, warehouses):
+ rows = []
+ for item in plan.sub_assembly_items:
+ if item.type_of_manufacturing == "Subcontract":
+ documents = [d for d in purchase_orders if d.production_plan_sub_assembly_item == item.name]
+ else:
+ documents = [d for d in work_orders if d.production_plan_sub_assembly_item == item.name]
+
+ produced_qty = sum(flt(d.produced_qty) for d in documents)
+ stock_known = item.fg_warehouse in warehouses
+ level = stock.get((item.production_item, item.fg_warehouse)) or frappe._dict()
+ rows.append(
+ {
+ "row_name": item.name,
+ "production_plan_item": item.production_plan_item,
+ "parent_item_code": item.parent_item_code,
+ "sales_order": item.get("sales_order"),
+ "item_code": item.production_item,
+ "item_name": item.item_name,
+ "qty": flt(item.qty),
+ "produced_qty": produced_qty,
+ "pending_qty": flt(item.qty) - produced_qty,
+ "available_qty": flt(level.actual_qty) if stock_known else 0.0,
+ "stock_known": stock_known,
+ "bom_no": item.bom_no,
+ "bom_level": item.bom_level,
+ "indent": item.indent or 0,
+ "type_of_manufacturing": item.type_of_manufacturing,
+ "supplier": item.get("supplier"),
+ "schedule_date": item.schedule_date,
+ "uom": item.stock_uom or item.uom,
+ "documents": documents,
+ }
+ )
+
+ return rows
+
+
+def get_work_orders(production_plan):
+ if not frappe.has_permission("Work Order"):
+ return []
+
+ work_orders = frappe.get_list(
+ "Work Order",
+ filters={"production_plan": production_plan, "docstatus": ("<", 2)},
+ fields=[
+ "name",
+ "qty",
+ "produced_qty",
+ "material_transferred_for_manufacturing",
+ "status",
+ "docstatus",
+ "planned_start_date",
+ "production_item as item_code",
+ "item_name",
+ "production_plan_item",
+ "production_plan_sub_assembly_item",
+ ],
+ order_by="creation",
+ limit_page_length=0,
+ )
+
+ for row in work_orders:
+ row.doctype = "Work Order"
+
+ return work_orders
+
+
+def get_purchase_orders(production_plan):
+ names = get_permitted_names("Purchase Order", "Purchase Order Item", production_plan)
+ if not names:
+ return []
+
+ po_item = frappe.qb.DocType("Purchase Order Item")
+ purchase_order = frappe.qb.DocType("Purchase Order")
+
+ purchase_orders = (
+ frappe.qb.from_(po_item)
+ .inner_join(purchase_order)
+ .on(po_item.parent == purchase_order.name)
+ .select(
+ po_item.parent.as_("name"),
+ po_item.qty.as_("order_qty"),
+ po_item.received_qty,
+ po_item.fg_item,
+ po_item.fg_item_qty,
+ po_item.production_plan_sub_assembly_item,
+ purchase_order.status,
+ purchase_order.docstatus,
+ purchase_order.supplier,
+ )
+ .where(
+ (po_item.production_plan == production_plan)
+ & (po_item.docstatus < 2)
+ & po_item.parent.isin(names)
+ )
+ .run(as_dict=True)
+ )
+
+ for row in purchase_orders:
+ row.doctype = "Purchase Order"
+ row.qty = flt(row.fg_item_qty) if row.fg_item else flt(row.order_qty)
+ row.produced_qty = flt(row.received_qty)
+ if row.fg_item:
+ row.produced_qty = flt(row.received_qty) / (flt(row.order_qty) / flt(row.fg_item_qty) or 1)
+
+ return purchase_orders
+
+
+def get_material_requests(production_plan):
+ names = get_permitted_names("Material Request", "Material Request Item", production_plan)
+ if not names:
+ return []
+
+ mr_item = frappe.qb.DocType("Material Request Item")
+ material_request = frappe.qb.DocType("Material Request")
+
+ return (
+ frappe.qb.from_(mr_item)
+ .inner_join(material_request)
+ .on(mr_item.parent == material_request.name)
+ .select(
+ mr_item.parent.as_("name"),
+ material_request.status,
+ material_request.material_request_type,
+ material_request.transaction_date,
+ material_request.per_ordered,
+ material_request.per_received,
+ Sum(mr_item.qty).as_("qty"),
+ )
+ .where(
+ (mr_item.production_plan == production_plan)
+ & (mr_item.docstatus < 2)
+ & mr_item.parent.isin(names)
+ )
+ .groupby(
+ mr_item.parent,
+ material_request.status,
+ material_request.material_request_type,
+ material_request.transaction_date,
+ material_request.per_ordered,
+ material_request.per_received,
+ )
+ .orderby(material_request.transaction_date)
+ .run(as_dict=True)
+ )
+
+
+def get_schedule(production_plan):
+ if not frappe.has_permission("Production Plan Schedule"):
+ return []
+
+ return frappe.get_list(
+ "Production Plan Schedule",
+ filters={"production_plan": production_plan},
+ fields=[
+ "name",
+ "subject",
+ "row_type",
+ "plan_row",
+ "item_code",
+ "item_name",
+ "operation",
+ "workstation",
+ "supplier",
+ "from_time",
+ "to_time",
+ "duration_mins",
+ ],
+ order_by="from_time",
+ limit_page_length=0,
+ )
diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.js b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.js
index 8536ccd1993..98c44b29bd7 100644
--- a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.js
+++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.js
@@ -21,14 +21,32 @@ frappe.query_reports["Production Plan Summary"] = {
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
- if (column.fieldname == "item_code") {
- var color = data.pending_qty > 0 ? "red" : "green";
+ if (column.fieldname == "item_code" && !data.document_type) {
+ var color = data.pending_qty > 0 ? "var(--red-500)" : "var(--green-600)";
value = `${frappe.utils.escape_html(data["item_code"])}`;
}
+ if (column.fieldname == "status" && data.status && frappe.ui.badge) {
+ const themes = {
+ Completed: "green",
+ "In Process": "blue",
+ "Not Started": "amber",
+ Submitted: "blue",
+ Stopped: "red",
+ Closed: "gray",
+ "To Receive and Bill": "amber",
+ "To Receive": "amber",
+ "To Bill": "amber",
+ };
+ value = frappe.ui.badge.html({
+ label: __(data.status),
+ theme: themes[data.status] || "gray",
+ });
+ }
+
return value;
},
};
diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py
index 82e150f807a..245fc651a47 100644
--- a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py
+++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py
@@ -8,163 +8,173 @@ from frappe.utils import flt
def execute(filters=None):
- columns, data = [], []
- data = get_data(filters)
- columns = get_column(filters)
-
- return columns, data
+ return get_column(filters), get_data(filters)
def get_data(filters):
- data = []
+ plan = frappe.get_cached_doc("Production Plan", filters.get("production_plan"))
+ work_orders = get_work_orders(filters)
+ purchase_orders = get_purchase_orders(filters)
- order_details = {}
- get_work_order_details(filters, order_details)
- get_purchase_order_details(filters, order_details)
- get_production_plan_item_details(filters, data, order_details)
+ data = []
+ for row in plan.po_items:
+ fg_work_orders = [d for d in work_orders if d.production_plan_item == row.name]
+ data.append(get_finished_good_row(row, fg_work_orders))
+ data.extend(get_document_row(d, indent=1) for d in fg_work_orders)
+ sub_items = [d for d in plan.sub_assembly_items if d.production_plan_item == row.name]
+ add_sub_assembly_rows(sub_items, data, work_orders, purchase_orders)
+
+ po_row_names = {row.name for row in plan.po_items}
+ orphan_items = [d for d in plan.sub_assembly_items if d.production_plan_item not in po_row_names]
+ add_sub_assembly_rows(orphan_items, data, work_orders, purchase_orders)
return data
-def get_production_plan_item_details(filters, data, order_details):
- production_plan_doc = frappe.get_cached_doc("Production Plan", filters.get("production_plan"))
- for row in production_plan_doc.po_items:
- work_orders = frappe.get_all(
- "Work Order",
- filters={
- "production_plan_item": row.name,
- "bom_no": row.bom_no,
- "production_item": row.item_code,
- "docstatus": 1,
- },
- pluck="name",
- )
+def get_finished_good_row(row, fg_work_orders):
+ produced_qty = sum(flt(d.produced_qty) for d in fg_work_orders)
+ return {
+ "indent": 0,
+ "item_code": row.item_code,
+ "item_name": frappe.get_cached_value("Item", row.item_code, "item_name"),
+ "sales_order": row.get("sales_order"),
+ "bom_level": 0,
+ "qty": flt(row.planned_qty),
+ "produced_qty": produced_qty,
+ "pending_qty": flt(row.planned_qty) - produced_qty,
+ }
- order_qty = row.planned_qty
- total_produced_qty = 0.0
- # default to the full planned qty so a plan without any work order still
- # reports everything as pending rather than a misleading zero
- pending_qty = flt(order_qty)
- for work_order in work_orders:
- produced_qty = flt(order_details.get((work_order, row.item_code), {}).get("produced_qty", 0))
- pending_qty = flt(order_qty) - produced_qty
- total_produced_qty += produced_qty
-
- data.append(
- {
- "indent": 0,
- "item_code": row.item_code,
- "sales_order": row.get("sales_order"),
- "item_name": frappe.get_cached_value("Item", row.item_code, "item_name"),
- "qty": order_qty,
- "document_type": "Work Order",
- "document_name": work_order or "",
- "bom_level": 0,
- "produced_qty": produced_qty,
- "pending_qty": pending_qty,
- }
- )
-
- order_qty = pending_qty
+def add_sub_assembly_rows(items, data, work_orders, purchase_orders):
+ for item in items:
+ if item.type_of_manufacturing == "Subcontract":
+ documents = [d for d in purchase_orders if d.production_plan_sub_assembly_item == item.name]
+ else:
+ documents = [d for d in work_orders if d.production_plan_sub_assembly_item == item.name]
+ indent = 1 + (item.indent or 0)
+ produced_qty = sum(flt(d.produced_qty) for d in documents)
data.append(
{
- "item_code": row.item_code,
- "indent": 0,
- "qty": row.planned_qty,
- "produced_qty": total_produced_qty,
- "pending_qty": pending_qty,
+ "indent": indent,
+ "item_code": item.production_item,
+ "item_name": item.item_name,
+ "bom_level": item.bom_level,
+ "qty": flt(item.qty),
+ "produced_qty": produced_qty,
+ "pending_qty": flt(item.qty) - produced_qty,
}
)
-
- get_production_plan_sub_assembly_item_details(filters, row, production_plan_doc, data, order_details)
+ data.extend(get_document_row(d, indent=indent + 1) for d in documents)
-def get_production_plan_sub_assembly_item_details(filters, row, production_plan_doc, data, order_details):
- for item in production_plan_doc.sub_assembly_items:
- if row.name == item.production_plan_item:
- subcontracted_item = item.type_of_manufacturing == "Subcontract"
-
- if subcontracted_item:
- docnames = frappe.get_all(
- "Purchase Order Item",
- filters={"production_plan_sub_assembly_item": item.name, "docstatus": 1},
- fields=["parent"],
- order_by="creation",
- pluck="parent",
- )
- else:
- docnames = frappe.get_all(
- "Work Order",
- filters={"production_plan_sub_assembly_item": item.name, "docstatus": 1},
- fields=["name"],
- order_by="creation",
- pluck="name",
- )
-
- for docname in docnames:
- data_to_append = {
- "indent": 1 + item.indent,
- "item_code": item.production_item,
- "item_name": item.item_name,
- "qty": item.qty,
- "document_type": "Work Order" if not subcontracted_item else "Purchase Order",
- "document_name": docname or "",
- "bom_level": item.bom_level,
- "produced_qty": order_details.get((docname, item.production_item), {}).get(
- "produced_qty", 0
- ),
- "pending_qty": flt(item.qty)
- - flt(order_details.get((docname, item.production_item), {}).get("produced_qty", 0)),
- }
- if data[-1] and data[-1]["item_code"] == item.production_item:
- data_to_append["pending_qty"] = data[-1]["pending_qty"] - data_to_append["produced_qty"]
- data.append(data_to_append)
+def get_document_row(doc, indent):
+ return {
+ "indent": indent,
+ "item_code": doc.item_code,
+ "item_name": doc.item_name,
+ "sales_order": doc.get("sales_order"),
+ "document_type": doc.document_type,
+ "document_name": doc.document_name,
+ "status": doc.status,
+ "qty": flt(doc.qty),
+ "produced_qty": flt(doc.produced_qty),
+ "pending_qty": flt(doc.qty) - flt(doc.produced_qty),
+ }
-def get_work_order_details(filters, order_details):
- for row in frappe.get_all(
+def get_work_orders(filters):
+ work_orders = frappe.get_all(
"Work Order",
filters={"production_plan": filters.get("production_plan"), "docstatus": 1},
- fields=["name", "produced_qty", "production_plan", "production_item", "sales_order"],
- ):
- order_details.setdefault((row.name, row.production_item), row)
+ fields=[
+ "name",
+ "qty",
+ "produced_qty",
+ "status",
+ "sales_order",
+ "production_item as item_code",
+ "item_name",
+ "production_plan_item",
+ "production_plan_sub_assembly_item",
+ ],
+ )
+
+ for row in work_orders:
+ row.document_type = "Work Order"
+ row.document_name = row.name
+
+ return work_orders
-def get_purchase_order_details(filters, order_details):
- for row in frappe.get_all(
- "Purchase Order Item",
- filters={"production_plan": filters.get("production_plan"), "docstatus": 1},
- fields=["parent", "qty", "received_qty as produced_qty", "item_code", "fg_item", "fg_item_qty"],
- ):
- if row.fg_item:
- row.produced_qty /= row.qty / row.fg_item_qty or 1
- order_details.setdefault((row.parent, row.fg_item or row.item_code), row)
+def get_purchase_orders(filters):
+ po_item = frappe.qb.DocType("Purchase Order Item")
+ purchase_order = frappe.qb.DocType("Purchase Order")
+
+ purchase_orders = (
+ frappe.qb.from_(po_item)
+ .inner_join(purchase_order)
+ .on(po_item.parent == purchase_order.name)
+ .select(
+ po_item.parent.as_("document_name"),
+ po_item.qty.as_("order_qty"),
+ po_item.received_qty,
+ po_item.item_code.as_("po_item_code"),
+ po_item.item_name.as_("po_item_name"),
+ po_item.fg_item,
+ po_item.fg_item_qty,
+ po_item.production_plan_sub_assembly_item,
+ purchase_order.status,
+ )
+ .where((po_item.production_plan == filters.get("production_plan")) & (po_item.docstatus == 1))
+ .run(as_dict=True)
+ )
+
+ return [get_purchase_order_row(row) for row in purchase_orders]
+
+
+def get_purchase_order_row(row):
+ produced_qty = flt(row.received_qty)
+ if row.fg_item:
+ produced_qty = flt(row.received_qty) / (flt(row.order_qty) / flt(row.fg_item_qty) or 1)
+
+ item_code = row.fg_item or row.po_item_code
+ return frappe._dict(
+ {
+ "document_type": "Purchase Order",
+ "document_name": row.document_name,
+ "status": row.status,
+ "item_code": item_code,
+ "item_name": frappe.get_cached_value("Item", item_code, "item_name"),
+ "qty": flt(row.fg_item_qty) if row.fg_item else flt(row.order_qty),
+ "produced_qty": produced_qty,
+ "production_plan_sub_assembly_item": row.production_plan_sub_assembly_item,
+ }
+ )
def get_column(filters):
return [
{
- "label": _("Finished Good"),
+ "label": _("Item Code"),
"fieldtype": "Link",
"fieldname": "item_code",
"width": 240,
"options": "Item",
},
- {"label": _("Item Name"), "fieldtype": "data", "fieldname": "item_name", "width": 150},
+ {"label": _("Item Name"), "fieldtype": "Data", "fieldname": "item_name", "width": 180},
{
"label": _("Sales Order"),
"options": "Sales Order",
"fieldtype": "Link",
"fieldname": "sales_order",
- "width": 100,
+ "width": 120,
},
{
"label": _("Document Type"),
"fieldtype": "Data",
"fieldname": "document_type",
- "width": 150,
+ "width": 120,
},
{
"label": _("Document Name"),
@@ -173,6 +183,7 @@ def get_column(filters):
"options": "document_type",
"width": 180,
},
+ {"label": _("Status"), "fieldtype": "Data", "fieldname": "status", "width": 110},
{"label": _("BOM Level"), "fieldtype": "Int", "fieldname": "bom_level", "width": 100},
{"label": _("Order Qty"), "fieldtype": "Float", "fieldname": "qty", "width": 120},
{