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 @@ + + +
+ ++ {% 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 %} +
+ +| N° | +Data | +Fornitore / Descrizione | +{{ 'Imponibile' if use_taxable else 'Totale' }} dichiarato | +{{ 'Imponibile' if use_taxable else 'Totale' }} ammesso | +Stato | +Motivazione 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 }} | ++ | |||
Nessuna fattura rendicontata.
+{% endif %} + +{# ============ ULA ============ #} +{% if ula_section_enabled and practice.ula_employees %} +Soglia incremento richiesta: ≥ {{ '%.2f'|format(ula_threshold) }} · FTE dichiarato: {{ '%.2f'|format(ula_fte_decl) }} · FTE ammesso: {{ '%.2f'|format(ula_fte_verif) }}
+| CF | +Dipendente | +Contratto | +Periodo | +FTE dich. | +FTE amm. | +Stato | +Note | +
|---|---|---|---|---|---|---|---|
| {{ 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('—') }} | +
| Documento | +File allegato | +Esito | +Note istruttore | +
|---|---|---|---|
| {{ dr.label }} {{ dr.code }} |
+ + {% if doc.filename %}{{ doc.filename }} + {% else %}non caricato + {% endif %} + | +{{ stat }} | +{{ doc.verification_notes|default('—') }} | +
Nessun documento richiesto dallo schema del bando.
+{% endif %} + +{# ============ SOCCORSI ============ #} +{% if amendments %} +