diff --git a/makap_backend/makap_backend/report/__init__.py b/makap_backend/makap_backend/report/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/makap_backend/makap_backend/report/roll_genealogy/__init__.py b/makap_backend/makap_backend/report/roll_genealogy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/makap_backend/makap_backend/report/roll_genealogy/roll_genealogy.js b/makap_backend/makap_backend/report/roll_genealogy/roll_genealogy.js
new file mode 100644
index 0000000..b23178f
--- /dev/null
+++ b/makap_backend/makap_backend/report/roll_genealogy/roll_genealogy.js
@@ -0,0 +1,31 @@
+frappe.query_reports["Roll Genealogy"] = {
+ filters: [
+ {
+ fieldname: "batch_no",
+ label: __("Batch / Roll"),
+ fieldtype: "Link",
+ options: "Batch",
+ reqd: 1,
+ },
+ {
+ fieldname: "direction",
+ label: __("Direction"),
+ fieldtype: "Select",
+ options: [
+ { value: "Backward", label: __("Backward: what went into it") },
+ { value: "Forward", label: __("Forward: where it was used") },
+ ],
+ default: "Backward",
+ },
+ ],
+ formatter(value, row, column, data, default_formatter) {
+ value = default_formatter(value, row, column, data);
+ if (column.fieldname === "batch_no" && data && data.level) {
+ value = " ".repeat(data.level * 4) + value;
+ }
+ if (column.fieldname === "quality" && data && ["Hold", "Reject", "Scrap"].includes(data.quality)) {
+ value = `${value}`;
+ }
+ return value;
+ },
+};
diff --git a/makap_backend/makap_backend/report/roll_genealogy/roll_genealogy.json b/makap_backend/makap_backend/report/roll_genealogy/roll_genealogy.json
new file mode 100644
index 0000000..1aae03f
--- /dev/null
+++ b/makap_backend/makap_backend/report/roll_genealogy/roll_genealogy.json
@@ -0,0 +1,40 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2026-10-09 00:00:00",
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "is_standard": "Yes",
+ "letterhead": null,
+ "modified": "2026-10-09 00:00:00",
+ "modified_by": "Administrator",
+ "module": "makap_backend",
+ "name": "Roll Genealogy",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Batch",
+ "report_name": "Roll Genealogy",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Stock User"
+ },
+ {
+ "role": "Stock Manager"
+ },
+ {
+ "role": "Quality Manager"
+ },
+ {
+ "role": "Manufacturing User"
+ },
+ {
+ "role": "Manufacturing Manager"
+ },
+ {
+ "role": "Sales User"
+ }
+ ]
+}
diff --git a/makap_backend/makap_backend/report/roll_genealogy/roll_genealogy.py b/makap_backend/makap_backend/report/roll_genealogy/roll_genealogy.py
new file mode 100644
index 0000000..c850acc
--- /dev/null
+++ b/makap_backend/makap_backend/report/roll_genealogy/roll_genealogy.py
@@ -0,0 +1,131 @@
+"""Trace a roll/batch through every Manufacture entry that made or used it.
+
+Backward answers "what went into this carton?" (complaint investigation);
+forward answers "where did this resin lot end up?" (recall). Genealogy comes
+from submitted Manufacture Stock Entries, so it needs no extra data entry:
+every input batch on an entry is a parent of every output batch on it.
+"""
+
+import frappe
+from frappe import _
+
+MAX_DEPTH = 12
+
+# One row per (stock entry row, batch), whether the batch sits on the row
+# itself or inside its Serial and Batch Bundle.
+MOVEMENTS = """
+ select
+ sed.parent as stock_entry, sed.item_code,
+ coalesce(sbe.batch_no, sed.batch_no) as batch_no,
+ abs(coalesce(sbe.qty, sed.transfer_qty)) as qty,
+ ifnull(sed.s_warehouse, '') != '' as is_input,
+ se.work_order, se.posting_date
+ from `tabStock Entry Detail` sed
+ join `tabStock Entry` se on se.name = sed.parent
+ left join `tabSerial and Batch Entry` sbe on sbe.parent = sed.serial_and_batch_bundle
+ where se.docstatus = 1
+ and se.purpose = 'Manufacture'
+ and coalesce(sbe.batch_no, sed.batch_no) is not null
+ and sed.is_scrap_item = 0
+"""
+
+
+def _movements(condition, values):
+ return frappe.db.sql(f"{MOVEMENTS} and {condition}", values, as_dict=True)
+
+
+def _links(batch_no, backward):
+ """Batches one step away: inputs of the entries that produced this batch,
+ or outputs of the entries that consumed it."""
+ found = _movements(
+ "coalesce(sbe.batch_no, sed.batch_no) = %(batch)s and (ifnull(sed.s_warehouse, '') != '') = %(is_input)s",
+ {"batch": batch_no, "is_input": 0 if backward else 1},
+ )
+ entries = list({row.stock_entry for row in found})
+ if not entries:
+ return []
+ return _movements(
+ "sed.parent in %(entries)s and (ifnull(sed.s_warehouse, '') != '') = %(is_input)s",
+ {"entries": entries, "is_input": 1 if backward else 0},
+ )
+
+
+def _final_disposition(item_code, batch_no):
+ row = frappe.get_all(
+ "Quality Inspection",
+ filters={"item_code": item_code, "batch_no": batch_no, "docstatus": 1},
+ fields=["makap_quality_disposition"],
+ order_by="creation desc",
+ limit=1,
+ )
+ return row[0].makap_quality_disposition if row else _("Not inspected")
+
+
+def _row(level, movement):
+ roll = frappe.db.get_value(
+ "Batch",
+ movement.batch_no,
+ ["item", "makap_roll_stage", "makap_width_mm", "makap_length_m", "makap_net_weight_kg", "makap_supplier_lot"],
+ as_dict=True,
+ ) or {}
+ item_code = movement.get("item_code") or roll.get("item")
+ return {
+ "level": level,
+ "indent": level,
+ "batch_no": movement.batch_no,
+ "item_code": item_code,
+ "item_name": frappe.db.get_value("Item", item_code, "item_name") if item_code else None,
+ "roll_stage": roll.get("makap_roll_stage"),
+ "width_mm": roll.get("makap_width_mm"),
+ "length_m": roll.get("makap_length_m"),
+ "net_weight_kg": roll.get("makap_net_weight_kg"),
+ "supplier_lot": roll.get("makap_supplier_lot"),
+ "qty": movement.get("qty"),
+ "stock_entry": movement.get("stock_entry"),
+ "work_order": movement.get("work_order"),
+ "posting_date": movement.get("posting_date"),
+ "quality": _final_disposition(item_code, movement.batch_no) if item_code else None,
+ }
+
+
+def execute(filters=None):
+ filters = frappe._dict(filters or {})
+ if not filters.batch_no:
+ frappe.throw(_("Select a Batch / Roll to trace."))
+ backward = filters.get("direction", "Backward") == "Backward"
+
+ rows = [_row(0, frappe._dict(batch_no=filters.batch_no))]
+ visited = {filters.batch_no}
+
+ def walk(batch_no, level):
+ if level > MAX_DEPTH:
+ return
+ for movement in _links(batch_no, backward):
+ if movement.batch_no in visited:
+ continue
+ visited.add(movement.batch_no)
+ rows.append(_row(level, movement))
+ walk(movement.batch_no, level + 1)
+
+ walk(filters.batch_no, 1)
+ return _columns(backward), rows
+
+
+def _columns(backward):
+ via = _("Made In") if not backward else _("Consumed In")
+ return [
+ {"fieldname": "level", "label": _("Level"), "fieldtype": "Int", "width": 60},
+ {"fieldname": "batch_no", "label": _("Batch / Roll"), "fieldtype": "Link", "options": "Batch", "width": 170},
+ {"fieldname": "item_code", "label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 140},
+ {"fieldname": "item_name", "label": _("Item Name"), "fieldtype": "Data", "width": 180},
+ {"fieldname": "roll_stage", "label": _("Stage"), "fieldtype": "Data", "width": 120},
+ {"fieldname": "width_mm", "label": _("Width (mm)"), "fieldtype": "Float", "width": 90},
+ {"fieldname": "length_m", "label": _("Length (m)"), "fieldtype": "Float", "width": 90},
+ {"fieldname": "net_weight_kg", "label": _("Net (kg)"), "fieldtype": "Float", "width": 90},
+ {"fieldname": "supplier_lot", "label": _("Supplier Lot"), "fieldtype": "Data", "width": 120},
+ {"fieldname": "qty", "label": _("Qty"), "fieldtype": "Float", "width": 90},
+ {"fieldname": "stock_entry", "label": via, "fieldtype": "Link", "options": "Stock Entry", "width": 150},
+ {"fieldname": "work_order", "label": _("Work Order"), "fieldtype": "Link", "options": "Work Order", "width": 150},
+ {"fieldname": "posting_date", "label": _("Date"), "fieldtype": "Date", "width": 100},
+ {"fieldname": "quality", "label": _("Latest QI Disposition"), "fieldtype": "Data", "width": 140},
+ ]
diff --git a/makap_backend/makap_backend/report/work_order_cost_variance/__init__.py b/makap_backend/makap_backend/report/work_order_cost_variance/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/makap_backend/makap_backend/report/work_order_cost_variance/work_order_cost_variance.js b/makap_backend/makap_backend/report/work_order_cost_variance/work_order_cost_variance.js
new file mode 100644
index 0000000..1c791ac
--- /dev/null
+++ b/makap_backend/makap_backend/report/work_order_cost_variance/work_order_cost_variance.js
@@ -0,0 +1,34 @@
+frappe.query_reports["Work Order Cost Variance"] = {
+ filters: [
+ {
+ fieldname: "company",
+ label: __("Company"),
+ fieldtype: "Link",
+ options: "Company",
+ default: frappe.defaults.get_user_default("Company"),
+ },
+ {
+ fieldname: "from_date",
+ label: __("Completed From"),
+ fieldtype: "Date",
+ default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
+ },
+ {
+ fieldname: "to_date",
+ label: __("Completed To"),
+ fieldtype: "Date",
+ default: frappe.datetime.get_today(),
+ },
+ { fieldname: "sales_order", label: __("Sales Order"), fieldtype: "Link", options: "Sales Order" },
+ { fieldname: "work_order", label: __("Work Order"), fieldtype: "Link", options: "Work Order" },
+ { fieldname: "production_item", label: __("Item"), fieldtype: "Link", options: "Item" },
+ ],
+ formatter(value, row, column, data, default_formatter) {
+ value = default_formatter(value, row, column, data);
+ const variance = ["material_variance", "operating_variance", "total_variance", "variance_pct"];
+ if (variance.includes(column.fieldname) && data && data[column.fieldname] > 0) {
+ value = `${value}`;
+ }
+ return value;
+ },
+};
diff --git a/makap_backend/makap_backend/report/work_order_cost_variance/work_order_cost_variance.json b/makap_backend/makap_backend/report/work_order_cost_variance/work_order_cost_variance.json
new file mode 100644
index 0000000..09afbcd
--- /dev/null
+++ b/makap_backend/makap_backend/report/work_order_cost_variance/work_order_cost_variance.json
@@ -0,0 +1,31 @@
+{
+ "add_total_row": 1,
+ "columns": [],
+ "creation": "2026-10-09 00:00:00",
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "is_standard": "Yes",
+ "letterhead": null,
+ "modified": "2026-10-09 00:00:00",
+ "modified_by": "Administrator",
+ "module": "makap_backend",
+ "name": "Work Order Cost Variance",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Work Order",
+ "report_name": "Work Order Cost Variance",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Manufacturing Manager"
+ },
+ {
+ "role": "Accounts User"
+ },
+ {
+ "role": "Accounts Manager"
+ }
+ ]
+}
diff --git a/makap_backend/makap_backend/report/work_order_cost_variance/work_order_cost_variance.py b/makap_backend/makap_backend/report/work_order_cost_variance/work_order_cost_variance.py
new file mode 100644
index 0000000..cea9355
--- /dev/null
+++ b/makap_backend/makap_backend/report/work_order_cost_variance/work_order_cost_variance.py
@@ -0,0 +1,135 @@
+"""Planned versus actual cost per completed Work Order (PRD 4 stage 5).
+
+Planned = the Work Order's BOM costs scaled to the quantity actually produced.
+Actual = material consumed on submitted Manufacture / Material Consumption
+entries, plus operating cost from Job Card time logs. Values are in company
+currency. Material actuals are only meaningful when Manufacturing Settings
+backflush on "Material Transferred for Manufacture", not on BOM.
+"""
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+CONSUMING_PURPOSES = ("Manufacture", "Material Consumption for Manufacture")
+
+
+def execute(filters=None):
+ filters = frappe._dict(filters or {})
+ work_orders = _work_orders(filters)
+ if not work_orders:
+ return _columns(), []
+
+ names = [wo.name for wo in work_orders]
+ actuals = {row.work_order: row for row in _stock_actuals(names)}
+ waste = {row.work_order: row.waste_kg for row in _waste(names)}
+
+ rows = []
+ for wo in work_orders:
+ share = flt(wo.produced_qty) / flt(wo.qty) if wo.qty else 0
+ per_unit = flt(wo.produced_qty) / flt(wo.bom_qty or 1)
+ planned_material = flt(wo.raw_material_cost) * per_unit
+ planned_scrap = flt(wo.scrap_material_cost) * per_unit
+ planned_operating = flt(wo.planned_operating_cost) * share
+ actual = actuals.get(wo.name) or frappe._dict()
+ actual_material = flt(actual.material)
+ actual_operating = flt(wo.actual_operating_cost)
+ scrap = flt(actual.scrap)
+
+ planned_total = planned_material + planned_operating - planned_scrap
+ actual_total = actual_material + actual_operating - scrap
+ rows.append(
+ {
+ "work_order": wo.name,
+ "production_item": wo.production_item,
+ "sales_order": wo.sales_order,
+ "produced_qty": wo.produced_qty,
+ "planned_material": planned_material,
+ "actual_material": actual_material,
+ "material_variance": actual_material - planned_material,
+ "planned_operating": planned_operating,
+ "actual_operating": actual_operating,
+ "operating_variance": actual_operating - planned_operating,
+ "scrap_value": scrap,
+ "waste_kg": flt(waste.get(wo.name)),
+ "total_variance": actual_total - planned_total,
+ "variance_pct": (actual_total - planned_total) / planned_total * 100 if planned_total else None,
+ }
+ )
+ return _columns(), rows
+
+
+def _work_orders(filters):
+ conditions = ["wo.docstatus = 1", "wo.produced_qty > 0"]
+ for key in ("company", "work_order", "sales_order", "production_item"):
+ if filters.get(key):
+ column = "name" if key == "work_order" else key
+ conditions.append(f"wo.{column} = %({key})s")
+ if filters.get("from_date"):
+ conditions.append("date(coalesce(wo.actual_end_date, wo.modified)) >= %(from_date)s")
+ if filters.get("to_date"):
+ conditions.append("date(coalesce(wo.actual_end_date, wo.modified)) <= %(to_date)s")
+
+ return frappe.db.sql(
+ f"""
+ select wo.name, wo.production_item, wo.sales_order, wo.qty, wo.produced_qty,
+ wo.planned_operating_cost, wo.actual_operating_cost,
+ bom.raw_material_cost, bom.scrap_material_cost, bom.quantity as bom_qty
+ from `tabWork Order` wo
+ left join `tabBOM` bom on bom.name = wo.bom_no
+ where {" and ".join(conditions)}
+ order by coalesce(wo.actual_end_date, wo.modified) desc
+ """,
+ filters,
+ as_dict=True,
+ )
+
+
+def _stock_actuals(work_orders):
+ return frappe.db.sql(
+ """
+ select se.work_order,
+ sum(case when ifnull(sed.s_warehouse, '') != '' then sed.amount else 0 end) as material,
+ sum(case when sed.is_scrap_item = 1 then sed.amount else 0 end) as scrap
+ from `tabStock Entry` se
+ join `tabStock Entry Detail` sed on sed.parent = se.name
+ where se.docstatus = 1 and se.purpose in %(purposes)s and se.work_order in %(work_orders)s
+ group by se.work_order
+ """,
+ {"purposes": CONSUMING_PURPOSES, "work_orders": work_orders},
+ as_dict=True,
+ )
+
+
+def _waste(work_orders):
+ return frappe.db.sql(
+ """
+ select work_order,
+ sum(ifnull(makap_setup_waste_kg, 0) + ifnull(makap_running_waste_kg, 0)) as waste_kg
+ from `tabJob Card`
+ where docstatus = 1 and work_order in %(work_orders)s
+ group by work_order
+ """,
+ {"work_orders": work_orders},
+ as_dict=True,
+ )
+
+
+def _columns():
+ money = {"fieldtype": "Currency", "width": 130}
+ return [
+ {"fieldname": "work_order", "label": _("Work Order"), "fieldtype": "Link", "options": "Work Order", "width": 150},
+ {"fieldname": "production_item", "label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 140},
+ {"fieldname": "sales_order", "label": _("Sales Order"), "fieldtype": "Link", "options": "Sales Order", "width": 140},
+ {"fieldname": "produced_qty", "label": _("Produced Qty"), "fieldtype": "Float", "width": 100},
+ {"fieldname": "planned_material", "label": _("Planned Material"), **money},
+ {"fieldname": "actual_material", "label": _("Actual Material"), **money},
+ {"fieldname": "material_variance", "label": _("Material Variance"), **money},
+ {"fieldname": "planned_operating", "label": _("Planned Operating"), **money},
+ {"fieldname": "actual_operating", "label": _("Actual Operating"), **money},
+ {"fieldname": "operating_variance", "label": _("Operating Variance"), **money},
+ {"fieldname": "scrap_value", "label": _("Scrap Recovered"), **money},
+ {"fieldname": "waste_kg", "label": _("Waste (kg)"), "fieldtype": "Float", "width": 100},
+ {"fieldname": "total_variance", "label": _("Total Variance"), **money},
+ {"fieldname": "variance_pct", "label": _("Variance %"), "fieldtype": "Percent", "width": 100},
+ ]