feat(amendment): soccorso istruttorio v3 — base dati + endpoint CRUD + internal BE

ROUND 1 della replica soccorso istruttorio speculare al BE Gepafin
bflows-bandi-be. Pacchetto base pronto, mancano scheduler/upload/email/FE
che vengono in round successivi.

==ARCHITETTURA DECISA CON CARLO==
- multi-tenancy lato BE: microservizio resta tenant-agnostic
- BE (bflows-bandi-be) fa polling sul nostro /internal e invia PEC/protocollo
  tenant-aware (hub=1 Gepafin PEC_SERVICE, hub=2 SviluppUmbria MAILGUN_SERVICE)
- microservizio NON fa PEC ne protocollo, NON conosce hub_id
- endpoint interni autenticati via shared secret X-Internal-Secret

==MIGRATION DB (2)==
mig 7: ALTER TABLE remission_amendment_request ADD
  response_days, extended_days, extension_date, internal_note,
  amendment_document_path/type, amendment_initial_document_path,
  response_document_path/type, protocol_id, email_log_id, user_action_id,
  pec_sent_at, pec_failed_reason, pec_retry_after
  + 2 index partial (status pec-pending, deadline scadenti)

mig 8: nuova tabella remission_expiration_config (type, interval_days,
  is_deleted) per reminder data-driven speculare a expiration_config BE.
  Seeded con (AMENDMENT, 7) e (AMENDMENT, 2).

==MODELLI==
- RemissionAmendmentRequest esteso con 13 colonne nuove
- RemissionExpirationConfig nuovo

==SCHEMAS==
- AmendmentStatus enum (DRAFT, AWAITING, RESPONSE_RECEIVED, EXPIRED, CLOSED)
- AmendmentRequestCreate esteso (response_days, internal_note)
- AmendmentRequestUpdate nuovo (solo DRAFT)
- AmendmentExtend nuovo (proroga)
- AmendmentPendingPecOut, AmendmentPecDetail (per BE polling)
- MarkPecSent, MarkPecFailed (callback BE)

==ENDPOINT ISTRUTTORE (estesi o nuovi)==
- POST /{pid}/amendment              crea DRAFT (modifica: non piu AWAITING diretto)
- PUT  /{pid}/amendment/{id}         modifica solo DRAFT [NUOVO]
- DELETE /{pid}/amendment/{id}       elimina solo DRAFT [NUOVO]
- POST /{pid}/amendment/{id}/send    DRAFT -> AWAITING [NUOVO]
- POST /{pid}/amendment/{id}/extend  proroga deadline [NUOVO]
- POST /{pid}/amendment/{id}/reminder reminder manuale (flag pec_retry_after) [NUOVO]
- POST /{pid}/amendment/{id}/close   chiude (AmendmentStatus enum al posto di stringhe)
- POST /{pid}/amendment/{id}/respond-beneficiary  benef risponde

==ENDPOINT INTERNI /internal/remission-amendments (nuovi)==
- GET     ?status=pending-pec|pending-reminder&since=
- GET     /{id}                        detail per composizione PEC
- POST    /{id}/mark-pec-sent          callback BE success
- POST    /{id}/mark-pec-failed        callback BE failure
Auth: X-Internal-Secret header, 401 altrimenti.

==CONFIG==
RENDIC_INTERNAL_SECRET env var (default sandbox hard-coded).

==TEST E2E==
/tmp/test_amendment_v3.py - 10 step tutti verdi:
  A reset T2 UNDER_REVIEW
  B create DRAFT (response_days=15 default)
  C update DRAFT (response_days=20, internal_note)
  D send DRAFT->AWAITING, pratica AWAITING_AMENDMENT
  E BE poll pending-pec vede amendment
  F BE detail+mark-pec-sent salva protocol_id/email_log_id/user_action_id
  G dopo mark-pec-sent scompare da pending-pec
  H benef respond -> RESPONSE_RECEIVED
  I istruttore close -> CLOSED, pratica torna UNDER_REVIEW
  AUTH internal senza secret -> 401

