Add Roll Genealogy and Work Order Cost Variance reports
- Roll Genealogy traces a batch backward (what went into it) or forward (where it was used) through submitted Manufacture entries, showing roll dimensions and the latest inspection disposition at each step (B3). - Work Order Cost Variance compares BOM-planned material, operating cost and scrap with actual consumption and Job Card time, plus recorded waste kg (M5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = `<span class="text-danger">${value}</span>`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
]
|
||||||
+34
@@ -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 = `<span class="text-danger">${value}</span>`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
+31
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+135
@@ -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},
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user