Compare commits

..

3 Commits

Author SHA1 Message Date
BFLOWS
c19b2aa0b1 feat(v2): seed scenario napoli-sas-multi + main include routers
A7 scripts/seed_sandbox.py:
- ensure_assigned_application() popola gepafin_schema.assigned_applications
- scenario napoli-sas-multi: tranche 1 APPROVED + tranche 2 DRAFT vuota
  Tranche 1 caso reale Cecilia: 1 fattura B3 524.50 EUR con rettifica 57.36 EUR
  (storno assicurazione), ammesso 467.14 EUR. 1 ULA T_IND AMMESSA.
  4 documenti VALIDO. 2 custom_checks VALIDO (antiriciclaggio + polizza con PDF).
  Tranche 2 DRAFT: assigned_instructor_id=NULL (simula workflow capo)
- TRUNCATE include remission_custom_check_value (CASCADE gia la gestiva)

main.py: include router custom_checks + assignment, version bump 0.4.0

Test: seed --reset --scenario=napoli-sas-multi -> 2 tranche create in 6s,
PDF polizza 10KB generato in custom_checks/<T1>/polizza_fidejussoria/.
2026-04-18 17:35:56 +02:00
BFLOWS
86681678c4 feat(v2): endpoint multi-tranche + custom_checks + assignment manager
A4 /mine + /start + /copy-ula-options (practices.py):
- GET /mine raggruppa per application_id, ogni app ha:
  tranches[], max_tranches, can_start_new, start_blocked_reason,
  already_approved_sum, max_remission_global, max_remission_next_tranche
- POST /start valida: count<max, last terminale, residuo>0 -> 400 con detail parlante
- Bulk copy ULA da tranche N-1 se copy_ula_from_previous=true (reset verification_*)
- Legge suggested_instructor da gepafin_schema.assigned_applications (solo tranche 1)
- upgrade_schema_to_v2 al snapshot per allineare a v2 schemi vecchi
- GET /{id}/copy-ula-options: preview ULA tranche N-1 per pre-fill FE

A5 custom_checks.py (nuovo router):
- GET /{id}/custom-checks merge definition+values con defaults
- PUT /.../declare (beneficiary form-data + optional file upload 15MB, PDF/JPG/PNG)
  storage dedicato /var/uploads/custom_checks/{practice_id}/{code}/<sha12>-file
- DELETE /.../document (beneficiary) reset metadata + cleanup FS
- PUT /.../verify (istruttore) VALIDO/NON_VALIDO/PENDING + notes
- GET /.../document?inline=0|1 stream con Content-Disposition
- Matrix autorizzazioni: declare solo benef su DRAFT/AWAITING, verify solo istruttore

A6 assignment.py (nuovo router, manager view):
- GET /instructor-manager/assignments: pratiche attive con suggested+assigned+is_unassigned
- GET /instructor-manager/instructors: elenco PRE_INSTRUCTOR+MANAGER per dropdown riassegna
- POST /instructor/{id}/reassign: cambio assigned_instructor_id + audit log in
  instructor_checklist.reassignment_log [{at,by_user_id,from,to,reason}]
- Solo ROLE_INSTRUCTOR_MANAGER + ROLE_SUPER_ADMIN

Test curl E2E tutti passati: tranche 2 creata con copy 2 ULA, check dichiarati+verificati,
download PDF polizza OK, reassign con audit log scritto.
2026-04-18 17:35:56 +02:00
BFLOWS
25215f388b feat(v2): multi-tranche DB schema + gate cumulativo 5 voci Cecilia
A1 migrations.py:
- remission_practice DROP uq_application + ADD sequence_number/period_label/suggested_instructor_id
- UNIQUE composita (application_id, sequence_number)
- partial index idx_remission_practice_unassigned su assigned_instructor_id NULL
- nuova tabella remission_custom_check_value (storage_path/mime/size/sha256 allineata adapter)

A2 models.py + templates.py:
- RemissionPractice: UniqueConstraint composita, campi multi-tranche, relationship custom_checks
- classe RemissionCustomCheckValue
- RESTART_TEMPLATE schema_version=2, max_tranches=2, custom_checks esempio
  (antiriciclaggio required no-doc, polizza_fidejussoria optional con-doc)
- upgrade_schema_to_v2 idempotente per snapshot v1 esistenti

A3 _compute_gate_check(db, practice) CUMULATIVO:
- max_remission_global = min(cap_pct * erogato, cap_abs)
- already_approved = func.sum(approved_remission) su tranche APPROVED precedenti
  dello stesso application_id con sequence_number < corrente
- max_remission_this_tranche = max(0, global - already_approved)
- pre_check_admissible = min(grand_total_declared, this_tranche)  [voce 2 Cecilia]
- remission_due = min(effective_total, this_tranche)
- residuo_da_restituire = erogato - already_approved - remission_due (cumulativo)
- output totals esteso: sequence_number, tranches_count, tranches_max
- signature (db, practice) - aggiornati 6 call site in practices/instructor/verbale

Test su NAPOLI SAS: erogato 17K, cap 8500, tranche 1 approvata 467.14EUR,
tranche 2 vuota -> residuo disponibile 8032.86EUR, residuo_da_restituire 16532.86EUR.
2026-04-18 17:35:56 +02:00
11 changed files with 1263 additions and 65 deletions

View File

@@ -15,7 +15,7 @@ from sqlalchemy import text
from .config import get_settings
from .db import engine, Base
from .migrations import run_migrations
from .routers import health, schemas, practices, debug, instructor, files, verbale
from .routers import health, schemas, practices, debug, instructor, files, verbale, custom_checks, assignment
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("rendicontazione-api")
@@ -42,7 +42,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="rendicontazione-api",
description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS",
version="0.3.0",
version="0.4.0",
lifespan=lifespan,
)
@@ -61,13 +61,15 @@ app.include_router(debug.router)
app.include_router(instructor.router)
app.include_router(files.router)
app.include_router(verbale.router)
app.include_router(custom_checks.router)
app.include_router(assignment.router)
@app.get("/", tags=["root"])
def root():
return {
"service": "rendicontazione-api",
"version": "0.3.0",
"version": "0.4.0",
"docs": "/docs",
"health": "/health",
}

View File

