feat: production plan visualizer page and summary report fix (#58541)

* fix: production plan summary report tree structure and quantities

* feat: production plan visualizer page

* feat: single screen production plan visualizer with material readiness

* fix: show live stock and received status for production plan materials

* fix: remove duplicate border under production plan visualizer header

* fix: drop page head border on production plan visualizer

* fix: add horizontal margin to production plan visualizer

* fix: apply record level permissions and resolve shared material owners

* fix: list shared raw materials under every finished good that needs them

* feat: open linked documents in a side panel from the visualizer

* fix: never fall back to stored qty when warehouse stock is not readable

* fix: include directly consuming finished goods in material ownership

* fix: show each finished good's own share of shared material demand

* fix: match production plan quantities and labels in the visualizer

* fix: resolve nested sub assembly owners when parent link is missing

* fix: keep every matching owner when resolving sub assemblies by item code

* feat: flat work order list in place of the items to manufacture tree

* fix: flatten items to manufacture rows without changing the table design

* fix: align table numbers, units and progress cells

* fix: scope nested owner resolution to the same sales order

* fix: keep quantity columns numeric and move uom to the item line

* fix: recover all finished goods for consolidated sub assembly rows

* fix: scope raw material owners to the same sales order
This commit is contained in:
rohitwaghchaure
2026-08-31 16:24:12 +05:30
committed by GitHub
parent 509501c299
commit b36895a4c3
7 changed files with 2341 additions and 116 deletions

View File

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

View File

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

View File

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

View File

@@ -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 = `<a style='color:${color}' href="${frappe.utils.get_form_link(
"Item",
data["item_code"]
)}" data-doctype="Item">${frappe.utils.escape_html(data["item_code"])}</a>`;
}
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;
},
};

View File

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