Files
cetana-backend/apps/reports/services.py
T

197 lines
8.5 KiB
Python

"""Final report generation.
Reports are built from the workflow's active report definition and the order's
data (customer, weights, selected steps, price breakdown, execution timeline,
measurements, payments and invoices). The schema is configurable — there is no
hard-coded pistachio quality schema.
"""
from io import BytesIO
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from apps.core.jalali import jalali_datetime
from apps.reports.models import FinalReport, ReportDefinition
GREEN = colors.HexColor("#166534")
DARK = colors.HexColor("#123B24")
LINE = colors.HexColor("#E7E1D4")
CREAM = colors.HexColor("#FAF6EE")
class ReportService:
def generate(self, order) -> FinalReport:
definition = ReportDefinition.objects.filter(
workflow=order.workflow_version.workflow, active=True
).first()
data = self._build_data(order, definition)
report, _ = FinalReport.objects.update_or_create(order=order, defaults={"data": data})
return report
def _build_data(self, order, definition) -> dict:
measurements = {}
timeline = []
for execution in order.step_executions.select_related("step", "assigned_to").order_by("created_at"):
for key, value in (execution.measurements or {}).items():
measurements.setdefault(key, value)
timeline.append({
"step": execution.step.name,
"state": execution.state,
"attempt": execution.attempt,
"operator": execution.assigned_to.username if execution.assigned_to else "",
"started_at": execution.started_at.isoformat() if execution.started_at else "",
"completed_at": execution.completed_at.isoformat() if execution.completed_at else "",
})
fields = []
if definition:
for field in definition.fields.all():
fields.append({
"key": field.key,
"label": field.label,
"type": field.field_type,
"value": measurements.get(field.key, ""),
})
customer = order.customer
customer_name = customer.company_name or str(customer.user)
return {
"order_number": order.order_number,
"customer": {
"name": customer_name,
"code": customer.customer_code or "",
"phone": customer.user.phone,
"email": customer.user.email,
},
"customer_name": customer_name,
"gross_weight_kg": str(order.gross_weight_kg or ""),
"tare_weight_kg": str(order.tare_weight_kg or ""),
"net_weight_kg": str(order.net_weight_kg or ""),
"selected_steps": order.selected_step_keys or [],
"price": order.price_snapshot or {},
"status": order.status,
"created_at": order.created_at.isoformat(),
"confirmed_at": order.confirmed_at.isoformat() if order.confirmed_at else "",
"timeline": timeline,
"fields": fields,
"measurements": measurements,
"payments": [
{"status": p.status, "amount": str(p.amount), "currency": p.currency}
for p in order.payments.all()
],
"invoices": [
{"number": i.number, "sync_state": i.sync_state, "amount": str(i.amount)}
for i in order.invoices.all()
],
}
# ------------------------------------------------------------------ PDF
def render_pdf(self, report) -> bytes:
buf = BytesIO()
doc = SimpleDocTemplate(
buf, pagesize=A4,
rightMargin=18 * mm, leftMargin=18 * mm, topMargin=18 * mm, bottomMargin=18 * mm,
title=f"Cetana report {report.order.order_number}",
)
styles = getSampleStyleSheet()
h2 = ParagraphStyle("H2", parent=styles["Heading2"], textColor=GREEN, spaceBefore=10, spaceAfter=6)
title = ParagraphStyle("T", parent=styles["Title"], fontSize=20, textColor=DARK)
data = report.data or {}
story = [Paragraph("Cetana — Processing Report", title), Spacer(1, 4 * mm)]
# Summary
summary = [
["Order", data.get("order_number", "")],
["Customer", data.get("customer_name", "")],
["Status", data.get("status", "")],
["Net weight (kg)", data.get("net_weight_kg", "")],
["Gross / Tare (kg)", f"{data.get('gross_weight_kg', '')} / {data.get('tare_weight_kg', '')}"],
]
story.append(self._table(summary, [50 * mm, 120 * mm]))
story.append(Spacer(1, 4 * mm))
# Selected steps
steps = data.get("selected_steps") or []
if steps:
story.append(Paragraph("Selected processing steps", h2))
story.append(Paragraph(", ".join(steps), styles["Normal"]))
story.append(Spacer(1, 3 * mm))
# Execution timeline
timeline = data.get("timeline") or []
if timeline:
story.append(Paragraph("Execution timeline", h2))
rows = [["Step", "State", "Attempt", "Operator", "Completed"]]
for item in timeline:
rows.append([
item.get("step", ""), item.get("state", ""), str(item.get("attempt", "")),
item.get("operator", ""), jalali_datetime(item.get("completed_at", "")),
])
story.append(self._table(rows, [45 * mm, 30 * mm, 18 * mm, 35 * mm, 42 * mm], header=True))
story.append(Spacer(1, 3 * mm))
# Quality / dynamic fields
fields = data.get("fields") or []
if fields:
story.append(Paragraph("Quality & results", h2))
rows = [["Field", "Value"]]
for field in fields:
rows.append([field.get("label", ""), str(field.get("value", "") or "—")])
story.append(self._table(rows, [70 * mm, 100 * mm], header=True))
story.append(Spacer(1, 3 * mm))
# Price breakdown
price = data.get("price") or {}
if price:
story.append(Paragraph("Price breakdown", h2))
rows = [["Item", "Quantity", "Unit price", "Amount"]]
for line in price.get("lines", []):
rows.append([
line.get("rule", ""), line.get("quantity", ""),
line.get("unit_price", ""), line.get("amount", ""),
])
rows.append(["Subtotal", "", "", price.get("subtotal", "")])
rows.append(["Discounts", "", "", price.get("discounts", "")])
rows.append(["Taxes", "", "", price.get("taxes", "")])
rows.append(["TOTAL", "", "", f"{price.get('total', '')} {price.get('currency', '')}"])
story.append(self._table(rows, [70 * mm, 25 * mm, 30 * mm, 45 * mm], header=True))
story.append(Spacer(1, 3 * mm))
# Payments / invoices
payments = data.get("payments") or []
invoices = data.get("invoices") or []
if payments or invoices:
story.append(Paragraph("Payments & invoices", h2))
rows = [["Type", "Reference", "Status", "Amount"]]
for p in payments:
rows.append(["Payment", "—", p.get("status", ""), f"{p.get('amount', '')} {p.get('currency', '')}"])
for i in invoices:
rows.append(["Invoice", i.get("number", ""), i.get("sync_state", ""), i.get("amount", "")])
story.append(self._table(rows, [30 * mm, 60 * mm, 40 * mm, 40 * mm], header=True))
doc.build(story)
return buf.getvalue()
def _table(self, rows, col_widths, header=False):
table = Table(rows, colWidths=col_widths)
style = [
("GRID", (0, 0), (-1, -1), 0.5, LINE),
("FONTSIZE", (0, 0), (-1, -1), 9),
("PADDING", (0, 0), (-1, -1), 5),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 0), (-1, -1), [colors.white, CREAM]),
]
if header:
style += [
("BACKGROUND", (0, 0), (-1, 0), GREEN),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
]
table.setStyle(TableStyle(style))
return table