@@ -41,6 +41,60 @@ MIGRATIONS = [
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
ADD COLUMN IF NOT EXISTS uploaded_by integer;
""",
# 2026-04-18 v2: multi-tranche su remission_practice
# DROP UNIQUE su application_id (permette piu tranche per stessa domanda)
# aggiunge sequence_number, period_label, suggested_instructor_id
# nuova UNIQUE (application_id, sequence_number)
# partial index su assigned_instructor_id IS NULL per coda "da assegnare"
"""
ALTER TABLE gepafin_rendic.remission_practice
DROP CONSTRAINT IF EXISTS uq_remission_practice_application;
ALTER TABLE gepafin_rendic.remission_practice
ADD COLUMN IF NOT EXISTS sequence_number integer NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS period_label varchar(100),
ADD COLUMN IF NOT EXISTS suggested_instructor_id integer;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_remission_practice_app_seq'
AND conrelid = 'gepafin_rendic.remission_practice'::regclass
) THEN
ALTER TABLE gepafin_rendic.remission_practice
ADD CONSTRAINT uq_remission_practice_app_seq UNIQUE (application_id, sequence_number);
END IF;
END$$;
CREATE INDEX IF NOT EXISTS idx_remission_practice_unassigned
ON gepafin_rendic.remission_practice(assigned_instructor_id)
WHERE assigned_instructor_id IS NULL;
""",
# 2026-04-18 v2: tabella custom checks
# allineata allo storage adapter esistente (storage_path + mime + size + sha256)
# NON segue le specs RAG p1 che usavano document_filename (v1 obsoleta)
"""
CREATE TABLE IF NOT EXISTS gepafin_rendic.remission_custom_check_value (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
practice_id uuid NOT NULL REFERENCES gepafin_rendic.remission_practice(id) ON DELETE CASCADE,
check_code varchar(64) NOT NULL,
beneficiary_declared boolean NOT NULL DEFAULT false,
declared_at timestamptz,
storage_path varchar(1024),
mime varchar(128),
size_bytes bigint,
sha256 varchar(64),
document_uploaded_at timestamptz,
uploaded_by integer,
verification_status varchar(20) NOT NULL DEFAULT 'PENDING',
verification_notes text,
verified_by integer,
verified_at timestamptz,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
CONSTRAINT uq_custom_check_practice_code UNIQUE (practice_id, check_code)
);
CREATE INDEX IF NOT EXISTS idx_custom_check_practice
ON gepafin_rendic.remission_custom_check_value(practice_id);
""",
]

View File

@@ -43,13 +43,14 @@ class RemissionPractice(Base):
"""
__tablename__ = "remission_practice"
__table_args__ = (
UniqueConstraint("application_id", name="uq_remission_practice_application"),
UniqueConstraint("application_id", "sequence_number",
name="uq_remission_practice_app_seq"),
{"schema": "gepafin_rendic"},
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
call_id = Column(Integer, nullable=False)
application_id = Column(Integer, nullable=False, unique=True)
application_id = Column(Integer, nullable=False) # unique (application_id, sequence_number)
company_id = Column(Integer, nullable=False)
user_id = Column(Integer, nullable=False) # beneficiario che compila
@@ -61,6 +62,11 @@ class RemissionPractice(Base):
amount_erogato = Column(Numeric(14, 2), nullable=False) # copiato da application.amount_accepted
notes_beneficiario = Column(Text, nullable=True)
# Multi-tranche v2 (2026-04-18)
sequence_number = Column(Integer, nullable=False, default=1)
period_label = Column(String(100), nullable=True) # libero, es "I trimestre 2021"
suggested_instructor_id = Column(Integer, nullable=True) # letto da BE assigned_applications
# colonne istruttoria
assigned_instructor_id = Column(Integer, nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
@@ -80,6 +86,7 @@ class RemissionPractice(Base):
ula_employees = relationship("RemissionUlaEmployee", back_populates="practice", cascade="all, delete-orphan")
documents = relationship("RemissionDocument", back_populates="practice", cascade="all, delete-orphan")
amendment_requests = relationship("RemissionAmendmentRequest", back_populates="practice", cascade="all, delete-orphan")
custom_checks = relationship("RemissionCustomCheckValue", back_populates="practice", cascade="all, delete-orphan")
class RemissionInvoice(Base):
@@ -227,3 +234,47 @@ class RemissionAmendmentRequest(Base):
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
practice = relationship("RemissionPractice", back_populates="amendment_requests")
class RemissionCustomCheckValue(Base):
"""Valore di un controllo custom configurato dallo schema del bando.
Schema custom_checks[] nel template definisce code/label/description/requires_document/required.
Qui salviamo dichiarazione beneficiario + eventuale documento + verifica istruttore.
"""
__tablename__ = "remission_custom_check_value"
__table_args__ = (
UniqueConstraint("practice_id", "check_code", name="uq_custom_check_practice_code"),
{"schema": "gepafin_rendic"},
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
practice_id = Column(UUID(as_uuid=True),
ForeignKey("gepafin_rendic.remission_practice.id", ondelete="CASCADE"),
nullable=False)
check_code = Column(String(64), nullable=False) # es "antiriciclaggio", "polizza_fidejussoria"
# Dichiarazione beneficiario
beneficiary_declared = Column(Boolean, nullable=False, default=False)
declared_at = Column(DateTime(timezone=True), nullable=True)
# Documento allegato (se requires_document)
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)
document_uploaded_at = Column(DateTime(timezone=True), nullable=True)
uploaded_by = Column(Integer, nullable=True)
# Verifica istruttore
verification_status = Column(String(20), nullable=False, default="PENDING")
# PENDING | VALIDO | NON_VALIDO
verification_notes = Column(Text, nullable=True)
verified_by = Column(Integer, nullable=True)
verified_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(DateTime(timezone=True), nullable=False,
server_default=func.now(), onupdate=func.now())
practice = relationship("RemissionPractice", back_populates="custom_checks")

183
app/routers/assignment.py Normal file
View File

@@ -0,0 +1,183 @@
"""
Endpoint v2 per gestione assegnazione istruttori (capo istruttore / manager).
Solo ROLE_INSTRUCTOR_MANAGER + ROLE_SUPER_ADMIN.
"""
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
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 ..schemas import ApiResponse, PracticeReassignBody
router = APIRouter(
prefix="/api/remission-practices",
tags=["assignment-manager"],
)
def _require_manager(user: AuthUser = Depends(get_current_user)) -> AuthUser:
if user.role not in ("ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN"):
raise HTTPException(
status_code=403,
detail="Richiesto ruolo manager o superadmin"
)
return user
@router.get("/instructor-manager/assignments", response_model=ApiResponse)
def assignments_overview(
db: Session = Depends(get_db),
manager: AuthUser = Depends(_require_manager),
):
"""Vista capo istruttore: pratiche con suggested + assigned + flag 'da assegnare'."""
practices = db.query(RemissionPractice).filter(
RemissionPractice.status.in_(["SUBMITTED", "UNDER_REVIEW", "AWAITING_AMENDMENT"])
).order_by(
RemissionPractice.application_id,
RemissionPractice.sequence_number
).all()
# Enrichment: nome istruttori + company
items = []
user_cache: dict = {}
def _user_name(uid: Optional[int]) -> Optional[str]:
if uid is None:
return None
if uid in user_cache:
return user_cache[uid]
row = db.execute(text("""
SELECT first_name || ' ' || last_name as name, email
FROM gepafin_schema.gepafin_user WHERE id = :uid
"""), {"uid": uid}).mappings().first()
name = (row["name"] if row else None) or (row["email"] if row else None)
user_cache[uid] = name
return name
for p in practices:
company_row = db.execute(text("""
SELECT company_name, vat_number FROM gepafin_schema.company WHERE id = :cid
"""), {"cid": p.company_id}).mappings().first()
call_row = db.execute(text("""
SELECT name FROM gepafin_schema.call WHERE id = :cid
"""), {"cid": p.call_id}).mappings().first()
items.append({
"id": str(p.id),
"application_id": p.application_id,
"sequence_number": p.sequence_number,
"period_label": p.period_label,
"call_id": p.call_id,
"call_name": call_row["name"] if call_row else None,
"company_id": p.company_id,
"company_name": company_row["company_name"] if company_row else None,
"status": p.status,
"submitted_at": p.submitted_at.isoformat() if p.submitted_at else None,
"amount_erogato": float(p.amount_erogato or 0),
"suggested_instructor_id": p.suggested_instructor_id,
"suggested_instructor_name": _user_name(p.suggested_instructor_id),
"assigned_instructor_id": p.assigned_instructor_id,
"assigned_instructor_name": _user_name(p.assigned_instructor_id),
"is_unassigned": p.assigned_instructor_id is None,
})
return ApiResponse(data={"assignments": items})
@router.get("/instructor-manager/instructors", response_model=ApiResponse)
def list_available_instructors(
db: Session = Depends(get_db),
manager: AuthUser = Depends(_require_manager),
):
"""Elenco istruttori disponibili per riassegnazione (pre_instructor + manager ACTIVE)."""
rows = db.execute(text("""
SELECT u.id, u.email, u.first_name, u.last_name, r.role_type
FROM gepafin_schema.gepafin_user u
JOIN gepafin_schema.role r ON r.id = u.role_id
WHERE u.is_deleted = false
AND r.role_type IN ('ROLE_PRE_INSTRUCTOR', 'ROLE_INSTRUCTOR_MANAGER')
ORDER BY u.last_name, u.first_name
""")).mappings().all()
return ApiResponse(data={"instructors": [
{
"user_id": r["id"],
"email": r["email"],
"first_name": r["first_name"],
"last_name": r["last_name"],
"role_type": r["role_type"],
"display_name": f"{r['first_name'] or ''} {r['last_name'] or ''}".strip() or r["email"],
} for r in rows
]})
@router.post("/instructor/{practice_id}/reassign", response_model=ApiResponse)
def reassign_instructor(
practice_id: UUID,
body: PracticeReassignBody,
db: Session = Depends(get_db),
manager: AuthUser = Depends(_require_manager),
):
"""Manager assegna/riassegna la pratica a un istruttore diverso (o unassign se new_instructor_id=None).
Scrive audit entry in instructor_checklist.reassignment_log.
"""
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
if not p:
raise HTTPException(status_code=404, detail="Pratica non trovata")
old_instructor_id = p.assigned_instructor_id
# Verifica nuovo istruttore se specificato
if body.new_instructor_id is not None:
row = db.execute(text("""
SELECT u.id, r.role_type
FROM gepafin_schema.gepafin_user u
JOIN gepafin_schema.role r ON r.id = u.role_id
WHERE u.id = :uid AND u.is_deleted = false
"""), {"uid": body.new_instructor_id}).mappings().first()
if not row:
raise HTTPException(status_code=404,
detail=f"Istruttore {body.new_instructor_id} non trovato")
if row["role_type"] not in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER"):
raise HTTPException(status_code=422,
detail="Utente non ha ruolo istruttore")
# Audit log
checklist = dict(p.instructor_checklist or {})
log = list(checklist.get("reassignment_log") or [])
log.append({
"at": datetime.now(timezone.utc).isoformat(),
"by_user_id": manager.user_id,
"by_email": manager.email,
"from_instructor_id": old_instructor_id,
"to_instructor_id": body.new_instructor_id,
"reason": body.reassignment_reason,
})
checklist["reassignment_log"] = log
p.assigned_instructor_id = body.new_instructor_id
p.instructor_checklist = checklist
# Se passo da SUBMITTED + assegnato -> UNDER_REVIEW
# Altrimenti lascio status invariato (manager puo riassegnare anche durante review)
if p.status == "SUBMITTED" and body.new_instructor_id is not None:
p.status = "UNDER_REVIEW"
db.commit()
db.refresh(p)
action = "unassigned" if body.new_instructor_id is None else f"assigned to {body.new_instructor_id}"
return ApiResponse(
message=f"Pratica {action}",
data={
"id": str(p.id),
"status": p.status,
"assigned_instructor_id": p.assigned_instructor_id,
"suggested_instructor_id": p.suggested_instructor_id,
"reassignment_log": log,
}
)

View File

@@ -0,0 +1,319 @@
"""
Endpoint custom_checks v2: dichiarazione beneficiario + documento opzionale + verifica istruttore.
Merge definition (da schema_snapshot.custom_checks[]) + value (RemissionCustomCheckValue).
Path storage custom_checks: /var/uploads/custom_checks/{practice_id}/{code}/<sha12>-file.pdf
(fuori dal pattern invoice/ula/document per isolarli — non confondibili con allegati fattura/LUL).
"""
import io
import os
from datetime import datetime, timezone
from typing import List, Optional, Literal
from uuid import UUID
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from ..db import get_db
from ..auth import AuthUser, get_current_user
from ..models import RemissionPractice, RemissionCustomCheckValue
from ..schemas import ApiResponse, CustomCheckOut, CustomCheckVerifyBody
from ..storage import (
save_upload, delete_file, open_file,
FileTooLargeError, MimeNotAllowedError, StorageError, BASE_PATH,
)
router = APIRouter(prefix="/api/remission-practices", tags=["custom-checks"])
def _is_instructor(user: AuthUser) -> bool:
return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
def _get_practice(db: Session, practice_id: UUID, user: AuthUser) -> RemissionPractice:
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
if not p:
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
# Autorizzazione base: beneficiario owner o istruttore
if user.is_beneficiary() and p.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Accesso negato")
if not user.is_beneficiary() and not _is_instructor(user):
raise HTTPException(status_code=403, detail="Ruolo non autorizzato")
return p
def _can_declare(user: AuthUser, practice: RemissionPractice) -> bool:
"""Solo beneficiario owner e solo su DRAFT | AWAITING_AMENDMENT."""
if not user.is_beneficiary():
return False
if practice.user_id != user.user_id:
return False
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
def _can_verify(user: AuthUser, practice: RemissionPractice) -> bool:
if not _is_instructor(user):
return False
return practice.status in ("UNDER_REVIEW", "AWAITING_AMENDMENT")
def _schema_check_defs(practice: RemissionPractice) -> List[dict]:
return practice.schema_snapshot.get("custom_checks") or []
def _merge_check(definition: dict, value: Optional[RemissionCustomCheckValue]) -> dict:
out = {
"code": definition.get("code"),
"label": definition.get("label"),
"description": definition.get("description"),
"requires_document": bool(definition.get("requires_document", False)),
"required": bool(definition.get("required", False)),
# valori default
"beneficiary_declared": False,
"declared_at": None,
"filename_original": None,
"storage_path": None,
"size_bytes": None,
"document_uploaded_at": None,
"verification_status": "PENDING",
"verification_notes": None,
"verified_by": None,
"verified_at": None,
}
if value is not None:
out.update({
"beneficiary_declared": value.beneficiary_declared,
"declared_at": value.declared_at,
"storage_path": value.storage_path,
"size_bytes": value.size_bytes,
"document_uploaded_at": value.document_uploaded_at,
"verification_status": value.verification_status,
"verification_notes": value.verification_notes,
"verified_by": value.verified_by,
"verified_at": value.verified_at,
})
# filename originale ricostruito dal path (dopo il sha12-)
if value.storage_path:
basename = Path(value.storage_path).name
# formato: <sha12>-<original>
parts = basename.split("-", 1)
out["filename_original"] = parts[1] if len(parts) == 2 else basename
return out
def _get_or_create_value(db: Session, practice_id: UUID, code: str) -> RemissionCustomCheckValue:
v = db.query(RemissionCustomCheckValue).filter(
RemissionCustomCheckValue.practice_id == practice_id,
RemissionCustomCheckValue.check_code == code,
).first()
if not v:
v = RemissionCustomCheckValue(practice_id=practice_id, check_code=code)
db.add(v)
db.flush()
return v
# ---------- endpoints ----------
@router.get("/{practice_id}/custom-checks", response_model=ApiResponse)
def list_custom_checks(practice_id: UUID, db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user)):
"""Ritorna i custom_checks della pratica: schema definition + valori correnti."""
p = _get_practice(db, practice_id, user)
defs = _schema_check_defs(p)
values_by_code = {v.check_code: v for v in p.custom_checks}
out = [_merge_check(d, values_by_code.get(d.get("code"))) for d in defs]
return ApiResponse(data={"custom_checks": out})
@router.put("/{practice_id}/custom-checks/{code}/declare", response_model=ApiResponse)
async def declare_custom_check(
practice_id: UUID,
code: str,
beneficiary_declared: bool = Form(...),
file: Optional[UploadFile] = File(None),
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Beneficiario dichiara il check (bool) e opzionalmente allega un documento.
Se requires_document=true nello schema, l'upload e raccomandato ma non imposto
lato server (la required-ness e un gate su /submit)."""
p = _get_practice(db, practice_id, user)
if not _can_declare(user, p):
raise HTTPException(
status_code=403,
detail="Solo beneficiario owner su pratica DRAFT o AWAITING_AMENDMENT puo dichiarare"
)
defs = {d["code"]: d for d in _schema_check_defs(p)}
if code not in defs:
raise HTTPException(status_code=404, detail=f"Custom check '{code}' non definito nello schema")
v = _get_or_create_value(db, p.id, code)
v.beneficiary_declared = bool(beneficiary_declared)
v.declared_at = datetime.now(timezone.utc) if beneficiary_declared else None
# Se arriva un file sostituisce l'eventuale esistente
if file is not None and file.filename:
try:
# path custom_checks/<practice_id>/<code>/<sha12>-<name> — sfrutto storage_adapter
# con entity_type "document" fittizio e un app_id = practice_id (sfrutto la dir)
# In alternativa faccio path custom scrivendolo direttamente qui.
# Scelgo via diretta per evitare collisione con document reale.
from hashlib import sha256
size = 0
hasher = sha256()
content = b""
while True:
chunk = await file.read(65536)
if not chunk:
break
content += chunk
size += len(chunk)
if size > 15 * 1024 * 1024:
raise HTTPException(status_code=413, detail="File troppo grande (max 15 MB)")
hasher.update(chunk)
mime = (file.content_type or "").lower().split(";")[0].strip()
if mime not in ("application/pdf", "image/jpeg", "image/png"):
raise HTTPException(
status_code=415,
detail=f"MIME non consentito: {mime}. Accettati: pdf, jpeg, png"
)
digest = hasher.hexdigest()
# sanitize filename
safe = "".join(c if (c.isalnum() or c in "-_.() ") else "_" for c in file.filename).strip().replace(" ", "_")
if len(safe) > 120:
root, ext = os.path.splitext(safe)
safe = root[:120 - len(ext)] + ext
target_dir = BASE_PATH / "custom_checks" / str(p.id) / code
target_dir.mkdir(parents=True, exist_ok=True)
final_path = target_dir / f"{digest[:12]}-{safe}"
final_path.write_bytes(content)
# Rimuovi eventuale file precedente (path diverso)
if v.storage_path and Path(BASE_PATH / v.storage_path) != final_path:
try:
delete_file(v.storage_path)
except Exception:
pass
v.storage_path = str(final_path.relative_to(BASE_PATH))
v.mime = mime
v.size_bytes = size
v.sha256 = digest
v.document_uploaded_at = datetime.now(timezone.utc)
v.uploaded_by = user.user_id
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Errore upload: {e}")
# Reset eventuale validazione precedente (beneficiario ha cambiato qualcosa)
v.verification_status = "PENDING"
v.verification_notes = None
v.verified_by = None
v.verified_at = None
db.commit()
db.refresh(v)
defs_by_code = {d["code"]: d for d in _schema_check_defs(p)}
return ApiResponse(message="Check aggiornato", data=_merge_check(defs_by_code[code], v))
@router.delete("/{practice_id}/custom-checks/{code}/document", response_model=ApiResponse)
def delete_custom_check_document(
practice_id: UUID, code: str,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Beneficiario rimuove il documento allegato (dichiarazione resta)."""
p = _get_practice(db, practice_id, user)
if not _can_declare(user, p):
raise HTTPException(status_code=403, detail="Non autorizzato")
v = db.query(RemissionCustomCheckValue).filter(
RemissionCustomCheckValue.practice_id == practice_id,
RemissionCustomCheckValue.check_code == code,
).first()
if not v or not v.storage_path:
return ApiResponse(message="Nessun documento da rimuovere")
try:
delete_file(v.storage_path)
except Exception:
pass
v.storage_path = None
v.mime = None
v.size_bytes = None
v.sha256 = None
v.document_uploaded_at = None
v.verification_status = "PENDING" # reset verify
v.verification_notes = None
db.commit()
return ApiResponse(message="Documento rimosso")
@router.put("/{practice_id}/custom-checks/{code}/verify", response_model=ApiResponse)
def verify_custom_check(
practice_id: UUID, code: str,
body: CustomCheckVerifyBody,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Istruttore valida il check (VALIDO | NON_VALIDO | PENDING)."""
p = _get_practice(db, practice_id, user)
if not _can_verify(user, p):
raise HTTPException(status_code=403, detail="Solo istruttore su pratica in lavorazione")
if body.verification_status not in ("PENDING", "VALIDO", "NON_VALIDO"):
raise HTTPException(status_code=422, detail="verification_status non valido")
defs = {d["code"]: d for d in _schema_check_defs(p)}
if code not in defs:
raise HTTPException(status_code=404, detail=f"Check '{code}' non nello schema")
v = _get_or_create_value(db, p.id, code)
v.verification_status = body.verification_status
v.verification_notes = body.verification_notes
v.verified_by = user.user_id
v.verified_at = datetime.now(timezone.utc) if body.verification_status != "PENDING" else None
db.commit()
db.refresh(v)
return ApiResponse(message="Check verificato", data=_merge_check(defs[code], v))
@router.get("/{practice_id}/custom-checks/{code}/document")
def download_custom_check_document(
practice_id: UUID, code: str,
inline: int = 0,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Download del documento allegato (stream con Content-Disposition)."""
from fastapi.responses import FileResponse
p = _get_practice(db, practice_id, user)
v = db.query(RemissionCustomCheckValue).filter(
RemissionCustomCheckValue.practice_id == practice_id,
RemissionCustomCheckValue.check_code == code,
).first()
if not v or not v.storage_path:
raise HTTPException(status_code=404, detail="Nessun documento allegato")
try:
abs_path = open_file(v.storage_path)
except FileNotFoundError:
raise HTTPException(status_code=410, detail="File non piu disponibile su storage")
basename = Path(v.storage_path).name
parts = basename.split("-", 1)
filename = parts[1] if len(parts) == 2 else basename
disp = "inline" if inline else "attachment"
return FileResponse(
path=str(abs_path),
media_type=v.mime or "application/octet-stream",
headers={"Content-Disposition": f'{disp}; filename="{filename}"'},
)

View File

@@ -64,7 +64,7 @@ def _enrich_queue_item(db: Session, p: RemissionPractice) -> InstructorQueueItem
# calcolo remissione due dalla schema_snapshot
try:
check = _compute_gate_check(p)
check = _compute_gate_check(db, p)
item.remission_due = check.totals.get("remission_due", 0)
except Exception:
item.remission_due = None
@@ -109,7 +109,7 @@ def instructor_view_practice(practice_id: UUID, db: Session = Depends(get_db),
"""Vista completa della pratica per istruttore (readonly + gate check + amendments)."""
p = _get_practice_or_404(db, practice_id)
check = _compute_gate_check(p)
check = _compute_gate_check(db, p)
amendments = [AmendmentRequestOut.model_validate(a).model_dump(mode="json") for a in p.amendment_requests]
return ApiResponse(data={
@@ -152,7 +152,7 @@ def approve_practice(practice_id: UUID, body: ReviewApproveBody,
if body.approved_remission is not None:
p.approved_remission = body.approved_remission
else:
check = _compute_gate_check(p)
check = _compute_gate_check(db, p)
p.approved_remission = Decimal(str(check.totals.get("remission_due", 0)))
p.status = "APPROVED"

View File

@@ -4,12 +4,12 @@ Endpoint pratiche di rendicontazione (lato beneficiario).
import copy
from datetime import datetime, timezone
from decimal import Decimal
from typing import List
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import text
from sqlalchemy import text, func
from ..db import get_db
from ..auth import AuthUser, get_current_user
@@ -23,8 +23,10 @@ from ..schemas import (
UlaEmployeeCreate, UlaEmployeeOut,
DocumentUpsert, DocumentOut,
GateCheckResult,
ApplicationTranchesSummary, CopyUlaOption,
ApiResponse
)
from ..templates import upgrade_schema_to_v2
router = APIRouter(prefix="/api/remission-practices", tags=["remission-practices"])
@@ -51,7 +53,7 @@ def _ensure_editable(practice: RemissionPractice):
)
def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
def _compute_gate_check(db: Session, practice: RemissionPractice) -> GateCheckResult:
"""Valuta le gate_rules dello schema snapshot contro il contenuto della pratica.
Calcola:
- per_category_declared: totali dichiarati dal beneficiario (sempre)
@@ -117,12 +119,43 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
amt_erogato = practice.amount_erogato
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
max_remission = min(cap_pct * amt_erogato, cap_abs)
# Cap assoluto per l'application (somma di tutte le tranche ammissibili)
max_remission_global = min(cap_pct * amt_erogato, cap_abs)
# Cumulativo multi-tranche v2: sommo remission approvate delle tranche precedenti
# della stessa application per calcolare il residuo disponibile.
already_approved = db.query(
func.coalesce(func.sum(RemissionPractice.approved_remission), 0)
).filter(
RemissionPractice.application_id == practice.application_id,
RemissionPractice.sequence_number < practice.sequence_number,
RemissionPractice.status == 'APPROVED'
).scalar() or 0
already_approved = Decimal(str(already_approved))
max_remission_this_tranche = max(Decimal("0"), max_remission_global - already_approved)
# Legacy: max_remission = questo tranche (usato dai check sotto).
max_remission = max_remission_this_tranche
# 5 VOCI CECILIA:
# (1) max_remission_global
# (2) pre_check_admissible = min(grand_total_declared, max_remission_this_tranche)
# (3) remission_due = min(effective_total, max_remission_this_tranche)
# (4) amount_erogato
# (5) residuo_da_restituire = amt_erogato - SUM(approvata) (post-controllo su tutte le tranche)
pre_check_admissible = min(grand_total, max_remission_this_tranche)
# Se almeno 1 verifica fatta -> uso grand_total_verified per remission_due
# altrimenti uso grand_total (dichiarato) per preview pre-istruttoria
effective_total = grand_total_verified if any_verified else grand_total
remission_due = min(effective_total, max_remission)
remission_due = min(effective_total, max_remission_this_tranche)
# Conteggio tranche totali per questa application (per info UI/PDF)
tranches_count = db.query(RemissionPractice).filter(
RemissionPractice.application_id == practice.application_id
).count()
tranches_max = int(rules.get("max_tranches", 1))
# Per compatibilità: per_category e grand_total restano "dichiarato"
per_category = per_category_declared
@@ -209,9 +242,17 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
"amount_erogato": float(amt_erogato),
"any_verified": any_verified,
"all_verified": all_verified,
"residuo_da_restituire": float(max(amt_erogato - Decimal(str(remission_due)), Decimal("0"))),
"residuo_da_restituire": float(max(amt_erogato - already_approved - Decimal(str(remission_due)), Decimal("0"))),
"amount_basis": amount_basis,
"use_taxable_only": use_taxable_only
"use_taxable_only": use_taxable_only,
# multi-tranche v2
"max_remission_global": float(max_remission_global),
"already_approved_previous_tranches": float(already_approved),
"max_remission_this_tranche": float(max_remission_this_tranche),
"pre_check_admissible": float(pre_check_admissible),
"sequence_number": practice.sequence_number,
"tranches_count": tranches_count,
"tranches_max": tranches_max
}
)
@@ -235,48 +276,132 @@ def _enrich_list_item(db: Session, p: RemissionPractice) -> PracticeListItem:
return item
def _read_original_instructor(db: Session, application_id: int) -> Optional[int]:
"""Legge l'istruttore originariamente assegnato alla domanda nel BE Gepafin.
Restituisce user_id solo se l'utente e ancora attivo con ruolo PRE_INSTRUCTOR o INSTRUCTOR_MANAGER.
Altrimenti None (finira in coda 'da assegnare' per il manager).
"""
row = db.execute(text("""
SELECT aa.user_id, r.role_type, u.is_deleted
FROM gepafin_schema.assigned_applications aa
JOIN gepafin_schema.gepafin_user u ON u.id = aa.user_id
JOIN gepafin_schema.role r ON r.id = u.role_id
WHERE aa.application_id = :aid
AND aa.is_deleted = false
AND u.is_deleted = false
ORDER BY aa.assigned_at DESC
LIMIT 1
"""), {"aid": application_id}).mappings().first()
if not row:
return None
if row["role_type"] not in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER"):
return None
return row["user_id"]
def _get_schema_published(db: Session, call_id: int) -> Optional[CallRemissionSchema]:
return db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
# ---------- endpoints ----------
@router.get("/mine", response_model=ApiResponse)
def list_my_practices(db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
"""Lista pratiche del beneficiario corrente + applications CONTRACT_SIGNED pronte per start."""
# pratiche esistenti
practices = db.query(RemissionPractice).filter(RemissionPractice.user_id == user.user_id).all()
existing_app_ids = {p.application_id for p in practices}
"""Lista pratiche del beneficiario raggruppate per application_id (v2 multi-tranche).
Ogni application ha il riepilogo cumulativo + elenco tranche esistenti + stato apertura nuova tranche.
"""
# Tutte le pratiche del beneficiario ordinate per application+sequence
practices = db.query(RemissionPractice).filter(
RemissionPractice.user_id == user.user_id
).order_by(
RemissionPractice.application_id, RemissionPractice.sequence_number
).all()
# applications CONTRACT_SIGNED del beneficiario che non hanno ancora una pratica
# Raggruppo per application_id
by_app = {}
for p in practices:
by_app.setdefault(p.application_id, []).append(p)
# Applications CONTRACT_SIGNED del beneficiario
rows = db.execute(text("""
SELECT a.id as application_id, a.call_id, a.company_id, a.amount_accepted,
a.status, c.name as call_name, comp.company_name as company_name
SELECT a.id as application_id, a.call_id, a.company_id, a.amount_accepted, a.status,
c.name as call_name, comp.company_name as company_name
FROM gepafin_schema.application a
JOIN gepafin_schema.call c ON c.id = a.call_id
LEFT JOIN gepafin_schema.company comp ON comp.id = a.company_id
WHERE a.user_id = :uid AND a.status = 'CONTRACT_SIGNED' AND a.is_deleted = false
ORDER BY a.id
"""), {"uid": user.user_id}).mappings().all()
pending = []
applications = []
for r in rows:
if r["application_id"] not in existing_app_ids:
pending.append({
"application_id": r["application_id"],
"call_id": r["call_id"],
"company_id": r["company_id"],
"amount_erogato": float(r["amount_accepted"] or 0),
"call_name": r["call_name"],
"company_name": r["company_name"],
"status": "NOT_STARTED"
})
app_id = r["application_id"]
trs = by_app.get(app_id, [])
return ApiResponse(data={
"practices": [_enrich_list_item(db, p).model_dump(mode="json") for p in practices],
"ready_to_start": pending
})
# leggo schema del bando per max_tranches e cap
schema = _get_schema_published(db, r["call_id"])
rules = (schema.schema_json.get("gate_rules", {}) if schema else {}) or {}
max_tranches = int(rules.get("max_tranches", 1))
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
amt_erogato = Decimal(str(r["amount_accepted"] or 0))
max_remission_global = min(cap_pct * amt_erogato, cap_abs)
already_approved_sum = sum(
(t.approved_remission or Decimal("0")) for t in trs if t.status == "APPROVED"
)
max_remission_next = max(Decimal("0"), max_remission_global - already_approved_sum)
# Stato apertura nuova tranche
can_start = True
reason = None
if len(trs) >= max_tranches:
can_start = False
reason = f"Limite tranches raggiunto ({max_tranches})"
elif len(trs) > 0 and trs[-1].status not in ("APPROVED", "REJECTED"):
can_start = False
reason = "Completa prima la rendicontazione in corso"
elif max_remission_next <= 0:
can_start = False
reason = f"Remissione massima gia raggiunta (euro {float(already_approved_sum):.2f})"
# Summary tranche (serialize with enriched fields)
tranche_items = []
for t in trs:
item = _enrich_list_item(db, t).model_dump(mode="json")
tranche_items.append(item)
applications.append({
"application_id": app_id,
"call_id": r["call_id"],
"call_name": r["call_name"],
"company_id": r["company_id"],
"company_name": r["company_name"],
"amount_erogato": float(amt_erogato),
"max_tranches": max_tranches,
"tranches": tranche_items,
"can_start_new": can_start,
"start_blocked_reason": reason,
"already_approved_sum": float(already_approved_sum),
"max_remission_global": float(max_remission_global),
"max_remission_next_tranche": float(max_remission_next),
})
return ApiResponse(data={"applications": applications})
@router.post("/start", response_model=ApiResponse)
def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user)):
"""Avvia una pratica di rendicontazione per una application CONTRACT_SIGNED."""
"""Avvia una nuova pratica o tranche N+1 per una application CONTRACT_SIGNED.
Validazioni server-side v2:
- count(tranches) < max_tranches
- last tranche in {APPROVED, REJECTED} oppure count==0
- max_remission_this_tranche > 0
Se sequence_number > 1 e copy_ula_from_previous=True: bulk copy ULA dalla tranche N-1
con reset verification_*.
"""
# Verifica application
app_row = db.execute(text("""
SELECT id, call_id, company_id, user_id, status, amount_accepted
@@ -286,28 +411,69 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
if not app_row:
raise HTTPException(status_code=404, detail=f"Application {body.application_id} non trovata")
if app_row["status"] != "CONTRACT_SIGNED":
raise HTTPException(status_code=409,
detail=f"Application in stato {app_row['status']}, richiesto CONTRACT_SIGNED")
if user.is_beneficiary() and app_row["user_id"] != user.user_id:
raise HTTPException(status_code=403, detail="Application non di tua proprietà")
raise HTTPException(status_code=403, detail="Application non di tua proprieta")
# Schema del bando: richiede PUBLISHED (o DRAFT se superadmin per test)
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == app_row["call_id"]).first()
# Schema del bando
schema = _get_schema_published(db, app_row["call_id"])
if not schema:
raise HTTPException(status_code=409,
detail="Nessuno schema di rendicontazione configurato per questo bando. "
"Contatta l'ente gestore.")
detail="Nessuno schema di rendicontazione configurato per questo bando.")
if schema.status != "PUBLISHED" and user.is_beneficiary():
raise HTTPException(status_code=409,
detail="Lo schema di rendicontazione non è ancora stato pubblicato.")
detail="Lo schema di rendicontazione non e ancora stato pubblicato.")
# Pratica esistente?
exists = db.query(RemissionPractice).filter(RemissionPractice.application_id == body.application_id).first()
if exists:
raise HTTPException(status_code=409, detail="Pratica già esistente")
# Tranche esistenti
existing_tranches = db.query(RemissionPractice).filter(
RemissionPractice.application_id == body.application_id
).order_by(RemissionPractice.sequence_number).all()
rules = (schema.schema_json.get("gate_rules", {}) or {})
max_tranches = int(rules.get("max_tranches", 1))
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
amt_erogato = Decimal(str(app_row["amount_accepted"] or 0))
max_remission_global = min(cap_pct * amt_erogato, cap_abs)
# VALIDAZIONI v2
if len(existing_tranches) >= max_tranches:
raise HTTPException(status_code=400,
detail=f"Limite tranches raggiunto (max {max_tranches})")
if existing_tranches:
last = existing_tranches[-1]
if last.status not in ("APPROVED", "REJECTED"):
raise HTTPException(status_code=400,
detail="Completa prima la rendicontazione in corso")
already_approved = sum(
(t.approved_remission or Decimal("0")) for t in existing_tranches if t.status == "APPROVED"
)
max_remission_this = max(Decimal("0"), max_remission_global - already_approved)
if max_remission_this <= 0:
raise HTTPException(status_code=400,
detail=f"Remissione massima gia raggiunta (euro {float(already_approved):.2f})")
# Nuovo sequence_number
next_seq = (existing_tranches[-1].sequence_number + 1) if existing_tranches else 1
# suggested_instructor: solo alla tranche 1 leggo da assigned_applications
suggested_instructor_id = None
assigned_instructor_id = None
if next_seq == 1:
suggested_instructor_id = _read_original_instructor(db, body.application_id)
assigned_instructor_id = suggested_instructor_id
else:
# tranche successiva: eredita suggested dalla tranche 1, assegnato ricomincia NULL
first = existing_tranches[0]
suggested_instructor_id = first.suggested_instructor_id
# Snapshot schema aggiornato a v2 se schema_version < 2
snapshot = copy.deepcopy(schema.schema_json)
snapshot = upgrade_schema_to_v2(snapshot)
practice = RemissionPractice(
call_id=app_row["call_id"],
@@ -315,15 +481,70 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
company_id=app_row["company_id"],
user_id=app_row["user_id"],
status="DRAFT",
schema_snapshot=copy.deepcopy(schema.schema_json),
amount_erogato=app_row["amount_accepted"] or Decimal("0"),
schema_snapshot=snapshot,
amount_erogato=amt_erogato,
sequence_number=next_seq,
period_label=body.period_label,
suggested_instructor_id=suggested_instructor_id,
assigned_instructor_id=assigned_instructor_id,
)
db.add(practice)
db.flush()
# Copy ULA da tranche precedente
if next_seq > 1 and body.copy_ula_from_previous:
prev = existing_tranches[-1]
for prev_emp in prev.ula_employees:
new_emp = RemissionUlaEmployee(
practice_id=practice.id,
codice_fiscale=prev_emp.codice_fiscale,
full_name=prev_emp.full_name,
contract_type=prev_emp.contract_type,
role_description=prev_emp.role_description,
fte_pct=prev_emp.fte_pct,
period_start_date=prev_emp.period_start_date,
period_end_date=prev_emp.period_end_date,
supporting_doc_type=prev_emp.supporting_doc_type,
# reset verification: non copiare status/notes/verified_by/verified_at
verification_status="PENDING",
)
db.add(new_emp)
db.commit()
db.refresh(practice)
return ApiResponse(message="Pratica avviata",
data=PracticeOut.model_validate(practice).model_dump(mode="json"))
return ApiResponse(
message=f"Tranche {next_seq}/{max_tranches} avviata",
data=PracticeOut.model_validate(practice).model_dump(mode="json")
)
@router.get("/{practice_id}/copy-ula-options", response_model=ApiResponse)
def copy_ula_options(practice_id: UUID, db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user)):
"""Preview dei dipendenti ULA della tranche N-1 copiabili in questa tranche N.
Usato dal FE al click su "+Nuova rendicontazione" per mostrare il pre-fill."""
p = _get_practice_or_404(db, practice_id, user)
if p.sequence_number <= 1:
return ApiResponse(data={"options": [], "previous_sequence": None})
prev = db.query(RemissionPractice).filter(
RemissionPractice.application_id == p.application_id,
RemissionPractice.sequence_number == p.sequence_number - 1
).first()
if not prev:
return ApiResponse(data={"options": [], "previous_sequence": None})
options = [CopyUlaOption(
codice_fiscale=e.codice_fiscale,
full_name=e.full_name,
contract_type=e.contract_type,
role_description=e.role_description,
fte_pct=float(e.fte_pct),
period_start_date=e.period_start_date,
period_end_date=e.period_end_date,
supporting_doc_type=e.supporting_doc_type,
).model_dump(mode="json") for e in prev.ula_employees]
return ApiResponse(data={"options": options, "previous_sequence": prev.sequence_number,
"previous_id": str(prev.id)})
@router.get("/{practice_id}", response_model=ApiResponse)
@@ -451,7 +672,7 @@ def clear_document(practice_id: UUID, doc_code: str,
def gate_check(practice_id: UUID, db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user)):
p = _get_practice_or_404(db, practice_id, user)
result = _compute_gate_check(p)
result = _compute_gate_check(db, p)
return ApiResponse(data=result.model_dump(mode="json"))
@@ -461,7 +682,7 @@ def submit_practice(practice_id: UUID, db: Session = Depends(get_db),
p = _get_practice_or_404(db, practice_id, user)
_ensure_editable(p)
check = _compute_gate_check(p)
check = _compute_gate_check(db, p)
if not check.passed:
raise HTTPException(status_code=422, detail={
"message": "Gate rules non soddisfatte",

View File

@@ -106,7 +106,7 @@ def _is_instructor(user: AuthUser) -> bool:
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)
gate_obj = _compute_gate_check(db, practice); gate = gate_obj.model_dump() if hasattr(gate_obj, "model_dump") else dict(gate_obj)
totals = gate.get("totals") or {}
# Schema sections

View File

@@ -40,8 +40,10 @@ class RemissionSchemaOut(BaseModel):
# ====================== Pratica di rendicontazione (beneficiario) ======================
class PracticeStartRequest(BaseModel):
"""Input minimo per avviare una pratica: solo application_id. Il resto viene dal DB."""
"""Input per avviare una (nuova) pratica o tranche."""
application_id: int
period_label: Optional[str] = None # es "I trimestre 2021" — libero
copy_ula_from_previous: bool = True # ignorato se e la prima tranche
class PracticeUpdate(BaseModel):
@@ -160,6 +162,11 @@ class PracticeOut(BaseModel):
instructor_checklist: Optional[dict] = None
verbale_date: Optional[date] = None
# v2 multi-tranche
sequence_number: int = 1
period_label: Optional[str] = None
suggested_instructor_id: Optional[int] = None
invoices: List[InvoiceOut] = []
ula_employees: List[UlaEmployeeOut] = []
documents: List[DocumentOut] = []
@@ -178,6 +185,11 @@ class PracticeListItem(BaseModel):
created_at: datetime
submitted_at: Optional[datetime] = None
# v2 multi-tranche
sequence_number: int = 1
period_label: Optional[str] = None
suggested_instructor_id: Optional[int] = None
# campi denormalizzati aggiunti a runtime
call_name: Optional[str] = None
company_name: Optional[str] = None
@@ -237,6 +249,9 @@ class InstructorQueueItem(BaseModel):
id: UUID
call_id: int
application_id: int
sequence_number: int = 1
period_label: Optional[str] = None
suggested_instructor_id: Optional[int] = None
company_id: int
status: str
amount_erogato: Decimal
@@ -288,3 +303,76 @@ class ApiResponse(BaseModel):
status: str = "SUCCESS"
message: Optional[str] = None
data: Optional[Any] = None
# ====================== v2 Custom checks ======================
class CustomCheckDeclareBody(BaseModel):
beneficiary_declared: bool
class CustomCheckVerifyBody(BaseModel):
verification_status: str # PENDING | VALIDO | NON_VALIDO
verification_notes: Optional[str] = None
class CustomCheckOut(BaseModel):
"""Vista merged di definition (da schema) + value (dal DB)."""
code: str
label: str
description: Optional[str] = None
requires_document: bool = False
required: bool = False
# valori
beneficiary_declared: bool = False
declared_at: Optional[datetime] = None
filename_original: Optional[str] = None
storage_path: Optional[str] = None
size_bytes: Optional[int] = None
document_uploaded_at: Optional[datetime] = None
verification_status: str = "PENDING"
verification_notes: Optional[str] = None
verified_by: Optional[int] = None
verified_at: Optional[datetime] = None
# ====================== v2 Reassign istruttore ======================
class PracticeReassignBody(BaseModel):
new_instructor_id: Optional[int] = None # None = unassign ritorno in coda
reassignment_reason: Optional[str] = None
# ====================== v2 Tranches ======================
class ApplicationTranchesSummary(BaseModel):
"""Riepilogo pratiche/tranche per una application."""
application_id: int
call_id: int
call_name: Optional[str] = None
company_id: int
company_name: Optional[str] = None
amount_erogato: float
max_tranches: int = 1
# summary tranche esistenti
tranches: List[PracticeListItem] = []
# stato apertura nuova tranche
can_start_new: bool = False
start_blocked_reason: Optional[str] = None
# importi cumulativi
already_approved_sum: float = 0
max_remission_global: float = 0
max_remission_next_tranche: float = 0
class CopyUlaOption(BaseModel):
"""Dipendente copiabile da tranche precedente."""
codice_fiscale: str
full_name: str
contract_type: str
role_description: Optional[str] = None
fte_pct: float
period_start_date: date
period_end_date: date
supporting_doc_type: Optional[str] = None

View File

@@ -1,11 +1,14 @@
"""
Template schemi precompilati per bandi noti.
RE-START: il bando del xlsx di Cecilia, base per la prima iterazione.
v2 (2026-04-18): schema_version=2, max_tranches, custom_checks[]
"""
RESTART_TEMPLATE = {
"version": "1.0",
"template_id": "RESTART_V1",
"version": "2.0",
"schema_version": 2,
"template_id": "RESTART_V2",
"template_label": "RE-START (fondo prestiti con remissione del debito)",
"sections": [
{
@@ -115,6 +118,22 @@ RESTART_TEMPLATE = {
],
},
],
"custom_checks": [
{
"code": "antiriciclaggio",
"label": "Dichiarazione antiriciclaggio",
"description": "Dichiaro che il beneficiario rispetta la normativa antiriciclaggio (D.Lgs. 231/2007 e s.m.i.) e che i soggetti coinvolti non sono iscritti in liste sanzionatorie.",
"requires_document": False,
"required": True,
},
{
"code": "polizza_fidejussoria",
"label": "Polizza fidejussoria",
"description": "Allegare copia della polizza fidejussoria a garanzia dell'importo erogato (se richiesta da bando).",
"requires_document": True,
"required": False,
},
],
"gate_rules": {
"amount_range": {"min": 5000, "max": 25000},
"cap_pct_erogato": 0.5,
@@ -125,5 +144,35 @@ RESTART_TEMPLATE = {
"require_at_least_one_invoice_per_nonzero_category": True,
"require_ula_above_threshold": True,
"require_all_documents_resolved": True,
"max_tranches": 2, # v2: superadmin configurabile, default 1
},
}
def upgrade_schema_to_v2(schema_json: dict) -> dict:
"""Upgrade in-place di schema v1 a v2.
- Aggiunge schema_version=2 se mancante
- Aggiunge gate_rules.max_tranches=1 se mancante
- Aggiunge custom_checks=[] se mancante
- Assicura ula_section.enabled presente (default True se ula_block esiste)
Idempotente: se lo schema e gia v2, no-op.
"""
if not isinstance(schema_json, dict):
return schema_json
changed = False
if schema_json.get("schema_version", 1) < 2:
schema_json["schema_version"] = 2
changed = True
gate = schema_json.setdefault("gate_rules", {})
if "max_tranches" not in gate:
gate["max_tranches"] = 1
changed = True
if "custom_checks" not in schema_json:
schema_json["custom_checks"] = []
changed = True
# ula_section.enabled esplicito
for sec in schema_json.get("sections", []):
if sec.get("type") == "ula_block" and "enabled" not in sec:
sec["enabled"] = True
changed = True
return schema_json

View File

@@ -36,6 +36,7 @@ from app.models import (
RemissionUlaEmployee,
RemissionDocument,
RemissionAmendmentRequest,
RemissionCustomCheckValue,
)
from app.storage import save_upload, BASE_PATH
from app.templates import RESTART_TEMPLATE
@@ -44,6 +45,7 @@ CALL_ID = 1
COMPANY_ID = 1
BENEFICIARY_USER_ID = 9 # beneficiario@sandbox.local
INSTRUCTOR_USER_ID = 10 # istruttore@sandbox.local
MANAGER_USER_ID = 11 # manager@sandbox.local
APPLICATION_ID = 1
@@ -110,6 +112,27 @@ def attach_pdf(db, entity, entity_type: str, application_id: int,
entity.uploaded_at = datetime.now(timezone.utc)
def ensure_assigned_application(db):
"""Popola gepafin_schema.assigned_applications per abilitare suggested_instructor_id
alla creazione della prima tranche. Idempotente."""
from sqlalchemy import text
existing = db.execute(text("""
SELECT id FROM gepafin_schema.assigned_applications
WHERE application_id = :aid AND user_id = :uid AND is_deleted = false
"""), {"aid": APPLICATION_ID, "uid": INSTRUCTOR_USER_ID}).scalar()
if existing:
print(f"[assigned_applications] gia presente (id={existing})")
return
db.execute(text("""
INSERT INTO gepafin_schema.assigned_applications
(user_id, assigned_by, application_id, status, is_deleted, assigned_at, created_date, updated_date)
VALUES (:uid, :admin, :aid, 'ASSIGNED', false, NOW(), NOW(), NOW())
"""), {"uid": INSTRUCTOR_USER_ID, "admin": 8, "aid": APPLICATION_ID})
db.commit()
print(f"[assigned_applications] user={INSTRUCTOR_USER_ID} assegnato a application={APPLICATION_ID}")
# ---------------------------------------------------------------------------
# Reset
# ---------------------------------------------------------------------------
@@ -120,6 +143,7 @@ def do_reset(scope: str):
if scope == 'all':
conn.execute(text("""
TRUNCATE
gepafin_rendic.remission_custom_check_value,
gepafin_rendic.remission_amendment_request,
gepafin_rendic.remission_document,
gepafin_rendic.remission_ula_employee,
@@ -396,6 +420,207 @@ def scenario_napoli_sas(db, advance='draft'):
return practice.id
def scenario_napoli_sas_multi(db):
"""Scenario multi-tranche:
- tranche 1 APPROVED con 1 fattura B3 524.50€, rettifica 57.36€ assicurazione, ammesso 467.14€
(caso reale pratica 5888 di Cecilia)
- tranche 2 DRAFT vuota, pronta per la demo
Popola anche:
- assigned_applications (istruttore originariamente assegnato)
- 2 custom_checks dichiarati + polizza con PDF allegato su tranche 1
"""
schema_row = ensure_schema_published(db)
ensure_assigned_application(db)
# ---------- Tranche 1 APPROVED ----------
practice1 = RemissionPractice(
call_id=CALL_ID,
application_id=APPLICATION_ID,
company_id=COMPANY_ID,
user_id=BENEFICIARY_USER_ID,
status="APPROVED",
schema_snapshot=schema_row.schema_json,
iva_regime="ORDINARIO",
amount_erogato=Decimal("17000"),
sequence_number=1,
period_label="I fase 2021",
suggested_instructor_id=INSTRUCTOR_USER_ID,
assigned_instructor_id=INSTRUCTOR_USER_ID,
approved_remission=Decimal("467.14"),
reviewed_at=datetime.now(timezone.utc),
reviewed_by=INSTRUCTOR_USER_ID,
instructor_final_notes="Pratica tranche I: ammessa 1 fattura B3 con rettifica quota assicurativa.",
submitted_at=datetime.now(timezone.utc),
)
db.add(practice1)
db.flush()
print(f"[practice] tranche 1 APPROVED id={practice1.id}")
# 1 fattura B3 con PARZIALE (storno 57.36)
inv1 = RemissionInvoice(
practice_id=practice1.id,
category_code="B3",
invoice_number="2021/042",
invoice_date=date(2021, 4, 15),
payment_date=date(2021, 4, 30),
supplier_name="Formazione Digitale S.r.l.",
supplier_vat="IT03521460542",
description="Corso di formazione digitale 40h + quota assicurazione partecipanti",
taxable=Decimal("524.50"),
vat=Decimal("115.39"),
total=Decimal("639.89"),
taxable_verified=Decimal("467.14"),
vat_verified=Decimal("102.77"),
total_verified=Decimal("569.91"),
verification_status="PARZIALE",
verification_notes="Storno di 57.36 EUR per quota assicurazione partecipanti non ammissibile (non rientra nelle spese formative dirette).",
verified_by=INSTRUCTOR_USER_ID,
verified_at=datetime.now(timezone.utc),
)
db.add(inv1)
db.flush()
attach_pdf(
db, inv1, "invoice", APPLICATION_ID,
filename="ft_2021_042_formazione.pdf",
title=f"Fattura n. {inv1.invoice_number}",
subtitle=f"{inv1.supplier_name}",
lines=[
f"Fornitore: {inv1.supplier_name} P.IVA {inv1.supplier_vat}",
f"Descrizione: {inv1.description}",
f"Imponibile: EUR {inv1.taxable}",
f"IVA 22%: EUR {inv1.vat}",
f"Totale: EUR {inv1.total}",
],
uploader_id=BENEFICIARY_USER_ID,
)
print(f"[invoice T1] B3 2021/042 PARZIALE + PDF {inv1.size_bytes}b")
# 1 ULA T_IND 1.0 AMMESSA
emp1 = RemissionUlaEmployee(
practice_id=practice1.id,
codice_fiscale="RSSMRA85T10H501Z",
full_name="Mario Rossi",
contract_type="T_IND",
role_description="Sviluppatore senior",
fte_pct=Decimal("1.0000"),
fte_pct_verified=Decimal("1.0000"),
period_start_date=date(2021, 1, 27),
period_end_date=date(2021, 12, 31),
supporting_doc_type="LUL",
verification_status="AMMESSA",
verified_by=INSTRUCTOR_USER_ID,
verified_at=datetime.now(timezone.utc),
)
db.add(emp1)
db.flush()
attach_pdf(
db, emp1, "ula", APPLICATION_ID,
filename="lul_rossi_2021_t1.pdf",
title=f"LUL {emp1.full_name}",
subtitle=f"{emp1.period_start_date} to {emp1.period_end_date}",
lines=[f"CF: {emp1.codice_fiscale}", f"FTE: 1.00", "Contratto: T_IND"],
uploader_id=BENEFICIARY_USER_ID,
)
# Documenti validati
for code, label in [("DURC", "DURC"), ("VISURA_CAMERALE", "Visura"),
("BILANCIO", "Bilancio 2021"), ("ANTIRICICLAGGIO", "Antiriciclaggio")]:
doc = RemissionDocument(
practice_id=practice1.id,
doc_code=code,
verification_status="VALIDO",
verified_by=INSTRUCTOR_USER_ID,
verified_at=datetime.now(timezone.utc),
)
db.add(doc)
db.flush()
attach_pdf(
db, doc, "document", APPLICATION_ID,
filename=f"{code.lower()}_napoli_t1.pdf",
title=label,
subtitle="Tranche I — NAPOLI SAS",
lines=["Documento valido", "Approvato dall istruttore"],
uploader_id=BENEFICIARY_USER_ID,
)
# Custom checks tranche 1: antiriciclaggio dichiarato + polizza con PDF
cc_antir = RemissionCustomCheckValue(
practice_id=practice1.id,
check_code="antiriciclaggio",
beneficiary_declared=True,
declared_at=datetime.now(timezone.utc),
verification_status="VALIDO",
verified_by=INSTRUCTOR_USER_ID,
verified_at=datetime.now(timezone.utc),
)
db.add(cc_antir)
cc_polizza = RemissionCustomCheckValue(
practice_id=practice1.id,
check_code="polizza_fidejussoria",
beneficiary_declared=True,
declared_at=datetime.now(timezone.utc),
verification_status="VALIDO",
verified_by=INSTRUCTOR_USER_ID,
verified_at=datetime.now(timezone.utc),
)
db.add(cc_polizza)
db.flush()
# Genero PDF polizza e lo salvo direttamente in custom_checks/
from pathlib import Path as _P
pdf = make_pdf_bytes(
"Polizza fidejussoria tranche I",
"NAPOLI SAS Sandbox — garanzia bando RE-START",
[
"Compagnia: Generali Assicurazioni",
"Importo garantito: EUR 17.000",
"Data emissione: 15/01/2021",
"Scadenza: 31/12/2022",
"N. polizza: FID-2021-NS-0042",
],
)
import hashlib
digest = hashlib.sha256(pdf).hexdigest()
target_dir = BASE_PATH / "custom_checks" / str(practice1.id) / "polizza_fidejussoria"
target_dir.mkdir(parents=True, exist_ok=True)
target_file = target_dir / f"{digest[:12]}-polizza_fidejussoria.pdf"
target_file.write_bytes(pdf)
cc_polizza.storage_path = str(target_file.relative_to(BASE_PATH))
cc_polizza.mime = "application/pdf"
cc_polizza.size_bytes = len(pdf)
cc_polizza.sha256 = digest
cc_polizza.document_uploaded_at = datetime.now(timezone.utc)
cc_polizza.uploaded_by = BENEFICIARY_USER_ID
db.commit()
print(f"[custom_checks T1] antiriciclaggio VALIDO, polizza VALIDO + PDF {len(pdf)}b")
# ---------- Tranche 2 DRAFT vuota ----------
practice2 = 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"),
sequence_number=2,
period_label="II fase 2021",
suggested_instructor_id=INSTRUCTOR_USER_ID,
assigned_instructor_id=None, # non ancora assegnata (simulo workflow capo)
)
db.add(practice2)
db.flush()
print(f"[practice] tranche 2 DRAFT id={practice2.id} (vuota, pronta demo)")
db.commit()
return practice1.id, practice2.id
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
@@ -403,7 +628,7 @@ 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('--scenario', choices=['napoli-sas', 'napoli-sas-multi', '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()
@@ -417,11 +642,17 @@ def main():
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 == 'napoli-sas-multi':
pid1, pid2 = scenario_napoli_sas_multi(db)
print(f"\n✓ Scenario napoli-sas-multi completato")
print(f" tranche 1 APPROVED id={pid1}")
print(f" tranche 2 DRAFT id={pid2}")
print(f" Istruttoria T1: http://78.46.41.91:18072/istruttoria/{pid1}")
print(f" Rendicontazione T2: http://78.46.41.91:18072/rendicontazioni/{pid2}")
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}")
pid1, pid2 = scenario_napoli_sas_multi(db)
print(f"\n✓ Scenario full = napoli-sas-multi (solo questo disponibile).")
print(f" tranche 1={pid1} tranche 2={pid2}")
finally:
db.close()