Compare commits

...

3 Commits

Author SHA1 Message Date
BFLOWS
6c089fb7b2 feat(seed): script sandbox riproducibile con PDF fixture reali
- scripts/seed_sandbox.py --reset --scenario=napoli-sas --advance=draft|submitted|under_review
- Reset chirurgico (TRUNCATE CASCADE) tabelle remission_* + cleanup /var/uploads
- Ensure schema RE-START pubblicato (idempotente)
- Scenario napoli-sas: 5 fatture (2 B1 Dell/HP, 2 B2 Netcomm/Romano, 1 B3 CertQuality),
  2 ULA (Rossi T_IND FTE 1.0, Bianchi T_DET FTE 0.5), 4 documenti (DURC, VISURA, BILANCIO, ALTRO)
- Tutti gli 11 record hanno PDF fixture generati via weasyprint e caricati nello storage
  tramite lo stesso adapter usato dagli endpoint (no bypass)
- Advance opzionale: lascia la pratica DRAFT / la invia / la fa prendere in carico dall'istruttore
- Documentato nel docstring con esempi di invocazione
- scripts/fixtures/pdf/ directory predisposta per futuri PDF custom

Test: seed --reset --advance=under_review -> 11 PDF reali 196KB totali, practice UNDER_REVIEW
pronta per demo istruttore Cecilia.
2026-04-18 16:54:48 +02:00
BFLOWS
23a2b525a4 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.
2026-04-18 16:54:35 +02:00
BFLOWS
9a0a401ffa feat(files): upload/preview/delete allegati su fatture, ULA, documenti
- models: colonne file inline (storage_path, mime, size_bytes, sha256, uploaded_by, uploaded_at)
  su remission_invoice, remission_ula_employee, remission_document
- migrations: ALTER idempotente al lifespan per evolvere schema in sandbox
- storage: FS adapter /var/uploads con validazione MIME/size, dedup sha256, sanitize
- routers/files: POST upload / GET download (con ?inline=1) / DELETE
  matrix autorizzazioni: beneficiary su DRAFT|AWAITING_AMENDMENT, istruttore read-only, superadmin full
- main: include router files, version bump 0.2.0

Testato E2E con admin JWT: upload 549B PDF -> DB coerente, storage 1/invoice/<uuid>/<sha12>-file.pdf,
download con magic bytes PDF corretti, delete chirurgico con cleanup FS e metadata.
2026-04-18 16:54:24 +02:00
10 changed files with 1690 additions and 11 deletions

View File

@@ -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

View File

@@ -1,9 +1,9 @@
"""
rendicontazione-api — microservizio sviluppato da BFLOWS per Gepafin.
Gestisce schemi di rendicontazione per bando, pratiche di rendicontazione,
fatture, ULA, soccorso istruttorio.
fatture, ULA, soccorso istruttorio, upload file, verbale istruttoria.
Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic).
Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic) + weasyprint.
Auth: JWT condiviso con GEPAFIN-BE.
"""
import logging
@@ -14,7 +14,8 @@ from sqlalchemy import text
from .config import get_settings
from .db import engine, Base
from .routers import health, schemas, practices, debug, instructor
from .migrations import run_migrations
from .routers import health, schemas, practices, debug, instructor, files, verbale
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("rendicontazione-api")
@@ -25,12 +26,12 @@ settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Avvio rendicontazione-api")
# Crea schema e tabelle se non esistono (bootstrap sandbox)
try:
with engine.begin() as conn:
conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS {settings.db_schema}'))
Base.metadata.create_all(bind=engine)
log.info(f"Schema '{settings.db_schema}' e tabelle inizializzate")
run_migrations(engine)
log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK")
except Exception as e:
log.error(f"Errore bootstrap DB: {e}")
raise
@@ -41,7 +42,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="rendicontazione-api",
description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS",
version="0.1.0",
version="0.3.0",
lifespan=lifespan,
)
@@ -58,13 +59,15 @@ app.include_router(schemas.router)
app.include_router(practices.router)
app.include_router(debug.router)
app.include_router(instructor.router)
app.include_router(files.router)
app.include_router(verbale.router)
@app.get("/", tags=["root"])
def root():
return {
"service": "rendicontazione-api",
"version": "0.1.0",
"version": "0.3.0",
"docs": "/docs",
"health": "/health",
}

56
app/migrations.py Normal file
View File

