Files
gepafin-rendicontazione-api/app/routers/instructor.py
BFLOWS 83bb0a29ec feat(auth): autorizza ROLE_CONFIDI come proprietario pratica (parallelo BENEFICIARY)
Risoluzione 403 segnalato da Rinaldo Bonazzo su upload fattura con utente
ROLE_CONFIDI (confidi4@test.test). Pattern allineato al BE Gepafin che
in DashboardDao, CompanyDocumentDao e FaqDao raggruppa BENEFICIARY+CONFIDI
con stessi diritti operativi sulla pratica.

==RAZIONALE==
Sui bandi con call.confidi=true il confidi sottomette la application
per conto dell'azienda e diventa user_id della application. Lato
microservizio rendicontazione la pratica viene ereditata con stesso
user_id, quindi il confidi e proprietario della pratica e deve poter
fare upload/download/delete come il beneficiario.

==MODIFICHE==

app/auth.py:
- Aggiunto AuthUser.is_confidi() — controlla ROLE_CONFIDI
- Aggiunto AuthUser.is_owner_role() — True per BENEFICIARY o CONFIDI
- Aggiornato docstring header con ROLE_CONFIDI
- Manteno is_beneficiary() per backward compat (non rimosso, non chiamato)

Sostituzione is_beneficiary() -> is_owner_role() in 11 punti dove la
semantica era 'proprietario pratica':
- app/routers/files.py: 3 (_can_upload, _can_download, _can_delete)
- app/routers/instructor.py: 2 (respond-beneficiary, ack-amendment)
- app/routers/practices.py: 3 (visibilita, create, schema gating)
- app/routers/custom_checks.py: 3 (declared, gate)

==COMPORTAMENTO==

Per ROLE_CONFIDI vale ora la stessa regola di BENEFICIARY:
- upload/download/delete: solo se practice.user_id == user.user_id
  AND practice.status IN ('DRAFT','AWAITING_AMENDMENT')
- respond-beneficiary: solo se proprietario pratica
- visualizzazione: solo proprie pratiche
- creazione: solo se schema PUBLISHED

Confidi su pratica di altri o su pratica non editabile -> 403 come prima.

==TEST E2E (4 step verdi)==
/tmp/test_confidi_upload.py:
1. CONFIDI proprietario DRAFT upload Invoice_zapier2024.pdf -> 200 (era 403)
2. CONFIDI NON proprietario -> 403 (scoping)
3. CONFIDI proprietario ma SUBMITTED -> 403 (stato)
4. BENEFICIARY proprietario DRAFT (regressione) -> 200
2026-04-27 09:06:10 +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_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})