From 25215f388bcf97ab52d8b57b6b1b9de072d1954d Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Sat, 18 Apr 2026 17:35:56 +0200 Subject: [PATCH] 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. --- app/migrations.py | 54 +++++++ app/models.py | 55 ++++++- app/routers/instructor.py | 6 +- app/routers/practices.py | 317 ++++++++++++++++++++++++++++++++------ app/routers/verbale.py | 2 +- app/schemas.py | 90 ++++++++++- app/templates.py | 53 ++++++- 7 files changed, 520 insertions(+), 57 deletions(-) diff --git a/app/migrations.py b/app/migrations.py index 33b7ce8..4ae4000 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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); + """, ] diff --git a/app/models.py b/app/models.py index 1bb1358..d31253e 100644 --- a/app/models.py +++ b/app/models.py @@ -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") + diff --git a/app/routers/instructor.py b/app/routers/instructor.py index d7bec59..8ab469b 100644 --- a/app/routers/instructor.py +++ b/app/routers/instructor.py @@ -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" diff --git a/app/routers/practices.py b/app/routers/practices.py index 6706b33..80e380b 100644 --- a/app/routers/practices.py +++ b/app/routers/practices.py @@ -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", diff --git a/app/routers/verbale.py b/app/routers/verbale.py index 787bed7..c28a3ae 100644 --- a/app/routers/verbale.py +++ b/app/routers/verbale.py @@ -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 diff --git a/app/schemas.py b/app/schemas.py index 9b606e9..e4f72fd 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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 + diff --git a/app/templates.py b/app/templates.py index d1cf507..daa9e59 100644 --- a/app/templates.py +++ b/app/templates.py @@ -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