@@ -0,0 +1,56 @@
"""
Migration idempotente per sandbox.
Base.metadata.create_all() crea solo tabelle mancanti, non colonne aggiuntive.
Qui gestiamo ALTER TABLE ADD COLUMN IF NOT EXISTS per l'evoluzione dello schema.
Ogni migration è una stringa SQL che puo essere eseguita piu volte senza errori.
"""
from sqlalchemy import text
import logging
log = logging.getLogger("rendicontazione-api.migrations")
MIGRATIONS = [
# 2026-04-18: colonne file upload su remission_invoice
"""
ALTER TABLE gepafin_rendic.remission_invoice
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
ADD COLUMN IF NOT EXISTS mime varchar(128),
ADD COLUMN IF NOT EXISTS size_bytes bigint,
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
ADD COLUMN IF NOT EXISTS uploaded_by integer,
ADD COLUMN IF NOT EXISTS uploaded_at timestamptz;
""",
# 2026-04-18: colonne file upload su remission_ula_employee
"""
ALTER TABLE gepafin_rendic.remission_ula_employee
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
ADD COLUMN IF NOT EXISTS mime varchar(128),
ADD COLUMN IF NOT EXISTS size_bytes bigint,
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
ADD COLUMN IF NOT EXISTS uploaded_by integer,
ADD COLUMN IF NOT EXISTS uploaded_at timestamptz;
""",
# 2026-04-18: colonne file upload su remission_document
"""
ALTER TABLE gepafin_rendic.remission_document
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
ADD COLUMN IF NOT EXISTS mime varchar(128),
ADD COLUMN IF NOT EXISTS size_bytes bigint,
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
ADD COLUMN IF NOT EXISTS uploaded_by integer;
""",
]
def run_migrations(engine) -> None:
"""Esegue tutte le migration in transazione. Log su ciascuna."""
with engine.begin() as conn:
for i, sql in enumerate(MIGRATIONS, 1):
try:
conn.execute(text(sql))
log.info(f"migration {i}/{len(MIGRATIONS)} OK")
except Exception as e:
log.error(f"migration {i} FAILED: {e}")
raise

View File

