Files
gepafin-rendicontazione-api/app/routers/instructor.py
BFLOWS 34c4a47a1c feat(amendment): ROUND 2 — scheduler + upload documenti
Seconda parte della replica soccorso istruttorio speculare al BE Gepafin.
Completata: scheduler cron (expire + reminder), upload documenti istruttore
e benef, fix duplicati config.

==SCHEDULER (app/scheduler.py NUOVO)==
APScheduler BackgroundScheduler integrato nel lifespan FastAPI.
Due cron attivi (timezone Europe/Rome):

  expire_amendments() - cron 01:05 ogni notte
    Speculare a ApplicationAmendmentScheduler.processAmendmentExpirationScheduler.
    Trova amendment AWAITING con deadline < today, passa a EXPIRED.
    Rimette pratica a UNDER_REVIEW se non ha altri amendment aperti.
    Ritorna dict stats per logging/test.

  queue_reminders() - cron 09:00 ogni mattina
    Speculare a ExpirationScheduler.processAmendmentExpiration (data-driven).
    Legge remission_expiration_config (type='AMENDMENT', interval_days=N),
    per ogni riga trova amendment con deadline esattamente today+N e setta
    pec_retry_after (marker che il BE vede via /internal pending-reminder).
    Multipli row = multipli reminder (seed: 7gg + 2gg).

Il microservizio aggiorna solo stato DB. L invio effettivo di email
reminder lo fa il BE Gepafin tramite polling, tenant-aware.

==UPLOAD DOCUMENTI==
3 nuovi endpoint nel router istruttoria:

  POST   /instructor/{pid}/amendment/{aid}/upload-document
    - Istruttore allega PDF al soccorso (motivazione, scheda tecnica).
    - Consentito in DRAFT o AWAITING. Sostituisce precedente se esiste.
    - Popola amendment_document_path + amendment_document_type.

  DELETE /instructor/{pid}/amendment/{aid}/upload-document
    - Rimuove allegato (solo in DRAFT).

  POST   /instructor/{pid}/amendment/{aid}/upload-response-document
    - Benef allega PDF come supporto alla risposta.
    - Consentito in AWAITING/RESPONSE_RECEIVED, solo proprietario.
    - Popola response_document_path + response_document_type.

Riusa save_upload() esistente con entity_type dedicati.

==FIX storage.py==
Whitelist entity_type estesa con 'amendment-instructor-doc' +
'amendment-response-doc' (prima accettava solo invoice/ula/document,
bloccando l'upload con StorageError).

==FIX migration dedup==
Scoperto in test: migration 8 faceva INSERT ON CONFLICT DO NOTHING su
remission_expiration_config ma senza UNIQUE constraint. Ogni restart
inseriva duplicati (16 righe in DB invece di 2). Fix in migration 9:
DELETE duplicati + ADD UNIQUE (type, interval_days) + re-seed pulito.

==REQUIREMENTS==
APScheduler==3.10.4

==TEST E2E==
/tmp/test_amendment_r2_fixed.py passa su tutto:
  [A] upload amendment_document istruttore + response_document benef + respond
  [B] amendment scaduto artificiale -> expire_amendments lo marca EXPIRED,
      pratica torna UNDER_REVIEW
  [C] amendment a +2gg e +7gg -> queue_reminders accoda 2 reminder,
      /internal pending-reminder li espone entrambi
2026-04-20 22:35:01 +02:00

654 lines
31 KiB
Python

"""
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_beneficiary() 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_beneficiary() 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})