Compare commits

..

10 Commits

Author SHA1 Message Date
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
BFLOWS
1dbf542104 feat(internal): download endpoint per PDF allegati — istruttore + response benef
Risposta a richiesta Rinaldo (team BE bflows-bandi-be) per integrazione S3
folder pratica unico. Il BE scaricherà i PDF binary via nostri endpoint
e li archivierà su S3 nel folder {practice_id}/amendments/{id}/.

==ENDPOINT NUOVI (2, simmetrici)==
GET /internal/remission-amendments/{id}/document
    → PDF istruttore (amendment_document_path) binary stream

GET /internal/remission-amendments/{id}/response-document
    → PDF benef risposta (response_document_path) binary stream

Auth: X-Internal-Secret (riusa _check_internal_auth come gli altri /internal).
Risposta: application/pdf con Content-Disposition attachment + filename originale
(estratto dal pattern {sha256}-{nome} del path fisico).

==IMPLEMENTAZIONE==
- Nuovo helper _resolve_amendment_file(amendment_id, db, kind) che:
    1. Carica l'amendment (404 se non esiste)
    2. Seleziona il path in base al kind ('instructor' | 'response')
    3. 404 se il campo è NULL (es. benef non ha ancora risposto)
    4. Hardening path traversal: abs_path.resolve().relative_to(BASE_PATH)
    5. 404 se file non presente su filesystem
    6. Estrae safe_name dal pattern {sha}-{nome.ext}
- FileResponse streaming, media_type da amendment.*_document_type
- Import BASE_PATH + StorageError da ..storage

==TEST (8 step, /tmp/td2.py, tutti verdi)==
1. crea amendment DRAFT
2. upload PDF istruttore  → HTTP 200, 526 bytes
3. GET /document          → 200, byte-exact, ct=application/pdf, filename preservato
4. GET senza X-Internal-Secret → 401
5. GET amendment inesistente → 404
6. GET /response-document prima che benef allegi → 404
7. benef upload response_document
8. GET /response-document → 200, byte-exact, filename preservato

==RISPOSTA RINALDO==
- amendment_document_path NON è S3 → path FS relativo a /var/uploads
- 1 solo file per ruolo (istruttore + benef): due campi distinti in DB
- Download via questi 2 endpoint simmetrici con shared secret
- Pull-on-upload dal poller BE: dopo mark-pec-sent scarica PDF e lo archivia
  S3 folder pratica; simmetrico per response_document quando il benef risponde

==NOTA MIGRAZIONE FUTURA==
app/storage.py aveva già nota 'Migrazione futura a S3/MinIO: cambiare solo
questa classe'. Questi endpoint download restano validi anche dopo migrazione
S3 lato microservizio (cambia solo impl interna di _resolve_amendment_file).

Breakdown effort a Rinaldo si aggiorna: opzione A = volume condiviso, o
opzione B = questi endpoint (ora implementati, disponibili subito).
2026-04-24 15:38:21 +02:00
BFLOWS
34c4a47a1c 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
2026-04-20 22:35:01 +02:00
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
BFLOWS
7c8de6aec8 feat(docs): link documenti dal repository company + gate submit su EXPIRED
Implementa il riutilizzo dei documenti caricati in fase domanda (gepafin_schema.company_document):
il benef puo selezionarli dal picker repository invece di caricarli dal PC, ereditando
filename/expires_at/storage_path. Tracciato via source_company_document_id per lookup
live dello stato (VALID/DUE/EXPIRED).

Modifiche:
- migrations.py: ALTER TABLE remission_document ADD source_company_document_id + index partial
- models.py: aggiunto campo source_company_document_id su RemissionDocument
- schemas.py: esposto source_company_document_id in DocumentUpsert + DocumentOut
- routers/files.py: nuovo POST /document/{id}/link-from-repository — verifica ownership
  company, pulisce file PC precedente, copia metadati dal sorgente, ritorna source_status
- routers/practices.py: nuovo check documents_not_expired in _compute_gate_check —
  JOIN live su gepafin_schema.company_document.status per doc linkati, controllo expires_at
  per upload diretti. Gate hard: documento EXPIRED blocca submit (422).

Test E2E verificati via curl/JWT offline:
- link VALID → metadati copiati, gate passed
- link EXPIRED → gate overall FAIL con detail 'Scaduti: DURC'
- re-link VALID → gate torna passed
- submit bloccato solo su check non-doc (fatture/altri doc mancanti), docs_not_expired OK

Seed sandbox: 4 document_category + 5 company_document su NAPOLI SAS (3 VALID / 1 DUE / 1 EXPIRED).
2026-04-20 18:47:03 +02:00
BFLOWS
a3f863ecdb feat(verbale): logo Gepafin SVG ufficiale nel verbale PDF
Carlo ha fornito il logo Gepafin ufficiale (SVG vettoriale, colore #4A644E verde
istituzionale, 118x50 viewbox). Sostituito il placeholder testuale
<span class="hdr__logo">GEPAFIN</span> con il logo reale.

