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
This commit is contained in:
BFLOWS
2026-04-20 22:35:01 +02:00
parent da13ca7478
commit 34c4a47a1c
6 changed files with 281 additions and 4 deletions

View File

@@ -6,12 +6,13 @@ from decimal import Decimal
from uuid import UUID
from typing import List
from fastapi import APIRouter, Depends, HTTPException
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,
@@ -384,6 +385,59 @@ def close_amendment(practice_id: UUID, amendment_id: UUID,
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,
@@ -556,3 +610,44 @@ def set_instructor_final_notes(practice_id: UUID, body: InstructorFinalNotesBody
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})