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("")} +
+
+
${line("100%", "240px")}
+
${line("100%", "240px")}
+
+ `); + 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 ` + + + + ${Math.round(completion)}% + + `; + } + + 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 = $(` + + `); + 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 = $(` + + `); + 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 = $(`${head}
`); + 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_name || row.item_code)} + +
+
${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_name || material.item_code + )} + +
+
${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(` +
+
${__("Timeline")}
+
${axis.ticks + .map((tick) => `
${this.esc(tick)}
`) + .join("")}
+
+ `); + 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}, {