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:
@@ -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);
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user