From 23a2b525a456b9bd2e52349e4faed414b8337e78 Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Sat, 18 Apr 2026 16:54:35 +0200 Subject: [PATCH] feat(verbale): export PDF verbale istruttoria via weasyprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: dipendenze sistema libpango/libgdk-pixbuf/libcairo/shared-mime-info + fonts-dejavu per rendering WeasyPrint su debian slim - requirements: weasyprint==61.2 + pydyf==0.10.0 (vincolo compatibilita, weasyprint 62.x ha bug con pydyf 0.11 su stream.transform) + jinja2==3.1.3 - templates_jinja/verbale_istruttoria.html: layout A4 professionale con intestazione Gepafin, dati pratica, tabelle fatture raggruppate per categoria (dichiarato vs ammesso con motivazione rettifica), ULA, documenti, soccorsi istruttori, totali, checklist finale, note istruttore, blocco firma - routers/verbale: endpoint /verbale.html (debug preview) e /verbale.pdf (weasyprint on-the-fly) — solo ruoli istruttore/superadmin - main: include router verbale, version bump 0.3.0 Testato E2E: PDF 27KB generato su pratica UNDER_REVIEW, magic bytes PDF-1.7 OK. --- Dockerfile | 9 + app/routers/verbale.py | 245 ++++++++++ app/templates_jinja/verbale_istruttoria.html | 482 +++++++++++++++++++ requirements.txt | 3 + 4 files changed, 739 insertions(+) create mode 100644 app/routers/verbale.py create mode 100644 app/templates_jinja/verbale_istruttoria.html diff --git a/Dockerfile b/Dockerfile index 2c2c782..50684dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,15 @@ FROM python:3.12-slim WORKDIR /app ENV TZ=Europe/Rome PYTHONUNBUFFERED=1 +# Dipendenze sistema: weasyprint serve libpango, libgdk-pixbuf, libcairo, +# shared-mime-info per MIME detection, libffi per cffi, fonts-dejavu come fallback. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 \ + libgdk-pixbuf-2.0-0 libcairo2 \ + libffi-dev shared-mime-info \ + fonts-dejavu fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/app/routers/verbale.py b/app/routers/verbale.py new file mode 100644 index 0000000..787bed7 --- /dev/null +++ b/app/routers/verbale.py @@ -0,0 +1,245 @@ +""" +Endpoint generazione verbale di istruttoria in HTML/PDF via Jinja2 + weasyprint. +Solo ruoli istruttore/superadmin possono scaricare. +""" +from datetime import datetime, date +from uuid import UUID +from pathlib import Path +from collections import OrderedDict +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response, HTMLResponse +from jinja2 import Environment, FileSystemLoader, select_autoescape +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ..db import get_db +from ..auth import AuthUser, get_current_user +from ..models import RemissionPractice +from .practices import _compute_gate_check + +router = APIRouter(prefix="/api/remission-practices/instructor", tags=["verbale"]) + + +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates_jinja" + + +# ---------- Jinja env & filters ---------- +def _euro(v): + if v is None: + return "—" + try: + n = float(v) + except (TypeError, ValueError): + return "—" + s = f"{n:,.2f}" + # IT locale: 1,234,567.89 → 1.234.567,89 + s = s.replace(",", "X").replace(".", ",").replace("X", ".") + return f"€ {s}" + + +def _datefmt(v): + if v is None: + return "—" + if isinstance(v, str): + try: + v = datetime.fromisoformat(v).date() + except ValueError: + return v + if hasattr(v, "strftime"): + return v.strftime("%d/%m/%Y") + return str(v) + + +def _datetimefmt(v): + if v is None: + return "—" + if isinstance(v, str): + try: + v = datetime.fromisoformat(v) + except ValueError: + return v + if hasattr(v, "strftime"): + return v.strftime("%d/%m/%Y %H:%M") + return str(v) + + +_AMEND_STATUS = { + "AWAITING": "In attesa risposta", + "RESPONSE_RECEIVED": "Risposta ricevuta", + "CLOSED": "Chiuso", + "EXPIRED": "Scaduto", + "REJECTED": "Rifiutato", +} + + +def _amendstatus(s): + return _AMEND_STATUS.get(s, s or "—") + + +_env = Environment( + loader=FileSystemLoader(str(TEMPLATES_DIR)), + autoescape=select_autoescape(["html", "xml"]), + trim_blocks=True, lstrip_blocks=True, +) +_env.filters["euro"] = _euro +_env.filters["datefmt"] = _datefmt +_env.filters["datetimefmt"] = _datetimefmt +_env.filters["amendstatus"] = _amendstatus + + +_CONTRACT_LABELS = { + "T_IND": "Tempo indeterminato", + "T_DET": "Tempo determinato", + "APPR": "Apprendistato", + "STAGE": "Tirocinio / Stage", + "COLL": "Collaborazione coordinata", + "ALTRO": "Altro", +} + + +def _is_instructor(user: AuthUser) -> bool: + return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN") + + +def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) -> dict: + """Prepara tutto il contesto per il template.""" + # Gate check + totali + gate_obj = _compute_gate_check(practice); gate = gate_obj.model_dump() if hasattr(gate_obj, "model_dump") else dict(gate_obj) + totals = gate.get("totals") or {} + + # Schema sections + sections = practice.schema_snapshot.get("sections") or [] + cat_section = next((s for s in sections if s.get("type") == "category_grid"), {}) or {} + categories = cat_section.get("categories") or [] + categories_map = {c.get("code"): c.get("label") for c in categories} + cat_order = {c.get("code"): i for i, c in enumerate(categories)} + + ula_section = next((s for s in sections if s.get("type") == "ula_block"), {}) or {} + ula_enabled = bool(ula_section.get("enabled")) + ula_threshold = float(ula_section.get("threshold") or 1) + + docs_section = next((s for s in sections if s.get("type") == "document_checklist"), {}) or {} + docs_required_raw = docs_section.get("required_types") or [] + docs_required = [ + (r if isinstance(r, dict) else {"code": r, "label": r}) + for r in docs_required_raw + ] + docs_by_code = {d.doc_code: { + "filename": d.filename, "verification_status": d.verification_status, + "verification_notes": d.verification_notes, + } for d in practice.documents} + + # Raggruppo fatture per categoria in ordine schema + sorted_invoices = sorted( + practice.invoices, + key=lambda i: (cat_order.get(i.category_code, 999), i.invoice_number or "") + ) + invoices_by_cat = OrderedDict() + for c in categories: + invoices_by_cat[c.get("code")] = [] + for inv in sorted_invoices: + invoices_by_cat.setdefault(inv.category_code, []).append(inv) + + per_cat_declared = totals.get("per_category_declared") or {} + per_cat_verified = totals.get("per_category_verified") or {} + + # ULA aggregati + ula_fte_decl = sum(float(e.fte_pct or 0) for e in practice.ula_employees) + ula_fte_verif = sum( + float((e.fte_pct_verified if e.fte_pct_verified is not None else e.fte_pct) or 0) + for e in practice.ula_employees + if e.verification_status in ("AMMESSA", "PARZIALE") + ) + ula_ok = ula_fte_verif >= ula_threshold + + # Anagrafica beneficiario (da gepafin_schema.company) + company_row = db.execute(text(""" + SELECT company_name, vat_number + FROM gepafin_schema.company + WHERE id = :cid + """), {"cid": practice.company_id}).mappings().first() + company = dict(company_row) if company_row else {} + + # Istruttore + instructor_name = None + if practice.reviewed_by: + row = db.execute(text(""" + SELECT first_name || ' ' || last_name AS name + FROM gepafin_schema.gepafin_user WHERE id = :uid + """), {"uid": practice.reviewed_by}).scalar() + instructor_name = row + elif user.user_id: + row = db.execute(text(""" + SELECT first_name || ' ' || last_name AS name + FROM gepafin_schema.gepafin_user WHERE id = :uid + """), {"uid": user.user_id}).scalar() + instructor_name = row + + return { + "practice": practice, + "totals": totals, + "categories_map": categories_map, + "invoices_by_cat": invoices_by_cat, + "per_cat_declared": per_cat_declared, + "per_cat_verified": per_cat_verified, + "ula_section_enabled": ula_enabled, + "ula_threshold": ula_threshold, + "ula_fte_decl": ula_fte_decl, + "ula_fte_verif": ula_fte_verif, + "ula_ok": ula_ok, + "docs_required": docs_required, + "docs_by_code": docs_by_code, + "amendments": practice.amendment_requests or [], + "contract_labels": _CONTRACT_LABELS, + "company": company, + "instructor_name": instructor_name, + "generated_at": datetime.now().strftime("%d/%m/%Y"), + } + + +def _render_html(db: Session, practice_id: UUID, user: AuthUser) -> tuple[RemissionPractice, str]: + practice = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first() + if not practice: + raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata") + if not _is_instructor(user): + raise HTTPException(status_code=403, detail="Ruolo istruttore richiesto") + ctx = _build_context(db, practice, user) + tpl = _env.get_template("verbale_istruttoria.html") + html = tpl.render(**ctx) + return practice, html + + +@router.get("/{practice_id}/verbale.html", response_class=HTMLResponse) +def verbale_html( + practice_id: UUID, + db: Session = Depends(get_db), + user: AuthUser = Depends(get_current_user), +): + """Rendering HTML del verbale — utile per debug e preview rapida.""" + _, html = _render_html(db, practice_id, user) + return HTMLResponse(content=html) + + +@router.get("/{practice_id}/verbale.pdf") +def verbale_pdf( + practice_id: UUID, + db: Session = Depends(get_db), + user: AuthUser = Depends(get_current_user), +): + """Generazione PDF via weasyprint.""" + # Import locale per non rallentare il cold start se weasyprint manca + from weasyprint import HTML as WeasyHTML + + practice, html = _render_html(db, practice_id, user) + pdf_bytes = WeasyHTML(string=html).write_pdf() + filename = f"verbale_istruttoria_pratica_{practice.application_id}.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) diff --git a/app/templates_jinja/verbale_istruttoria.html b/app/templates_jinja/verbale_istruttoria.html new file mode 100644 index 0000000..2c54f5c --- /dev/null +++ b/app/templates_jinja/verbale_istruttoria.html @@ -0,0 +1,482 @@ + + + + +Verbale di istruttoria — Pratica {{ practice.application_id }} + + + + +
+
+
Verbale di istruttoria
+
Rendicontazione bando
+
Pratica n. {{ practice.application_id }}
+
+
+ +
Finanziaria regionale dell'Umbria
+
+
+ +

