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
654 lines
31 KiB
Python
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})
|