feat: endpoint istruttore (queue + review + soccorso istruttorio)

- 4 nuove colonne su remission_practice: assigned_instructor_id, reviewed_at,
  reviewed_by, rejection_reason, approved_remission
- Nuova tabella remission_amendment_request con cascade delete, scope JSONB,
  stati AWAITING -> RESPONSE_RECEIVED -> CLOSED / EXPIRED / REJECTED
- Router instructor.py (287 righe) con 8 endpoint:
  /queue, /{id}, /{id}/claim, /{id}/approve, /{id}/reject,
  /{id}/amendment, /{id}/amendment/{aid}/close,
  /{id}/amendment/{aid}/respond-beneficiary
- GET /{id} (router practices) ora include amendments nel payload
- Manager manager_view flag per ROLE_INSTRUCTOR_MANAGER + SUPER_ADMIN
  (vede tutto il pool vs solo le proprie assegnazioni)
- Logica status transitions verificata:
  SUBMITTED -> UNDER_REVIEW (claim)
  UNDER_REVIEW <-> AWAITING_AMENDMENT (amendment open/close)
  UNDER_REVIEW | AWAITING_AMENDMENT -> APPROVED | REJECTED
- _compute_gate_check riusato anche dal router istruttore per calcolo
  remission_due in coda e nel dettaglio

Test end-to-end verde: ciclo completo benef -> istruttore -> soccorso ->
risposta -> chiusura -> approvazione funzionante su NAPOLI SAS.
This commit is contained in:
BFLOWS
2026-04-18 10:15:32 +02:00
parent e217f15e5a
commit 26fbc03871
5 changed files with 375 additions and 2 deletions

View File

@@ -14,7 +14,7 @@ from sqlalchemy import text
from .config import get_settings
from .db import engine, Base
from .routers import health, schemas, practices, debug
from .routers import health, schemas, practices, debug, instructor
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("rendicontazione-api")
@@ -57,6 +57,7 @@ app.include_router(health.router, tags=["health"])
app.include_router(schemas.router)
app.include_router(practices.router)
app.include_router(debug.router)
app.include_router(instructor.router)
@app.get("/", tags=["root"])

View File

@@ -61,6 +61,13 @@ class RemissionPractice(Base):
amount_erogato = Column(Numeric(14, 2), nullable=False) # copiato da application.amount_accepted
notes_beneficiario = Column(Text, nullable=True)
# colonne istruttoria
assigned_instructor_id = Column(Integer, nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
reviewed_by = Column(Integer, nullable=True)
rejection_reason = Column(Text, nullable=True)
approved_remission = Column(Numeric(14, 2), 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())
submitted_at = Column(DateTime(timezone=True), nullable=True)
@@ -69,6 +76,7 @@ class RemissionPractice(Base):
invoices = relationship("RemissionInvoice", back_populates="practice", cascade="all, delete-orphan")
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")
class RemissionInvoice(Base):
@@ -141,3 +149,30 @@ class RemissionDocument(Base):
notes = Column(Text, nullable=True)
practice = relationship("RemissionPractice", back_populates="documents")
class RemissionAmendmentRequest(Base):
"""Richiesta di soccorso istruttorio: istruttore chiede integrazioni al beneficiario."""
__tablename__ = "remission_amendment_request"
__table_args__ = ({"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)
requested_by = Column(Integer, nullable=False)
request_text = Column(Text, nullable=False)
scope = Column(JSONB, nullable=True, default=dict)
deadline = Column(Date, nullable=False)
status = Column(String(32), nullable=False, default="AWAITING")
# AWAITING -> RESPONSE_RECEIVED -> CLOSED | EXPIRED | REJECTED
response_text = Column(Text, nullable=True)
response_at = Column(DateTime(timezone=True), nullable=True)
closed_at = Column(DateTime(timezone=True), nullable=True)
closed_by = Column(Integer, 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="amendment_requests")

276
app/routers/instructor.py Normal file
View File

@@ -0,0 +1,276 @@
"""
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
)
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(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(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(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"))

View File

@@ -268,7 +268,10 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
@router.get("/{practice_id}", response_model=ApiResponse)
def get_practice(practice_id: UUID, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
p = _get_practice_or_404(db, practice_id, user)
return ApiResponse(data=PracticeOut.model_validate(p).model_dump(mode="json"))
from ..schemas import AmendmentRequestOut
payload = PracticeOut.model_validate(p).model_dump(mode="json")
payload["amendments"] = [AmendmentRequestOut.model_validate(a).model_dump(mode="json") for a in p.amendment_requests]
return ApiResponse(data=payload)
@router.put("/{practice_id}", response_model=ApiResponse)

View File

@@ -165,6 +165,64 @@ class GateCheckResult(BaseModel):
totals: dict # {per_category: {B1: 1234.56, ...}, grand_total, max_remission_due, ...}
# ====================== Istruttoria ======================
class AmendmentRequestCreate(BaseModel):
request_text: str
deadline: date
scope: Optional[dict] = None
class AmendmentResponseSubmit(BaseModel):
response_text: str
class AmendmentRequestOut(BaseModel):
id: UUID
practice_id: UUID
requested_by: int
request_text: str
scope: Optional[dict] = None
deadline: date
status: str
response_text: Optional[str] = None
response_at: Optional[datetime] = None
closed_at: Optional[datetime] = None
closed_by: Optional[int] = None
created_at: datetime
model_config = {"from_attributes": True}
class ReviewRejectBody(BaseModel):
rejection_reason: str
class ReviewApproveBody(BaseModel):
approved_remission: Optional[Decimal] = None
notes: Optional[str] = None
class InstructorQueueItem(BaseModel):
id: UUID
call_id: int
application_id: int
company_id: int
status: str
amount_erogato: Decimal
submitted_at: Optional[datetime] = None
assigned_instructor_id: Optional[int] = None
call_name: Optional[str] = None
company_name: Optional[str] = None
invoice_count: int = 0
ula_count: int = 0
document_count: int = 0
open_amendments: int = 0
remission_due: Optional[float] = None
model_config = {"from_attributes": True}
# ====================== Wrapper ======================
class ApiResponse(BaseModel):