Files
gepafin-rendicontazione-api/app/routers/instructor.py
BFLOWS 25215f388b feat(v2): multi-tranche DB schema + gate cumulativo 5 voci Cecilia
A1 migrations.py:
- remission_practice DROP uq_application + ADD sequence_number/period_label/suggested_instructor_id
- UNIQUE composita (application_id, sequence_number)
- partial index idx_remission_practice_unassigned su assigned_instructor_id NULL
- nuova tabella remission_custom_check_value (storage_path/mime/size/sha256 allineata adapter)

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

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

Test su NAPOLI SAS: erogato 17K, cap 8500, tranche 1 approvata 467.14EUR,
tranche 2 vuota -> residuo disponibile 8032.86EUR, residuo_da_restituire 16532.86EUR.
2026-04-18 17:35:56 +02:00

432 lines
19 KiB
Python

"""
Endpoint istruttoria (lato pre-instructor / instructor-manager).
"""
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text, or_, and_
from ..db import get_db
from ..auth import AuthUser, get_current_user
from ..models import RemissionPractice, RemissionAmendmentRequest
from ..schemas import (
AmendmentRequestCreate, AmendmentRequestOut, AmendmentResponseSubmit,
ReviewApproveBody, ReviewRejectBody,
InstructorQueueItem, PracticeOut, ApiResponse,
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
InstructorFinalNotesBody,
InvoiceOut, UlaEmployeeOut, DocumentOut
)
from ..models import RemissionInvoice, RemissionUlaEmployee, RemissionDocument
from datetime import date
from .practices import _compute_gate_check
router = APIRouter(prefix="/api/remission-practices/instructor", tags=["instructor"])
def _is_instructor(user: AuthUser) -> bool:
return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
def _require_instructor(user: AuthUser = Depends(get_current_user)) -> AuthUser:
if not _is_instructor(user):
raise HTTPException(status_code=403, detail="Richiesto ruolo istruttore o superadmin")
return user
def _get_practice_or_404(db: Session, practice_id: UUID) -> RemissionPractice:
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
if not p:
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
return p
def _enrich_queue_item(db: Session, p: RemissionPractice) -> InstructorQueueItem:
q = db.execute(text("""
SELECT c.name as call_name, comp.company_name as company_name
FROM gepafin_schema.call c
JOIN gepafin_schema.company comp ON comp.id = :cid
WHERE c.id = :call_id
"""), {"call_id": p.call_id, "cid": p.company_id}).first()
item = InstructorQueueItem.model_validate(p)
if q:
item.call_name = q[0]
item.company_name = q[1]
item.invoice_count = len(p.invoices)
item.ula_count = len(p.ula_employees)
item.document_count = len([d for d in p.documents if d.filename])
item.open_amendments = len([a for a in p.amendment_requests if a.status == "AWAITING"])
# calcolo remissione due dalla schema_snapshot
try:
check = _compute_gate_check(db, p)
item.remission_due = check.totals.get("remission_due", 0)
except Exception:
item.remission_due = None
return item
# ========== QUEUE ==========
@router.get("/queue", response_model=ApiResponse)
def instructor_queue(db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""
Lista pratiche rilevanti per un istruttore:
- SUBMITTED non assegnate (pool)
- UNDER_REVIEW assegnate all'istruttore (o tutte se manager/superadmin)
- AWAITING_AMENDMENT con richieste aperte
"""
manager = user.role in ("ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
q = db.query(RemissionPractice).filter(
RemissionPractice.status.in_(["SUBMITTED", "UNDER_REVIEW", "AWAITING_AMENDMENT"])
)
if not manager:
# solo: SUBMITTED non assegnate OR UNDER_REVIEW assegnate a me OR AWAITING_AMENDMENT assegnate a me
q = q.filter(or_(
and_(RemissionPractice.status == "SUBMITTED", RemissionPractice.assigned_instructor_id.is_(None)),
and_(RemissionPractice.status == "UNDER_REVIEW", RemissionPractice.assigned_instructor_id == user.user_id),
and_(RemissionPractice.status == "AWAITING_AMENDMENT", RemissionPractice.assigned_instructor_id == user.user_id),
))
practices = q.order_by(RemissionPractice.submitted_at.asc().nullsfirst()).all()
return ApiResponse(data={
"items": [_enrich_queue_item(db, p).model_dump(mode="json") for p in practices],
"manager_view": manager
})
# ========== DETTAGLIO ==========
@router.get("/{practice_id}", response_model=ApiResponse)
def instructor_view_practice(practice_id: UUID, db: Session = Depends(get_db),
user: AuthUser = Depends(_require_instructor)):
"""Vista completa della pratica per istruttore (readonly + gate check + amendments)."""
p = _get_practice_or_404(db, practice_id)
check = _compute_gate_check(db, p)
amendments = [AmendmentRequestOut.model_validate(a).model_dump(mode="json") for a in p.amendment_requests]
return ApiResponse(data={
"practice": PracticeOut.model_validate(p).model_dump(mode="json"),
"gate_check": check.model_dump(mode="json"),
"amendments": amendments
})
# ========== PRENDI IN CARICO ==========
@router.post("/{practice_id}/claim", response_model=ApiResponse)
def claim_practice(practice_id: UUID, db: Session = Depends(get_db),
user: AuthUser = Depends(_require_instructor)):
"""Istruttore prende in carico una pratica SUBMITTED."""
p = _get_practice_or_404(db, practice_id)
if p.status != "SUBMITTED":
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}, richiesto SUBMITTED")
if p.assigned_instructor_id and p.assigned_instructor_id != user.user_id:
raise HTTPException(status_code=409, detail="Pratica già assegnata a un altro istruttore")
p.status = "UNDER_REVIEW"
p.assigned_instructor_id = user.user_id
db.commit()
db.refresh(p)
return ApiResponse(message="Pratica presa in carico",
data=PracticeOut.model_validate(p).model_dump(mode="json"))
# ========== APPROVA ==========
@router.post("/{practice_id}/approve", response_model=ApiResponse)
def approve_practice(practice_id: UUID, body: ReviewApproveBody,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}, non approvabile")
# Default remissione approvata = quella calcolata
if body.approved_remission is not None:
p.approved_remission = body.approved_remission
else:
check = _compute_gate_check(db, p)
p.approved_remission = Decimal(str(check.totals.get("remission_due", 0)))
p.status = "APPROVED"
p.reviewed_at = datetime.now(timezone.utc)
p.reviewed_by = user.user_id
if body.notes:
p.rejection_reason = None # cleanup
db.commit()
db.refresh(p)
return ApiResponse(message=f"Pratica approvata: remissione {p.approved_remission} EUR",
data=PracticeOut.model_validate(p).model_dump(mode="json"))
# ========== RESPINGI ==========
@router.post("/{practice_id}/reject", response_model=ApiResponse)
def reject_practice(practice_id: UUID, body: ReviewRejectBody,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}, non rifiutabile")
if not body.rejection_reason or len(body.rejection_reason.strip()) < 10:
raise HTTPException(status_code=422, detail="Motivazione richiesta (min 10 caratteri)")
p.status = "REJECTED"
p.rejection_reason = body.rejection_reason
p.reviewed_at = datetime.now(timezone.utc)
p.reviewed_by = user.user_id
db.commit()
db.refresh(p)
return ApiResponse(message="Pratica respinta",
data=PracticeOut.model_validate(p).model_dump(mode="json"))
# ========== SOCCORSO ISTRUTTORIO ==========
@router.post("/{practice_id}/amendment", response_model=ApiResponse)
def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Crea una richiesta di soccorso istruttorio."""
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409,
detail=f"Pratica in stato {p.status}, soccorso non attivabile")
if not body.request_text or len(body.request_text.strip()) < 10:
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
# controllo: non ci deve essere già una amendment AWAITING aperta
open_ar = [a for a in p.amendment_requests if a.status == "AWAITING"]
if open_ar:
raise HTTPException(status_code=409,
detail="C'è già una richiesta di soccorso aperta su questa pratica")
ar = RemissionAmendmentRequest(
practice_id=p.id,
requested_by=user.user_id,
request_text=body.request_text,
deadline=body.deadline,
scope=body.scope or {},
status="AWAITING"
)
db.add(ar)
p.status = "AWAITING_AMENDMENT"
db.commit()
db.refresh(ar)
return ApiResponse(message="Soccorso istruttorio avviato",
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
@router.post("/{practice_id}/amendment/{amendment_id}/close", response_model=ApiResponse)
def close_amendment(practice_id: UUID, amendment_id: UUID,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Istruttore chiude il soccorso (dopo aver visto la risposta beneficiario).
La pratica torna in UNDER_REVIEW."""
ar = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.id == amendment_id,
RemissionAmendmentRequest.practice_id == practice_id
).first()
if not ar:
raise HTTPException(status_code=404, detail="Amendment non trovata")
if ar.status == "CLOSED":
raise HTTPException(status_code=409, detail="Amendment già chiusa")
ar.status = "CLOSED"
ar.closed_at = datetime.now(timezone.utc)
ar.closed_by = user.user_id
# rimetto la pratica in UNDER_REVIEW se non ci sono altre amendment aperte
p = _get_practice_or_404(db, practice_id)
others_open = [a for a in p.amendment_requests if a.id != ar.id and a.status == "AWAITING"]
if not others_open:
p.status = "UNDER_REVIEW"
db.commit()
db.refresh(ar)
return ApiResponse(message="Soccorso chiuso",
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
# Endpoint beneficiario: visualizza amendments sulla sua pratica + risponde
@router.post("/{practice_id}/amendment/{amendment_id}/respond-beneficiary", response_model=ApiResponse)
def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
body: AmendmentResponseSubmit,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user)):
"""Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED)."""
p = _get_practice_or_404(db, practice_id)
if user.is_beneficiary() and p.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
ar = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.id == amendment_id,
RemissionAmendmentRequest.practice_id == practice_id
).first()
if not ar:
raise HTTPException(status_code=404, detail="Amendment non trovata")
if ar.status != "AWAITING":
raise HTTPException(status_code=409, detail=f"Amendment in stato {ar.status}, non rispondibile")
ar.status = "RESPONSE_RECEIVED"
ar.response_text = body.response_text
ar.response_at = datetime.now(timezone.utc)
db.commit()
db.refresh(ar)
return ApiResponse(message="Risposta registrata. L'istruttore verrà notificato.",
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
# ========== VERIFICA SINGOLA RIGA ==========
def _auto_check_dates(inv, practice) -> dict:
"""Verifica automatica: fattura emessa e pagata dentro il periodo ammissibilita.
Legge period_start/period_end dal gate_rules dello schema_snapshot."""
rules = practice.schema_snapshot.get("gate_rules", {}) or {}
pstart = rules.get("period_start")
pend = rules.get("period_end")
try:
from datetime import date as _date
pstart_d = _date.fromisoformat(pstart) if pstart else None
pend_d = _date.fromisoformat(pend) if pend else None
except Exception:
pstart_d = pend_d = None
def _in_range(d):
if not d: return None
ok = True
if pstart_d: ok = ok and d >= pstart_d
if pend_d: ok = ok and d <= pend_d
return ok
return {
"period_start": pstart,
"period_end": pend,
"invoice_in_period": _in_range(inv.invoice_date),
"payment_in_period": _in_range(inv.payment_date)
}
@router.put("/{practice_id}/invoices/{invoice_id}/verify", response_model=ApiResponse)
def verify_invoice(practice_id: UUID, invoice_id: UUID, body: InvoiceVerifyBody,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Rettifica/verifica una singola fattura."""
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}")
if body.verification_status not in ("AMMESSA", "PARZIALE", "RESPINTA", "PENDING"):
raise HTTPException(status_code=422, detail="verification_status non valido")
inv = db.query(RemissionInvoice).filter(
RemissionInvoice.id == invoice_id,
RemissionInvoice.practice_id == practice_id
).first()
if not inv:
raise HTTPException(status_code=404, detail="Fattura non trovata")
inv.verification_status = body.verification_status
inv.verification_notes = body.verification_notes
if body.verification_status in ("AMMESSA", "PARZIALE"):
# Se AMMESSA e nessuna rettifica: copio i dichiarati come verificati
if body.verification_status == "AMMESSA" and body.taxable_verified is None and body.total_verified is None:
inv.taxable_verified = inv.taxable
inv.vat_verified = inv.vat
inv.total_verified = inv.total
else:
if body.taxable_verified is not None: inv.taxable_verified = body.taxable_verified
if body.vat_verified is not None: inv.vat_verified = body.vat_verified
if body.total_verified is not None: inv.total_verified = body.total_verified
else: # RESPINTA | PENDING
inv.taxable_verified = inv.vat_verified = inv.total_verified = None
inv.date_checks = _auto_check_dates(inv, p)
inv.verified_by = user.user_id
inv.verified_at = datetime.now(timezone.utc)
db.commit()
db.refresh(inv)
return ApiResponse(message=f"Fattura {body.verification_status}",
data=InvoiceOut.model_validate(inv).model_dump(mode="json"))
@router.put("/{practice_id}/ula-employees/{employee_id}/verify", response_model=ApiResponse)
def verify_ula_employee(practice_id: UUID, employee_id: UUID, body: UlaVerifyBody,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}")
if body.verification_status not in ("AMMESSA", "PARZIALE", "RESPINTA", "PENDING"):
raise HTTPException(status_code=422, detail="verification_status non valido")
e = db.query(RemissionUlaEmployee).filter(
RemissionUlaEmployee.id == employee_id,
RemissionUlaEmployee.practice_id == practice_id
).first()
if not e:
raise HTTPException(status_code=404, detail="Dipendente non trovato")
e.verification_status = body.verification_status
e.verification_notes = body.verification_notes
if body.verification_status in ("AMMESSA", "PARZIALE"):
if body.verification_status == "AMMESSA" and body.fte_pct_verified is None:
e.fte_pct_verified = e.fte_pct
elif body.fte_pct_verified is not None:
e.fte_pct_verified = body.fte_pct_verified
else:
e.fte_pct_verified = None
e.verified_by = user.user_id
e.verified_at = datetime.now(timezone.utc)
db.commit()
db.refresh(e)
return ApiResponse(message=f"Dipendente {body.verification_status}",
data=UlaEmployeeOut.model_validate(e).model_dump(mode="json"))
@router.put("/{practice_id}/documents/{doc_code}/verify", response_model=ApiResponse)
def verify_document(practice_id: UUID, doc_code: str, body: DocumentVerifyBody,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}")
if body.verification_status not in ("VALIDO", "NON_VALIDO", "SCADUTO", "PENDING"):
raise HTTPException(status_code=422, detail="verification_status non valido")
d = db.query(RemissionDocument).filter(
RemissionDocument.practice_id == practice_id,
RemissionDocument.doc_code == doc_code
).first()
if not d:
raise HTTPException(status_code=404, detail="Documento non trovato")
d.verification_status = body.verification_status
d.verification_notes = body.verification_notes
d.verified_by = user.user_id
d.verified_at = datetime.now(timezone.utc)
db.commit()
db.refresh(d)
return ApiResponse(message=f"Documento {body.verification_status}",
data=DocumentOut.model_validate(d).model_dump(mode="json"))
@router.put("/{practice_id}/final-notes", response_model=ApiResponse)
def set_instructor_final_notes(practice_id: UUID, body: InstructorFinalNotesBody,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}")
if body.instructor_final_notes is not None:
p.instructor_final_notes = body.instructor_final_notes
if body.instructor_checklist is not None:
p.instructor_checklist = body.instructor_checklist
db.commit()
db.refresh(p)
return ApiResponse(message="Verbale aggiornato",
data=PracticeOut.model_validate(p).model_dump(mode="json"))