""" 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"))