Close review gaps: gate fixes, cylinder register, roll tracking, costing, Uganda setup #1

Merged
denis_ireri merged 8 commits from feat/review-backlog-fixes into develop 2026-10-10 10:02:47 +00:00
9 changed files with 402 additions and 0 deletions
Showing only changes of commit c515066403 - Show all commits
@@ -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},
]
@@ -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;
},
};
@@ -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"
}
]
}
@@ -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},
]