@@ -4,7 +4,7 @@ Schema: gepafin_rendic (stesso DB del BE Gepafin sandbox).
"""
import uuid
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date, BigInteger
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
@@ -102,7 +102,15 @@ class RemissionInvoice(Base):
taxable = Column(Numeric(14, 2), nullable=False) # imponibile
vat = Column(Numeric(14, 2), nullable=False, default=0)
total = Column(Numeric(14, 2), nullable=False)
pdf_filename = Column(String(512), nullable=True) # per ora solo nome, upload vero dopo
pdf_filename = Column(String(512), nullable=True) # nome originale
# File upload (bind mount /var/uploads dentro container)
storage_path = Column(String(1024), nullable=True) # relativo a /var/uploads
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, nullable=True)
uploaded_at = Column(DateTime(timezone=True), nullable=True)
# Campi istruttoria (dual declared/verified)
taxable_verified = Column(Numeric(14, 2), nullable=True)
@@ -139,7 +147,15 @@ class RemissionUlaEmployee(Base):
period_end_date = Column(Date, nullable=False)
supporting_doc_type = Column(String(64), nullable=True)
supporting_doc_filename = Column(String(512), nullable=True)
supporting_doc_filename = Column(String(512), nullable=True) # nome originale
# File upload
storage_path = Column(String(1024), nullable=True)
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, nullable=True)
uploaded_at = Column(DateTime(timezone=True), nullable=True)
# Campi istruttoria
fte_pct_verified = Column(Numeric(5, 4), nullable=True)
@@ -164,11 +180,18 @@ class RemissionDocument(Base):
nullable=False)
doc_code = Column(String(64), nullable=False) # DURC / VISURA_CAMERALE / ...
filename = Column(String(512), nullable=True)
filename = Column(String(512), nullable=True) # nome originale
uploaded_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(Date, nullable=True)
notes = Column(Text, nullable=True)
# File upload
storage_path = Column(String(1024), nullable=True)
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, nullable=True)
# Campi istruttoria
verification_status = Column(String(16), nullable=False, default="PENDING")
# PENDING | VALIDO | NON_VALIDO | SCADUTO

269
app/routers/files.py Normal file
View File

@@ -0,0 +1,269 @@
"""
Endpoint file upload/download/delete per fatture, ULA, documenti.
Storage: FS locale via app.storage.
Autorizzazione:
- UPLOAD: beneficiario su sua pratica in DRAFT/AWAITING_AMENDMENT, o istruttore
- DOWNLOAD: beneficiario su sua pratica, istruttore, superadmin
- DELETE: solo beneficiario su DRAFT/AWAITING_AMENDMENT
"""
from datetime import datetime, timezone
from uuid import UUID
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..auth import AuthUser, get_current_user
from ..models import (
RemissionPractice, RemissionInvoice, RemissionUlaEmployee, RemissionDocument
)
from ..storage import (
save_upload, delete_file, open_file,
FileTooLargeError, MimeNotAllowedError, StorageError
)
from ..schemas import ApiResponse
router = APIRouter(prefix="/api/remission-files", tags=["files"])
def _is_instructor(user: AuthUser) -> bool:
return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
"""Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore."""
if _is_instructor(user):
return True
if user.is_beneficiary() and practice.user_id == user.user_id:
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
return False
def _can_download(user: AuthUser, practice: RemissionPractice) -> bool:
if _is_instructor(user):
return True
if user.is_beneficiary() and practice.user_id == user.user_id:
return True
return False
def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool:
"""Solo beneficiario su pratica modificabile. Istruttore non elimina file."""
if user.is_beneficiary() and practice.user_id == user.user_id:
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
if user.is_superadmin():
return True
return False
def _load_entity(db: Session, entity_type: str, entity_id: UUID):
if entity_type == "invoice":
return db.query(RemissionInvoice).filter(RemissionInvoice.id == entity_id).first()
if entity_type == "ula":
return db.query(RemissionUlaEmployee).filter(RemissionUlaEmployee.id == entity_id).first()
if entity_type == "document":
return db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first()
raise HTTPException(status_code=400, detail=f"entity_type non valido: {entity_type}")
def _serialize_file_meta(entity, entity_type: str) -> dict:
"""Estrae metadata file dall'entita (invoice/ula/document)."""
filename_field = {
"invoice": "pdf_filename",
"ula": "supporting_doc_filename",
"document": "filename",
}[entity_type]
has_file = bool(getattr(entity, "storage_path", None))
return {
"has_file": has_file,
"filename_original": getattr(entity, filename_field, None),
"storage_path": entity.storage_path if has_file else None,
"mime": entity.mime,
"size_bytes": entity.size_bytes,
"sha256": entity.sha256,
"uploaded_at": entity.uploaded_at.isoformat() if getattr(entity, "uploaded_at", None) else None,
}
@router.post("/{entity_type}/{entity_id}/upload", response_model=ApiResponse)
async def upload_entity_file(
entity_type: Literal["invoice", "ula", "document"],
entity_id: UUID,
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""
Upload file per una entita (invoice/ula/document).
Se l'entita ha gia un file, lo sostituisce (vecchio file eliminato da FS).
"""
entity = _load_entity(db, entity_type, entity_id)
if not entity:
raise HTTPException(status_code=404, detail=f"{entity_type} {entity_id} non trovata")
# Pratica di riferimento
practice = db.query(RemissionPractice).filter(
RemissionPractice.id == entity.practice_id
).first()
if not practice:
raise HTTPException(status_code=404, detail="Pratica non trovata")
if not _can_upload(user, practice):
raise HTTPException(
status_code=403,
detail="Non autorizzato a caricare file su questa pratica"
)
# Salva su FS
try:
rel_path, size, digest, mime, safe_name = save_upload(
application_id=practice.application_id,
entity_type=entity_type,
entity_id=entity.id,
file_obj=file.file,
original_filename=file.filename or "file.bin",
content_type=file.content_type,
)
except FileTooLargeError as e:
raise HTTPException(status_code=413, detail=str(e))
except MimeNotAllowedError as e:
raise HTTPException(status_code=415, detail=str(e))
except StorageError as e:
raise HTTPException(status_code=500, detail=f"Errore storage: {e}")
# Rimuovi eventuale file precedente su FS se diverso
old_path = entity.storage_path
if old_path and old_path != rel_path:
try:
delete_file(old_path)
except Exception:
pass # non blocchiamo l'upload per cleanup fallito
# Aggiorna metadata
entity.storage_path = rel_path
entity.mime = mime
entity.size_bytes = size
entity.sha256 = digest
entity.uploaded_by = user.user_id
if hasattr(entity, "uploaded_at"):
entity.uploaded_at = datetime.now(timezone.utc)
# Aggiorna filename originale a seconda del tipo
if entity_type == "invoice":
entity.pdf_filename = safe_name
elif entity_type == "ula":
entity.supporting_doc_filename = safe_name
elif entity_type == "document":
entity.filename = safe_name
# document ha gia uploaded_at proprio campo
entity.uploaded_at = datetime.now(timezone.utc)
db.commit()
db.refresh(entity)
return ApiResponse(
success=True,
data=_serialize_file_meta(entity, entity_type),
message="File caricato",
)
@router.get("/{entity_type}/{entity_id}")
def download_entity_file(
entity_type: Literal["invoice", "ula", "document"],
entity_id: UUID,
request: Request,
inline: int = 0,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""
Download (inline=0) o preview (inline=1) del file.
Ritorna il file con header appropriati.
"""
entity = _load_entity(db, entity_type, entity_id)
if not entity or not entity.storage_path:
raise HTTPException(status_code=404, detail="File non presente")
practice = db.query(RemissionPractice).filter(
RemissionPractice.id == entity.practice_id
).first()
if not practice:
raise HTTPException(status_code=404, detail="Pratica non trovata")
if not _can_download(user, practice):
raise HTTPException(status_code=403, detail="Non autorizzato")
try:
abs_path = open_file(entity.storage_path)
except FileNotFoundError:
raise HTTPException(status_code=410, detail="File non piu disponibile su storage")
filename = None
if entity_type == "invoice":
filename = entity.pdf_filename
elif entity_type == "ula":
filename = entity.supporting_doc_filename
elif entity_type == "document":
filename = entity.filename
disposition = "inline" if inline else "attachment"
headers = {
"Content-Disposition": f'{disposition}; filename="{filename or "file"}"',
}
return FileResponse(
path=str(abs_path),
media_type=entity.mime or "application/octet-stream",
headers=headers,
)
@router.delete("/{entity_type}/{entity_id}", response_model=ApiResponse)
def delete_entity_file(
entity_type: Literal["invoice", "ula", "document"],
entity_id: UUID,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Elimina il file allegato. Entita resta, metadata file azzerati."""
entity = _load_entity(db, entity_type, entity_id)
if not entity:
raise HTTPException(status_code=404, detail=f"{entity_type} {entity_id} non trovata")
practice = db.query(RemissionPractice).filter(
RemissionPractice.id == entity.practice_id
).first()
if not practice:
raise HTTPException(status_code=404, detail="Pratica non trovata")
if not _can_delete(user, practice):
raise HTTPException(status_code=403, detail="Non autorizzato")
if not entity.storage_path:
return ApiResponse(success=True, message="Nessun file da eliminare")
try:
delete_file(entity.storage_path)
except Exception:
pass # rimuoviamo comunque i metadati
entity.storage_path = None
entity.mime = None
entity.size_bytes = None
entity.sha256 = None
entity.uploaded_by = None
if hasattr(entity, "uploaded_at"):
entity.uploaded_at = None
if entity_type == "invoice":
entity.pdf_filename = None
elif entity_type == "ula":
entity.supporting_doc_filename = None
elif entity_type == "document":
entity.filename = None
db.commit()
return ApiResponse(success=True, message="File eliminato")

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

159
app/storage.py Normal file
View File

@@ -0,0 +1,159 @@
"""
Storage adapter per file upload.
Implementazione attuale: filesystem locale con bind mount /var/uploads.
Struttura: /var/uploads/{application_id}/{entity_type}/{entity_id}/{sha256}-{filename}
Migrazione futura a S3/MinIO: cambiare solo questa classe.
"""
import hashlib
import os
import shutil
from pathlib import Path
from typing import BinaryIO, Optional, Tuple
from uuid import UUID
BASE_PATH = Path(os.environ.get("RENDIC_UPLOAD_BASE", "/var/uploads"))
MAX_SIZE_BYTES = 15 * 1024 * 1024 # 15 MB
ALLOWED_MIMES = {
"application/pdf": ".pdf",
"image/jpeg": ".jpg",
"image/png": ".png",
}
class StorageError(Exception):
pass
class FileTooLargeError(StorageError):
pass
class MimeNotAllowedError(StorageError):
pass
def _safe_filename(name: str, max_len: int = 120) -> str:
"""Rimuove caratteri pericolosi, tronca."""
keep = "-_.() "
clean = "".join(c if (c.isalnum() or c in keep) else "_" for c in name)
clean = clean.strip().replace(" ", "_")
if len(clean) > max_len:
root, ext = os.path.splitext(clean)
clean = root[: max_len - len(ext)] + ext
return clean or "file"
def save_upload(
application_id: int,
entity_type: str, # invoice | ula | document
entity_id: UUID,
file_obj: BinaryIO,
original_filename: str,
content_type: Optional[str],
) -> Tuple[str, int, str, str, str]:
"""
Salva il file su FS e ritorna (storage_path, size_bytes, sha256, mime, safe_filename).
storage_path è RELATIVO a BASE_PATH (es: "1/invoice/xxx/yyy-fattura.pdf").
Valida:
- mime in ALLOWED_MIMES (usa content_type del client, fallback su estensione)
- dimensione <= MAX_SIZE_BYTES
"""
if entity_type not in ("invoice", "ula", "document"):
raise StorageError(f"entity_type non valido: {entity_type}")
safe_name = _safe_filename(original_filename)
ext = os.path.splitext(safe_name)[1].lower()
# Risolvi mime: prima content_type client, poi da estensione
mime = (content_type or "").lower().split(";")[0].strip()
if mime not in ALLOWED_MIMES:
# Fallback da estensione
ext_to_mime = {v: k for k, v in ALLOWED_MIMES.items()}
mime = ext_to_mime.get(ext, "")
if mime not in ALLOWED_MIMES:
raise MimeNotAllowedError(
f"MIME non consentito: '{content_type}' / estensione '{ext}'. "
f"Accettati: {list(ALLOWED_MIMES.keys())}"
)
# Calcola sha256 e size streaming per non tenere tutto in RAM
hasher = hashlib.sha256()
size = 0
# Salva in tmp poi rename atomico
target_dir = BASE_PATH / str(application_id) / entity_type / str(entity_id)
target_dir.mkdir(parents=True, exist_ok=True)
tmp_path = target_dir / f".tmp-{entity_id}"
try:
with open(tmp_path, "wb") as out:
while True:
chunk = file_obj.read(65536)
if not chunk:
break
size += len(chunk)
if size > MAX_SIZE_BYTES:
raise FileTooLargeError(
f"File {size} byte oltre limite {MAX_SIZE_BYTES}"
)
hasher.update(chunk)
out.write(chunk)
digest = hasher.hexdigest()
final_name = f"{digest[:12]}-{safe_name}"
final_path = target_dir / final_name
# Se già esiste (dedup per sha+nome) rimuovi tmp e usa esistente
if final_path.exists():
tmp_path.unlink(missing_ok=True)
else:
os.replace(tmp_path, final_path)
rel_path = str(final_path.relative_to(BASE_PATH))
return rel_path, size, digest, mime, safe_name
except Exception:
# cleanup tmp in caso di errore
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
raise
def delete_file(storage_path: str) -> bool:
"""Elimina fisicamente il file. Ritorna True se rimosso."""
if not storage_path:
return False
abs_path = BASE_PATH / storage_path
# hardening: resta sotto BASE_PATH
try:
abs_path.resolve().relative_to(BASE_PATH.resolve())
except ValueError:
raise StorageError(f"Path fuori da BASE_PATH: {storage_path}")
if abs_path.exists():
abs_path.unlink()
# prova a rimuovere directory vuote (entity_id/entity_type)
try:
abs_path.parent.rmdir()
abs_path.parent.parent.rmdir()
except OSError:
pass
return True
return False
def open_file(storage_path: str) -> Path:
"""Ritorna Path assoluto del file. Solleva FileNotFoundError se mancante."""
if not storage_path:
raise FileNotFoundError("storage_path vuoto")
abs_path = BASE_PATH / storage_path
try:
abs_path.resolve().relative_to(BASE_PATH.resolve())
except ValueError:
raise StorageError(f"Path fuori da BASE_PATH: {storage_path}")
if not abs_path.is_file():
raise FileNotFoundError(f"File non trovato: {storage_path}")
return abs_path

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
python-jose[cryptography]==3.3.0
python-multipart==0.0.9
weasyprint==61.2
pydyf==0.10.0
jinja2==3.1.3

430
scripts/seed_sandbox.py Normal file
View File

@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""
Seed sandbox riproducibile per Gepafin rendicontazione.
Usage:
python /app/scripts/seed_sandbox.py --reset --scenario=napoli-sas
python /app/scripts/seed_sandbox.py --reset --scenario=napoli-sas --advance=under_review
Scenari disponibili:
napoli-sas : pratica completa con 5 fatture (B1/B2/B3), 2 ULA, 4 documenti,
PDF fixture generati e caricati nello storage /var/uploads.
Advance (opzionale, default = draft):
draft : lascia la pratica in DRAFT (stato beneficiario)
submitted : pratica inviata, pronta a essere presa in carico dall'istruttore
under_review : istruttore l'ha presa in carico, pronta per verifica
"""
import argparse
import io
import os
import shutil
import sys
import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from pathlib import Path
sys.path.insert(0, '/app')
from sqlalchemy import text
from app.db import engine, SessionLocal
from app.models import (
CallRemissionSchema,
RemissionPractice,
RemissionInvoice,
RemissionUlaEmployee,
RemissionDocument,
RemissionAmendmentRequest,
)
from app.storage import save_upload, BASE_PATH
from app.templates import RESTART_TEMPLATE
CALL_ID = 1
COMPANY_ID = 1
BENEFICIARY_USER_ID = 9 # beneficiario@sandbox.local
INSTRUCTOR_USER_ID = 10 # istruttore@sandbox.local
APPLICATION_ID = 1
# ---------------------------------------------------------------------------
# PDF fixture generator (weasyprint)
# ---------------------------------------------------------------------------
def make_pdf_bytes(title: str, subtitle: str, lines: list[str]) -> bytes:
from weasyprint import HTML
rows = "".join(f"<tr><td>{i+1}</td><td>{ln}</td></tr>" for i, ln in enumerate(lines))
html = f"""
<html><head><meta charset="utf-8"><style>
@page {{ size: A4; margin: 2cm; }}
body {{ font-family: "DejaVu Sans", sans-serif; color: #1a202c; }}
h1 {{ color: #1a365d; font-size: 18pt; margin: 0 0 4pt 0; }}
h2 {{ color: #4a5568; font-size: 11pt; margin: 0 0 18pt 0; font-weight: normal; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 12pt; }}
th, td {{ border: 0.5pt solid #cbd5e0; padding: 6pt 10pt; text-align: left; font-size: 10pt; }}
th {{ background: #2d3748; color: white; }}
.stamp {{ margin-top: 40pt; padding: 10pt; border: 1pt solid #4299e1;
background: #ebf4ff; color: #2a4365; font-size: 9pt; }}
</style></head><body>
<h1>{title}</h1>
<h2>{subtitle}</h2>
<table>
<thead><tr><th style="width:50pt">#</th><th>Voce</th></tr></thead>
<tbody>{rows}</tbody>
</table>
<div class="stamp">
<strong>FIXTURE DEMO</strong> — documento generato automaticamente
da seed_sandbox.py il {datetime.now().strftime('%d/%m/%Y %H:%M')}.
Questo PDF è un placeholder a scopo dimostrativo per la sandbox Gepafin.
</div>
</body></html>
"""
return HTML(string=html).write_pdf()
def attach_pdf(db, entity, entity_type: str, application_id: int,
filename: str, title: str, subtitle: str, lines: list[str], uploader_id: int):
"""Genera PDF e lo carica tramite lo storage adapter, aggiorna entity."""
pdf = make_pdf_bytes(title, subtitle, lines)
rel_path, size, digest, mime, safe_name = save_upload(
application_id=application_id,
entity_type=entity_type,
entity_id=entity.id,
file_obj=io.BytesIO(pdf),
original_filename=filename,
content_type='application/pdf',
)
entity.storage_path = rel_path
entity.mime = mime
entity.size_bytes = size
entity.sha256 = digest
entity.uploaded_by = uploader_id
if hasattr(entity, 'uploaded_at'):
entity.uploaded_at = datetime.now(timezone.utc)
# filename originale specifico per tipo
if entity_type == 'invoice':
entity.pdf_filename = safe_name
elif entity_type == 'ula':
entity.supporting_doc_filename = safe_name
elif entity_type == 'document':
entity.filename = safe_name
entity.uploaded_at = datetime.now(timezone.utc)
# ---------------------------------------------------------------------------
# Reset
# ---------------------------------------------------------------------------
def do_reset(scope: str):
"""TRUNCATE tabelle remission_* e pulizia storage. scope: 'all' | 'practices'."""
print(f"[reset] scope={scope}")
with engine.begin() as conn:
if scope == 'all':
conn.execute(text("""
TRUNCATE
gepafin_rendic.remission_amendment_request,
gepafin_rendic.remission_document,
gepafin_rendic.remission_ula_employee,
gepafin_rendic.remission_invoice,
gepafin_rendic.remission_practice
RESTART IDENTITY CASCADE
"""))
print("[reset] tabelle remission_* truncate-ate")
else:
conn.execute(text("""
DELETE FROM gepafin_rendic.remission_amendment_request;
DELETE FROM gepafin_rendic.remission_document;
DELETE FROM gepafin_rendic.remission_ula_employee;
DELETE FROM gepafin_rendic.remission_invoice;
DELETE FROM gepafin_rendic.remission_practice;
"""))
# Pulizia storage
if BASE_PATH.exists():
for p in BASE_PATH.iterdir():
if p.is_dir():
shutil.rmtree(p)
elif p.is_file() and not p.name.startswith('.'):
p.unlink()
print(f"[reset] {BASE_PATH} pulito")
# ---------------------------------------------------------------------------
# Schema RE-START pubblicato
# ---------------------------------------------------------------------------
def ensure_schema_published(db):
schema_row = db.query(CallRemissionSchema).filter(
CallRemissionSchema.call_id == CALL_ID
).first()
if not schema_row:
schema_row = CallRemissionSchema(
call_id=CALL_ID,
schema_version=1,
status='PUBLISHED',
schema_json=RESTART_TEMPLATE,
created_by=1,
published_at=datetime.now(timezone.utc),
published_by=1,
)
db.add(schema_row)
db.flush()
print("[schema] creato e pubblicato schema RE-START")
elif schema_row.status != 'PUBLISHED':
schema_row.status = 'PUBLISHED'
schema_row.published_at = datetime.now(timezone.utc)
schema_row.published_by = 1
print("[schema] schema RE-START promosso a PUBLISHED")
return schema_row
# ---------------------------------------------------------------------------
# Scenario: NAPOLI SAS
# ---------------------------------------------------------------------------
def scenario_napoli_sas(db, advance='draft'):
"""
Pratica NAPOLI SAS completa.
5 fatture (2 B1 / 2 B2 / 1 B3), 2 ULA, 4 documenti. Tutti con PDF fixture allegato.
"""
schema_row = ensure_schema_published(db)
practice = RemissionPractice(
call_id=CALL_ID,
application_id=APPLICATION_ID,
company_id=COMPANY_ID,
user_id=BENEFICIARY_USER_ID,
status='DRAFT',
schema_snapshot=schema_row.schema_json,
iva_regime='ORDINARIO',
amount_erogato=Decimal('17000'),
notes_beneficiario='Pratica di rendicontazione bando RE-START — investimento digitale 2021.',
)
db.add(practice)
db.flush()
print(f"[practice] creata id={practice.id}")
invoices_data = [
dict(category_code='B1',
invoice_number='2021/487', invoice_date=date(2021, 3, 15), payment_date=date(2021, 3, 31),
supplier_name='Dell Italia S.r.l.', supplier_vat='IT12345678901',
description='Fornitura 5 workstation Precision 3660 per sviluppo software',
taxable=Decimal('3000'), vat=Decimal('660'), total=Decimal('3660'),
fname='ft_2021_487_dell.pdf'),
dict(category_code='B1',
invoice_number='2020/988', invoice_date=date(2020, 11, 20), payment_date=date(2020, 12, 10),
supplier_name='HP Italia S.p.A.', supplier_vat='IT98765432109',
description='2 laptop EliteBook 840 G7 + 3 monitor DreamColor',
taxable=Decimal('2000'), vat=Decimal('440'), total=Decimal('2440'),
fname='ft_2020_988_hp.pdf'),
dict(category_code='B2',
invoice_number='2021/58', invoice_date=date(2021, 5, 8), payment_date=date(2021, 5, 30),
supplier_name='Netcomm S.c. a r.l.', supplier_vat='IT07008690961',
description='Canone annuale Osservatorio eCommerce B2c 2021 + assicurazione accessoria',
taxable=Decimal('2500'), vat=Decimal('550'), total=Decimal('3050'),
fname='ft_2021_58_netcomm.pdf'),
dict(category_code='B2',
invoice_number='2021/115', invoice_date=date(2021, 7, 2), payment_date=date(2021, 7, 20),
supplier_name='Studio Romano & Associati', supplier_vat='IT03214569876',
description='Consulenza strategica digital transformation — 20 ore senior partner',
taxable=Decimal('1500'), vat=Decimal('330'), total=Decimal('1830'),
fname='ft_2021_115_romano.pdf'),
dict(category_code='B3',
invoice_number='2021/221', invoice_date=date(2021, 9, 15), payment_date=date(2021, 9, 30),
supplier_name='CertQuality S.r.l.', supplier_vat='IT11223344556',
description='Certificazione ISO 27001:2013 — audit + rilascio certificato triennale',
taxable=Decimal('2400'), vat=Decimal('528'), total=Decimal('2928'),
fname='ft_2021_221_certquality.pdf'),
]
for i, d in enumerate(invoices_data):
inv = RemissionInvoice(
practice_id=practice.id,
category_code=d['category_code'],
invoice_number=d['invoice_number'],
invoice_date=d['invoice_date'],
payment_date=d['payment_date'],
supplier_name=d['supplier_name'],
supplier_vat=d['supplier_vat'],
description=d['description'],
taxable=d['taxable'],
vat=d['vat'],
total=d['total'],
)
db.add(inv)
db.flush()
attach_pdf(
db, inv, 'invoice', APPLICATION_ID,
filename=d['fname'],
title=f"Fattura n. {d['invoice_number']}",
subtitle=f"{d['supplier_name']}{d['invoice_date'].strftime('%d/%m/%Y')}",
lines=[
f"Fornitore: {d['supplier_name']} — P.IVA {d['supplier_vat']}",
f"Descrizione: {d['description']}",
f"Imponibile: € {d['taxable']}",
f"IVA 22%: € {d['vat']}",
f"Totale: € {d['total']}",
f"Data fattura: {d['invoice_date'].strftime('%d/%m/%Y')}",
f"Data pagamento: {d['payment_date'].strftime('%d/%m/%Y')}",
],
uploader_id=BENEFICIARY_USER_ID,
)
print(f"[invoice] {i+1}/5 {d['category_code']} {d['invoice_number']} + PDF {inv.size_bytes}b")
ula_data = [
dict(cf='RSSMRA85T10H501Z', name='Mario Rossi', ctype='T_IND',
role='Sviluppatore senior', fte=Decimal('1.0000'),
start=date(2021, 1, 27), end=date(2021, 12, 31),
doc_type='LUL', fname='lul_rossi_2021.pdf'),
dict(cf='BNCLRA90A41F205D', name='Laura Bianchi', ctype='T_DET',
role='Digital marketing specialist', fte=Decimal('0.5000'),
start=date(2021, 4, 1), end=date(2021, 12, 31),
doc_type='LUL', fname='lul_bianchi_2021.pdf'),
]
for i, d in enumerate(ula_data):
emp = RemissionUlaEmployee(
practice_id=practice.id,
codice_fiscale=d['cf'],
full_name=d['name'],
contract_type=d['ctype'],
role_description=d['role'],
fte_pct=d['fte'],
period_start_date=d['start'],
period_end_date=d['end'],
supporting_doc_type=d['doc_type'],
)
db.add(emp)
db.flush()
attach_pdf(
db, emp, 'ula', APPLICATION_ID,
filename=d['fname'],
title=f"Libro Unico del Lavoro — {d['name']}",
subtitle=f"Periodo {d['start'].strftime('%d/%m/%Y')}{d['end'].strftime('%d/%m/%Y')}",
lines=[
f"Codice fiscale: {d['cf']}",
f"Nome e cognome: {d['name']}",
f"Tipo contratto: {d['ctype']}",
f"Mansione: {d['role']}",
f"FTE: {float(d['fte']):.2f}",
f"Inizio rapporto: {d['start'].strftime('%d/%m/%Y')}",
f"Fine rapporto: {d['end'].strftime('%d/%m/%Y')}",
f"Tipo documento: {d['doc_type']}",
],
uploader_id=BENEFICIARY_USER_ID,
)
print(f"[ula] {i+1}/2 {d['name']} FTE={d['fte']} + PDF {emp.size_bytes}b")
docs_data = [
dict(code='DURC', label='DURC', fname='durc_napoli.pdf',
title='DURC — Documento Unico di Regolarità Contributiva',
subtitle='NAPOLI SAS — regolarità contributiva verificata',
lines=[
'Impresa: NAPOLI SAS Sandbox',
'P.IVA: 03517010546-SBX',
'Stato: REGOLARE',
'Data emissione: 15/11/2021',
'Scadenza: 15/03/2022',
'Ente erogatore: INPS',
]),
dict(code='VISURA_CAMERALE', label='Visura camerale', fname='visura_napoli.pdf',
title='Visura camerale ordinaria',
subtitle='NAPOLI SAS Sandbox — estratto CCIAA Perugia',
lines=[
'Denominazione: NAPOLI SAS Sandbox',
'Forma giuridica: Società in accomandita semplice',
'Sede legale: Perugia',
'Iscrizione REA: PG-123456',
'Attività prevalente: 62.02 Consulenza informatica',
'Stato: ATTIVA',
]),
dict(code='BILANCIO', label='Bilancio', fname='bilancio_napoli_2021.pdf',
title='Bilancio di esercizio 2021',
subtitle='NAPOLI SAS Sandbox — stato patrimoniale e conto economico',
lines=[
'Anno: 2021',
'Ricavi: € 85.200',
'Costi: € 68.300',
'Utile ante imposte: € 16.900',
'Patrimonio netto: € 45.600',
'Immobilizzazioni materiali: € 12.400',
]),
dict(code='ALTRO', label='Altra documentazione', fname='quietanze_napoli.pdf',
title='Quietanze di pagamento',
subtitle='Raccolta quietanze fatture rendicontate',
lines=[
'Bonifico Dell € 3.660 il 31/03/2021',
'Bonifico HP € 2.440 il 10/12/2020',
'Bonifico Netcomm € 3.050 il 30/05/2021',
'Bonifico Romano € 1.830 il 20/07/2021',
'Bonifico CertQuality € 2.928 il 30/09/2021',
]),
]
for i, d in enumerate(docs_data):
doc = RemissionDocument(
practice_id=practice.id,
doc_code=d['code'],
notes=None,
)
db.add(doc)
db.flush()
attach_pdf(
db, doc, 'document', APPLICATION_ID,
filename=d['fname'],
title=d['title'],
subtitle=d['subtitle'],
lines=d['lines'],
uploader_id=BENEFICIARY_USER_ID,
)
print(f"[doc] {i+1}/4 {d['code']} + PDF {doc.size_bytes}b")
db.commit()
# ----- advance state se richiesto -----
if advance == 'draft':
print(f"[advance] rimasta in DRAFT")
return practice.id
# submit
practice.status = 'SUBMITTED'
practice.submitted_at = datetime.now(timezone.utc)
db.commit()
print(f"[advance] pratica SUBMITTED")
if advance == 'submitted':
return practice.id
# claim by instructor
practice.status = 'UNDER_REVIEW'
practice.assigned_instructor_id = INSTRUCTOR_USER_ID
db.commit()
print(f"[advance] pratica UNDER_REVIEW (claimed by user {INSTRUCTOR_USER_ID})")
return practice.id
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description="Seed sandbox Gepafin rendicontazione")
ap.add_argument('--reset', action='store_true',
help='Cancella tutti i dati remission_* e pulisci storage prima del seed')
ap.add_argument('--scenario', choices=['napoli-sas', 'full'], default='napoli-sas')
ap.add_argument('--advance', choices=['draft', 'submitted', 'under_review'], default='under_review',
help='Stato finale della pratica dopo il seed')
args = ap.parse_args()
if args.reset:
do_reset('all')
db = SessionLocal()
try:
if args.scenario == 'napoli-sas':
pid = scenario_napoli_sas(db, advance=args.advance)
print(f"\n✓ Scenario napoli-sas completato. practice_id={pid}")
print(f" Accedi a: http://78.46.41.91:18072/istruttoria/{pid}")
elif args.scenario == 'full':
pid = scenario_napoli_sas(db, advance=args.advance)
# placeholder per futuri scenari ROMA-SRL / BOLOGNA-SPA
print(f"\n✓ Scenario 'full' eseguito (solo napoli-sas disponibile).")
print(f" practice_id={pid}")
finally:
db.close()
if __name__ == '__main__':
main()