Implementazione:
- app/static/gepafin-logo.svg — logo copiato dal repo FE Gepafin (8.7KB SVG)
- verbale.py: aggiunto STATIC_DIR + LOGO_FILE_URL con path file:// assoluto
  (weasyprint risolve file:// localmente senza HTTP fetch)
- template passa logo_path nel contesto Jinja
- verbale_istruttoria.html: CSS .hdr__logo-img (altezza 38pt) sostituisce
  .hdr__logo testuale

Verificato: PDF da 23KB -> 32KB (incluso SVG), XObject embedded OK,
header PDF-1.7 valido.

Nel PDF il logo appare in alto a destra dell'header accanto al sottotitolo
'Finanziaria regionale dell\'Umbria'.
2026-04-18 22:15:24 +02:00
BFLOWS
8950633481 fix(instructor): pratiche SUBMITTED pre-assegnate ora compaiono in coda
Il filtro coda istruttore escludeva le pratiche in stato SUBMITTED
gia assegnate all'istruttore stesso. Risultato: una pratica appena
inviata dal benef a un istruttore preferenziale (suggested_instructor
letto da gepafin_schema.assigned_applications) cadeva nel limbo:
- non 'pool unassigned' perche ha assigned_instructor_id != NULL
- non 'UNDER_REVIEW assegnata a me' perche era ancora SUBMITTED

Aggiunta clausola: SUBMITTED AND assigned_instructor_id == user.user_id
nell'elenco degli stati visibili.

Segnalazione Carlo: istruttore loggato vede 'Nessuna pratica in coda'
anche se T2 e stata assegnata a lui.
2026-04-18 19:27:01 +02:00
BFLOWS
345856f55c fix(assignment): vista manager mostra TUTTE le pratiche non solo quelle attive
Il capo istruttore deve vedere anche pratiche in DRAFT (beneficiario le sta
compilando) e APPROVED/REJECTED (chiuse) per: monitorare carico istruttori,
riassegnare tranches successive prima del submit, verificare storici.

Prima il filtro era RemissionPractice.status.in_(SUBMITTED,UNDER_REVIEW,AWAITING_AMENDMENT)
ed escludeva drafts e closed. Ora nessun filtro su status — tutte le pratiche.

Segnalazione Carlo: capo istruttore vedeva 'Nessuna pratica in coda' anche
con 2 tranches NAPOLI SAS in DB.
2026-04-18 19:13:01 +02:00
BFLOWS
aeab399afa feat(schemas): picker 3-card blank/template/clone per inizializzazione schema
Sostituisce il vecchio flusso 'initialize-restart' (unico template hardcoded)
con un picker unificato che offre 3 sorgenti di inizializzazione:

1. BLANK — schema scheletro v2 con sezioni vuote (categorie, documenti, custom_checks)
   da popolare; ULA disabilitata di default; max_tranches=1.
2. TEMPLATE — parte da template predefinito nel registry TEMPLATES di app/templates.py.
   Oggi: 'blank' + 'restart'. Struttura estendibile per nuovi template bandi.
3. CLONE — copia schema_json di un altro bando esistente (DRAFT o PUBLISHED).
   Useful per bandi 'sorella'. upgrade_schema_to_v2 applicato on-copy per schemi v1 legacy.

app/templates.py: aggiunto BLANK_TEMPLATE, registry TEMPLATES, helper list_templates()
e get_template(id).

app/routers/schemas.py: riscritto con 3 nuovi endpoint:
- GET /templates  -> lista metadati template disponibili
- GET /templates/{id}  -> preview schema completo
- GET /clonable-calls  -> bandi con schema (per dropdown clone)
- POST /{call_id}/initialize body {source, template_id?, source_call_id?}  -> unificato
Endpoint /initialize-restart mantenuto come alias di /initialize con template=restart
per backward compat del vecchio FE.

Testato E2E via curl: blank OK, template restart OK, clone da call 1 OK, errori
(source invalido/template_id inesistente/clone senza source_call_id/schema gia esistente/
bando inesistente) tutti gestiti con HTTP corretto.
2026-04-18 18:51:42 +02:00
BFLOWS
3021792c31 feat(v2): verbale istruttoria PDF con tranche N/M + custom_checks + 5 voci Cecilia + storico
B6 routers/verbale.py:
- _build_context arricchito con custom_checks_merged (schema+values da RemissionCustomCheckValue)
- previous_tranches: elenco tranche APPROVED precedenti con cumulative progressivo
- max_tranches_snapshot letto dallo schema_snapshot.gate_rules.max_tranches
- filename include _t{sequence_number}.pdf

B6 templates_jinja/verbale_istruttoria.html:
- Header: 'Tranche N/M' + period_label dopo numero pratica
- Meta-grid: riga 'Tranche / fase' quando max_tranches > 1
- Nuova sezione 'Controlli aggiuntivi' (dopo verifica documenti):
  tabella label, obbligatorio, dichiarato SI/NO, doc allegato SI/NO, validazione, note
- Sezione 'Storico tranches precedenti' (solo se sequence > 1):
  tabella con cumulativo progressivo
- Box totali riscritto con **5 VOCI UFFICIALI CECILIA**:
  (1) Importo massimo ammissibile (cap globale) + gia approvato tranche precedenti
  (2) Richiesto pre-controllo = pre_check_admissible
  (3) Ammesso post-controllo = remission_due
  (4) Importo finanziamento erogato + tranches count/max
  (5) Residuo da restituire = erogato - approvato_prec - ammesso
- Box 'REMISSIONE APPROVATA PER QUESTA TRANCHE' evidenziato quando APPROVED

Test E2E: verbale T1 APPROVED 29.3KB con tutte sezioni presenti.
Verbale T2 simulata con storico T1 e cap tranche 2 correttamente calcolato.
2026-04-18 17:53:04 +02:00
20 changed files with 1498 additions and 108 deletions

View File

@@ -3,7 +3,7 @@ JWT validation compatibile con GEPAFIN-BE.
Il BE Spring emette token HS512 con payload:
sub: "email:userId:hubId"
userId: int
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | ...
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | "ROLE_CONFIDI" | ...
exp: unix timestamp
loginAttemptId: int
"""
@@ -29,6 +29,15 @@ class AuthUser:
def is_beneficiary(self) -> bool:
return self.role == "ROLE_BENEFICIARY"
def is_confidi(self) -> bool:
return self.role == "ROLE_CONFIDI"
def is_owner_role(self) -> bool:
"""Ruoli che possono essere proprietari di una pratica (user_id match):
BENEFICIARY (azienda diretta) o CONFIDI (delegato per conto azienda).
Pattern allineato al BE Gepafin (DashboardDao, CompanyDocumentDao)."""
return self.role in ("ROLE_BENEFICIARY", "ROLE_CONFIDI")
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),

View File

@@ -18,6 +18,10 @@ class Settings(BaseSettings):
# CORS
cors_origins: str = "http://78.46.41.91:18072,http://localhost:18072"
# Shared secret per endpoint /internal chiamati dal BE Gepafin
# In PROD va cambiato via env var RENDIC_INTERNAL_SECRET
internal_secret: str = "sandbox-internal-secret-ChangeMeInProd-AtLeast32Chars"
class Config:
env_file = ".env"
env_prefix = "RENDIC_"

View File

@@ -15,7 +15,8 @@ from sqlalchemy import text
from .config import get_settings
from .db import engine, Base
from .migrations import run_migrations
from .routers import health, schemas, practices, debug, instructor, files, verbale, custom_checks, assignment
from .scheduler import start_scheduler, stop_scheduler
from .routers import health, schemas, practices, debug, instructor, files, verbale, custom_checks, assignment, internal
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("rendicontazione-api")
@@ -32,10 +33,12 @@ async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
run_migrations(engine)
log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK")
start_scheduler()
except Exception as e:
log.error(f"Errore bootstrap DB: {e}")
raise
yield
stop_scheduler()
log.info("Shutdown rendicontazione-api")
@@ -63,6 +66,7 @@ app.include_router(files.router)
app.include_router(verbale.router)
app.include_router(custom_checks.router)
app.include_router(assignment.router)
app.include_router(internal.router)
@app.get("/", tags=["root"])

View File

@@ -68,6 +68,17 @@ MIGRATIONS = [
ON gepafin_rendic.remission_practice(assigned_instructor_id)
WHERE assigned_instructor_id IS NULL;
""",
# 2026-04-20: link documento a company_document del BE Gepafin (riutilizzo dal repository)
# Se source_company_document_id e valorizzato, il documento e selezionato dal repository
# company (gepafin_schema.company_document). Lo status/scadenza del sorgente governa
# semaforo UI e gate submit (documenti EXPIRED bloccano la trasmissione).
"""
ALTER TABLE gepafin_rendic.remission_document
ADD COLUMN IF NOT EXISTS source_company_document_id integer;
CREATE INDEX IF NOT EXISTS idx_remission_document_source
ON gepafin_rendic.remission_document(source_company_document_id)
WHERE source_company_document_id IS NOT NULL;
""",
# 2026-04-18 v2: tabella custom checks
# allineata allo storage adapter esistente (storage_path + mime + size + sha256)
# NON segue le specs RAG p1 che usavano document_filename (v1 obsoleta)
@@ -95,6 +106,79 @@ MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_custom_check_practice
ON gepafin_rendic.remission_custom_check_value(practice_id);
""",
# 2026-04-20 v3: soccorso istruttorio speculare al BE Gepafin
# - stato DRAFT (istruttore prepara, non ancora inviato)
# - response_days + extended_days + extension_date (prolunghe)
# - internal_note (visibile solo istruttore, separata da request_text)
# - amendment_document_* (allegato istruttore al soccorso, firmato e no)
# - response_document_* (upload risposta beneficiario)
# - protocol_id + email_log_id + user_action_id (popolati dal BE via mark-pec-sent)
# - pec_sent_at + pec_failed_reason + pec_retry_after (tracking PEC asincrono)
# Lato microservizio NON gestiamo PEC ne protocollo: il BE multi-tenant
# (gepafin_schema.hub id=1 PEC_SERVICE, id=2 MAILGUN_SERVICE) fa polling
# su endpoint /internal/remission-amendments e notifica via mark-pec-sent/failed.
"""
ALTER TABLE gepafin_rendic.remission_amendment_request
ADD COLUMN IF NOT EXISTS response_days integer,
ADD COLUMN IF NOT EXISTS extended_days integer,
ADD COLUMN IF NOT EXISTS extension_date timestamptz,
ADD COLUMN IF NOT EXISTS internal_note text,
ADD COLUMN IF NOT EXISTS amendment_document_path varchar(1024),
ADD COLUMN IF NOT EXISTS amendment_document_type varchar(128),
ADD COLUMN IF NOT EXISTS amendment_initial_document_path varchar(1024),
ADD COLUMN IF NOT EXISTS response_document_path varchar(1024),
ADD COLUMN IF NOT EXISTS response_document_type varchar(128),
ADD COLUMN IF NOT EXISTS protocol_id varchar(128),
ADD COLUMN IF NOT EXISTS email_log_id integer,
ADD COLUMN IF NOT EXISTS user_action_id integer,
ADD COLUMN IF NOT EXISTS pec_sent_at timestamptz,
ADD COLUMN IF NOT EXISTS pec_failed_reason text,
ADD COLUMN IF NOT EXISTS pec_retry_after timestamptz;
CREATE INDEX IF NOT EXISTS idx_amendment_status_pec
ON gepafin_rendic.remission_amendment_request(status)
WHERE status IN ('DRAFT','AWAITING');
CREATE INDEX IF NOT EXISTS idx_amendment_deadline
ON gepafin_rendic.remission_amendment_request(deadline)
WHERE status = 'AWAITING';
""",
# 2026-04-20 v4: tabella config reminder data-driven, speculare al BE
# (expiration_config type='AMENDMENT' interval_days=N). Permette righe multiple
# per triggerare reminder a N gg diversi dalla scadenza (es. 7gg + 2gg).
"""
CREATE TABLE IF NOT EXISTS gepafin_rendic.remission_expiration_config (
id serial PRIMARY KEY,
type varchar(50) NOT NULL,
interval_days integer NOT NULL CHECK (interval_days > 0),
is_deleted boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_expiration_config_type
ON gepafin_rendic.remission_expiration_config(type)
WHERE is_deleted = false;
""",
# 2026-04-20 v5: dedup duplicati (ON CONFLICT DO NOTHING non funzionava senza UNIQUE)
# + aggiungo UNIQUE constraint per prevenire futuri duplicati
"""
DELETE FROM gepafin_rendic.remission_expiration_config ec
USING gepafin_rendic.remission_expiration_config ec2
WHERE ec.id > ec2.id
AND ec.type = ec2.type
AND ec.interval_days = ec2.interval_days;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_expiration_config_type_days'
AND conrelid = 'gepafin_rendic.remission_expiration_config'::regclass
) THEN
ALTER TABLE gepafin_rendic.remission_expiration_config
ADD CONSTRAINT uq_expiration_config_type_days UNIQUE (type, interval_days);
END IF;
END$$;
INSERT INTO gepafin_rendic.remission_expiration_config (type, interval_days)
VALUES ('AMENDMENT', 7), ('AMENDMENT', 2)
ON CONFLICT (type, interval_days) DO NOTHING;
""",
]

View File

@@ -199,6 +199,11 @@ class RemissionDocument(Base):
sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, nullable=True)
# Link al repository documenti della company (gepafin_schema.company_document).
# Se valorizzato, il documento e stato selezionato dal picker repository invece
# che caricato dal PC. filename/expires_at vengono copiati al momento del link.
source_company_document_id = Column(Integer, nullable=True)
# Campi istruttoria
verification_status = Column(String(16), nullable=False, default="PENDING")
# PENDING | VALIDO | NON_VALIDO | SCADUTO
@@ -230,12 +235,47 @@ class RemissionAmendmentRequest(Base):
closed_at = Column(DateTime(timezone=True), nullable=True)
closed_by = Column(Integer, nullable=True)
# soccorso v3: extended/document/PEC tracking
response_days = Column(Integer, nullable=True)
extended_days = Column(Integer, nullable=True)
extension_date = Column(DateTime(timezone=True), nullable=True)
internal_note = Column(Text, nullable=True)
amendment_document_path = Column(String(1024), nullable=True)
amendment_document_type = Column(String(128), nullable=True)
amendment_initial_document_path = Column(String(1024), nullable=True)
response_document_path = Column(String(1024), nullable=True)
response_document_type = Column(String(128), nullable=True)
# popolati dal BE via endpoint interni mark-pec-sent / mark-pec-failed
protocol_id = Column(String(128), nullable=True)
email_log_id = Column(Integer, nullable=True)
user_action_id = Column(Integer, nullable=True)
pec_sent_at = Column(DateTime(timezone=True), nullable=True)
pec_failed_reason = Column(Text, nullable=True)
pec_retry_after = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
practice = relationship("RemissionPractice", back_populates="amendment_requests")
class RemissionExpirationConfig(Base):
"""Config data-driven per reminder scadenze amendment (speculare a BE Gepafin
expiration_config). Ogni riga con type='AMENDMENT' e interval_days=N triggera
un reminder esattamente N giorni prima della scadenza. Multipli row = multipli reminder."""
__tablename__ = "remission_expiration_config"
__table_args__ = ({"schema": "gepafin_rendic"},)
id = Column(Integer, primary_key=True, autoincrement=True)
type = Column(String(50), nullable=False)
interval_days = Column(Integer, nullable=False)
is_deleted = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
class RemissionCustomCheckValue(Base):
"""Valore di un controllo custom configurato dallo schema del bando.
Schema custom_checks[] nel template definisce code/label/description/requires_document/required.

View File

@@ -36,9 +36,10 @@ def assignments_overview(
manager: AuthUser = Depends(_require_manager),
):
"""Vista capo istruttore: pratiche con suggested + assigned + flag 'da assegnare'."""
practices = db.query(RemissionPractice).filter(
RemissionPractice.status.in_(["SUBMITTED", "UNDER_REVIEW", "AWAITING_AMENDMENT"])
).order_by(
# Vista manager: tutte le pratiche (incluso DRAFT in compilazione dal benef e
# APPROVED/REJECTED chiuse) perche il capo istruttore deve vedere tutto per
# riassegnare, monitorare carico, verificare storici.
practices = db.query(RemissionPractice).order_by(
RemissionPractice.application_id,
RemissionPractice.sequence_number
).all()

View File

@@ -36,16 +36,16 @@ def _get_practice(db: Session, practice_id: UUID, user: AuthUser) -> RemissionPr
if not p:
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
# Autorizzazione base: beneficiario owner o istruttore
if user.is_beneficiary() and p.user_id != user.user_id:
if user.is_owner_role() and p.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Accesso negato")
if not user.is_beneficiary() and not _is_instructor(user):
if not user.is_owner_role() and not _is_instructor(user):
raise HTTPException(status_code=403, detail="Ruolo non autorizzato")
return p
def _can_declare(user: AuthUser, practice: RemissionPractice) -> bool:
"""Solo beneficiario owner e solo su DRAFT | AWAITING_AMENDMENT."""
if not user.is_beneficiary():
if not user.is_owner_role():
return False
if practice.user_id != user.user_id:
return False

View File

@@ -11,7 +11,9 @@ from uuid import UUID
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from pydantic import BaseModel
from fastapi.responses import FileResponse, Response
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..db import get_db
@@ -36,7 +38,7 @@ def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
"""Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore."""
if _is_instructor(user):
return True
if user.is_beneficiary() and practice.user_id == user.user_id:
if user.is_owner_role() and practice.user_id == user.user_id:
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
return False
@@ -44,14 +46,14 @@ def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
def _can_download(user: AuthUser, practice: RemissionPractice) -> bool:
if _is_instructor(user):
return True
if user.is_beneficiary() and practice.user_id == user.user_id:
if user.is_owner_role() and practice.user_id == user.user_id:
return True
return False
def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool:
"""Solo beneficiario su pratica modificabile. Istruttore non elimina file."""
if user.is_beneficiary() and practice.user_id == user.user_id:
if user.is_owner_role() and practice.user_id == user.user_id:
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
if user.is_superadmin():
return True
@@ -267,3 +269,97 @@ def delete_entity_file(
db.commit()
return ApiResponse(success=True, message="File eliminato")
# ---------- Link da repository company ----------
# 2026-04-20: riutilizzo documenti caricati in fase domanda.
# Il benef seleziona un documento dal proprio repository company invece di caricarlo
# dal PC. Non c'e upload fisico: copiamo solo i metadati (filename, expires_at,
# storage_path per preview/download) e tracciamo source_company_document_id per
# permettere lookup live dello status sorgente (VALID/DUE/EXPIRED).
class LinkFromRepositoryRequest(BaseModel):
company_document_id: int
@router.post("/document/{entity_id}/link-from-repository", response_model=ApiResponse)
def link_document_from_repository(
entity_id: UUID,
body: LinkFromRepositoryRequest,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""
Associa un remission_document esistente a un company_document del repository
della fase domanda. Sostituisce eventuali file precedenti caricati dal PC
(elimina dallo storage, azzera storage_path).
"""
# 1) carica il remission_document e verifica permesso upload (benef owner o admin)
entity = db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first()
if not entity:
raise HTTPException(status_code=404, detail="Documento non trovato")
practice = db.query(RemissionPractice).filter(RemissionPractice.id == entity.practice_id).first()
if not practice:
raise HTTPException(status_code=404, detail="Pratica non trovata")
if not _can_upload(user, practice):
raise HTTPException(status_code=403, detail="Non autorizzato")
# pratica editabile solo in DRAFT (stessa regola dell'upload)
if practice.status != "DRAFT":
raise HTTPException(status_code=409, detail=f"Pratica in stato {practice.status}: non modificabile")
# 2) leggi il company_document (deve esistere, stessa company della pratica, non eliminato)
row = db.execute(text("""
SELECT id, file_name, file_path, type, status, expiration_date, company_id
FROM gepafin_schema.company_document
WHERE id = :cid AND is_deleted = false
"""), {"cid": body.company_document_id}).mappings().first()
if not row:
raise HTTPException(status_code=404, detail=f"company_document {body.company_document_id} non trovato")
if row["company_id"] != practice.company_id:
raise HTTPException(
status_code=403,
detail="Documento repository non appartiene alla company di questa pratica"
)
# 3) se c'era un file fisico caricato dal PC in precedenza, lo rimuoviamo per pulizia
if entity.storage_path and not entity.source_company_document_id:
try:
delete_file(entity.storage_path)
except Exception:
pass # non bloccare se il file non c'e piu
# 4) aggiorna metadati con quelli del repository
from datetime import datetime, timezone, date
entity.source_company_document_id = row["id"]
entity.filename = row["file_name"]
entity.storage_path = row["file_path"] # riuso del path fisico del BE per preview/download
entity.mime = None
entity.size_bytes = None
entity.sha256 = None
entity.uploaded_by = user.user_id
entity.uploaded_at = datetime.now(timezone.utc)
# scadenza dal sorgente (timestamp -> date)
exp = row["expiration_date"]
if exp is not None:
entity.expires_at = exp.date() if hasattr(exp, 'date') else exp
else:
entity.expires_at = None
db.commit()
db.refresh(entity)
return ApiResponse(
success=True,
message=f"Documento collegato dal repository (source_status={row['status']})",
data={
"id": str(entity.id),
"doc_code": entity.doc_code,
"filename": entity.filename,
"source_company_document_id": entity.source_company_document_id,
"expires_at": entity.expires_at.isoformat() if entity.expires_at else None,
"source_status": row["status"], # VALID | DUE | EXPIRED — per UI semaforo
}
)

View File

@@ -6,15 +6,16 @@ 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, AmendmentRequestOut, AmendmentResponseSubmit,
AmendmentRequestCreate, AmendmentRequestUpdate, AmendmentExtend, AmendmentRequestOut, AmendmentResponseSubmit, AmendmentStatus,
ReviewApproveBody, ReviewRejectBody,
InstructorQueueItem, PracticeOut, ApiResponse,
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
@@ -88,8 +89,14 @@ def instructor_queue(db: Session = Depends(get_db), user: AuthUser = Depends(_re
)
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),
))
@@ -189,10 +196,29 @@ def reject_practice(practice_id: UUID, body: ReviewRejectBody,
# ========== 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."""
"""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,
@@ -200,11 +226,13 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
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 già una amendment AWAITING aperta
open_ar = [a for a in p.amendment_requests if a.status == "AWAITING"]
# 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'è già una richiesta di soccorso aperta su questa pratica")
detail="C'e gia una richiesta di soccorso aperta su questa pratica")
ar = RemissionAmendmentRequest(
practice_id=p.id,
@@ -212,37 +240,142 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
request_text=body.request_text,
deadline=body.deadline,
scope=body.scope or {},
status="AWAITING"
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 istruttorio avviato",
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 (dopo aver visto la risposta beneficiario).
La pratica torna in UNDER_REVIEW."""
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")
if ar.status == "CLOSED":
raise HTTPException(status_code=409, detail="Amendment già chiusa")
"""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 = "CLOSED"
ar.status = AmendmentStatus.CLOSED.value
ar.closed_at = datetime.now(timezone.utc)
ar.closed_by = user.user_id
# rimetto la pratica in UNDER_REVIEW se non ci sono altre amendment aperte
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 == "AWAITING"]
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"
@@ -252,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,
@@ -260,15 +446,10 @@ def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
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_beneficiary() and p.user_id != user.user_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 = 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")
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")
@@ -429,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_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})

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

@@ -0,0 +1,267 @@
"""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 fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, text
from ..storage import BASE_PATH, StorageError
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]})
def _resolve_amendment_file(amendment_id: UUID, db: Session,
kind: str) -> tuple:
"""Risolve il path fisico di un allegato amendment.
kind: 'instructor' (amendment_document_path) | 'response' (response_document_path).
Ritorna tuple (abs_path, mime, safe_filename_hint).
Solleva HTTPException(404) se amendment non esiste o non ha il file.
Hardening: path deve restare dentro BASE_PATH (no traversal).
"""
ar = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.id == amendment_id
).first()
if not ar:
raise HTTPException(status_code=404, detail="Amendment non trovata")
if kind == "instructor":
rel_path, mime = ar.amendment_document_path, ar.amendment_document_type
elif kind == "response":
rel_path, mime = ar.response_document_path, ar.response_document_type
else:
raise HTTPException(status_code=422, detail=f"kind invalido: {kind}")
if not rel_path:
raise HTTPException(status_code=404,
detail=f"Nessun documento {kind} per amendment {amendment_id}")
abs_path = BASE_PATH / rel_path
try:
abs_path.resolve().relative_to(BASE_PATH.resolve())
except ValueError:
raise HTTPException(status_code=500, detail="Path non valido (hardening)")
if not abs_path.is_file():
raise HTTPException(status_code=404,
detail=f"File non presente su storage: {rel_path}")
# filename hint = tail dopo l'ultimo "-" (struttura {sha}-{nome.ext})
base = abs_path.name
if "-" in base:
safe_name = base.split("-", 1)[1]
else:
safe_name = base
return abs_path, (mime or "application/pdf"), safe_name
@router.get("/{amendment_id}/document")
def get_amendment_document(
amendment_id: UUID,
db: Session = Depends(get_db),
_: bool = Depends(_check_internal_auth),
):
"""Scarica il PDF allegato dall'istruttore al soccorso (binary stream).
Usato dal poller BE per archiviare su S3 nel folder pratica.
"""
abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "instructor")
return FileResponse(
path=str(abs_path), media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
)
@router.get("/{amendment_id}/response-document")
def get_response_document(
amendment_id: UUID,
db: Session = Depends(get_db),
_: bool = Depends(_check_internal_auth),
):
"""Scarica il PDF allegato dal beneficiario come risposta (binary stream).
Usato dal poller BE per archiviare su S3 nel folder pratica.
"""
abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "response")
return FileResponse(
path=str(abs_path), media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
)

