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

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