Verbale di istruttoria — Rendicontazione

+

+ {% if practice.status == 'APPROVED' %} + ESITO: APPROVATA + {% elif practice.status == 'REJECTED' %} + ESITO: RESPINTA + {% elif practice.status == 'AWAITING_AMENDMENT' %} + SOCCORSO ISTRUTTORIO IN CORSO + {% else %} + IN ISTRUTTORIA + {% endif %} +

+ +

Dati pratica

+
+
+
Bando
+
{{ practice.schema_snapshot.template_label or ('Bando #' ~ practice.call_id) }}
+
+
+
Numero applicazione
+
#{{ practice.application_id }}
+
+
+
Beneficiario
+
+ {{ company.company_name or '(non disponibile)' }} + {% if company.vat_number %} · P.IVA {{ company.vat_number }}{% endif %} +
+
+
+
Regime IVA
+
{{ practice.iva_regime or '—' }}
+
+
+
Importo erogato
+
{{ practice.amount_erogato|euro }}
+
+
+
Periodo rendicontazione
+
+ {% set period = practice.schema_snapshot.get('period') or {} %} + {% if period.get('start_date') and period.get('end_date') %} + {{ period.start_date|datefmt }} — {{ period.end_date|datefmt }} + {% else %}—{% endif %} +
+
+
+
Data presentazione
+
{{ practice.submitted_at|datetimefmt if practice.submitted_at else '—' }}
+
+
+
Data istruttoria
+
{{ practice.reviewed_at|datetimefmt if practice.reviewed_at else generated_at }}
+
+
+ +{# ============ FATTURE ============ #} +

Verifica fatture

+{% if practice.invoices %} + {% set use_taxable = totals.use_taxable_only is not sameas false %} + + + + + + + + + + + + + + {% for cat_code, items in invoices_by_cat.items() %} + {% set cat_label = categories_map.get(cat_code, cat_code) %} + {% set cat_decl = per_cat_declared.get(cat_code, 0) %} + {% set cat_verif = per_cat_verified.get(cat_code, 0) %} + + + + + + + {% for inv in items %} + {% set cls = 'rejected' if inv.verification_status == 'RESPINTA' else ('partial' if inv.verification_status == 'PARZIALE' else '') %} + {% set declared = inv.taxable if use_taxable else inv.total %} + {% if inv.verification_status == 'PENDING' %} + {% set verified_val = None %} + {% elif inv.verification_status == 'RESPINTA' %} + {% set verified_val = 0 %} + {% elif use_taxable %} + {% set verified_val = inv.taxable_verified if inv.taxable_verified is not none else declared %} + {% else %} + {% set verified_val = inv.total_verified if inv.total_verified is not none else declared %} + {% endif %} + + + + + + + + + + {% endfor %} + {% endfor %} + + + + + + + +
DataFornitore / Descrizione{{ 'Imponibile' if use_taxable else 'Totale' }} dichiarato{{ 'Imponibile' if use_taxable else 'Totale' }} ammessoStatoMotivazione istruttore
{{ cat_code }} — {{ cat_label }}{{ cat_decl|euro }}{{ cat_verif|euro }}{{ items|length }} fatture
{{ inv.invoice_number }}{{ inv.invoice_date|datefmt }} + {{ inv.supplier_name }}
+ {{ inv.description|truncate(80) }} +
{{ declared|euro }}{{ verified_val|euro if verified_val is not none else '—' }}{{ inv.verification_status }}{{ inv.verification_notes or '—' }}
Totale complessivo{{ totals.grand_total_declared|euro }}{{ totals.grand_total_verified|euro }}
+{% else %} +

Nessuna fattura rendicontata.

+{% endif %} + +{# ============ ULA ============ #} +{% if ula_section_enabled and practice.ula_employees %} +

Verifica dipendenti ULA

+

Soglia incremento richiesta: ≥ {{ '%.2f'|format(ula_threshold) }} · FTE dichiarato: {{ '%.2f'|format(ula_fte_decl) }} · FTE ammesso: {{ '%.2f'|format(ula_fte_verif) }}

+ + + + + + + + + + + + + + + {% for emp in practice.ula_employees %} + {% set cls = 'rejected' if emp.verification_status == 'RESPINTA' else ('partial' if emp.verification_status == 'PARZIALE' else '') %} + {% set fte_verif = emp.fte_pct_verified if emp.fte_pct_verified is not none else emp.fte_pct %} + + + + + + + + + + + {% endfor %} + +
CFDipendenteContrattoPeriodoFTE dich.FTE amm.StatoNote
{{ emp.codice_fiscale }}{{ emp.full_name }}{{ contract_labels.get(emp.contract_type, emp.contract_type) }}{{ emp.period_start_date|datefmt }}
→ {{ emp.period_end_date|datefmt }}
{{ '%.2f'|format(emp.fte_pct|float) }} + {% if emp.verification_status == 'PENDING' %}— + {% elif emp.verification_status == 'RESPINTA' %}0.00 + {% else %}{{ '%.2f'|format(fte_verif|float) }} + {% endif %} + {{ emp.verification_status }}{{ emp.verification_notes|default('—') }}
+{% endif %} + +{# ============ DOCUMENTI ============ #} +

Verifica documenti

+{% if docs_required %} + + + + + + + + + + + {% for dr in docs_required %} + {% set doc = docs_by_code.get(dr.code, {}) %} + {% set stat = doc.verification_status or 'PENDING' %} + {% set cls = 'rejected' if stat in ('NON_VALIDO', 'SCADUTO') else '' %} + + + + + + + {% endfor %} + +
DocumentoFile allegatoEsitoNote istruttore
{{ dr.label }}
{{ dr.code }}
+ {% if doc.filename %}{{ doc.filename }} + {% else %}non caricato + {% endif %} + {{ stat }}{{ doc.verification_notes|default('—') }}
+{% else %} +

Nessun documento richiesto dallo schema del bando.

+{% endif %} + +{# ============ SOCCORSI ============ #} +{% if amendments %} +

Soccorso istruttorio

+ {% for a in amendments %} +
+
+ {{ a.status|amendstatus }} + · deadline {{ a.deadline|datefmt }} + · aperto il {{ a.created_at|datetimefmt }} + {% if a.closed_at %} · chiuso il {{ a.closed_at|datetimefmt }}{% endif %} +
+
Richiesta istruttore:
{{ a.request_text }}
+ {% if a.response_text %} +
Risposta beneficiario ({{ a.response_at|datetimefmt }}):
{{ a.response_text }}
+ {% endif %} +
+ {% endfor %} +{% endif %} + +{# ============ TOTALI ============ #} +

Riepilogo finanziario

+
+
+
+
Totale dichiarato
+
{{ totals.grand_total_declared|euro }}
+
+
+
Totale ammesso
+
{{ totals.grand_total_verified|euro }}
+
+
+
Cap remissione
+
{{ totals.max_remission|euro }}
+
+
+
Remissione spettante
+
{{ totals.remission_due|euro }}
+
+
+ {% if practice.status == 'APPROVED' %} +
+
+
Remissione approvata
+
{{ practice.approved_remission|euro }}
+
+
+
Residuo da restituire
+
{{ (practice.amount_erogato - (practice.approved_remission or 0))|euro }}
+
+
+
+
+ {% endif %} +
+ +{# ============ CHECKLIST + NOTE ============ #} +{% set checklist = practice.instructor_checklist or {} %} +{% if checklist %} +

Checklist finale istruttore

+ +{% endif %} + +{% if practice.instructor_final_notes %} +

Note sintetiche di istruttoria

+
{{ practice.instructor_final_notes }}
+{% endif %} + +{% if practice.rejection_reason %} +

Motivazione del rigetto

+
{{ practice.rejection_reason }}
+{% endif %} + +{# ============ FIRMA ============ #} +
+
+
LUOGO E DATA
+
Perugia, {{ generated_at }}
+
 
+
+
+
ISTRUTTORE
+
{{ instructor_name or '(firma)' }}
+
Firma
+
+
+ + + diff --git a/requirements.txt b/requirements.txt index f630ff4..461352f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,6 @@ pydantic==2.6.3 pydantic-settings==2.2.1 python-jose[cryptography]==3.3.0 python-multipart==0.0.9 +weasyprint==61.2 +pydyf==0.10.0 +jinja2==3.1.3