fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# reports app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReportsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.reports"
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('orders', '0001_initial'),
|
||||
('workflows', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FinalReport',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('data', models.JSONField(blank=True, default=dict)),
|
||||
('generated_at', models.DateTimeField(auto_now_add=True)),
|
||||
('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='final_report', to='orders.processingorder')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReportDefinition',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_definitions', to='workflows.workflow')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReportField',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('key', models.SlugField(max_length=100)),
|
||||
('label', models.CharField(max_length=200)),
|
||||
('field_type', models.CharField(choices=[('text', 'Text'), ('integer', 'Integer'), ('decimal', 'Decimal'), ('boolean', 'Boolean'), ('date', 'Date'), ('datetime', 'DateTime'), ('choice', 'Choice'), ('multichoice', 'Multi-choice'), ('measurement', 'Measurement'), ('percentage', 'Percentage'), ('weight', 'Weight'), ('file', 'File / image')], default='text', max_length=16)),
|
||||
('required', models.BooleanField(default=False)),
|
||||
('choices', models.JSONField(blank=True, default=list)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('definition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='reports.reportdefinition')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
'constraints': [models.UniqueConstraint(fields=('definition', 'key'), name='uniq_report_field_key')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class ReportDefinition(UUIDModel, TimeStampedModel):
|
||||
workflow = models.ForeignKey("workflows.Workflow", on_delete=models.CASCADE, related_name="report_definitions")
|
||||
name = models.CharField(max_length=200)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class ReportField(UUIDModel):
|
||||
class FieldType(models.TextChoices):
|
||||
TEXT = "text", "Text"
|
||||
INTEGER = "integer", "Integer"
|
||||
DECIMAL = "decimal", "Decimal"
|
||||
BOOLEAN = "boolean", "Boolean"
|
||||
DATE = "date", "Date"
|
||||
DATETIME = "datetime", "DateTime"
|
||||
CHOICE = "choice", "Choice"
|
||||
MULTICHOICE = "multichoice", "Multi-choice"
|
||||
MEASUREMENT = "measurement", "Measurement"
|
||||
PERCENTAGE = "percentage", "Percentage"
|
||||
WEIGHT = "weight", "Weight"
|
||||
FILE = "file", "File / image"
|
||||
|
||||
definition = models.ForeignKey(ReportDefinition, on_delete=models.CASCADE, related_name="fields")
|
||||
key = models.SlugField(max_length=100)
|
||||
label = models.CharField(max_length=200)
|
||||
field_type = models.CharField(max_length=16, choices=FieldType.choices, default=FieldType.TEXT)
|
||||
required = models.BooleanField(default=False)
|
||||
choices = models.JSONField(default=list, blank=True)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["definition", "key"], name="uniq_report_field_key")
|
||||
]
|
||||
|
||||
|
||||
class FinalReport(UUIDModel, TimeStampedModel):
|
||||
order = models.OneToOneField("orders.ProcessingOrder", on_delete=models.CASCADE, related_name="final_report")
|
||||
data = models.JSONField(default=dict, blank=True)
|
||||
generated_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -0,0 +1,24 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.reports.models import FinalReport, ReportDefinition, ReportField
|
||||
|
||||
|
||||
class ReportFieldSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ReportField
|
||||
fields = "__all__"
|
||||
read_only_fields = ["id"]
|
||||
|
||||
|
||||
class ReportDefinitionSerializer(serializers.ModelSerializer):
|
||||
fields = ReportFieldSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ReportDefinition
|
||||
fields = ["id", "workflow", "name", "active", "fields"]
|
||||
|
||||
|
||||
class FinalReportSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FinalReport
|
||||
fields = "__all__"
|
||||
@@ -0,0 +1,194 @@
|
||||
"""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.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", ""), 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
|
||||
@@ -0,0 +1,36 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Customer
|
||||
from apps.orders.models import ProcessingOrder
|
||||
from apps.reports.services import ReportService
|
||||
from apps.workflows.models import Workflow, WorkflowVersion
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ReportServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="c", password="***")
|
||||
self.customer = Customer.objects.create(user=self.user)
|
||||
wf = Workflow.objects.create(name="W", slug="w-report")
|
||||
version = WorkflowVersion.objects.create(workflow=wf, version=1)
|
||||
self.order = ProcessingOrder.objects.create(
|
||||
customer=self.customer,
|
||||
workflow_version=version,
|
||||
net_weight_kg=Decimal("920"),
|
||||
price_snapshot={"total": "4600000.00", "currency": "IRR"},
|
||||
)
|
||||
|
||||
def test_generate_builds_data(self):
|
||||
report = ReportService().generate(self.order)
|
||||
self.assertEqual(report.data["order_number"], self.order.order_number)
|
||||
self.assertEqual(report.data["net_weight_kg"], "920")
|
||||
|
||||
def test_render_pdf(self):
|
||||
report = ReportService().generate(self.order)
|
||||
pdf = ReportService().render_pdf(report)
|
||||
self.assertGreater(len(pdf), 1000)
|
||||
self.assertTrue(pdf.startswith(b"%PDF"))
|
||||
@@ -0,0 +1,9 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.reports.views import FinalReportViewSet, ReportDefinitionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("definitions", ReportDefinitionViewSet, basename="report-definition")
|
||||
router.register("", FinalReportViewSet, basename="final-report")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,69 @@
|
||||
from django.http import HttpResponse
|
||||
from rest_framework import decorators, mixins, response, status, viewsets
|
||||
|
||||
from apps.core.errors import error_response
|
||||
from apps.orders.models import ProcessingOrder
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
from apps.reports.models import FinalReport, ReportDefinition
|
||||
from apps.reports.serializers import FinalReportSerializer, ReportDefinitionSerializer
|
||||
from apps.reports.services import ReportService
|
||||
|
||||
|
||||
class ReportDefinitionViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
queryset = ReportDefinition.objects.prefetch_related("fields").all()
|
||||
serializer_class = ReportDefinitionSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {"list": "report.view", "retrieve": "report.view"}
|
||||
|
||||
|
||||
class FinalReportViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
queryset = FinalReport.objects.select_related("order").all()
|
||||
serializer_class = FinalReportSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {
|
||||
"list": "report.view",
|
||||
"retrieve": "report.view",
|
||||
"generate": "report.create",
|
||||
"pdf": "report.view",
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = FinalReport.objects.select_related("order").all()
|
||||
if user.is_superuser:
|
||||
return qs
|
||||
return qs.filter(order__customer__user=user)
|
||||
|
||||
@decorators.action(detail=False, methods=["post"])
|
||||
def generate(self, request):
|
||||
order_id = request.data.get("order")
|
||||
if not order_id:
|
||||
return response.Response(
|
||||
error_response("ORDER_REQUIRED", "An order id is required."),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
order = ProcessingOrder.objects.select_related("customer").get(pk=order_id)
|
||||
except ProcessingOrder.DoesNotExist:
|
||||
return response.Response(
|
||||
error_response("ORDER_NOT_FOUND", "Order not found."),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if not request.user.is_superuser and order.customer.user_id != request.user.id:
|
||||
return response.Response(
|
||||
error_response("FORBIDDEN", "You do not have access to this order."),
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
report = ReportService().generate(order)
|
||||
return response.Response(FinalReportSerializer(report).data)
|
||||
|
||||
@decorators.action(detail=True, methods=["get"])
|
||||
def pdf(self, request, pk=None):
|
||||
report = self.get_object()
|
||||
pdf_bytes = ReportService().render_pdf(report)
|
||||
filename = f"{report.order.order_number}.pdf"
|
||||
return HttpResponse(
|
||||
pdf_bytes,
|
||||
content_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
Reference in New Issue
Block a user