Compare commits
3 Commits
7fd56175ef
...
6c089fb7b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c089fb7b2 | ||
|
|
23a2b525a4 | ||
|
|
9a0a401ffa |
@@ -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
|
||||
|
||||
|
||||
17
app/main.py
17
app/main.py
@@ -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
56
app/migrations.py
Normal 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
|
||||
@@ -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
269
app/routers/files.py
Normal 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
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",
|
||||
},
|
||||
)
|
||||
159
app/storage.py
Normal file
159
app/storage.py
Normal 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
|
||||
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
|
||||
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
430
scripts/seed_sandbox.py
Normal 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()
|
||||
Reference in New Issue
Block a user