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:
@@ -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"])
|
||||
|
||||
@@ -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
276
app/routers/instructor.py
Normal 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"))
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user