197 lines
8.5 KiB
Python
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
|