diff --git a/app/main.py b/app/main.py index 2c5c8b5..ad2e049 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/app/models.py b/app/models.py index 1f93656..05a6834 100644 --- a/app/models.py +++ b/app/models.py @@ -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") diff --git a/app/routers/instructor.py b/app/routers/instructor.py new file mode 100644 index 0000000..1435d6d --- /dev/null +++ b/app/routers/instructor.py @@ -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")) diff --git a/app/routers/practices.py b/app/routers/practices.py index a649e1e..5edd69c 100644 --- a/app/routers/practices.py +++ b/app/routers/practices.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py index ab88f4e..da1b27b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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):