==NEXT (non in questo commit)==
- scheduler APScheduler cron 01:00 EXPIRED + cron 09:00 reminder
- upload amendment_document (istruttore) + response_document (benef) via files router
- template email locali non-PEC (reminder istruttore, notifica chiusura)
- UI istruttore: lista amendment + form crea/invia + proroga + reminder manuale
- UI benef: vista amendment + risposta con upload
This commit is contained in:
BFLOWS
2026-04-20 22:22:37 +02:00
parent 7c8de6aec8
commit da13ca7478
7 changed files with 509 additions and 27 deletions

187
app/routers/internal.py Normal file
View File

@@ -0,0 +1,187 @@
"""Endpoint /internal/* chiamati dal BE Gepafin (polling + callback).
Auth: header X-Internal-Secret con valore settings.internal_secret.
Non passa per JWT utente — e comunicazione M2M tra servizi.
Flusso:
1. BE poller chiama GET /internal/remission-amendments?status=pending-pec
2. Per ogni item chiama GET /internal/remission-amendments/{id} per dettagli
3. BE compone PEC (template per-hub), chiama PEC Massiva / Mailgun
4. BE callback POST /internal/remission-amendments/{id}/mark-pec-sent (o failed)
Il microservizio resta tenant-agnostic: non conosce hub_id, non tocca PEC.
"""
from datetime import datetime, timezone
from uuid import UUID
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, text
from ..db import get_db
from ..config import get_settings, Settings
from ..models import RemissionAmendmentRequest, RemissionPractice
from ..schemas import (
ApiResponse, AmendmentPendingPecOut, AmendmentPecDetail,
MarkPecSent, MarkPecFailed, AmendmentStatus
)
router = APIRouter(prefix="/internal/remission-amendments", tags=["internal"])
def _check_internal_auth(
x_internal_secret: Optional[str] = Header(None, alias="X-Internal-Secret"),
settings: Settings = Depends(get_settings),
):
"""Valida shared secret. In PROD aggiungere anche IP allowlist via middleware."""
if not x_internal_secret or x_internal_secret != settings.internal_secret:
raise HTTPException(status_code=401, detail="Invalid internal secret")
return True
def _fetch_application_id(db: Session, practice_id: UUID) -> int:
"""Recupera application_id dalla pratica. Il BE lo userà per risolvere hub/tenant."""
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
if not p:
raise HTTPException(status_code=404, detail="Pratica non trovata")
return p.application_id
@router.get("", response_model=ApiResponse)
def list_pending_pec(
status_filter: str = Query("pending-pec", alias="status",
description="pending-pec (nuove AWAITING senza PEC), pending-reminder (retry richiesto)"),
since: Optional[datetime] = Query(None, description="ISO datetime, filtra updated_at >= since"),
limit: int = Query(50, ge=1, le=500),
db: Session = Depends(get_db),
_: bool = Depends(_check_internal_auth),
):
"""Lista amendment da processare (polling BE). Due filtri:
- pending-pec: status=AWAITING AND pec_sent_at IS NULL (prime invio)
- pending-reminder: status=AWAITING AND pec_sent_at IS NOT NULL AND pec_retry_after IS NOT NULL
"""
q = db.query(RemissionAmendmentRequest)
if status_filter == "pending-pec":
q = q.filter(
RemissionAmendmentRequest.status == AmendmentStatus.AWAITING.value,
RemissionAmendmentRequest.pec_sent_at.is_(None),
)
elif status_filter == "pending-reminder":
q = q.filter(
RemissionAmendmentRequest.status == AmendmentStatus.AWAITING.value,
RemissionAmendmentRequest.pec_sent_at.isnot(None),
RemissionAmendmentRequest.pec_retry_after.isnot(None),
)
else:
raise HTTPException(status_code=422, detail="status deve essere pending-pec o pending-reminder")
if since is not None:
q = q.filter(RemissionAmendmentRequest.updated_at >= since)
q = q.order_by(RemissionAmendmentRequest.created_at.asc()).limit(limit)
results = q.all()
items = []
for ar in results:
application_id = _fetch_application_id(db, ar.practice_id)
items.append(AmendmentPendingPecOut(
id=ar.id, practice_id=ar.practice_id, application_id=application_id,
request_text=ar.request_text, deadline=ar.deadline,
response_days=ar.response_days,
amendment_document_path=ar.amendment_document_path,
created_at=ar.created_at,
).model_dump(mode="json"))
return ApiResponse(message=f"{len(items)} amendment pending",
data={"items": items, "count": len(items)})
@router.get("/{amendment_id}", response_model=ApiResponse)
def get_amendment_detail(
amendment_id: UUID,
db: Session = Depends(get_db),
_: bool = Depends(_check_internal_auth),
):
"""Dettaglio completo per comporre PEC lato BE. Include application_id, company_id,
call_id, sequence_number (per il titolo 'II fase 2021', ecc.)."""
ar = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.id == amendment_id
).first()
if not ar:
raise HTTPException(status_code=404, detail="Amendment non trovata")
p = db.query(RemissionPractice).filter(RemissionPractice.id == ar.practice_id).first()
if not p:
raise HTTPException(status_code=404, detail="Pratica collegata non trovata")
# serve company_id + call_id: il BE li dovrebbe gia sapere da application_id,
# ma glieli restituiamo pure qui per evitare join extra lato loro.
# Non avendo accesso a application/call nel microservizio (sono su gepafin_schema),
# facciamo una SELECT diretta.
row = db.execute(text("""
SELECT a.company_id, a.call_id
FROM gepafin_schema.application a
WHERE a.id = :app_id
"""), {"app_id": p.application_id}).fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"Application {p.application_id} non trovata")
company_id, call_id = row
detail = AmendmentPecDetail(
id=ar.id, practice_id=p.id, application_id=p.application_id,
company_id=company_id, call_id=call_id,
sequence_number=p.sequence_number, period_label=p.period_label,
request_text=ar.request_text, deadline=ar.deadline,
response_days=ar.response_days,
amendment_document_path=ar.amendment_document_path,
)
return ApiResponse(message="ok", data=detail.model_dump(mode="json"))
@router.post("/{amendment_id}/mark-pec-sent", response_model=ApiResponse)
def mark_pec_sent(
amendment_id: UUID, body: MarkPecSent,
db: Session = Depends(get_db),
_: bool = Depends(_check_internal_auth),
):
"""Callback dal BE: PEC inviata con successo. Salva protocol_id + email_log_id + ts."""
ar = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.id == amendment_id
).first()
if not ar:
raise HTTPException(status_code=404, detail="Amendment non trovata")
if ar.status != AmendmentStatus.AWAITING.value:
raise HTTPException(status_code=409,
detail=f"mark-pec-sent atteso solo su AWAITING (attuale: {ar.status})")
ar.protocol_id = body.protocol_id
ar.email_log_id = body.email_log_id
ar.user_action_id = body.user_action_id
ar.pec_sent_at = body.pec_sent_at or datetime.now(timezone.utc)
ar.pec_failed_reason = None
ar.pec_retry_after = None # reset retry flag (era usato come "send reminder")
db.commit()
return ApiResponse(message="PEC marcata come inviata",
data={"id": str(amendment_id), "protocol_id": body.protocol_id,
"pec_sent_at": ar.pec_sent_at.isoformat()})
@router.post("/{amendment_id}/mark-pec-failed", response_model=ApiResponse)
def mark_pec_failed(
amendment_id: UUID, body: MarkPecFailed,
db: Session = Depends(get_db),
_: bool = Depends(_check_internal_auth),
):
"""Callback dal BE: PEC fallita. Salva motivazione + eventuale retry_after."""
ar = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.id == amendment_id
).first()
if not ar:
raise HTTPException(status_code=404, detail="Amendment non trovata")
ar.pec_failed_reason = body.reason[:2000] # limite safety
ar.pec_retry_after = body.retry_after
db.commit()
return ApiResponse(message="PEC marcata come fallita",
data={"id": str(amendment_id), "reason": body.reason[:200]})