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.
This commit is contained in:
BFLOWS
2026-04-18 17:35:56 +02:00
parent 6c089fb7b2
commit 25215f388b
7 changed files with 520 additions and 57 deletions

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

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