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.
432 lines
19 KiB
Python
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"))
|