"""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