View File

@@ -39,7 +39,7 @@ def _get_practice_or_404(db: Session, practice_id: UUID, user: AuthUser) -> Remi
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
# Solo il beneficiario owner o un superadmin può accedere
if user.is_beneficiary() and practice.user_id != user.user_id:
if user.is_owner_role() and practice.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Accesso negato a questa pratica")
return practice
@@ -216,6 +216,30 @@ def _compute_gate_check(db: Session, practice: RemissionPractice) -> GateCheckRe
"detail": f"Mancanti: {', '.join(missing_docs)}" if missing_docs else "Tutti presenti"
})
# Check 5b: documenti non scaduti (gate hard su EXPIRED)
# 2026-04-20: documento EXPIRED blocca la submit. Status letto live via JOIN sul
# BE Gepafin per doc collegati dal repository; per upload diretto PC controlla expires_at.
from datetime import date as _date_today_cls
today = _date_today_cls.today()
expired_docs = []
for doc in practice.documents:
if doc.source_company_document_id:
cd_status = db.execute(text("""
SELECT status FROM gepafin_schema.company_document
WHERE id = :cid AND is_deleted = false
"""), {"cid": doc.source_company_document_id}).scalar()
if cd_status == 'EXPIRED':
expired_docs.append(doc.doc_code)
elif doc.expires_at is not None and doc.expires_at < today:
expired_docs.append(doc.doc_code)
checks.append({
"id": "documents_not_expired",
"label": "Nessun documento scaduto",
"passed": len(expired_docs) == 0,
"detail": f"Scaduti: {', '.join(expired_docs)}" if expired_docs else "Tutti validi"
})
# Check 6: importo range (cap erogato)
amt_range = rules.get("amount_range", {})
min_e = Decimal(str(amt_range.get("min", 0)))
@@ -414,7 +438,7 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
if app_row["status"] != "CONTRACT_SIGNED":
raise HTTPException(status_code=409,
detail=f"Application in stato {app_row['status']}, richiesto CONTRACT_SIGNED")
if user.is_beneficiary() and app_row["user_id"] != user.user_id:
if user.is_owner_role() and app_row["user_id"] != user.user_id:
raise HTTPException(status_code=403, detail="Application non di tua proprieta")
# Schema del bando
@@ -422,7 +446,7 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
if not schema:
raise HTTPException(status_code=409,
detail="Nessuno schema di rendicontazione configurato per questo bando.")
if schema.status != "PUBLISHED" and user.is_beneficiary():
if schema.status != "PUBLISHED" and user.is_owner_role():
raise HTTPException(status_code=409,
detail="Lo schema di rendicontazione non e ancora stato pubblicato.")

