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:
@@ -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
245
app/routers/verbale.py
Normal 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
482
app/templates_jinja/verbale_istruttoria.html
Normal file
482
app/templates_jinja/verbale_istruttoria.html
Normal 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%">N°</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 > 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"> </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>
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user