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}, + ]