View File

@@ -1,19 +1,153 @@
"""
Endpoint gestione schema rendicontazione per bando.
v2.1 (2026-04-18): picker multi-sorgente (blank, template predefinito, clone da altro bando).
"""
import copy
from typing import Optional
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from ..db import get_db
from ..auth import AuthUser, get_current_user, require_superadmin
from ..models import CallRemissionSchema
from ..schemas import RemissionSchemaOut, RemissionSchemaCreate, RemissionSchemaUpdate, ApiResponse
from ..templates import RESTART_TEMPLATE
from ..templates import (
RESTART_TEMPLATE,
TEMPLATES,
list_templates,
get_template,
upgrade_schema_to_v2,
)
router = APIRouter(prefix="/api/rendicontazione-schemas", tags=["rendicontazione-schemas"])
# =========================================================================
# PICKER: template predefiniti + clonable calls
# =========================================================================
@router.get("/templates", response_model=ApiResponse)
def get_available_templates(user: AuthUser = Depends(require_superadmin)):
"""Elenca i template predefiniti disponibili (blank, restart, ...).
Ritorna solo metadati (template_id, label, description) — lo schema completo e con /templates/{id}."""
return ApiResponse(data={"templates": list_templates()})
@router.get("/templates/{template_id}", response_model=ApiResponse)
def get_template_preview(template_id: str, user: AuthUser = Depends(require_superadmin)):
"""Preview dello schema completo di un template. Non crea nulla."""
schema = get_template(template_id)
if schema is None:
raise HTTPException(status_code=404, detail=f"Template '{template_id}' non trovato")
return ApiResponse(data=schema)
@router.get("/clonable-calls", response_model=ApiResponse)
def list_clonable_calls(
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Elenca i bandi che hanno gia uno schema di rendicontazione (DRAFT o PUBLISHED),
disponibili come sorgenti di clonazione. Esclude il bando ancora non deciso dal context."""
rows = db.execute(text("""
SELECT c.id AS call_id, c.name, c.status AS call_status,
s.status AS schema_status, s.created_at AS schema_created_at
FROM gepafin_rendic.call_remission_schema s
JOIN gepafin_schema.call c ON c.id = s.call_id AND c.is_deleted = false
ORDER BY s.created_at DESC
""")).mappings().all()
return ApiResponse(data={"calls": [dict(r) for r in rows]})
class SchemaInitializeRequest(BaseModel):
source: str = Field(..., description="blank | template | clone")
template_id: Optional[str] = Field(None, description="se source=template")
source_call_id: Optional[int] = Field(None, description="se source=clone")
@router.post("/{call_id}/initialize", response_model=ApiResponse)
def initialize_schema(
call_id: int,
body: SchemaInitializeRequest,
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Inizializza uno schema di rendicontazione per call_id in modalita:
- blank: schema vuoto con solo scheletro sezioni
- template: parte da template predefinito (body.template_id)
- clone: copia schema_json di un altro bando (body.source_call_id)
Lo schema risultante e sempre DRAFT. Fallisce 409 se esiste gia.
"""
# Target vuoto
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schema gia esistente per call_id={call_id}. Usa PUT per modificarlo.",
)
# Verifica che il bando target esista
target = db.execute(text("""
SELECT id, name FROM gepafin_schema.call
WHERE id = :cid AND is_deleted = false
"""), {"cid": call_id}).mappings().first()
if not target:
raise HTTPException(status_code=404, detail=f"Bando call_id={call_id} non trovato")
# Risolvi schema_json secondo source
source = (body.source or "").lower()
if source == "blank":
schema_json = get_template("blank")
msg = f"Schema vuoto inizializzato per call_id={call_id}"
elif source == "template":
tpl_id = body.template_id or ""
schema_json = get_template(tpl_id)
if schema_json is None:
raise HTTPException(status_code=422,
detail=f"template_id '{tpl_id}' non valido. Usa GET /templates per l'elenco.")
msg = f"Schema inizializzato da template '{tpl_id}' per call_id={call_id}"
elif source == "clone":
src_id = body.source_call_id
if not src_id:
raise HTTPException(status_code=422, detail="source_call_id richiesto per source=clone")
src_schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == src_id).first()
if not src_schema:
raise HTTPException(status_code=404,
detail=f"Schema sorgente non trovato per source_call_id={src_id}")
schema_json = copy.deepcopy(src_schema.schema_json)
# assicura upgrade a v2 se il sorgente era v1
schema_json = upgrade_schema_to_v2(schema_json)
msg = f"Schema clonato da call_id={src_id} per call_id={call_id}"
else:
raise HTTPException(status_code=422,
detail="source deve essere 'blank', 'template' o 'clone'")
# Persisti nuovo schema DRAFT
schema = CallRemissionSchema(
call_id=call_id,
schema_json=schema_json,
status="DRAFT",
created_by=user.user_id,
)
db.add(schema)
db.commit()
db.refresh(schema)
return ApiResponse(
message=msg,
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
)
# =========================================================================
# Endpoint esistenti (CRUD, publish, delete)
# =========================================================================
@router.get("/{call_id}", response_model=ApiResponse)
def get_schema(call_id: int, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
"""Legge lo schema di rendicontazione per un bando. 404 se non esiste."""
@@ -33,12 +167,12 @@ def create_schema(
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Crea uno schema di rendicontazione per un bando. Fallisce se esiste già."""
"""Crea uno schema di rendicontazione per un bando. Fallisce se esiste gia."""
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schema già esistente per call_id={call_id}. Usa PUT per aggiornarlo.",
detail=f"Schema gia esistente per call_id={call_id}. Usa PUT per aggiornarlo.",
)
schema = CallRemissionSchema(
call_id=call_id,
@@ -62,7 +196,7 @@ def update_schema(
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Aggiorna schema esistente. Blocca modifiche se già PUBLISHED."""
"""Aggiorna schema esistente. Blocca modifiche se gia PUBLISHED."""
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if not schema:
raise HTTPException(status_code=404, detail=f"Schema non trovato per call_id={call_id}")
@@ -87,26 +221,10 @@ def initialize_restart(
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Inizializza schema per un bando usando il template RE-START. Fallisce se esiste già."""
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schema già esistente per call_id={call_id}. Usa PUT per modificarlo.",
)
schema = CallRemissionSchema(
call_id=call_id,
schema_json=copy.deepcopy(RESTART_TEMPLATE),
status="DRAFT",
created_by=user.user_id,
)
db.add(schema)
db.commit()
db.refresh(schema)
return ApiResponse(
message=f"Schema RE-START inizializzato per call_id={call_id}",
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
)
"""DEPRECATO in 2.1 — alias di /initialize con source=template&template_id=restart.
Mantenuto per backward compatibility del FE."""
body = SchemaInitializeRequest(source="template", template_id="restart")
return initialize_schema(call_id=call_id, body=body, db=db, user=user)
@router.post("/{call_id}/publish", response_model=ApiResponse)
@@ -115,7 +233,7 @@ def publish_schema(
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Pubblica lo schema (status DRAFT -> PUBLISHED). Una volta pubblicato, non è più editabile."""
"""Pubblica lo schema (status DRAFT -> PUBLISHED). Una volta pubblicato, non e piu editabile."""
from datetime import datetime, timezone
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if not schema:
@@ -123,7 +241,7 @@ def publish_schema(
if schema.status == "PUBLISHED":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Schema già pubblicato.",
detail="Schema gia pubblicato.",
)
schema.status = "PUBLISHED"
schema.published_at = datetime.now(timezone.utc)
@@ -156,7 +274,7 @@ def delete_schema(
return ApiResponse(message=f"Schema cancellato per call_id={call_id}")
@router.get("/templates/restart", response_model=ApiResponse)
@router.get("/templates-preview/restart", response_model=ApiResponse)
def get_restart_template(user: AuthUser = Depends(require_superadmin)):
"""Restituisce il template RE-START senza persisterlo. Utile per preview."""
"""DEPRECATO: usa GET /templates/restart invece. Mantenuto per backward compat."""
return ApiResponse(data=RESTART_TEMPLATE)

View File

@@ -16,13 +16,16 @@ from sqlalchemy import text
from ..db import get_db
from ..auth import AuthUser, get_current_user
from ..models import RemissionPractice
from ..models import RemissionPractice, RemissionCustomCheckValue
from .practices import _compute_gate_check
router = APIRouter(prefix="/api/remission-practices/instructor", tags=["verbale"])
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates_jinja"
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
# URL file:// assoluto per weasyprint (che non fa HTTP fetch ma risolve file://)
LOGO_FILE_URL = f"file://{STATIC_DIR.resolve()}/gepafin-logo.svg"
# ---------- Jinja env & filters ----------
@@ -177,6 +180,50 @@ def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) ->
"""), {"uid": user.user_id}).scalar()
instructor_name = row
# v2: custom_checks merged (schema_snapshot.custom_checks[] + RemissionCustomCheckValue)
check_defs = practice.schema_snapshot.get("custom_checks") or []
values_by_code = {v.check_code: v for v in practice.custom_checks}
custom_checks_merged = []
for d in check_defs:
code = d.get("code")
val = values_by_code.get(code)
custom_checks_merged.append({
"code": code,
"label": d.get("label"),
"description": d.get("description"),
"requires_document": bool(d.get("requires_document")),
"required": bool(d.get("required")),
"beneficiary_declared": bool(val.beneficiary_declared) if val else False,
"declared_at": val.declared_at if val else None,
"has_document": bool(val and val.storage_path),
"verification_status": (val.verification_status if val else "PENDING"),
"verification_notes": (val.verification_notes if val else None),
})
# v2: storico tranche precedenti APPROVED (se sequence > 1)
previous_tranches = []
cumulative_approved = 0.0
if practice.sequence_number > 1:
prevs = db.query(RemissionPractice).filter(
RemissionPractice.application_id == practice.application_id,
RemissionPractice.sequence_number < practice.sequence_number,
RemissionPractice.status == "APPROVED",
).order_by(RemissionPractice.sequence_number).all()
for pv in prevs:
amt = float(pv.approved_remission or 0)
cumulative_approved += amt
previous_tranches.append({
"sequence_number": pv.sequence_number,
"period_label": pv.period_label,
"reviewed_at": pv.reviewed_at,
"approved_remission": amt,
"cumulative": cumulative_approved,
})
# v2 max_tranches dallo schema_snapshot (o dal bando corrente, fallback 1)
snap_rules = practice.schema_snapshot.get("gate_rules") or {}
max_tranches_snapshot = int(snap_rules.get("max_tranches") or totals.get("tranches_max") or 1)
return {
"practice": practice,
"totals": totals,
@@ -196,6 +243,11 @@ def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) ->
"company": company,
"instructor_name": instructor_name,
"generated_at": datetime.now().strftime("%d/%m/%Y"),
# v2
"custom_checks_merged": custom_checks_merged,
"previous_tranches": previous_tranches,
"max_tranches_snapshot": max_tranches_snapshot,
"logo_path": LOGO_FILE_URL,
}
@@ -234,7 +286,7 @@ def verbale_pdf(
practice, html = _render_html(db, practice_id, user)
pdf_bytes = WeasyHTML(string=html).write_pdf()
filename = f"verbale_istruttoria_pratica_{practice.application_id}.pdf"
filename = f"verbale_istruttoria_pratica_{practice.application_id}_t{practice.sequence_number}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",

158
app/scheduler.py Normal file
View File

@@ -0,0 +1,158 @@
"""Scheduler per lifecycle amendment.
Due cron job attivi:
- expire_amendments(): ogni giorno 01:05 — trova amendment AWAITING con
deadline < today, le passa a EXPIRED, rimette pratica a UNDER_REVIEW
se non ci sono altri amendment aperti. Equivalente a
ApplicationAmendmentScheduler.processAmendmentExpirationScheduler del BE.
- queue_reminders(): ogni giorno 09:00 — legge remission_expiration_config
(type='AMENDMENT', interval_days=N), trova amendment AWAITING con
deadline == today + N giorni, setta flag reminder_queued_at sul
record. Equivalente a ExpirationScheduler.processAmendmentExpiration
del BE (data-driven da expiration_config).
Le notifiche effettive (PEC reminder benef + email interna istruttore) le
invia il BE Gepafin via polling sui nostri endpoint /internal. Il
microservizio resta sender-agnostico.
"""
import logging
from datetime import datetime, timezone, date, timedelta
from typing import Optional
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.orm import Session
from sqlalchemy import text
from .db import SessionLocal
from .models import RemissionAmendmentRequest, RemissionPractice, RemissionExpirationConfig
log = logging.getLogger("rendicontazione-api.scheduler")
def expire_amendments() -> dict:
"""Espira amendment AWAITING con deadline passata.
Ritorna dict {expired_count, practices_reopened} per logging/test."""
db: Session = SessionLocal()
today = date.today()
stats = {"expired_count": 0, "practices_reopened": 0}
try:
expired_ars = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.status == "AWAITING",
RemissionAmendmentRequest.deadline < today,
).all()
practice_ids_to_check = set()
for ar in expired_ars:
ar.status = "EXPIRED"
practice_ids_to_check.add(ar.practice_id)
stats["expired_count"] += 1
log.info(f"Amendment {ar.id} EXPIRED (deadline era {ar.deadline})")
db.flush()
for pid in practice_ids_to_check:
p = db.query(RemissionPractice).filter(RemissionPractice.id == pid).first()
if not p:
continue
others_open = [
a for a in p.amendment_requests
if a.status in ("DRAFT", "AWAITING", "RESPONSE_RECEIVED")
]
if not others_open and p.status == "AWAITING_AMENDMENT":
p.status = "UNDER_REVIEW"
stats["practices_reopened"] += 1
log.info(f"Pratica {pid} ritornata a UNDER_REVIEW (amendment scaduto, nessun altro aperto)")
db.commit()
log.info(f"expire_amendments: {stats}")
except Exception as e:
db.rollback()
log.error(f"expire_amendments FAILED: {e}", exc_info=True)
raise
finally:
db.close()
return stats
def queue_reminders() -> dict:
"""Legge config data-driven, per ogni interval_days trova amendment
con deadline == today + N, scrive flag reminder_queued_at sull'amendment.
Il BE vedra questi amendment come pending-reminder via /internal.
Usiamo campo pec_retry_after come marker unificato (gia presente):
- NULL: nessun reminder accodato
- timestamp: reminder accodato in questo momento, BE invia al prossimo poll
Ritorna dict {reminders_queued_by_interval}."""
db: Session = SessionLocal()
today = date.today()
stats = {"reminders_queued": 0, "by_interval": {}}
try:
configs = db.query(RemissionExpirationConfig).filter(
RemissionExpirationConfig.type == "AMENDMENT",
RemissionExpirationConfig.is_deleted.is_(False),
).all()
for cfg in configs:
target_deadline = today + timedelta(days=cfg.interval_days)
ars = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.status == "AWAITING",
RemissionAmendmentRequest.deadline == target_deadline,
RemissionAmendmentRequest.pec_sent_at.isnot(None), # solo se gia inviata PEC iniziale
# evito di ri-accodare se gia accodato oggi
RemissionAmendmentRequest.pec_retry_after.is_(None),
).all()
for ar in ars:
ar.pec_retry_after = datetime.now(timezone.utc)
stats["reminders_queued"] += 1
log.info(f"Amendment {ar.id} reminder accodato ({cfg.interval_days}gg alla scadenza)")
stats["by_interval"][cfg.interval_days] = len(ars)
db.commit()
log.info(f"queue_reminders: {stats}")
except Exception as e:
db.rollback()
log.error(f"queue_reminders FAILED: {e}", exc_info=True)
raise
finally:
db.close()
return stats
_scheduler: Optional[BackgroundScheduler] = None
def start_scheduler():
"""Avvia BackgroundScheduler con i 2 cron. Chiamato in lifespan FastAPI."""
global _scheduler
if _scheduler is not None:
log.warning("start_scheduler chiamato due volte, skip")
return _scheduler
_scheduler = BackgroundScheduler(timezone="Europe/Rome")
# expire ogni notte 01:05 (dopo midnight, sicuro che today e cambiato)
_scheduler.add_job(
expire_amendments, CronTrigger(hour=1, minute=5),
id="expire_amendments", replace_existing=True, misfire_grace_time=3600,
)
# reminder ogni mattina 09:00
_scheduler.add_job(
queue_reminders, CronTrigger(hour=9, minute=0),
id="queue_reminders", replace_existing=True, misfire_grace_time=3600,
)
_scheduler.start()
log.info("Scheduler avviato: expire_amendments 01:05 + queue_reminders 09:00 (Europe/Rome)")
return _scheduler
def stop_scheduler():
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
log.info("Scheduler fermato")

View File

@@ -5,6 +5,7 @@ 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
@@ -117,6 +118,7 @@ class DocumentUpsert(BaseModel):
uploaded_at: Optional[datetime] = None
expires_at: Optional[date] = None
notes: Optional[str] = None
source_company_document_id: Optional[int] = None
class DocumentOut(BaseModel):
@@ -127,6 +129,7 @@ class DocumentOut(BaseModel):
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
@@ -209,10 +212,36 @@ class GateCheckResult(BaseModel):
# ====================== 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):
@@ -233,9 +262,63 @@ class AmendmentRequestOut(BaseModel):
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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -48,7 +48,7 @@ def _safe_filename(name: str, max_len: int = 120) -> str:
def save_upload(
application_id: int,
entity_type: str, # invoice | ula | document
entity_type: str, # invoice | ula | document | amendment-instructor-doc | amendment-response-doc
entity_id: UUID,
file_obj: BinaryIO,
original_filename: str,
@@ -62,7 +62,7 @@ def save_upload(
- mime in ALLOWED_MIMES (usa content_type del client, fallback su estensione)
- dimensione <= MAX_SIZE_BYTES
"""
if entity_type not in ("invoice", "ula", "document"):
if entity_type not in ("invoice", "ula", "document", "amendment-instructor-doc", "amendment-response-doc"):
raise StorageError(f"entity_type non valido: {entity_type}")
safe_name = _safe_filename(original_filename)

View File

@@ -176,3 +176,129 @@ def upgrade_schema_to_v2(schema_json: dict) -> dict:
sec["enabled"] = True
changed = True
return schema_json
# =========================================================================
# BLANK_TEMPLATE — scheletro minimo v2, solo sezioni vuote da popolare
# =========================================================================
BLANK_TEMPLATE = {
"version": "2.0",
"schema_version": 2,
"template_id": "BLANK_V2",
"template_label": "Nuovo schema vuoto",
"sections": [
{
"type": "static_fields",
"id": "general",
"label": "Dati generali",
"description": "Regime IVA, dati base del beneficiario, periodo di ammissibilita delle spese.",
"fields": [
{
"id": "period_start_date",
"type": "date",
"label": "Periodo ammissibilita — Data inizio",
"description": "Data minima di emissione/pagamento fatture ammissibili.",
"editable_by": "superadmin",
"required": True,
},
{
"id": "period_end_date",
"type": "date",
"label": "Periodo ammissibilita — Data fine",
"description": "Data massima di emissione/pagamento fatture ammissibili.",
"editable_by": "superadmin",
"required": True,
},
{
"id": "iva_regime",
"type": "select",
"label": "Regime IVA",
"required": True,
"options": [
{"value": "ORDINARIO", "label": "Ordinario — IVA non ammissibile"},
{"value": "FORFETTARIO", "label": "Forfettario — IVA ammissibile"},
{"value": "ESENTE", "label": "Esente"},
],
"help": "Il regime IVA determina se l'IVA delle fatture e rendicontabile.",
},
],
},
{
"type": "invoice_table",
"id": "invoices",
"label": "Fatture ammissibili",
"description": "Categorie di spesa da configurare. Aggiungi almeno una categoria prima di pubblicare.",
"categories": [],
},
{
"type": "ula_block",
"id": "ula",
"label": "Incremento occupazione (ULA)",
"description": "Dipendenti su cui calcolare l'incremento ULA. Disattiva la sezione se il bando non lo richiede.",
"enabled": False,
"threshold": 1.0,
"fields": [],
},
{
"type": "documents_required",
"id": "docs",
"label": "Documenti richiesti",
"description": "Documenti che il beneficiario deve allegare alla rendicontazione. Aggiungi almeno i documenti obbligatori.",
"items": [],
},
],
"custom_checks": [],
"gate_rules": {
"invoices_min_count": 1,
"amount_range": {"min": 0, "max": 100000},
"cap_pct_erogato": 0.5,
"cap_absolute": 100000,
"amount_basis": "imponibile_only_ordinario",
"period_start_rule": "erogato_date",
"period_end": None,
"require_at_least_one_invoice_per_nonzero_category": True,
"require_ula_above_threshold": False,
"require_all_documents_resolved": True,
"max_tranches": 1,
},
}
# =========================================================================
# TEMPLATES registry — esteso in futuro con nuovi bandi
# =========================================================================
TEMPLATES = {
"blank": {
"template_id": "blank",
"label": "Nuovo schema (da zero)",
"description": "Scheletro minimo: sezioni vuote (categorie, documenti, controlli) da popolare. Usa questo quando il bando e nuovo e non somiglia a bandi precedenti.",
"schema": BLANK_TEMPLATE,
},
"restart": {
"template_id": "restart",
"label": "RE-START (fondo prestiti con remissione del debito)",
"description": "Template del bando RE-START: 3 categorie B1/B2/B3 (tecnologie, ULA, formazione), sezione ULA attiva con soglia 1.0, 4 documenti standard, max 2 tranches.",
"schema": RESTART_TEMPLATE,
},
}
def list_templates():
"""Restituisce i template disponibili (senza lo schema completo, solo metadati)."""
return [
{
"template_id": t["template_id"],
"label": t["label"],
"description": t["description"],
}
for t in TEMPLATES.values()
]
def get_template(template_id: str):
"""Restituisce uno schema template pronto per l'uso (deep copy)."""
import copy
t = TEMPLATES.get(template_id)
if not t:
return None
return copy.deepcopy(t["schema"])

View File

@@ -50,8 +50,8 @@
padding-bottom: 10pt;
margin-bottom: 14pt;
}
.hdr__logo {
font-size: 22pt; font-weight: 900; color: #1a365d; letter-spacing: 1pt;
.hdr__logo-img {
height: 38pt; width: auto;
display: inline-block;
}
.hdr__subtitle {
@@ -182,10 +182,11 @@
<div class="hdr__right">
<div><strong>Verbale di istruttoria</strong></div>
<div>Rendicontazione bando</div>
<div>Pratica n. {{ practice.application_id }}</div>
<div>Pratica n. {{ practice.application_id }}{% if max_tranches_snapshot > 1 or practice.sequence_number > 1 %} — Tranche {{ practice.sequence_number }}/{{ max_tranches_snapshot }}{% endif %}</div>
{% if practice.period_label %}<div><small>{{ practice.period_label }}</small></div>{% endif %}
</div>
<div>
<span class="hdr__logo">GEPAFIN</span>
<img src="{{ logo_path }}" alt="Gepafin" class="hdr__logo-img" />
<div class="hdr__subtitle">Finanziaria regionale dell'Umbria</div>
</div>
</div>
@@ -237,6 +238,15 @@
{% else %}—{% endif %}
</div>
</div>
{% if max_tranches_snapshot > 1 %}
<div class="row">
<div class="cell label">Tranche / fase</div>
<div class="cell val">
<strong>Tranche {{ practice.sequence_number }} di {{ max_tranches_snapshot }}</strong>
{% if practice.period_label %} — {{ practice.period_label }}{% endif %}
</div>
</div>
{% endif %}
<div class="row">
<div class="cell label">Data presentazione</div>
<div class="cell val">{{ practice.submitted_at|datetimefmt if practice.submitted_at else '—' }}</div>
@@ -387,6 +397,45 @@
<p class="text-secondary">Nessun documento richiesto dallo schema del bando.</p>
{% endif %}
{# ============ CONTROLLI AGGIUNTIVI ============ #}
{% if custom_checks_merged %}
<h2>Controlli aggiuntivi (dichiarazioni beneficiario)</h2>
<table class="data">
<thead>
<tr>
<th style="width:32%">Controllo</th>
<th style="width:12%">Obbligatorio</th>
<th style="width:10%">Dichiarato</th>
<th style="width:11%">Doc. allegato</th>
<th style="width:11%">Validazione</th>
<th style="width:24%">Note istruttore</th>
</tr>
</thead>
<tbody>
{% for cc in custom_checks_merged %}
{% set stat = cc.verification_status or 'PENDING' %}
{% set cls = 'rejected' if stat == 'NON_VALIDO' else '' %}
{% set missing = cc.required and not cc.beneficiary_declared %}
<tr class="{{ 'rejected' if missing else cls }}">
<td>
<strong>{{ cc.label or cc.code }}</strong>
{% if cc.description %}<br><small>{{ cc.description|truncate(180) }}</small>{% endif %}
</td>
<td>{% if cc.required %}<span class="ko"></span>{% else %}<small class="text-secondary">opzionale</small>{% endif %}</td>
<td>{% if cc.beneficiary_declared %}<span class="ok"></span>{% else %}<span class="ko">NO</span>{% endif %}</td>
<td>
{% if cc.requires_document %}
{% if cc.has_document %}<span class="ok"></span>{% else %}<span class="ko">NO</span>{% endif %}
{% else %}<small class="text-secondary">non richiesto</small>{% endif %}
</td>
<td><span class="status-inline status-{{ stat }}">{{ stat }}</span></td>
<td>{{ cc.verification_notes or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# ============ SOCCORSI ============ #}
{% if amendments %}
<h2>Soccorso istruttorio</h2>
@@ -406,42 +455,93 @@
{% endfor %}
{% endif %}
{# ============ TOTALI ============ #}
<h2>Riepilogo finanziario</h2>
{# ============ STORICO TRANCHES PRECEDENTI ============ #}
{% if previous_tranches %}
<h2>Storico tranches precedenti</h2>
<table class="data">
<thead>
<tr>
<th style="width:8%">#</th>
<th style="width:35%">Periodo / fase</th>
<th style="width:17%">Data approvazione</th>
<th style="width:20%" class="num">Importo ammesso</th>
<th style="width:20%" class="num">Cumulativo</th>
</tr>
</thead>
<tbody>
{% for t in previous_tranches %}
<tr>
<td><strong>T{{ t.sequence_number }}</strong></td>
<td>{{ t.period_label or '—' }}</td>
<td>{{ t.reviewed_at|datefmt }}</td>
<td class="num">{{ t.approved_remission|euro }}</td>
<td class="num"><strong>{{ t.cumulative|euro }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# ============ 5 VOCI UFFICIALI CECILIA ============ #}
<h2>Riepilogo finanziario (cap tranche {{ practice.sequence_number }})</h2>
<div class="totals-summary">
<div class="row">
<div class="cell">
<div class="lbl">Totale dichiarato</div>
<div class="val">{{ totals.grand_total_declared|euro }}</div>
</div>
<div class="cell">
<div class="lbl">Totale ammesso</div>
<div class="val">{{ totals.grand_total_verified|euro }}</div>
</div>
<div class="cell">
<div class="lbl">Cap remissione</div>
<div class="val">{{ totals.max_remission|euro }}</div>
</div>
<div class="cell">
<div class="lbl">Remissione spettante</div>
<div class="val final">{{ totals.remission_due|euro }}</div>
</div>
</div>
{% if practice.status == 'APPROVED' %}
<div class="row">
<div class="cell" style="background: #f0fff4;">
<div class="lbl">Remissione approvata</div>
<div class="val" style="color: #22543d;">{{ practice.approved_remission|euro }}</div>
</div>
<div class="cell" style="background: #fff5f5;">
<div class="lbl">Residuo da restituire</div>
<div class="val residuo">{{ (practice.amount_erogato - (practice.approved_remission or 0))|euro }}</div>
</div>
<div class="cell" colspan="2"></div>
<div class="cell"></div>
</div>
<div class="cell" style="width:50%">
<div class="lbl">(1) Importo massimo ammissibile (cap globale)</div>
<div class="val">{{ totals.max_remission_global|euro }}</div>
{% if totals.already_approved_previous_tranches > 0 %}
<div class="lbl" style="margin-top:4pt">già approvato nelle tranche precedenti</div>
<div style="font-size:10pt; font-weight:700; color:#744210"> {{ totals.already_approved_previous_tranches|euro }}</div>
<div class="lbl" style="margin-top:4pt">max. disponibile per questa tranche</div>
<div style="font-size:11pt; font-weight:700; color:#2b6cb0">= {{ totals.max_remission_this_tranche|euro }}</div>
{% endif %}
</div>
<div class="cell" style="width:50%">
<div class="lbl">(4) Importo finanziamento erogato</div>
<div class="val">{{ totals.amount_erogato|euro }}</div>
<div class="lbl" style="margin-top:6pt">tranches complessive</div>
<div style="font-size:10pt">{{ totals.tranches_count }} / {{ totals.tranches_max }}</div>
</div>
</div>
</div>
<div class="totals-summary" style="margin-top:8pt">
<div class="row">
<div class="cell" style="width:33%">
<div class="lbl">(2) Richiesto pre-controllo (ammissibile)</div>
<div class="val">{{ totals.pre_check_admissible|euro }}</div>
<div class="lbl" style="margin-top:4pt">dichiarato tranche</div>
<div style="font-size:9pt">{{ totals.grand_total_declared|euro }}</div>
</div>
<div class="cell" style="width:33%">
<div class="lbl">(3) Ammesso post-controllo istruttore</div>
<div class="val final">{{ totals.remission_due|euro }}</div>
{% if totals.any_verified %}
<div class="lbl" style="margin-top:4pt">verificato tranche</div>
<div style="font-size:9pt">{{ totals.grand_total_verified|euro }}</div>
{% else %}
<div class="lbl" style="margin-top:4pt"><em>in attesa di verifica istruttore</em></div>
{% endif %}
</div>
<div class="cell" style="width:34%; background:#fff5f5">
<div class="lbl">(5) Residuo da restituire</div>
<div class="val residuo">{{ totals.residuo_da_restituire|euro }}</div>
<div class="lbl" style="margin-top:4pt">= erogato approvato precedente ammesso tranche</div>
</div>
</div>
</div>
{% if practice.status == 'APPROVED' %}
<div class="totals-summary" style="margin-top:8pt; background:#f0fff4; border-color:#68d391">
<div class="row">
<div class="cell" style="width:100%">
<div class="lbl">REMISSIONE APPROVATA PER QUESTA TRANCHE</div>
<div class="val" style="color:#22543d; font-size:18pt">{{ practice.approved_remission|euro }}</div>
</div>
</div>
</div>
{% endif %}
{# ============ CHECKLIST + NOTE ============ #}
{% set checklist = practice.instructor_checklist or {} %}

View File

@@ -9,3 +9,4 @@ python-multipart==0.0.9
weasyprint==61.2
pydyf==0.10.0
jinja2==3.1.3
APScheduler==3.10.4