feat(verbale): export PDF verbale istruttoria via weasyprint

- 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.
This commit is contained in:
BFLOWS
2026-04-18 16:54:35 +02:00
parent 9a0a401ffa
commit 23a2b525a4
4 changed files with 739 additions and 0 deletions

View File

@@ -3,6 +3,15 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
ENV TZ=Europe/Rome PYTHONUNBUFFERED=1 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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

245
app/routers/verbale.py Normal file
View File

@@ -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",
},
)

View File

@@ -0,0 +1,482 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>Verbale di istruttoria — Pratica {{ practice.application_id }}</title>
<style>
@page {
size: A4;
margin: 22mm 18mm 20mm 18mm;
@top-left {
content: "GEPAFIN S.p.A.";
font-family: "DejaVu Sans", Helvetica, sans-serif;
font-size: 8pt;
color: #4a5568;
letter-spacing: 0.5px;
}
@top-right {
content: "Verbale di istruttoria — Pratica {{ practice.application_id }}";
font-family: "DejaVu Sans", Helvetica, sans-serif;
font-size: 8pt;
color: #4a5568;
}
@bottom-center {
content: "Pagina " counter(page) " di " counter(pages);
font-family: "DejaVu Sans", Helvetica, sans-serif;
font-size: 8pt;
color: #718096;
}
@bottom-right {
content: "Generato: {{ generated_at }}";
font-family: "DejaVu Sans", Helvetica, sans-serif;
font-size: 7pt;
color: #a0aec0;
}
}
html { font-family: "DejaVu Sans", Helvetica, sans-serif; font-size: 10pt; color: #1a202c; }
body { margin: 0; }
h1 { font-size: 18pt; margin: 0 0 4pt 0; color: #1a365d; letter-spacing: -0.3px; }
h2 { font-size: 12pt; margin: 18pt 0 6pt 0; padding: 4pt 0 4pt 8pt;
border-left: 3pt solid #2b6cb0; color: #1a365d; page-break-after: avoid; }
h3 { font-size: 10pt; margin: 12pt 0 4pt 0; color: #2d3748; page-break-after: avoid; }
p { margin: 4pt 0; line-height: 1.4; }
small { color: #4a5568; }
/* Intestazione GEPAFIN */
.hdr {
border-bottom: 2pt solid #1a365d;
padding-bottom: 10pt;
margin-bottom: 14pt;
}
.hdr__logo {
font-size: 22pt; font-weight: 900; color: #1a365d; letter-spacing: 1pt;
display: inline-block;
}
.hdr__subtitle {
font-size: 9pt; color: #4a5568; margin-top: 2pt; letter-spacing: 0.5px;
}
.hdr__right {
float: right; text-align: right; font-size: 9pt; color: #4a5568;
}
/* Badge esito */
.badge {
display: inline-block; padding: 3pt 8pt; border-radius: 4pt;
font-size: 10pt; font-weight: 700; letter-spacing: 0.4pt;
}
.badge--approved { background: #c6f6d5; color: #22543d; border: 0.5pt solid #68d391; }
.badge--rejected { background: #fed7d7; color: #742a2a; border: 0.5pt solid #fc8181; }
.badge--review { background: #fefcbf; color: #744210; border: 0.5pt solid #ecc94b; }
.badge--amendment { background: #feebc8; color: #7b341e; border: 0.5pt solid #f6ad55; }
/* Grid dati pratica */
.meta-grid {
display: table; width: 100%; border-collapse: collapse;
margin: 8pt 0 4pt 0; background: #f7fafc; border: 0.5pt solid #e2e8f0;
}
.meta-grid .row { display: table-row; }
.meta-grid .cell { display: table-cell; padding: 5pt 8pt;
border-bottom: 0.3pt solid #edf2f7; vertical-align: top; }
.meta-grid .cell.label { width: 32%; font-weight: 700; color: #4a5568; font-size: 9pt; background: #edf2f7; }
.meta-grid .cell.val { font-size: 10pt; }
/* Tabelle fatture / ULA / doc */
table.data {
width: 100%; border-collapse: collapse; margin: 4pt 0 8pt 0;
font-size: 8.5pt; border: 0.5pt solid #cbd5e0;
}
table.data th {
background: #2d3748; color: white; padding: 4pt 6pt;
font-weight: 600; text-align: left; border-right: 0.3pt solid #4a5568;
}
table.data td {
padding: 4pt 6pt; border-bottom: 0.3pt solid #e2e8f0;
border-right: 0.3pt solid #edf2f7; vertical-align: top;
}
table.data td.num { text-align: right; font-variant-numeric: tabular-nums; }
table.data tr.subheader td {
background: #ebf4ff; color: #2a4365; font-weight: 700;
border-top: 0.5pt solid #4299e1; border-bottom: 0.5pt solid #4299e1;
padding: 5pt 6pt; font-size: 9pt;
}
table.data tr.totals td {
background: #f7fafc; font-weight: 700; border-top: 0.8pt solid #2d3748;
}
table.data tr.rejected td {
background: #fff5f5; color: #742a2a;
}
table.data tr.partial td {
background: #fffaf0;
}
/* Stato tag inline */
.status-inline {
display: inline-block; padding: 1pt 5pt; border-radius: 3pt;
font-size: 7.5pt; font-weight: 700; letter-spacing: 0.3pt;
}
.status-AMMESSA, .status-VALIDO { background: #c6f6d5; color: #22543d; }
.status-PARZIALE { background: #feebc8; color: #7b341e; }
.status-RESPINTA, .status-NON_VALIDO, .status-SCADUTO { background: #fed7d7; color: #742a2a; }
.status-PENDING { background: #edf2f7; color: #4a5568; }
.note-box {
margin: 4pt 0 6pt 0;
padding: 6pt 8pt;
background: #fffaf0;
border-left: 2pt solid #ed8936;
font-size: 9pt; font-style: italic;
}
.totals-summary {
display: table; width: 100%; margin: 10pt 0;
background: #f7fafc; border: 0.5pt solid #cbd5e0;
}
.totals-summary .row { display: table-row; }
.totals-summary .cell {
display: table-cell; padding: 8pt 10pt; width: 25%;
border-right: 0.3pt solid #e2e8f0; vertical-align: middle;
}
.totals-summary .cell:last-child { border-right: none; }
.totals-summary .lbl { font-size: 8pt; color: #4a5568; text-transform: uppercase; letter-spacing: 0.3pt; }
.totals-summary .val { font-size: 14pt; font-weight: 700; color: #1a365d; margin-top: 2pt; }
.totals-summary .val.final { color: #2b6cb0; }
.totals-summary .val.residuo { color: #c53030; }
.amend-box {
border: 0.5pt solid #f6ad55; border-left: 2pt solid #ed8936;
background: #fffaf0; padding: 6pt 10pt; margin: 4pt 0 6pt 0;
}
.amend-box .head { font-size: 9pt; color: #744210; margin-bottom: 4pt; }
.amend-box .req, .amend-box .resp { font-size: 9pt; margin: 3pt 0; white-space: pre-wrap; }
.amend-box .resp { padding: 4pt 6pt; background: white; border-radius: 2pt; }
/* Firma */
.sign-block {
margin-top: 18pt;
display: table; width: 100%;
}
.sign-block .col { display: table-cell; width: 50%; padding: 6pt 10pt; vertical-align: top; }
.sign-block .lbl { font-size: 9pt; color: #4a5568; font-weight: 700; letter-spacing: 0.3pt; }
.sign-block .val { font-size: 10pt; margin-top: 2pt; }
.sign-block .sig-line {
margin-top: 30pt;
border-top: 0.5pt solid #2d3748;
font-size: 8pt;
color: #4a5568;
padding-top: 2pt;
}
.ok { color: #22543d; font-weight: 700; }
.ko { color: #c53030; font-weight: 700; }
.text-secondary { color: #718096; }
/* Clearfix per header float */
.clearfix::after { content: ""; display: table; clear: both; }
</style>
</head>
<body>
<div class="hdr clearfix">
<div class="hdr__right">
<div><strong>Verbale di istruttoria</strong></div>
<div>Rendicontazione bando</div>
<div>Pratica n. {{ practice.application_id }}</div>
</div>
<div>
<span class="hdr__logo">GEPAFIN</span>
<div class="hdr__subtitle">Finanziaria regionale dell'Umbria</div>
</div>
</div>
<h1>Verbale di istruttoria — Rendicontazione</h1>
<p>
{% if practice.status == 'APPROVED' %}
<span class="badge badge--approved">ESITO: APPROVATA</span>
{% elif practice.status == 'REJECTED' %}
<span class="badge badge--rejected">ESITO: RESPINTA</span>
{% elif practice.status == 'AWAITING_AMENDMENT' %}
<span class="badge badge--amendment">SOCCORSO ISTRUTTORIO IN CORSO</span>
{% else %}
<span class="badge badge--review">IN ISTRUTTORIA</span>
{% endif %}
</p>
<h2>Dati pratica</h2>
<div class="meta-grid">
<div class="row">
<div class="cell label">Bando</div>
<div class="cell val">{{ practice.schema_snapshot.template_label or ('Bando #' ~ practice.call_id) }}</div>
</div>
<div class="row">
<div class="cell label">Numero applicazione</div>
<div class="cell val">#{{ practice.application_id }}</div>
</div>
<div class="row">
<div class="cell label">Beneficiario</div>
<div class="cell val">
{{ company.company_name or '(non disponibile)' }}
{% if company.vat_number %} · P.IVA {{ company.vat_number }}{% endif %}
</div>
</div>
<div class="row">
<div class="cell label">Regime IVA</div>
<div class="cell val">{{ practice.iva_regime or '—' }}</div>
</div>
<div class="row">
<div class="cell label">Importo erogato</div>
<div class="cell val">{{ practice.amount_erogato|euro }}</div>
</div>
<div class="row">
<div class="cell label">Periodo rendicontazione</div>
<div class="cell val">
{% 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 %}
</div>
</div>
<div class="row">
<div class="cell label">Data presentazione</div>
<div class="cell val">{{ practice.submitted_at|datetimefmt if practice.submitted_at else '—' }}</div>
</div>
<div class="row">
<div class="cell label">Data istruttoria</div>
<div class="cell val">{{ practice.reviewed_at|datetimefmt if practice.reviewed_at else generated_at }}</div>
</div>
</div>
{# ============ FATTURE ============ #}
<h2>Verifica fatture</h2>
{% if practice.invoices %}
{% set use_taxable = totals.use_taxable_only is not sameas false %}
<table class="data">
<thead>
<tr>
<th style="width:9%"></th>
<th style="width:9%">Data</th>
<th style="width:24%">Fornitore / Descrizione</th>
<th style="width:12%" class="num">{{ 'Imponibile' if use_taxable else 'Totale' }} dichiarato</th>
<th style="width:12%" class="num">{{ 'Imponibile' if use_taxable else 'Totale' }} ammesso</th>
<th style="width:10%">Stato</th>
<th style="width:24%">Motivazione istruttore</th>
</tr>
</thead>
<tbody>
{% 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) %}
<tr class="subheader">
<td colspan="3"><strong>{{ cat_code }}</strong> — {{ cat_label }}</td>
<td class="num">{{ cat_decl|euro }}</td>
<td class="num">{{ cat_verif|euro }}</td>
<td colspan="2"><small>{{ items|length }} fatture</small></td>
</tr>
{% 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 %}
<tr class="{{ cls }}">
<td>{{ inv.invoice_number }}</td>
<td>{{ inv.invoice_date|datefmt }}</td>
<td>
<strong>{{ inv.supplier_name }}</strong><br>
<small>{{ inv.description|truncate(80) }}</small>
</td>
<td class="num">{{ declared|euro }}</td>
<td class="num">{{ verified_val|euro if verified_val is not none else '—' }}</td>
<td><span class="status-inline status-{{ inv.verification_status }}">{{ inv.verification_status }}</span></td>
<td>{{ inv.verification_notes or '—' }}</td>
</tr>
{% endfor %}
{% endfor %}
<tr class="totals">
<td colspan="3"><strong>Totale complessivo</strong></td>
<td class="num">{{ totals.grand_total_declared|euro }}</td>
<td class="num">{{ totals.grand_total_verified|euro }}</td>
<td colspan="2"></td>
</tr>
</tbody>
</table>
{% else %}
<p class="text-secondary">Nessuna fattura rendicontata.</p>
{% endif %}
{# ============ ULA ============ #}
{% if ula_section_enabled and practice.ula_employees %}
<h2>Verifica dipendenti ULA</h2>
<p><small>Soglia incremento richiesta: <strong>≥ {{ '%.2f'|format(ula_threshold) }}</strong> · FTE dichiarato: <strong>{{ '%.2f'|format(ula_fte_decl) }}</strong> · FTE ammesso: <strong class="{{ 'ok' if ula_ok else 'ko' }}">{{ '%.2f'|format(ula_fte_verif) }}</strong></small></p>
<table class="data">
<thead>
<tr>
<th style="width:16%">CF</th>
<th style="width:18%">Dipendente</th>
<th style="width:14%">Contratto</th>
<th style="width:16%">Periodo</th>
<th style="width:8%" class="num">FTE dich.</th>
<th style="width:8%" class="num">FTE amm.</th>
<th style="width:8%">Stato</th>
<th style="width:12%">Note</th>
</tr>
</thead>
<tbody>
{% 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 %}
<tr class="{{ cls }}">
<td>{{ emp.codice_fiscale }}</td>
<td>{{ emp.full_name }}</td>
<td>{{ contract_labels.get(emp.contract_type, emp.contract_type) }}</td>
<td>{{ emp.period_start_date|datefmt }}<br><small>→ {{ emp.period_end_date|datefmt }}</small></td>
<td class="num">{{ '%.2f'|format(emp.fte_pct|float) }}</td>
<td class="num">
{% if emp.verification_status == 'PENDING' %}—
{% elif emp.verification_status == 'RESPINTA' %}0.00
{% else %}{{ '%.2f'|format(fte_verif|float) }}
{% endif %}
</td>
<td><span class="status-inline status-{{ emp.verification_status }}">{{ emp.verification_status }}</span></td>
<td>{{ emp.verification_notes|default('—') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# ============ DOCUMENTI ============ #}
<h2>Verifica documenti</h2>
{% if docs_required %}
<table class="data">
<thead>
<tr>
<th style="width:25%">Documento</th>
<th style="width:20%">File allegato</th>
<th style="width:12%">Esito</th>
<th style="width:43%">Note istruttore</th>
</tr>
</thead>
<tbody>
{% 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 '' %}
<tr class="{{ cls }}">
<td><strong>{{ dr.label }}</strong><br><small>{{ dr.code }}</small></td>
<td>
{% if doc.filename %}<i>{{ doc.filename }}</i>
{% else %}<span class="ko">non caricato</span>
{% endif %}
</td>
<td><span class="status-inline status-{{ stat }}">{{ stat }}</span></td>
<td>{{ doc.verification_notes|default('—') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-secondary">Nessun documento richiesto dallo schema del bando.</p>
{% endif %}
{# ============ SOCCORSI ============ #}
{% if amendments %}
<h2>Soccorso istruttorio</h2>
{% for a in amendments %}
<div class="amend-box">
<div class="head">
<strong>{{ a.status|amendstatus }}</strong>
· deadline {{ a.deadline|datefmt }}
· aperto il {{ a.created_at|datetimefmt }}
{% if a.closed_at %} · chiuso il {{ a.closed_at|datetimefmt }}{% endif %}
</div>
<div class="req"><strong>Richiesta istruttore:</strong><br>{{ a.request_text }}</div>
{% if a.response_text %}
<div class="resp"><strong>Risposta beneficiario</strong> ({{ a.response_at|datetimefmt }}):<br>{{ a.response_text }}</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
{# ============ TOTALI ============ #}
<h2>Riepilogo finanziario</h2>
<div class="totals-summary">
<div class="row">
<div class="cell">
<div class="lbl">Totale dichiarato</div>
<div class="val">{{ totals.grand_total_declared|euro }}</div>
</div>
<div class="cell">
<div class="lbl">Totale ammesso</div>
<div class="val">{{ totals.grand_total_verified|euro }}</div>
</div>
<div class="cell">
<div class="lbl">Cap remissione</div>
<div class="val">{{ totals.max_remission|euro }}</div>
</div>
<div class="cell">
<div class="lbl">Remissione spettante</div>
<div class="val final">{{ totals.remission_due|euro }}</div>
</div>
</div>
{% if practice.status == 'APPROVED' %}
<div class="row">
<div class="cell" style="background: #f0fff4;">
<div class="lbl">Remissione approvata</div>
<div class="val" style="color: #22543d;">{{ practice.approved_remission|euro }}</div>
</div>
<div class="cell" style="background: #fff5f5;">
<div class="lbl">Residuo da restituire</div>
<div class="val residuo">{{ (practice.amount_erogato - (practice.approved_remission or 0))|euro }}</div>
</div>
<div class="cell" colspan="2"></div>
<div class="cell"></div>
</div>
{% endif %}
</div>
{# ============ CHECKLIST + NOTE ============ #}
{% set checklist = practice.instructor_checklist or {} %}
{% if checklist %}
<h3>Checklist finale istruttore</h3>
<ul style="font-size:9pt; margin: 4pt 0 6pt 14pt;">
<li>Documentazione completa e coerente: {{ 'SÌ' if checklist.get('domanda_completa') else 'NO' }}</li>
<li>Incremento ULA &gt; 1 verificato: {{ 'SÌ' if checklist.get('ula_ok') else 'NO' }}</li>
<li>Importo erogato entro il range bando: {{ 'SÌ' if checklist.get('erogato_in_range') else 'NO' }}</li>
</ul>
{% endif %}
{% if practice.instructor_final_notes %}
<h3>Note sintetiche di istruttoria</h3>
<div class="note-box">{{ practice.instructor_final_notes }}</div>
{% endif %}
{% if practice.rejection_reason %}
<h3>Motivazione del rigetto</h3>
<div class="note-box" style="background:#fff5f5; border-left-color:#fc8181;">{{ practice.rejection_reason }}</div>
{% endif %}
{# ============ FIRMA ============ #}
<div class="sign-block">
<div class="col">
<div class="lbl">LUOGO E DATA</div>
<div class="val">Perugia, {{ generated_at }}</div>
<div class="sig-line">&nbsp;</div>
</div>
<div class="col">
<div class="lbl">ISTRUTTORE</div>
<div class="val">{{ instructor_name or '(firma)' }}</div>
<div class="sig-line">Firma</div>
</div>
</div>
</body>
</html>

View File

@@ -6,3 +6,6 @@ pydantic==2.6.3
pydantic-settings==2.2.1 pydantic-settings==2.2.1
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
python-multipart==0.0.9 python-multipart==0.0.9
weasyprint==61.2
pydyf==0.10.0
jinja2==3.1.3