""" 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, UploadFile, File from sqlalchemy.orm import Session from sqlalchemy import text, or_, and_ from ..db import get_db from ..auth import AuthUser, get_current_user from ..storage import save_upload, FileTooLargeError, MimeNotAllowedError, StorageError from ..models import RemissionPractice, RemissionAmendmentRequest from ..schemas import ( AmendmentRequestCreate, AmendmentRequestUpdate, AmendmentExtend, AmendmentRequestOut, AmendmentResponseSubmit, AmendmentStatus, 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 # Un istruttore vede in coda: # - SUBMITTED non assegnate (pool da prendere in carico) # - SUBMITTED pre-assegnate a lui (suggested da gepafin_schema.assigned_applications) # - UNDER_REVIEW in lavorazione a lui # - AWAITING_AMENDMENT in attesa di risposta beneficiario q = q.filter(or_( and_(RemissionPractice.status == "SUBMITTED", RemissionPractice.assigned_instructor_id.is_(None)), and_(RemissionPractice.status == "SUBMITTED", RemissionPractice.assigned_instructor_id == user.user_id), 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 ========== DEFAULT_RESPONSE_DAYS = 15 def _amendment_or_404(db: Session, practice_id: UUID, amendment_id: UUID) -> RemissionAmendmentRequest: 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") return ar @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 in stato DRAFT. La PEC parte solo quando l'istruttore chiama esplicitamente /send. Finche e DRAFT: - l'istruttore puo modificare/eliminare - la pratica resta UNDER_REVIEW (nessun impatto sul benef) - nessuna notifica PEC """ 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 gia una amendment non-CLOSED/non-EXPIRED aperta open_ar = [a for a in p.amendment_requests if a.status in (AmendmentStatus.DRAFT.value, AmendmentStatus.AWAITING.value, AmendmentStatus.RESPONSE_RECEIVED.value)] if open_ar: raise HTTPException(status_code=409, detail="C'e gia 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 {}, response_days=body.response_days if body.response_days is not None else DEFAULT_RESPONSE_DAYS, internal_note=body.internal_note, status=AmendmentStatus.DRAFT.value ) db.add(ar) # pratica resta UNDER_REVIEW in DRAFT (passa a AWAITING_AMENDMENT solo allo /send) db.commit() db.refresh(ar) return ApiResponse(message="Bozza soccorso istruttorio creata", data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json")) @router.put("/{practice_id}/amendment/{amendment_id}", response_model=ApiResponse) def update_amendment(practice_id: UUID, amendment_id: UUID, body: AmendmentRequestUpdate, db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)): """Modifica una bozza di soccorso. Consentito solo in stato DRAFT. Dopo invio (AWAITING) il contenuto PEC e immutabile; si puo solo chiudere o prorogare.""" ar = _amendment_or_404(db, practice_id, amendment_id) if ar.status != AmendmentStatus.DRAFT.value: raise HTTPException(status_code=409, detail=f"Modifica consentita solo in stato DRAFT (attuale: {ar.status})") if body.request_text is not None: if len(body.request_text.strip()) < 10: raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)") ar.request_text = body.request_text if body.deadline is not None: ar.deadline = body.deadline if body.scope is not None: ar.scope = body.scope if body.response_days is not None: if body.response_days < 1 or body.response_days > 120: raise HTTPException(status_code=422, detail="response_days deve essere 1-120") ar.response_days = body.response_days if body.internal_note is not None: ar.internal_note = body.internal_note db.commit() db.refresh(ar) return ApiResponse(message="Bozza aggiornata", data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json")) @router.delete("/{practice_id}/amendment/{amendment_id}", response_model=ApiResponse) def delete_amendment(practice_id: UUID, amendment_id: UUID, db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)): """Elimina una bozza di soccorso. Consentito solo in stato DRAFT. Una volta inviata (AWAITING) si puo solo chiudere o scadere.""" ar = _amendment_or_404(db, practice_id, amendment_id) if ar.status != AmendmentStatus.DRAFT.value: raise HTTPException(status_code=409, detail=f"Eliminazione consentita solo in stato DRAFT (attuale: {ar.status})") db.delete(ar) db.commit() return ApiResponse(message="Bozza eliminata", data={"id": str(amendment_id)}) @router.post("/{practice_id}/amendment/{amendment_id}/send", response_model=ApiResponse) def send_amendment(practice_id: UUID, amendment_id: UUID, db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)): """Invia il soccorso: DRAFT -> AWAITING. Da questo momento il BE Gepafin (poller interno) vedra l'amendment come pending-pec e si occupera di PEC/protocollo tenant-aware. La pratica passa a AWAITING_AMENDMENT (benef puo modificare) e il benef ricevera notifica quando la PEC arriva davvero.""" ar = _amendment_or_404(db, practice_id, amendment_id) if ar.status != AmendmentStatus.DRAFT.value: raise HTTPException(status_code=409, detail=f"Send consentito solo in stato DRAFT (attuale: {ar.status})") if not ar.request_text or len(ar.request_text.strip()) < 10: raise HTTPException(status_code=422, detail="Testo richiesta troppo breve") ar.status = AmendmentStatus.AWAITING.value p = _get_practice_or_404(db, practice_id) p.status = "AWAITING_AMENDMENT" db.commit() db.refresh(ar) return ApiResponse(message="Soccorso inviato. In attesa di invio PEC da backend.", data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json")) @router.post("/{practice_id}/amendment/{amendment_id}/extend", response_model=ApiResponse) def extend_amendment(practice_id: UUID, amendment_id: UUID, body: AmendmentExtend, db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)): """Proroga la deadline di un soccorso AWAITING. Somma extended_days alla deadline attuale. Traccia extension_date.""" ar = _amendment_or_404(db, practice_id, amendment_id) if ar.status != AmendmentStatus.AWAITING.value: raise HTTPException(status_code=409, detail=f"Proroga consentita solo in stato AWAITING (attuale: {ar.status})") from datetime import timedelta ar.deadline = ar.deadline + timedelta(days=body.extended_days) ar.extended_days = (ar.extended_days or 0) + body.extended_days ar.extension_date = datetime.now(timezone.utc) if body.motivation: ar.internal_note = ((ar.internal_note or "") + f"\n[Proroga {body.extended_days}gg {datetime.now(timezone.utc):%Y-%m-%d}]: {body.motivation}").strip() db.commit() db.refresh(ar) return ApiResponse(message=f"Deadline prorogata di {body.extended_days} giorni", data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json")) @router.post("/{practice_id}/amendment/{amendment_id}/reminder", response_model=ApiResponse) def send_reminder(practice_id: UUID, amendment_id: UUID, db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)): """Reminder manuale on-demand dell'istruttore al benef. Accoda un secondo invio PEC (stesso contenuto richiesta) via flag interno. Il BE vedra l'amendment come pending-pec=reminder e inviera email di reminder.""" ar = _amendment_or_404(db, practice_id, amendment_id) if ar.status != AmendmentStatus.AWAITING.value: raise HTTPException(status_code=409, detail="Reminder consentito solo su soccorsi AWAITING") # flag minimo: segnala via campo separato. Per ora usiamo pec_retry_after come "serve reminder" # (il BE poller distinguera pec_sent_at IS NULL vs pec_sent_at IS NOT NULL + pec_retry_after) ar.pec_retry_after = datetime.now(timezone.utc) db.commit() return ApiResponse(message="Reminder accodato. Il backend invierà l'email di sollecito.", data={"amendment_id": str(amendment_id), "queued_at": ar.pec_retry_after.isoformat()}) @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. La pratica torna in UNDER_REVIEW se non ci sono altri amendment aperti su di essa.""" ar = _amendment_or_404(db, practice_id, amendment_id) if ar.status == AmendmentStatus.CLOSED.value: raise HTTPException(status_code=409, detail="Amendment gia chiusa") ar.status = AmendmentStatus.CLOSED.value ar.closed_at = datetime.now(timezone.utc) ar.closed_by = user.user_id 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 in (AmendmentStatus.DRAFT.value, AmendmentStatus.AWAITING.value, AmendmentStatus.RESPONSE_RECEIVED.value)] 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")) @router.post("/{practice_id}/amendment/{amendment_id}/upload-document", response_model=ApiResponse) async def upload_amendment_document(practice_id: UUID, amendment_id: UUID, file: UploadFile = File(...), db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)): """Allega documento dell'istruttore al soccorso (motivazione, scheda tecnica, ecc.). Consentito in DRAFT o AWAITING. Sostituisce il precedente se esiste.""" ar = _amendment_or_404(db, practice_id, amendment_id) if ar.status not in (AmendmentStatus.DRAFT.value, AmendmentStatus.AWAITING.value): raise HTTPException(status_code=409, detail=f"Upload consentito in DRAFT/AWAITING (attuale: {ar.status})") p = _get_practice_or_404(db, practice_id) try: rel_path, size, digest, mime, safe_name = save_upload( application_id=p.application_id, entity_type="amendment-instructor-doc", entity_id=ar.id, file_obj=file.file, original_filename=file.filename or "amendment.pdf", content_type=file.content_type, ) except FileTooLargeError as e: raise HTTPException(status_code=413, detail=str(e)) except MimeNotAllowedError as e: raise HTTPException(status_code=415, detail=str(e)) except StorageError as e: raise HTTPException(status_code=500, detail=f"Errore storage: {e}") ar.amendment_document_path = rel_path ar.amendment_document_type = mime db.commit() db.refresh(ar) return ApiResponse(message="Documento allegato al soccorso", data={"amendment_id": str(ar.id), "path": rel_path, "filename": safe_name, "size_bytes": size, "mime": mime}) @router.delete("/{practice_id}/amendment/{amendment_id}/upload-document", response_model=ApiResponse) def delete_amendment_document(practice_id: UUID, amendment_id: UUID, db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)): """Rimuove il documento istruttore allegato al soccorso (consentito solo in DRAFT).""" ar = _amendment_or_404(db, practice_id, amendment_id) if ar.status != AmendmentStatus.DRAFT.value: raise HTTPException(status_code=409, detail=f"Rimozione allegato consentita solo in DRAFT (attuale: {ar.status})") ar.amendment_document_path = None ar.amendment_document_type = None db.commit() return ApiResponse(message="Documento allegato rimosso", data={"amendment_id": str(ar.id)}) # 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_owner_role() and p.user_id != user.user_id: raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica") ar = _amendment_or_404(db, practice_id, amendment_id) 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")) @router.post("/{practice_id}/amendment/{amendment_id}/upload-response-document", response_model=ApiResponse) async def upload_response_document(practice_id: UUID, amendment_id: UUID, file: UploadFile = File(...), db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)): """Beneficiario allega un documento come supporto alla sua risposta al soccorso. Consentito su amendment in stato AWAITING, solo dal proprietario pratica.""" p = _get_practice_or_404(db, practice_id) if user.is_owner_role() and p.user_id != user.user_id: raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica") ar = _amendment_or_404(db, practice_id, amendment_id) if ar.status not in (AmendmentStatus.AWAITING.value, AmendmentStatus.RESPONSE_RECEIVED.value): raise HTTPException(status_code=409, detail=f"Upload risposta consentito solo in AWAITING/RESPONSE_RECEIVED (attuale: {ar.status})") try: rel_path, size, digest, mime, safe_name = save_upload( application_id=p.application_id, entity_type="amendment-response-doc", entity_id=ar.id, file_obj=file.file, original_filename=file.filename or "response.pdf", content_type=file.content_type, ) except FileTooLargeError as e: raise HTTPException(status_code=413, detail=str(e)) except MimeNotAllowedError as e: raise HTTPException(status_code=415, detail=str(e)) except StorageError as e: raise HTTPException(status_code=500, detail=f"Errore storage: {e}") ar.response_document_path = rel_path ar.response_document_type = mime db.commit() db.refresh(ar) return ApiResponse(message="Documento risposta allegato", data={"amendment_id": str(ar.id), "path": rel_path, "filename": safe_name, "size_bytes": size, "mime": mime})