Files
BFLOWS da13ca7478 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
2026-04-20 22:22:37 +02:00

462 lines
13 KiB
Python

"""
Pydantic schemas per API.
"""
from typing import Optional, Any, List
from datetime import datetime, date
from decimal import Decimal
from uuid import UUID
from enum import Enum
from pydantic import BaseModel, Field
# ====================== Schema di rendicontazione (bando) ======================
class RemissionSchemaBase(BaseModel):
schema_json: dict
class RemissionSchemaCreate(RemissionSchemaBase):
pass
class RemissionSchemaUpdate(BaseModel):
schema_json: Optional[dict] = None
class RemissionSchemaOut(BaseModel):
id: UUID
call_id: int
schema_version: int
status: str
schema_json: dict
created_by: int
created_at: datetime
updated_at: datetime
published_at: Optional[datetime] = None
published_by: Optional[int] = None
model_config = {"from_attributes": True}
# ====================== Pratica di rendicontazione (beneficiario) ======================
class PracticeStartRequest(BaseModel):
"""Input per avviare una (nuova) pratica o tranche."""
application_id: int
period_label: Optional[str] = None # es "I trimestre 2021" — libero
copy_ula_from_previous: bool = True # ignorato se e la prima tranche
class PracticeUpdate(BaseModel):
iva_regime: Optional[str] = None
notes_beneficiario: Optional[str] = None
# Fattura
class InvoiceCreate(BaseModel):
category_code: str
invoice_number: str
invoice_date: date
payment_date: date
supplier_name: str
supplier_vat: str
description: str
taxable: Decimal
vat: Decimal = Decimal("0")
total: Decimal
pdf_filename: Optional[str] = None
class InvoiceOut(InvoiceCreate):
id: UUID
practice_id: UUID
created_at: datetime
# istruttoria
taxable_verified: Optional[Decimal] = None
vat_verified: Optional[Decimal] = None
total_verified: Optional[Decimal] = None
verification_status: str = "PENDING"
verification_notes: Optional[str] = None
date_checks: Optional[dict] = None
verified_by: Optional[int] = None
verified_at: Optional[datetime] = None
model_config = {"from_attributes": True}
# ULA Employee
class UlaEmployeeCreate(BaseModel):
codice_fiscale: str
full_name: str
contract_type: str
role_description: Optional[str] = None
fte_pct: Decimal = Decimal("1")
period_start_date: date
period_end_date: date
supporting_doc_type: Optional[str] = None
supporting_doc_filename: Optional[str] = None
class UlaEmployeeOut(UlaEmployeeCreate):
id: UUID
practice_id: UUID
created_at: datetime
# istruttoria
fte_pct_verified: Optional[Decimal] = None
verification_status: str = "PENDING"
verification_notes: Optional[str] = None
verified_by: Optional[int] = None
verified_at: Optional[datetime] = None
model_config = {"from_attributes": True}
# Document
class DocumentUpsert(BaseModel):
doc_code: str
filename: Optional[str] = None
uploaded_at: Optional[datetime] = None
expires_at: Optional[date] = None
notes: Optional[str] = None
source_company_document_id: Optional[int] = None
class DocumentOut(BaseModel):
id: UUID
practice_id: UUID
doc_code: str
filename: Optional[str] = None
uploaded_at: Optional[datetime] = None
expires_at: Optional[date] = None
notes: Optional[str] = None
source_company_document_id: Optional[int] = None
# istruttoria
verification_status: str = "PENDING"
verification_notes: Optional[str] = None
verified_by: Optional[int] = None
verified_at: Optional[datetime] = None
model_config = {"from_attributes": True}
# Pratica dettagliata
class PracticeOut(BaseModel):
id: UUID
call_id: int
application_id: int
company_id: int
user_id: int
status: str
schema_snapshot: dict
iva_regime: Optional[str] = None
amount_erogato: Decimal
notes_beneficiario: Optional[str] = None
created_at: datetime
updated_at: datetime
submitted_at: Optional[datetime] = None
# istruttoria
assigned_instructor_id: Optional[int] = None
reviewed_at: Optional[datetime] = None
reviewed_by: Optional[int] = None
rejection_reason: Optional[str] = None
approved_remission: Optional[Decimal] = None
instructor_final_notes: Optional[str] = None
instructor_checklist: Optional[dict] = None
verbale_date: Optional[date] = None
# v2 multi-tranche
sequence_number: int = 1
period_label: Optional[str] = None
suggested_instructor_id: Optional[int] = None
invoices: List[InvoiceOut] = []
ula_employees: List[UlaEmployeeOut] = []
documents: List[DocumentOut] = []
model_config = {"from_attributes": True}
class PracticeListItem(BaseModel):
"""Riga leggera per liste."""
id: UUID
call_id: int
application_id: int
company_id: int
status: str
amount_erogato: Decimal
created_at: datetime
submitted_at: Optional[datetime] = None
# v2 multi-tranche
sequence_number: int = 1
period_label: Optional[str] = None
suggested_instructor_id: Optional[int] = None
# campi denormalizzati aggiunti a runtime
call_name: Optional[str] = None
company_name: Optional[str] = None
invoice_count: int = 0
ula_count: int = 0
document_count: int = 0
model_config = {"from_attributes": True}
# Gate check
class GateCheckResult(BaseModel):
passed: bool
checks: List[dict] # [{id, label, passed, detail}]
totals: dict # {per_category: {B1: 1234.56, ...}, grand_total, max_remission_due, ...}
# ====================== Istruttoria ======================
# Stati formali amendment (speculare BE Gepafin ApplicationAmendmentRequestEnum, ridotti)
class AmendmentStatus(str, Enum):
DRAFT = "DRAFT" # istruttore prepara, PEC non ancora partita
AWAITING = "AWAITING" # PEC inviata, attendo risposta benef
RESPONSE_RECEIVED = "RESPONSE_RECEIVED" # benef ha risposto, istruttore deve valutare
EXPIRED = "EXPIRED" # deadline passata senza risposta (scheduler)
CLOSED = "CLOSED" # istruttore chiude dopo response o comunque
class AmendmentRequestCreate(BaseModel):
request_text: str
deadline: date
scope: Optional[dict] = None
response_days: Optional[int] = None # pre-compilato default 15gg, variabile per istruttore
internal_note: Optional[str] = None # visibile solo istruttore
class AmendmentRequestUpdate(BaseModel):
"""Modifica amendment solo in stato DRAFT."""
request_text: Optional[str] = None
deadline: Optional[date] = None
scope: Optional[dict] = None
response_days: Optional[int] = None
internal_note: Optional[str] = None
class AmendmentExtend(BaseModel):
"""Prolunga la deadline di un amendment AWAITING."""
extended_days: int = Field(..., gt=0, le=60)
motivation: Optional[str] = None
class AmendmentResponseSubmit(BaseModel):
response_text: str
class AmendmentRequestOut(BaseModel):
id: UUID
practice_id: UUID
requested_by: int
request_text: str
scope: Optional[dict] = None
deadline: date
status: str
response_text: Optional[str] = None
response_at: Optional[datetime] = None
closed_at: Optional[datetime] = None
closed_by: Optional[int] = None
created_at: datetime
# soccorso v3
response_days: Optional[int] = None
extended_days: Optional[int] = None
extension_date: Optional[datetime] = None
internal_note: Optional[str] = None
amendment_document_path: Optional[str] = None
amendment_document_type: Optional[str] = None
response_document_path: Optional[str] = None
response_document_type: Optional[str] = None
protocol_id: Optional[str] = None
pec_sent_at: Optional[datetime] = None
pec_failed_reason: Optional[str] = None
model_config = {"from_attributes": True}
# ===== schemas per endpoint interni chiamati dal BE Gepafin =====
class AmendmentPendingPecOut(BaseModel):
"""Lista amendment che il BE deve processare per invio PEC."""
id: UUID
practice_id: UUID
application_id: int
request_text: str
deadline: date
response_days: Optional[int] = None
amendment_document_path: Optional[str] = None
created_at: datetime
class AmendmentPecDetail(BaseModel):
"""Dettaglio richiesto dal BE per comporre PEC."""
id: UUID
practice_id: UUID
application_id: int
company_id: int
call_id: int
sequence_number: int
period_label: Optional[str] = None
request_text: str
deadline: date
response_days: Optional[int] = None
amendment_document_path: Optional[str] = None
class MarkPecSent(BaseModel):
protocol_id: str
email_log_id: Optional[int] = None
user_action_id: Optional[int] = None
pec_sent_at: Optional[datetime] = None
class MarkPecFailed(BaseModel):
reason: str
retry_after: Optional[datetime] = None
class ReviewRejectBody(BaseModel):
rejection_reason: str
class ReviewApproveBody(BaseModel):
approved_remission: Optional[Decimal] = None
notes: Optional[str] = None
class InstructorQueueItem(BaseModel):
id: UUID
call_id: int
application_id: int
sequence_number: int = 1
period_label: Optional[str] = None
suggested_instructor_id: Optional[int] = None
company_id: int
status: str
amount_erogato: Decimal
submitted_at: Optional[datetime] = None
assigned_instructor_id: Optional[int] = None
call_name: Optional[str] = None
company_name: Optional[str] = None
invoice_count: int = 0
ula_count: int = 0
document_count: int = 0
open_amendments: int = 0
remission_due: Optional[float] = None
model_config = {"from_attributes": True}
# Verifica singola fattura
class InvoiceVerifyBody(BaseModel):
verification_status: str # AMMESSA | PARZIALE | RESPINTA
taxable_verified: Optional[Decimal] = None
vat_verified: Optional[Decimal] = None
total_verified: Optional[Decimal] = None
verification_notes: Optional[str] = None
# Verifica singolo dipendente ULA
class UlaVerifyBody(BaseModel):
verification_status: str # AMMESSA | PARZIALE | RESPINTA
fte_pct_verified: Optional[Decimal] = None
verification_notes: Optional[str] = None
# Verifica singolo documento
class DocumentVerifyBody(BaseModel):
verification_status: str # VALIDO | NON_VALIDO | SCADUTO
verification_notes: Optional[str] = None
# Note finali istruttore + checklist
class InstructorFinalNotesBody(BaseModel):
instructor_final_notes: Optional[str] = None
instructor_checklist: Optional[dict] = None
# ====================== Wrapper ======================
class ApiResponse(BaseModel):
status: str = "SUCCESS"
message: Optional[str] = None
data: Optional[Any] = None
# ====================== v2 Custom checks ======================
class CustomCheckDeclareBody(BaseModel):
beneficiary_declared: bool
class CustomCheckVerifyBody(BaseModel):
verification_status: str # PENDING | VALIDO | NON_VALIDO
verification_notes: Optional[str] = None
class CustomCheckOut(BaseModel):
"""Vista merged di definition (da schema) + value (dal DB)."""
code: str
label: str
description: Optional[str] = None
requires_document: bool = False
required: bool = False
# valori
beneficiary_declared: bool = False
declared_at: Optional[datetime] = None
filename_original: Optional[str] = None
storage_path: Optional[str] = None
size_bytes: Optional[int] = None
document_uploaded_at: Optional[datetime] = None
verification_status: str = "PENDING"
verification_notes: Optional[str] = None
verified_by: Optional[int] = None
verified_at: Optional[datetime] = None
# ====================== v2 Reassign istruttore ======================
class PracticeReassignBody(BaseModel):
new_instructor_id: Optional[int] = None # None = unassign ritorno in coda
reassignment_reason: Optional[str] = None
# ====================== v2 Tranches ======================
class ApplicationTranchesSummary(BaseModel):
"""Riepilogo pratiche/tranche per una application."""
application_id: int
call_id: int
call_name: Optional[str] = None
company_id: int
company_name: Optional[str] = None
amount_erogato: float
max_tranches: int = 1
# summary tranche esistenti
tranches: List[PracticeListItem] = []
# stato apertura nuova tranche
can_start_new: bool = False
start_blocked_reason: Optional[str] = None
# importi cumulativi
already_approved_sum: float = 0
max_remission_global: float = 0
max_remission_next_tranche: float = 0
class CopyUlaOption(BaseModel):
"""Dipendente copiabile da tranche precedente."""
codice_fiscale: str
full_name: str
contract_type: str
role_description: Optional[str] = None
fte_pct: float
period_start_date: date
period_end_date: date
supporting_doc_type: Optional[str] = None