Compare commits

...

13 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
BFLOWS
c19b2aa0b1 feat(v2): seed scenario napoli-sas-multi + main include routers
A7 scripts/seed_sandbox.py:
- ensure_assigned_application() popola gepafin_schema.assigned_applications
- scenario napoli-sas-multi: tranche 1 APPROVED + tranche 2 DRAFT vuota
  Tranche 1 caso reale Cecilia: 1 fattura B3 524.50 EUR con rettifica 57.36 EUR
  (storno assicurazione), ammesso 467.14 EUR. 1 ULA T_IND AMMESSA.
  4 documenti VALIDO. 2 custom_checks VALIDO (antiriciclaggio + polizza con PDF).
  Tranche 2 DRAFT: assigned_instructor_id=NULL (simula workflow capo)
- TRUNCATE include remission_custom_check_value (CASCADE gia la gestiva)

main.py: include router custom_checks + assignment, version bump 0.4.0

Test: seed --reset --scenario=napoli-sas-multi -> 2 tranche create in 6s,
PDF polizza 10KB generato in custom_checks/<T1>/polizza_fidejussoria/.
2026-04-18 17:35:56 +02:00
BFLOWS
86681678c4 feat(v2): endpoint multi-tranche + custom_checks + assignment manager
A4 /mine + /start + /copy-ula-options (practices.py):
- GET /mine raggruppa per application_id, ogni app ha:
  tranches[], max_tranches, can_start_new, start_blocked_reason,
  already_approved_sum, max_remission_global, max_remission_next_tranche
- POST /start valida: count<max, last terminale, residuo>0 -> 400 con detail parlante
- Bulk copy ULA da tranche N-1 se copy_ula_from_previous=true (reset verification_*)
- Legge suggested_instructor da gepafin_schema.assigned_applications (solo tranche 1)
- upgrade_schema_to_v2 al snapshot per allineare a v2 schemi vecchi
- GET /{id}/copy-ula-options: preview ULA tranche N-1 per pre-fill FE

A5 custom_checks.py (nuovo router):
- GET /{id}/custom-checks merge definition+values con defaults
- PUT /.../declare (beneficiary form-data + optional file upload 15MB, PDF/JPG/PNG)
  storage dedicato /var/uploads/custom_checks/{practice_id}/{code}/<sha12>-file
- DELETE /.../document (beneficiary) reset metadata + cleanup FS
- PUT /.../verify (istruttore) VALIDO/NON_VALIDO/PENDING + notes
- GET /.../document?inline=0|1 stream con Content-Disposition
- Matrix autorizzazioni: declare solo benef su DRAFT/AWAITING, verify solo istruttore

A6 assignment.py (nuovo router, manager view):
- GET /instructor-manager/assignments: pratiche attive con suggested+assigned+is_unassigned
- GET /instructor-manager/instructors: elenco PRE_INSTRUCTOR+MANAGER per dropdown riassegna
- POST /instructor/{id}/reassign: cambio assigned_instructor_id + audit log in
  instructor_checklist.reassignment_log [{at,by_user_id,from,to,reason}]
- Solo ROLE_INSTRUCTOR_MANAGER + ROLE_SUPER_ADMIN

Test curl E2E tutti passati: tranche 2 creata con copy 2 ULA, check dichiarati+verificati,
download PDF polizza OK, reassign con audit log scritto.
2026-04-18 17:35:56 +02:00
BFLOWS
25215f388b feat(v2): multi-tranche DB schema + gate cumulativo 5 voci Cecilia
A1 migrations.py:
- remission_practice DROP uq_application + ADD sequence_number/period_label/suggested_instructor_id
- UNIQUE composita (application_id, sequence_number)
- partial index idx_remission_practice_unassigned su assigned_instructor_id NULL
- nuova tabella remission_custom_check_value (storage_path/mime/size/sha256 allineata adapter)

A2 models.py + templates.py:
- RemissionPractice: UniqueConstraint composita, campi multi-tranche, relationship custom_checks
- classe RemissionCustomCheckValue
- RESTART_TEMPLATE schema_version=2, max_tranches=2, custom_checks esempio
  (antiriciclaggio required no-doc, polizza_fidejussoria optional con-doc)
- upgrade_schema_to_v2 idempotente per snapshot v1 esistenti

A3 _compute_gate_check(db, practice) CUMULATIVO:
- max_remission_global = min(cap_pct * erogato, cap_abs)
- already_approved = func.sum(approved_remission) su tranche APPROVED precedenti
  dello stesso application_id con sequence_number < corrente
- max_remission_this_tranche = max(0, global - already_approved)
- pre_check_admissible = min(grand_total_declared, this_tranche)  [voce 2 Cecilia]
- remission_due = min(effective_total, this_tranche)
- residuo_da_restituire = erogato - already_approved - remission_due (cumulativo)
- output totals esteso: sequence_number, tranches_count, tranches_max
- signature (db, practice) - aggiornati 6 call site in practices/instructor/verbale

Test su NAPOLI SAS: erogato 17K, cap 8500, tranche 1 approvata 467.14EUR,
tranche 2 vuota -> residuo disponibile 8032.86EUR, residuo_da_restituire 16532.86EUR.
2026-04-18 17:35:56 +02:00
21 changed files with 2754 additions and 166 deletions

View File

@@ -3,7 +3,7 @@ JWT validation compatibile con GEPAFIN-BE.
Il BE Spring emette token HS512 con payload: Il BE Spring emette token HS512 con payload:
sub: "email:userId:hubId" sub: "email:userId:hubId"
userId: int userId: int
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | ... auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | "ROLE_CONFIDI" | ...
exp: unix timestamp exp: unix timestamp
loginAttemptId: int loginAttemptId: int
""" """
@@ -29,6 +29,15 @@ class AuthUser:
def is_beneficiary(self) -> bool: def is_beneficiary(self) -> bool:
return self.role == "ROLE_BENEFICIARY" 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( def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),

View File

@@ -18,6 +18,10 @@ class Settings(BaseSettings):
# CORS # CORS
cors_origins: str = "http://78.46.41.91:18072,http://localhost:18072" 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: class Config:
env_file = ".env" env_file = ".env"
env_prefix = "RENDIC_" env_prefix = "RENDIC_"

View File

@@ -15,7 +15,8 @@ from sqlalchemy import text
from .config import get_settings from .config import get_settings
from .db import engine, Base from .db import engine, Base
from .migrations import run_migrations from .migrations import run_migrations
from .routers import health, schemas, practices, debug, instructor, files, verbale 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") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("rendicontazione-api") log = logging.getLogger("rendicontazione-api")
@@ -32,17 +33,19 @@ async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_migrations(engine) run_migrations(engine)
log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK") log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK")
start_scheduler()
except Exception as e: except Exception as e:
log.error(f"Errore bootstrap DB: {e}") log.error(f"Errore bootstrap DB: {e}")
raise raise
yield yield
stop_scheduler()
log.info("Shutdown rendicontazione-api") log.info("Shutdown rendicontazione-api")
app = FastAPI( app = FastAPI(
title="rendicontazione-api", title="rendicontazione-api",
description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS", description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS",
version="0.3.0", version="0.4.0",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -61,13 +64,16 @@ app.include_router(debug.router)
app.include_router(instructor.router) app.include_router(instructor.router)
app.include_router(files.router) app.include_router(files.router)
app.include_router(verbale.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"]) @app.get("/", tags=["root"])
def root(): def root():
return { return {
"service": "rendicontazione-api", "service": "rendicontazione-api",
"version": "0.3.0", "version": "0.4.0",
"docs": "/docs", "docs": "/docs",
"health": "/health", "health": "/health",
} }

View File

@@ -41,6 +41,144 @@ MIGRATIONS = [
ADD COLUMN IF NOT EXISTS sha256 varchar(64), ADD COLUMN IF NOT EXISTS sha256 varchar(64),
ADD COLUMN IF NOT EXISTS uploaded_by integer; ADD COLUMN IF NOT EXISTS uploaded_by integer;
""", """,
# 2026-04-18 v2: multi-tranche su remission_practice
# DROP UNIQUE su application_id (permette piu tranche per stessa domanda)
# aggiunge sequence_number, period_label, suggested_instructor_id
# nuova UNIQUE (application_id, sequence_number)
# partial index su assigned_instructor_id IS NULL per coda "da assegnare"
"""
ALTER TABLE gepafin_rendic.remission_practice
DROP CONSTRAINT IF EXISTS uq_remission_practice_application;
ALTER TABLE gepafin_rendic.remission_practice
ADD COLUMN IF NOT EXISTS sequence_number integer NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS period_label varchar(100),
ADD COLUMN IF NOT EXISTS suggested_instructor_id integer;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_remission_practice_app_seq'
AND conrelid = 'gepafin_rendic.remission_practice'::regclass
) THEN
ALTER TABLE gepafin_rendic.remission_practice
ADD CONSTRAINT uq_remission_practice_app_seq UNIQUE (application_id, sequence_number);
END IF;
END$$;
CREATE INDEX IF NOT EXISTS idx_remission_practice_unassigned
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)
"""
CREATE TABLE IF NOT EXISTS gepafin_rendic.remission_custom_check_value (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
practice_id uuid NOT NULL REFERENCES gepafin_rendic.remission_practice(id) ON DELETE CASCADE,
check_code varchar(64) NOT NULL,
beneficiary_declared boolean NOT NULL DEFAULT false,
declared_at timestamptz,
storage_path varchar(1024),
mime varchar(128),
size_bytes bigint,
sha256 varchar(64),
document_uploaded_at timestamptz,
uploaded_by integer,
verification_status varchar(20) NOT NULL DEFAULT 'PENDING',
verification_notes text,
verified_by integer,
verified_at timestamptz,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
CONSTRAINT uq_custom_check_practice_code UNIQUE (practice_id, check_code)
);
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

@@ -43,13 +43,14 @@ class RemissionPractice(Base):
""" """
__tablename__ = "remission_practice" __tablename__ = "remission_practice"
__table_args__ = ( __table_args__ = (
UniqueConstraint("application_id", name="uq_remission_practice_application"), UniqueConstraint("application_id", "sequence_number",
name="uq_remission_practice_app_seq"),
{"schema": "gepafin_rendic"}, {"schema": "gepafin_rendic"},
) )
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
call_id = Column(Integer, nullable=False) call_id = Column(Integer, nullable=False)
application_id = Column(Integer, nullable=False, unique=True) application_id = Column(Integer, nullable=False) # unique (application_id, sequence_number)
company_id = Column(Integer, nullable=False) company_id = Column(Integer, nullable=False)
user_id = Column(Integer, nullable=False) # beneficiario che compila user_id = Column(Integer, nullable=False) # beneficiario che compila
@@ -61,6 +62,11 @@ class RemissionPractice(Base):
amount_erogato = Column(Numeric(14, 2), nullable=False) # copiato da application.amount_accepted amount_erogato = Column(Numeric(14, 2), nullable=False) # copiato da application.amount_accepted
notes_beneficiario = Column(Text, nullable=True) notes_beneficiario = Column(Text, nullable=True)
# Multi-tranche v2 (2026-04-18)
sequence_number = Column(Integer, nullable=False, default=1)
period_label = Column(String(100), nullable=True) # libero, es "I trimestre 2021"
suggested_instructor_id = Column(Integer, nullable=True) # letto da BE assigned_applications
# colonne istruttoria # colonne istruttoria
assigned_instructor_id = Column(Integer, nullable=True) assigned_instructor_id = Column(Integer, nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True) reviewed_at = Column(DateTime(timezone=True), nullable=True)
@@ -80,6 +86,7 @@ class RemissionPractice(Base):
ula_employees = relationship("RemissionUlaEmployee", back_populates="practice", cascade="all, delete-orphan") ula_employees = relationship("RemissionUlaEmployee", back_populates="practice", cascade="all, delete-orphan")
documents = relationship("RemissionDocument", back_populates="practice", cascade="all, delete-orphan") documents = relationship("RemissionDocument", back_populates="practice", cascade="all, delete-orphan")
amendment_requests = relationship("RemissionAmendmentRequest", back_populates="practice", cascade="all, delete-orphan") amendment_requests = relationship("RemissionAmendmentRequest", back_populates="practice", cascade="all, delete-orphan")
custom_checks = relationship("RemissionCustomCheckValue", back_populates="practice", cascade="all, delete-orphan")
class RemissionInvoice(Base): class RemissionInvoice(Base):
@@ -192,6 +199,11 @@ class RemissionDocument(Base):
sha256 = Column(String(64), nullable=True) sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, 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 # Campi istruttoria
verification_status = Column(String(16), nullable=False, default="PENDING") verification_status = Column(String(16), nullable=False, default="PENDING")
# PENDING | VALIDO | NON_VALIDO | SCADUTO # PENDING | VALIDO | NON_VALIDO | SCADUTO
@@ -223,7 +235,86 @@ class RemissionAmendmentRequest(Base):
closed_at = Column(DateTime(timezone=True), nullable=True) closed_at = Column(DateTime(timezone=True), nullable=True)
closed_by = Column(Integer, 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()) 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()) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
practice = relationship("RemissionPractice", back_populates="amendment_requests") 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.
Qui salviamo dichiarazione beneficiario + eventuale documento + verifica istruttore.
"""
__tablename__ = "remission_custom_check_value"
__table_args__ = (
UniqueConstraint("practice_id", "check_code", name="uq_custom_check_practice_code"),
{"schema": "gepafin_rendic"},
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
practice_id = Column(UUID(as_uuid=True),
ForeignKey("gepafin_rendic.remission_practice.id", ondelete="CASCADE"),
nullable=False)
check_code = Column(String(64), nullable=False) # es "antiriciclaggio", "polizza_fidejussoria"
# Dichiarazione beneficiario
beneficiary_declared = Column(Boolean, nullable=False, default=False)
declared_at = Column(DateTime(timezone=True), nullable=True)
# Documento allegato (se requires_document)
storage_path = Column(String(1024), nullable=True)
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
document_uploaded_at = Column(DateTime(timezone=True), nullable=True)
uploaded_by = Column(Integer, nullable=True)
# Verifica istruttore
verification_status = Column(String(20), nullable=False, default="PENDING")
# PENDING | VALIDO | NON_VALIDO
verification_notes = Column(Text, nullable=True)
verified_by = Column(Integer, nullable=True)
verified_at = 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="custom_checks")

184
app/routers/assignment.py Normal file
View File

@@ -0,0 +1,184 @@
"""
Endpoint v2 per gestione assegnazione istruttori (capo istruttore / manager).
Solo ROLE_INSTRUCTOR_MANAGER + ROLE_SUPER_ADMIN.
"""
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..db import get_db
from ..auth import AuthUser, get_current_user
from ..models import RemissionPractice
from ..schemas import ApiResponse, PracticeReassignBody
router = APIRouter(
prefix="/api/remission-practices",
tags=["assignment-manager"],
)
def _require_manager(user: AuthUser = Depends(get_current_user)) -> AuthUser:
if user.role not in ("ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN"):
raise HTTPException(
status_code=403,
detail="Richiesto ruolo manager o superadmin"
)
return user
@router.get("/instructor-manager/assignments", response_model=ApiResponse)
def assignments_overview(
db: Session = Depends(get_db),
manager: AuthUser = Depends(_require_manager),
):
"""Vista capo istruttore: pratiche con suggested + assigned + flag 'da assegnare'."""
# 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()
# Enrichment: nome istruttori + company
items = []
user_cache: dict = {}
def _user_name(uid: Optional[int]) -> Optional[str]:
if uid is None:
return None
if uid in user_cache:
return user_cache[uid]
row = db.execute(text("""
SELECT first_name || ' ' || last_name as name, email
FROM gepafin_schema.gepafin_user WHERE id = :uid
"""), {"uid": uid}).mappings().first()
name = (row["name"] if row else None) or (row["email"] if row else None)
user_cache[uid] = name
return name
for p in practices:
company_row = db.execute(text("""
SELECT company_name, vat_number FROM gepafin_schema.company WHERE id = :cid
"""), {"cid": p.company_id}).mappings().first()
call_row = db.execute(text("""
SELECT name FROM gepafin_schema.call WHERE id = :cid
"""), {"cid": p.call_id}).mappings().first()
items.append({
"id": str(p.id),
"application_id": p.application_id,
"sequence_number": p.sequence_number,
"period_label": p.period_label,
"call_id": p.call_id,
"call_name": call_row["name"] if call_row else None,
"company_id": p.company_id,
"company_name": company_row["company_name"] if company_row else None,
"status": p.status,
"submitted_at": p.submitted_at.isoformat() if p.submitted_at else None,
"amount_erogato": float(p.amount_erogato or 0),
"suggested_instructor_id": p.suggested_instructor_id,
"suggested_instructor_name": _user_name(p.suggested_instructor_id),
"assigned_instructor_id": p.assigned_instructor_id,
"assigned_instructor_name": _user_name(p.assigned_instructor_id),
"is_unassigned": p.assigned_instructor_id is None,
})
return ApiResponse(data={"assignments": items})
@router.get("/instructor-manager/instructors", response_model=ApiResponse)
def list_available_instructors(
db: Session = Depends(get_db),
manager: AuthUser = Depends(_require_manager),
):
"""Elenco istruttori disponibili per riassegnazione (pre_instructor + manager ACTIVE)."""
rows = db.execute(text("""
SELECT u.id, u.email, u.first_name, u.last_name, r.role_type
FROM gepafin_schema.gepafin_user u
JOIN gepafin_schema.role r ON r.id = u.role_id
WHERE u.is_deleted = false
AND r.role_type IN ('ROLE_PRE_INSTRUCTOR', 'ROLE_INSTRUCTOR_MANAGER')
ORDER BY u.last_name, u.first_name
""")).mappings().all()
return ApiResponse(data={"instructors": [
{
"user_id": r["id"],
"email": r["email"],
"first_name": r["first_name"],
"last_name": r["last_name"],
"role_type": r["role_type"],
"display_name": f"{r['first_name'] or ''} {r['last_name'] or ''}".strip() or r["email"],
} for r in rows
]})
@router.post("/instructor/{practice_id}/reassign", response_model=ApiResponse)
def reassign_instructor(
practice_id: UUID,
body: PracticeReassignBody,
db: Session = Depends(get_db),
manager: AuthUser = Depends(_require_manager),
):
"""Manager assegna/riassegna la pratica a un istruttore diverso (o unassign se new_instructor_id=None).
Scrive audit entry in instructor_checklist.reassignment_log.
"""
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
if not p:
raise HTTPException(status_code=404, detail="Pratica non trovata")
old_instructor_id = p.assigned_instructor_id
# Verifica nuovo istruttore se specificato
if body.new_instructor_id is not None:
row = db.execute(text("""
SELECT u.id, r.role_type
FROM gepafin_schema.gepafin_user u
JOIN gepafin_schema.role r ON r.id = u.role_id
WHERE u.id = :uid AND u.is_deleted = false
"""), {"uid": body.new_instructor_id}).mappings().first()
if not row:
raise HTTPException(status_code=404,
detail=f"Istruttore {body.new_instructor_id} non trovato")
if row["role_type"] not in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER"):
raise HTTPException(status_code=422,
detail="Utente non ha ruolo istruttore")
# Audit log
checklist = dict(p.instructor_checklist or {})
log = list(checklist.get("reassignment_log") or [])
log.append({
"at": datetime.now(timezone.utc).isoformat(),
"by_user_id": manager.user_id,
"by_email": manager.email,
"from_instructor_id": old_instructor_id,
"to_instructor_id": body.new_instructor_id,
"reason": body.reassignment_reason,
})
checklist["reassignment_log"] = log
p.assigned_instructor_id = body.new_instructor_id
p.instructor_checklist = checklist
# Se passo da SUBMITTED + assegnato -> UNDER_REVIEW
# Altrimenti lascio status invariato (manager puo riassegnare anche durante review)
if p.status == "SUBMITTED" and body.new_instructor_id is not None:
p.status = "UNDER_REVIEW"
db.commit()
db.refresh(p)
action = "unassigned" if body.new_instructor_id is None else f"assigned to {body.new_instructor_id}"
return ApiResponse(
message=f"Pratica {action}",
data={
"id": str(p.id),
"status": p.status,
"assigned_instructor_id": p.assigned_instructor_id,
"suggested_instructor_id": p.suggested_instructor_id,
"reassignment_log": log,
}
)

View File

@@ -0,0 +1,319 @@
"""
Endpoint custom_checks v2: dichiarazione beneficiario + documento opzionale + verifica istruttore.
Merge definition (da schema_snapshot.custom_checks[]) + value (RemissionCustomCheckValue).
Path storage custom_checks: /var/uploads/custom_checks/{practice_id}/{code}/<sha12>-file.pdf
(fuori dal pattern invoice/ula/document per isolarli — non confondibili con allegati fattura/LUL).
"""
import io
import os
from datetime import datetime, timezone
from typing import List, Optional, Literal
from uuid import UUID
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from ..db import get_db
from ..auth import AuthUser, get_current_user
from ..models import RemissionPractice, RemissionCustomCheckValue
from ..schemas import ApiResponse, CustomCheckOut, CustomCheckVerifyBody
from ..storage import (
save_upload, delete_file, open_file,
FileTooLargeError, MimeNotAllowedError, StorageError, BASE_PATH,
)
router = APIRouter(prefix="/api/remission-practices", tags=["custom-checks"])
def _is_instructor(user: AuthUser) -> bool:
return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
def _get_practice(db: Session, practice_id: UUID, user: AuthUser) -> RemissionPractice:
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
if not p:
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
# Autorizzazione base: beneficiario owner o istruttore
if user.is_owner_role() and p.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Accesso negato")
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_owner_role():
return False
if practice.user_id != user.user_id:
return False
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
def _can_verify(user: AuthUser, practice: RemissionPractice) -> bool:
if not _is_instructor(user):
return False
return practice.status in ("UNDER_REVIEW", "AWAITING_AMENDMENT")
def _schema_check_defs(practice: RemissionPractice) -> List[dict]:
return practice.schema_snapshot.get("custom_checks") or []
def _merge_check(definition: dict, value: Optional[RemissionCustomCheckValue]) -> dict:
out = {
"code": definition.get("code"),
"label": definition.get("label"),
"description": definition.get("description"),
"requires_document": bool(definition.get("requires_document", False)),
"required": bool(definition.get("required", False)),
# valori default
"beneficiary_declared": False,
"declared_at": None,
"filename_original": None,
"storage_path": None,
"size_bytes": None,
"document_uploaded_at": None,
"verification_status": "PENDING",
"verification_notes": None,
"verified_by": None,
"verified_at": None,
}
if value is not None:
out.update({
"beneficiary_declared": value.beneficiary_declared,
"declared_at": value.declared_at,
"storage_path": value.storage_path,
"size_bytes": value.size_bytes,
"document_uploaded_at": value.document_uploaded_at,
"verification_status": value.verification_status,
"verification_notes": value.verification_notes,
"verified_by": value.verified_by,
"verified_at": value.verified_at,
})
# filename originale ricostruito dal path (dopo il sha12-)
if value.storage_path:
basename = Path(value.storage_path).name
# formato: <sha12>-<original>
parts = basename.split("-", 1)
out["filename_original"] = parts[1] if len(parts) == 2 else basename
return out
def _get_or_create_value(db: Session, practice_id: UUID, code: str) -> RemissionCustomCheckValue:
v = db.query(RemissionCustomCheckValue).filter(
RemissionCustomCheckValue.practice_id == practice_id,
RemissionCustomCheckValue.check_code == code,
).first()
if not v:
v = RemissionCustomCheckValue(practice_id=practice_id, check_code=code)
db.add(v)
db.flush()
return v
# ---------- endpoints ----------
@router.get("/{practice_id}/custom-checks", response_model=ApiResponse)
def list_custom_checks(practice_id: UUID, db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user)):
"""Ritorna i custom_checks della pratica: schema definition + valori correnti."""
p = _get_practice(db, practice_id, user)
defs = _schema_check_defs(p)
values_by_code = {v.check_code: v for v in p.custom_checks}
out = [_merge_check(d, values_by_code.get(d.get("code"))) for d in defs]
return ApiResponse(data={"custom_checks": out})
@router.put("/{practice_id}/custom-checks/{code}/declare", response_model=ApiResponse)
async def declare_custom_check(
practice_id: UUID,
code: str,
beneficiary_declared: bool = Form(...),
file: Optional[UploadFile] = File(None),
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Beneficiario dichiara il check (bool) e opzionalmente allega un documento.
Se requires_document=true nello schema, l'upload e raccomandato ma non imposto
lato server (la required-ness e un gate su /submit)."""
p = _get_practice(db, practice_id, user)
if not _can_declare(user, p):
raise HTTPException(
status_code=403,
detail="Solo beneficiario owner su pratica DRAFT o AWAITING_AMENDMENT puo dichiarare"
)
defs = {d["code"]: d for d in _schema_check_defs(p)}
if code not in defs:
raise HTTPException(status_code=404, detail=f"Custom check '{code}' non definito nello schema")
v = _get_or_create_value(db, p.id, code)
v.beneficiary_declared = bool(beneficiary_declared)
v.declared_at = datetime.now(timezone.utc) if beneficiary_declared else None
# Se arriva un file sostituisce l'eventuale esistente
if file is not None and file.filename:
try:
# path custom_checks/<practice_id>/<code>/<sha12>-<name> — sfrutto storage_adapter
# con entity_type "document" fittizio e un app_id = practice_id (sfrutto la dir)
# In alternativa faccio path custom scrivendolo direttamente qui.
# Scelgo via diretta per evitare collisione con document reale.
from hashlib import sha256
size = 0
hasher = sha256()
content = b""
while True:
chunk = await file.read(65536)
if not chunk:
break
content += chunk
size += len(chunk)
if size > 15 * 1024 * 1024:
raise HTTPException(status_code=413, detail="File troppo grande (max 15 MB)")
hasher.update(chunk)
mime = (file.content_type or "").lower().split(";")[0].strip()
if mime not in ("application/pdf", "image/jpeg", "image/png"):
raise HTTPException(
status_code=415,
detail=f"MIME non consentito: {mime}. Accettati: pdf, jpeg, png"
)
digest = hasher.hexdigest()
# sanitize filename
safe = "".join(c if (c.isalnum() or c in "-_.() ") else "_" for c in file.filename).strip().replace(" ", "_")
if len(safe) > 120:
root, ext = os.path.splitext(safe)
safe = root[:120 - len(ext)] + ext
target_dir = BASE_PATH / "custom_checks" / str(p.id) / code
target_dir.mkdir(parents=True, exist_ok=True)
final_path = target_dir / f"{digest[:12]}-{safe}"
final_path.write_bytes(content)
# Rimuovi eventuale file precedente (path diverso)
if v.storage_path and Path(BASE_PATH / v.storage_path) != final_path:
try:
delete_file(v.storage_path)
except Exception:
pass
v.storage_path = str(final_path.relative_to(BASE_PATH))
v.mime = mime
v.size_bytes = size
v.sha256 = digest
v.document_uploaded_at = datetime.now(timezone.utc)
v.uploaded_by = user.user_id
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Errore upload: {e}")
# Reset eventuale validazione precedente (beneficiario ha cambiato qualcosa)
v.verification_status = "PENDING"
v.verification_notes = None
v.verified_by = None
v.verified_at = None
db.commit()
db.refresh(v)
defs_by_code = {d["code"]: d for d in _schema_check_defs(p)}
return ApiResponse(message="Check aggiornato", data=_merge_check(defs_by_code[code], v))
@router.delete("/{practice_id}/custom-checks/{code}/document", response_model=ApiResponse)
def delete_custom_check_document(
practice_id: UUID, code: str,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Beneficiario rimuove il documento allegato (dichiarazione resta)."""
p = _get_practice(db, practice_id, user)
if not _can_declare(user, p):
raise HTTPException(status_code=403, detail="Non autorizzato")
v = db.query(RemissionCustomCheckValue).filter(
RemissionCustomCheckValue.practice_id == practice_id,
RemissionCustomCheckValue.check_code == code,
).first()
if not v or not v.storage_path:
return ApiResponse(message="Nessun documento da rimuovere")
try:
delete_file(v.storage_path)
except Exception:
pass
v.storage_path = None
v.mime = None
v.size_bytes = None
v.sha256 = None
v.document_uploaded_at = None
v.verification_status = "PENDING" # reset verify
v.verification_notes = None
db.commit()
return ApiResponse(message="Documento rimosso")
@router.put("/{practice_id}/custom-checks/{code}/verify", response_model=ApiResponse)
def verify_custom_check(
practice_id: UUID, code: str,
body: CustomCheckVerifyBody,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Istruttore valida il check (VALIDO | NON_VALIDO | PENDING)."""
p = _get_practice(db, practice_id, user)
if not _can_verify(user, p):
raise HTTPException(status_code=403, detail="Solo istruttore su pratica in lavorazione")
if body.verification_status not in ("PENDING", "VALIDO", "NON_VALIDO"):
raise HTTPException(status_code=422, detail="verification_status non valido")
defs = {d["code"]: d for d in _schema_check_defs(p)}
if code not in defs:
raise HTTPException(status_code=404, detail=f"Check '{code}' non nello schema")
v = _get_or_create_value(db, p.id, code)
v.verification_status = body.verification_status
v.verification_notes = body.verification_notes
v.verified_by = user.user_id
v.verified_at = datetime.now(timezone.utc) if body.verification_status != "PENDING" else None
db.commit()
db.refresh(v)
return ApiResponse(message="Check verificato", data=_merge_check(defs[code], v))
@router.get("/{practice_id}/custom-checks/{code}/document")
def download_custom_check_document(
practice_id: UUID, code: str,
inline: int = 0,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Download del documento allegato (stream con Content-Disposition)."""
from fastapi.responses import FileResponse
p = _get_practice(db, practice_id, user)
v = db.query(RemissionCustomCheckValue).filter(
RemissionCustomCheckValue.practice_id == practice_id,
RemissionCustomCheckValue.check_code == code,
).first()
if not v or not v.storage_path:
raise HTTPException(status_code=404, detail="Nessun documento allegato")
try:
abs_path = open_file(v.storage_path)
except FileNotFoundError:
raise HTTPException(status_code=410, detail="File non piu disponibile su storage")
basename = Path(v.storage_path).name
parts = basename.split("-", 1)
filename = parts[1] if len(parts) == 2 else basename
disp = "inline" if inline else "attachment"
return FileResponse(
path=str(abs_path),
media_type=v.mime or "application/octet-stream",
headers={"Content-Disposition": f'{disp}; filename="{filename}"'},
)

View File

@@ -11,7 +11,9 @@ from uuid import UUID
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from pydantic import BaseModel
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db 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.""" """Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore."""
if _is_instructor(user): if _is_instructor(user):
return True 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 practice.status in ("DRAFT", "AWAITING_AMENDMENT")
return False return False
@@ -44,14 +46,14 @@ def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
def _can_download(user: AuthUser, practice: RemissionPractice) -> bool: def _can_download(user: AuthUser, practice: RemissionPractice) -> bool:
if _is_instructor(user): if _is_instructor(user):
return True 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 True
return False return False
def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool: def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool:
"""Solo beneficiario su pratica modificabile. Istruttore non elimina file.""" """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") return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
if user.is_superadmin(): if user.is_superadmin():
return True return True
@@ -267,3 +269,97 @@ def delete_entity_file(
db.commit() db.commit()
return ApiResponse(success=True, message="File eliminato") 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 uuid import UUID
from typing import List 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.orm import Session
from sqlalchemy import text, or_, and_ from sqlalchemy import text, or_, and_
from ..db import get_db from ..db import get_db
from ..auth import AuthUser, get_current_user from ..auth import AuthUser, get_current_user
from ..storage import save_upload, FileTooLargeError, MimeNotAllowedError, StorageError
from ..models import RemissionPractice, RemissionAmendmentRequest from ..models import RemissionPractice, RemissionAmendmentRequest
from ..schemas import ( from ..schemas import (
AmendmentRequestCreate, AmendmentRequestOut, AmendmentResponseSubmit, AmendmentRequestCreate, AmendmentRequestUpdate, AmendmentExtend, AmendmentRequestOut, AmendmentResponseSubmit, AmendmentStatus,
ReviewApproveBody, ReviewRejectBody, ReviewApproveBody, ReviewRejectBody,
InstructorQueueItem, PracticeOut, ApiResponse, InstructorQueueItem, PracticeOut, ApiResponse,
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody, InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
@@ -64,7 +65,7 @@ def _enrich_queue_item(db: Session, p: RemissionPractice) -> InstructorQueueItem
# calcolo remissione due dalla schema_snapshot # calcolo remissione due dalla schema_snapshot
try: try:
check = _compute_gate_check(p) check = _compute_gate_check(db, p)
item.remission_due = check.totals.get("remission_due", 0) item.remission_due = check.totals.get("remission_due", 0)
except Exception: except Exception:
item.remission_due = None item.remission_due = None
@@ -88,8 +89,14 @@ def instructor_queue(db: Session = Depends(get_db), user: AuthUser = Depends(_re
) )
if not manager: if not manager:
# solo: SUBMITTED non assegnate OR UNDER_REVIEW assegnate a me OR AWAITING_AMENDMENT assegnate a me # 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_( q = q.filter(or_(
and_(RemissionPractice.status == "SUBMITTED", RemissionPractice.assigned_instructor_id.is_(None)), 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 == "UNDER_REVIEW", RemissionPractice.assigned_instructor_id == user.user_id),
and_(RemissionPractice.status == "AWAITING_AMENDMENT", RemissionPractice.assigned_instructor_id == user.user_id), and_(RemissionPractice.status == "AWAITING_AMENDMENT", RemissionPractice.assigned_instructor_id == user.user_id),
)) ))
@@ -109,7 +116,7 @@ def instructor_view_practice(practice_id: UUID, db: Session = Depends(get_db),
"""Vista completa della pratica per istruttore (readonly + gate check + amendments).""" """Vista completa della pratica per istruttore (readonly + gate check + amendments)."""
p = _get_practice_or_404(db, practice_id) p = _get_practice_or_404(db, practice_id)
check = _compute_gate_check(p) check = _compute_gate_check(db, p)
amendments = [AmendmentRequestOut.model_validate(a).model_dump(mode="json") for a in p.amendment_requests] amendments = [AmendmentRequestOut.model_validate(a).model_dump(mode="json") for a in p.amendment_requests]
return ApiResponse(data={ return ApiResponse(data={
@@ -152,7 +159,7 @@ def approve_practice(practice_id: UUID, body: ReviewApproveBody,
if body.approved_remission is not None: if body.approved_remission is not None:
p.approved_remission = body.approved_remission p.approved_remission = body.approved_remission
else: else:
check = _compute_gate_check(p) check = _compute_gate_check(db, p)
p.approved_remission = Decimal(str(check.totals.get("remission_due", 0))) p.approved_remission = Decimal(str(check.totals.get("remission_due", 0)))
p.status = "APPROVED" p.status = "APPROVED"
@@ -189,10 +196,29 @@ def reject_practice(practice_id: UUID, body: ReviewRejectBody,
# ========== SOCCORSO ISTRUTTORIO ========== # ========== 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) @router.post("/{practice_id}/amendment", response_model=ApiResponse)
def create_amendment(practice_id: UUID, body: AmendmentRequestCreate, def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)): 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) p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"): if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, 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: if not body.request_text or len(body.request_text.strip()) < 10:
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)") raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
# controllo: non ci deve essere già una amendment AWAITING aperta # 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 == "AWAITING"] 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: if open_ar:
raise HTTPException(status_code=409, 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( ar = RemissionAmendmentRequest(
practice_id=p.id, practice_id=p.id,
@@ -212,37 +240,142 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
request_text=body.request_text, request_text=body.request_text,
deadline=body.deadline, deadline=body.deadline,
scope=body.scope or {}, 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) 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" p.status = "AWAITING_AMENDMENT"
db.commit() db.commit()
db.refresh(ar) 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")) 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) @router.post("/{practice_id}/amendment/{amendment_id}/close", response_model=ApiResponse)
def close_amendment(practice_id: UUID, amendment_id: UUID, def close_amendment(practice_id: UUID, amendment_id: UUID,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)): db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Istruttore chiude il soccorso (dopo aver visto la risposta beneficiario). """Istruttore chiude il soccorso. La pratica torna in UNDER_REVIEW
La pratica torna in UNDER_REVIEW.""" se non ci sono altri amendment aperti su di essa."""
ar = db.query(RemissionAmendmentRequest).filter( ar = _amendment_or_404(db, practice_id, amendment_id)
RemissionAmendmentRequest.id == amendment_id, if ar.status == AmendmentStatus.CLOSED.value:
RemissionAmendmentRequest.practice_id == practice_id raise HTTPException(status_code=409, detail="Amendment gia chiusa")
).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")
ar.status = "CLOSED" ar.status = AmendmentStatus.CLOSED.value
ar.closed_at = datetime.now(timezone.utc) ar.closed_at = datetime.now(timezone.utc)
ar.closed_by = user.user_id 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) 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: if not others_open:
p.status = "UNDER_REVIEW" 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")) 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 # Endpoint beneficiario: visualizza amendments sulla sua pratica + risponde
@router.post("/{practice_id}/amendment/{amendment_id}/respond-beneficiary", response_model=ApiResponse) @router.post("/{practice_id}/amendment/{amendment_id}/respond-beneficiary", response_model=ApiResponse)
def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID, 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)): user: AuthUser = Depends(get_current_user)):
"""Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED).""" """Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED)."""
p = _get_practice_or_404(db, practice_id) 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") raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
ar = db.query(RemissionAmendmentRequest).filter( ar = _amendment_or_404(db, practice_id, amendment_id)
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 != "AWAITING": if ar.status != "AWAITING":
raise HTTPException(status_code=409, detail=f"Amendment in stato {ar.status}, non rispondibile") 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) db.refresh(p)
return ApiResponse(message="Verbale aggiornato", return ApiResponse(message="Verbale aggiornato",
data=PracticeOut.model_validate(p).model_dump(mode="json")) 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

@@ -4,12 +4,12 @@ Endpoint pratiche di rendicontazione (lato beneficiario).
import copy import copy
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import List from typing import List, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text, func
from ..db import get_db from ..db import get_db
from ..auth import AuthUser, get_current_user from ..auth import AuthUser, get_current_user
@@ -23,8 +23,10 @@ from ..schemas import (
UlaEmployeeCreate, UlaEmployeeOut, UlaEmployeeCreate, UlaEmployeeOut,
DocumentUpsert, DocumentOut, DocumentUpsert, DocumentOut,
GateCheckResult, GateCheckResult,
ApplicationTranchesSummary, CopyUlaOption,
ApiResponse ApiResponse
) )
from ..templates import upgrade_schema_to_v2
router = APIRouter(prefix="/api/remission-practices", tags=["remission-practices"]) router = APIRouter(prefix="/api/remission-practices", tags=["remission-practices"])
@@ -37,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") raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
# Solo il beneficiario owner o un superadmin può accedere # 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") raise HTTPException(status_code=403, detail="Accesso negato a questa pratica")
return practice return practice
@@ -51,7 +53,7 @@ def _ensure_editable(practice: RemissionPractice):
) )
def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult: def _compute_gate_check(db: Session, practice: RemissionPractice) -> GateCheckResult:
"""Valuta le gate_rules dello schema snapshot contro il contenuto della pratica. """Valuta le gate_rules dello schema snapshot contro il contenuto della pratica.
Calcola: Calcola:
- per_category_declared: totali dichiarati dal beneficiario (sempre) - per_category_declared: totali dichiarati dal beneficiario (sempre)
@@ -117,12 +119,43 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
amt_erogato = practice.amount_erogato amt_erogato = practice.amount_erogato
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5))) cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
cap_abs = Decimal(str(rules.get("cap_absolute", 12500))) cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
max_remission = min(cap_pct * amt_erogato, cap_abs) # Cap assoluto per l'application (somma di tutte le tranche ammissibili)
max_remission_global = min(cap_pct * amt_erogato, cap_abs)
# Cumulativo multi-tranche v2: sommo remission approvate delle tranche precedenti
# della stessa application per calcolare il residuo disponibile.
already_approved = db.query(
func.coalesce(func.sum(RemissionPractice.approved_remission), 0)
).filter(
RemissionPractice.application_id == practice.application_id,
RemissionPractice.sequence_number < practice.sequence_number,
RemissionPractice.status == 'APPROVED'
).scalar() or 0
already_approved = Decimal(str(already_approved))
max_remission_this_tranche = max(Decimal("0"), max_remission_global - already_approved)
# Legacy: max_remission = questo tranche (usato dai check sotto).
max_remission = max_remission_this_tranche
# 5 VOCI CECILIA:
# (1) max_remission_global
# (2) pre_check_admissible = min(grand_total_declared, max_remission_this_tranche)
# (3) remission_due = min(effective_total, max_remission_this_tranche)
# (4) amount_erogato
# (5) residuo_da_restituire = amt_erogato - SUM(approvata) (post-controllo su tutte le tranche)
pre_check_admissible = min(grand_total, max_remission_this_tranche)
# Se almeno 1 verifica fatta -> uso grand_total_verified per remission_due # Se almeno 1 verifica fatta -> uso grand_total_verified per remission_due
# altrimenti uso grand_total (dichiarato) per preview pre-istruttoria # altrimenti uso grand_total (dichiarato) per preview pre-istruttoria
effective_total = grand_total_verified if any_verified else grand_total effective_total = grand_total_verified if any_verified else grand_total
remission_due = min(effective_total, max_remission) remission_due = min(effective_total, max_remission_this_tranche)
# Conteggio tranche totali per questa application (per info UI/PDF)
tranches_count = db.query(RemissionPractice).filter(
RemissionPractice.application_id == practice.application_id
).count()
tranches_max = int(rules.get("max_tranches", 1))
# Per compatibilità: per_category e grand_total restano "dichiarato" # Per compatibilità: per_category e grand_total restano "dichiarato"
per_category = per_category_declared per_category = per_category_declared
@@ -183,6 +216,30 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
"detail": f"Mancanti: {', '.join(missing_docs)}" if missing_docs else "Tutti presenti" "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) # Check 6: importo range (cap erogato)
amt_range = rules.get("amount_range", {}) amt_range = rules.get("amount_range", {})
min_e = Decimal(str(amt_range.get("min", 0))) min_e = Decimal(str(amt_range.get("min", 0)))
@@ -209,9 +266,17 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
"amount_erogato": float(amt_erogato), "amount_erogato": float(amt_erogato),
"any_verified": any_verified, "any_verified": any_verified,
"all_verified": all_verified, "all_verified": all_verified,
"residuo_da_restituire": float(max(amt_erogato - Decimal(str(remission_due)), Decimal("0"))), "residuo_da_restituire": float(max(amt_erogato - already_approved - Decimal(str(remission_due)), Decimal("0"))),
"amount_basis": amount_basis, "amount_basis": amount_basis,
"use_taxable_only": use_taxable_only "use_taxable_only": use_taxable_only,
# multi-tranche v2
"max_remission_global": float(max_remission_global),
"already_approved_previous_tranches": float(already_approved),
"max_remission_this_tranche": float(max_remission_this_tranche),
"pre_check_admissible": float(pre_check_admissible),
"sequence_number": practice.sequence_number,
"tranches_count": tranches_count,
"tranches_max": tranches_max
} }
) )
@@ -235,48 +300,132 @@ def _enrich_list_item(db: Session, p: RemissionPractice) -> PracticeListItem:
return item return item
def _read_original_instructor(db: Session, application_id: int) -> Optional[int]:
"""Legge l'istruttore originariamente assegnato alla domanda nel BE Gepafin.
Restituisce user_id solo se l'utente e ancora attivo con ruolo PRE_INSTRUCTOR o INSTRUCTOR_MANAGER.
Altrimenti None (finira in coda 'da assegnare' per il manager).
"""
row = db.execute(text("""
SELECT aa.user_id, r.role_type, u.is_deleted
FROM gepafin_schema.assigned_applications aa
JOIN gepafin_schema.gepafin_user u ON u.id = aa.user_id
JOIN gepafin_schema.role r ON r.id = u.role_id
WHERE aa.application_id = :aid
AND aa.is_deleted = false
AND u.is_deleted = false
ORDER BY aa.assigned_at DESC
LIMIT 1
"""), {"aid": application_id}).mappings().first()
if not row:
return None
if row["role_type"] not in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER"):
return None
return row["user_id"]
def _get_schema_published(db: Session, call_id: int) -> Optional[CallRemissionSchema]:
return db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
# ---------- endpoints ---------- # ---------- endpoints ----------
@router.get("/mine", response_model=ApiResponse) @router.get("/mine", response_model=ApiResponse)
def list_my_practices(db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)): def list_my_practices(db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
"""Lista pratiche del beneficiario corrente + applications CONTRACT_SIGNED pronte per start.""" """Lista pratiche del beneficiario raggruppate per application_id (v2 multi-tranche).
# pratiche esistenti Ogni application ha il riepilogo cumulativo + elenco tranche esistenti + stato apertura nuova tranche.
practices = db.query(RemissionPractice).filter(RemissionPractice.user_id == user.user_id).all() """
existing_app_ids = {p.application_id for p in practices} # Tutte le pratiche del beneficiario ordinate per application+sequence
practices = db.query(RemissionPractice).filter(
RemissionPractice.user_id == user.user_id
).order_by(
RemissionPractice.application_id, RemissionPractice.sequence_number
).all()
# applications CONTRACT_SIGNED del beneficiario che non hanno ancora una pratica # Raggruppo per application_id
by_app = {}
for p in practices:
by_app.setdefault(p.application_id, []).append(p)
# Applications CONTRACT_SIGNED del beneficiario
rows = db.execute(text(""" rows = db.execute(text("""
SELECT a.id as application_id, a.call_id, a.company_id, a.amount_accepted, SELECT a.id as application_id, a.call_id, a.company_id, a.amount_accepted, a.status,
a.status, c.name as call_name, comp.company_name as company_name c.name as call_name, comp.company_name as company_name
FROM gepafin_schema.application a FROM gepafin_schema.application a
JOIN gepafin_schema.call c ON c.id = a.call_id JOIN gepafin_schema.call c ON c.id = a.call_id
LEFT JOIN gepafin_schema.company comp ON comp.id = a.company_id LEFT JOIN gepafin_schema.company comp ON comp.id = a.company_id
WHERE a.user_id = :uid AND a.status = 'CONTRACT_SIGNED' AND a.is_deleted = false WHERE a.user_id = :uid AND a.status = 'CONTRACT_SIGNED' AND a.is_deleted = false
ORDER BY a.id
"""), {"uid": user.user_id}).mappings().all() """), {"uid": user.user_id}).mappings().all()
pending = [] applications = []
for r in rows: for r in rows:
if r["application_id"] not in existing_app_ids: app_id = r["application_id"]
pending.append({ trs = by_app.get(app_id, [])
"application_id": r["application_id"],
# leggo schema del bando per max_tranches e cap
schema = _get_schema_published(db, r["call_id"])
rules = (schema.schema_json.get("gate_rules", {}) if schema else {}) or {}
max_tranches = int(rules.get("max_tranches", 1))
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
amt_erogato = Decimal(str(r["amount_accepted"] or 0))
max_remission_global = min(cap_pct * amt_erogato, cap_abs)
already_approved_sum = sum(
(t.approved_remission or Decimal("0")) for t in trs if t.status == "APPROVED"
)
max_remission_next = max(Decimal("0"), max_remission_global - already_approved_sum)
# Stato apertura nuova tranche
can_start = True
reason = None
if len(trs) >= max_tranches:
can_start = False
reason = f"Limite tranches raggiunto ({max_tranches})"
elif len(trs) > 0 and trs[-1].status not in ("APPROVED", "REJECTED"):
can_start = False
reason = "Completa prima la rendicontazione in corso"
elif max_remission_next <= 0:
can_start = False
reason = f"Remissione massima gia raggiunta (euro {float(already_approved_sum):.2f})"
# Summary tranche (serialize with enriched fields)
tranche_items = []
for t in trs:
item = _enrich_list_item(db, t).model_dump(mode="json")
tranche_items.append(item)
applications.append({
"application_id": app_id,
"call_id": r["call_id"], "call_id": r["call_id"],
"company_id": r["company_id"],
"amount_erogato": float(r["amount_accepted"] or 0),
"call_name": r["call_name"], "call_name": r["call_name"],
"company_id": r["company_id"],
"company_name": r["company_name"], "company_name": r["company_name"],
"status": "NOT_STARTED" "amount_erogato": float(amt_erogato),
"max_tranches": max_tranches,
"tranches": tranche_items,
"can_start_new": can_start,
"start_blocked_reason": reason,
"already_approved_sum": float(already_approved_sum),
"max_remission_global": float(max_remission_global),
"max_remission_next_tranche": float(max_remission_next),
}) })
return ApiResponse(data={ return ApiResponse(data={"applications": applications})
"practices": [_enrich_list_item(db, p).model_dump(mode="json") for p in practices],
"ready_to_start": pending
})
@router.post("/start", response_model=ApiResponse) @router.post("/start", response_model=ApiResponse)
def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db), def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user)): user: AuthUser = Depends(get_current_user)):
"""Avvia una pratica di rendicontazione per una application CONTRACT_SIGNED.""" """Avvia una nuova pratica o tranche N+1 per una application CONTRACT_SIGNED.
Validazioni server-side v2:
- count(tranches) < max_tranches
- last tranche in {APPROVED, REJECTED} oppure count==0
- max_remission_this_tranche > 0
Se sequence_number > 1 e copy_ula_from_previous=True: bulk copy ULA dalla tranche N-1
con reset verification_*.
"""
# Verifica application # Verifica application
app_row = db.execute(text(""" app_row = db.execute(text("""
SELECT id, call_id, company_id, user_id, status, amount_accepted SELECT id, call_id, company_id, user_id, status, amount_accepted
@@ -286,28 +435,69 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
if not app_row: if not app_row:
raise HTTPException(status_code=404, detail=f"Application {body.application_id} non trovata") raise HTTPException(status_code=404, detail=f"Application {body.application_id} non trovata")
if app_row["status"] != "CONTRACT_SIGNED": if app_row["status"] != "CONTRACT_SIGNED":
raise HTTPException(status_code=409, raise HTTPException(status_code=409,
detail=f"Application in stato {app_row['status']}, richiesto CONTRACT_SIGNED") detail=f"Application in stato {app_row['status']}, richiesto CONTRACT_SIGNED")
if user.is_owner_role() and app_row["user_id"] != user.user_id:
raise HTTPException(status_code=403, detail="Application non di tua proprieta")
if user.is_beneficiary() and app_row["user_id"] != user.user_id: # Schema del bando
raise HTTPException(status_code=403, detail="Application non di tua proprietà") schema = _get_schema_published(db, app_row["call_id"])
# Schema del bando: richiede PUBLISHED (o DRAFT se superadmin per test)
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == app_row["call_id"]).first()
if not schema: if not schema:
raise HTTPException(status_code=409, raise HTTPException(status_code=409,
detail="Nessuno schema di rendicontazione configurato per questo bando. " detail="Nessuno schema di rendicontazione configurato per questo bando.")
"Contatta l'ente gestore.") if schema.status != "PUBLISHED" and user.is_owner_role():
if schema.status != "PUBLISHED" and user.is_beneficiary():
raise HTTPException(status_code=409, raise HTTPException(status_code=409,
detail="Lo schema di rendicontazione non è ancora stato pubblicato.") detail="Lo schema di rendicontazione non e ancora stato pubblicato.")
# Pratica esistente? # Tranche esistenti
exists = db.query(RemissionPractice).filter(RemissionPractice.application_id == body.application_id).first() existing_tranches = db.query(RemissionPractice).filter(
if exists: RemissionPractice.application_id == body.application_id
raise HTTPException(status_code=409, detail="Pratica già esistente") ).order_by(RemissionPractice.sequence_number).all()
rules = (schema.schema_json.get("gate_rules", {}) or {})
max_tranches = int(rules.get("max_tranches", 1))
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
amt_erogato = Decimal(str(app_row["amount_accepted"] or 0))
max_remission_global = min(cap_pct * amt_erogato, cap_abs)
# VALIDAZIONI v2
if len(existing_tranches) >= max_tranches:
raise HTTPException(status_code=400,
detail=f"Limite tranches raggiunto (max {max_tranches})")
if existing_tranches:
last = existing_tranches[-1]
if last.status not in ("APPROVED", "REJECTED"):
raise HTTPException(status_code=400,
detail="Completa prima la rendicontazione in corso")
already_approved = sum(
(t.approved_remission or Decimal("0")) for t in existing_tranches if t.status == "APPROVED"
)
max_remission_this = max(Decimal("0"), max_remission_global - already_approved)
if max_remission_this <= 0:
raise HTTPException(status_code=400,
detail=f"Remissione massima gia raggiunta (euro {float(already_approved):.2f})")
# Nuovo sequence_number
next_seq = (existing_tranches[-1].sequence_number + 1) if existing_tranches else 1
# suggested_instructor: solo alla tranche 1 leggo da assigned_applications
suggested_instructor_id = None
assigned_instructor_id = None
if next_seq == 1:
suggested_instructor_id = _read_original_instructor(db, body.application_id)
assigned_instructor_id = suggested_instructor_id
else:
# tranche successiva: eredita suggested dalla tranche 1, assegnato ricomincia NULL
first = existing_tranches[0]
suggested_instructor_id = first.suggested_instructor_id
# Snapshot schema aggiornato a v2 se schema_version < 2
snapshot = copy.deepcopy(schema.schema_json)
snapshot = upgrade_schema_to_v2(snapshot)
practice = RemissionPractice( practice = RemissionPractice(
call_id=app_row["call_id"], call_id=app_row["call_id"],
@@ -315,15 +505,70 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
company_id=app_row["company_id"], company_id=app_row["company_id"],
user_id=app_row["user_id"], user_id=app_row["user_id"],
status="DRAFT", status="DRAFT",
schema_snapshot=copy.deepcopy(schema.schema_json), schema_snapshot=snapshot,
amount_erogato=app_row["amount_accepted"] or Decimal("0"), amount_erogato=amt_erogato,
sequence_number=next_seq,
period_label=body.period_label,
suggested_instructor_id=suggested_instructor_id,
assigned_instructor_id=assigned_instructor_id,
) )
db.add(practice) db.add(practice)
db.flush()
# Copy ULA da tranche precedente
if next_seq > 1 and body.copy_ula_from_previous:
prev = existing_tranches[-1]
for prev_emp in prev.ula_employees:
new_emp = RemissionUlaEmployee(
practice_id=practice.id,
codice_fiscale=prev_emp.codice_fiscale,
full_name=prev_emp.full_name,
contract_type=prev_emp.contract_type,
role_description=prev_emp.role_description,
fte_pct=prev_emp.fte_pct,
period_start_date=prev_emp.period_start_date,
period_end_date=prev_emp.period_end_date,
supporting_doc_type=prev_emp.supporting_doc_type,
# reset verification: non copiare status/notes/verified_by/verified_at
verification_status="PENDING",
)
db.add(new_emp)
db.commit() db.commit()
db.refresh(practice) db.refresh(practice)
return ApiResponse(message="Pratica avviata", return ApiResponse(
data=PracticeOut.model_validate(practice).model_dump(mode="json")) message=f"Tranche {next_seq}/{max_tranches} avviata",
data=PracticeOut.model_validate(practice).model_dump(mode="json")
)
@router.get("/{practice_id}/copy-ula-options", response_model=ApiResponse)
def copy_ula_options(practice_id: UUID, db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user)):
"""Preview dei dipendenti ULA della tranche N-1 copiabili in questa tranche N.
Usato dal FE al click su "+Nuova rendicontazione" per mostrare il pre-fill."""
p = _get_practice_or_404(db, practice_id, user)
if p.sequence_number <= 1:
return ApiResponse(data={"options": [], "previous_sequence": None})
prev = db.query(RemissionPractice).filter(
RemissionPractice.application_id == p.application_id,
RemissionPractice.sequence_number == p.sequence_number - 1
).first()
if not prev:
return ApiResponse(data={"options": [], "previous_sequence": None})
options = [CopyUlaOption(
codice_fiscale=e.codice_fiscale,
full_name=e.full_name,
contract_type=e.contract_type,
role_description=e.role_description,
fte_pct=float(e.fte_pct),
period_start_date=e.period_start_date,
period_end_date=e.period_end_date,
supporting_doc_type=e.supporting_doc_type,
).model_dump(mode="json") for e in prev.ula_employees]
return ApiResponse(data={"options": options, "previous_sequence": prev.sequence_number,
"previous_id": str(prev.id)})
@router.get("/{practice_id}", response_model=ApiResponse) @router.get("/{practice_id}", response_model=ApiResponse)
@@ -451,7 +696,7 @@ def clear_document(practice_id: UUID, doc_code: str,
def gate_check(practice_id: UUID, db: Session = Depends(get_db), def gate_check(practice_id: UUID, db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user)): user: AuthUser = Depends(get_current_user)):
p = _get_practice_or_404(db, practice_id, user) p = _get_practice_or_404(db, practice_id, user)
result = _compute_gate_check(p) result = _compute_gate_check(db, p)
return ApiResponse(data=result.model_dump(mode="json")) return ApiResponse(data=result.model_dump(mode="json"))
@@ -461,7 +706,7 @@ def submit_practice(practice_id: UUID, db: Session = Depends(get_db),
p = _get_practice_or_404(db, practice_id, user) p = _get_practice_or_404(db, practice_id, user)
_ensure_editable(p) _ensure_editable(p)
check = _compute_gate_check(p) check = _compute_gate_check(db, p)
if not check.passed: if not check.passed:
raise HTTPException(status_code=422, detail={ raise HTTPException(status_code=422, detail={
"message": "Gate rules non soddisfatte", "message": "Gate rules non soddisfatte",

View File

@@ -1,19 +1,153 @@
""" """
Endpoint gestione schema rendicontazione per bando. Endpoint gestione schema rendicontazione per bando.
v2.1 (2026-04-18): picker multi-sorgente (blank, template predefinito, clone da altro bando).
""" """
import copy import copy
from typing import Optional
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from ..db import get_db from ..db import get_db
from ..auth import AuthUser, get_current_user, require_superadmin from ..auth import AuthUser, get_current_user, require_superadmin
from ..models import CallRemissionSchema from ..models import CallRemissionSchema
from ..schemas import RemissionSchemaOut, RemissionSchemaCreate, RemissionSchemaUpdate, ApiResponse 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"]) 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) @router.get("/{call_id}", response_model=ApiResponse)
def get_schema(call_id: int, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)): 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.""" """Legge lo schema di rendicontazione per un bando. 404 se non esiste."""
@@ -33,12 +167,12 @@ def create_schema(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin), 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() existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if existing: if existing:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, 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( schema = CallRemissionSchema(
call_id=call_id, call_id=call_id,
@@ -62,7 +196,7 @@ def update_schema(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin), 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() schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if not schema: if not schema:
raise HTTPException(status_code=404, detail=f"Schema non trovato per call_id={call_id}") 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), db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin), user: AuthUser = Depends(require_superadmin),
): ):
"""Inizializza schema per un bando usando il template RE-START. Fallisce se esiste già.""" """DEPRECATO in 2.1 — alias di /initialize con source=template&template_id=restart.
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first() Mantenuto per backward compatibility del FE."""
if existing: body = SchemaInitializeRequest(source="template", template_id="restart")
raise HTTPException( return initialize_schema(call_id=call_id, body=body, db=db, user=user)
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"),
)
@router.post("/{call_id}/publish", response_model=ApiResponse) @router.post("/{call_id}/publish", response_model=ApiResponse)
@@ -115,7 +233,7 @@ def publish_schema(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin), 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 from datetime import datetime, timezone
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first() schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if not schema: if not schema:
@@ -123,7 +241,7 @@ def publish_schema(
if schema.status == "PUBLISHED": if schema.status == "PUBLISHED":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Schema già pubblicato.", detail="Schema gia pubblicato.",
) )
schema.status = "PUBLISHED" schema.status = "PUBLISHED"
schema.published_at = datetime.now(timezone.utc) 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}") 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)): 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) return ApiResponse(data=RESTART_TEMPLATE)

View File

@@ -16,13 +16,16 @@ from sqlalchemy import text
from ..db import get_db from ..db import get_db
from ..auth import AuthUser, get_current_user from ..auth import AuthUser, get_current_user
from ..models import RemissionPractice from ..models import RemissionPractice, RemissionCustomCheckValue
from .practices import _compute_gate_check from .practices import _compute_gate_check
router = APIRouter(prefix="/api/remission-practices/instructor", tags=["verbale"]) router = APIRouter(prefix="/api/remission-practices/instructor", tags=["verbale"])
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates_jinja" 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 ---------- # ---------- Jinja env & filters ----------
@@ -106,7 +109,7 @@ def _is_instructor(user: AuthUser) -> bool:
def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) -> dict: def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) -> dict:
"""Prepara tutto il contesto per il template.""" """Prepara tutto il contesto per il template."""
# Gate check + totali # Gate check + totali
gate_obj = _compute_gate_check(practice); gate = gate_obj.model_dump() if hasattr(gate_obj, "model_dump") else dict(gate_obj) gate_obj = _compute_gate_check(db, practice); gate = gate_obj.model_dump() if hasattr(gate_obj, "model_dump") else dict(gate_obj)
totals = gate.get("totals") or {} totals = gate.get("totals") or {}
# Schema sections # Schema sections
@@ -177,6 +180,50 @@ def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) ->
"""), {"uid": user.user_id}).scalar() """), {"uid": user.user_id}).scalar()
instructor_name = row 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 { return {
"practice": practice, "practice": practice,
"totals": totals, "totals": totals,
@@ -196,6 +243,11 @@ def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) ->
"company": company, "company": company,
"instructor_name": instructor_name, "instructor_name": instructor_name,
"generated_at": datetime.now().strftime("%d/%m/%Y"), "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) practice, html = _render_html(db, practice_id, user)
pdf_bytes = WeasyHTML(string=html).write_pdf() 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( return Response(
content=pdf_bytes, content=pdf_bytes,
media_type="application/pdf", 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 datetime import datetime, date
from decimal import Decimal from decimal import Decimal
from uuid import UUID from uuid import UUID
from enum import Enum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -40,8 +41,10 @@ class RemissionSchemaOut(BaseModel):
# ====================== Pratica di rendicontazione (beneficiario) ====================== # ====================== Pratica di rendicontazione (beneficiario) ======================
class PracticeStartRequest(BaseModel): class PracticeStartRequest(BaseModel):
"""Input minimo per avviare una pratica: solo application_id. Il resto viene dal DB.""" """Input per avviare una (nuova) pratica o tranche."""
application_id: int application_id: int
period_label: Optional[str] = None # es "I trimestre 2021" — libero
copy_ula_from_previous: bool = True # ignorato se e la prima tranche
class PracticeUpdate(BaseModel): class PracticeUpdate(BaseModel):
@@ -115,6 +118,7 @@ class DocumentUpsert(BaseModel):
uploaded_at: Optional[datetime] = None uploaded_at: Optional[datetime] = None
expires_at: Optional[date] = None expires_at: Optional[date] = None
notes: Optional[str] = None notes: Optional[str] = None
source_company_document_id: Optional[int] = None
class DocumentOut(BaseModel): class DocumentOut(BaseModel):
@@ -125,6 +129,7 @@ class DocumentOut(BaseModel):
uploaded_at: Optional[datetime] = None uploaded_at: Optional[datetime] = None
expires_at: Optional[date] = None expires_at: Optional[date] = None
notes: Optional[str] = None notes: Optional[str] = None
source_company_document_id: Optional[int] = None
# istruttoria # istruttoria
verification_status: str = "PENDING" verification_status: str = "PENDING"
verification_notes: Optional[str] = None verification_notes: Optional[str] = None
@@ -160,6 +165,11 @@ class PracticeOut(BaseModel):
instructor_checklist: Optional[dict] = None instructor_checklist: Optional[dict] = None
verbale_date: Optional[date] = None verbale_date: Optional[date] = None
# v2 multi-tranche
sequence_number: int = 1
period_label: Optional[str] = None
suggested_instructor_id: Optional[int] = None
invoices: List[InvoiceOut] = [] invoices: List[InvoiceOut] = []
ula_employees: List[UlaEmployeeOut] = [] ula_employees: List[UlaEmployeeOut] = []
documents: List[DocumentOut] = [] documents: List[DocumentOut] = []
@@ -178,6 +188,11 @@ class PracticeListItem(BaseModel):
created_at: datetime created_at: datetime
submitted_at: Optional[datetime] = None submitted_at: Optional[datetime] = None
# v2 multi-tranche
sequence_number: int = 1
period_label: Optional[str] = None
suggested_instructor_id: Optional[int] = None
# campi denormalizzati aggiunti a runtime # campi denormalizzati aggiunti a runtime
call_name: Optional[str] = None call_name: Optional[str] = None
company_name: Optional[str] = None company_name: Optional[str] = None
@@ -197,10 +212,36 @@ class GateCheckResult(BaseModel):
# ====================== Istruttoria ====================== # ====================== 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): class AmendmentRequestCreate(BaseModel):
request_text: str request_text: str
deadline: date deadline: date
scope: Optional[dict] = None 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): class AmendmentResponseSubmit(BaseModel):
@@ -221,9 +262,63 @@ class AmendmentRequestOut(BaseModel):
closed_by: Optional[int] = None closed_by: Optional[int] = None
created_at: datetime 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} 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): class ReviewRejectBody(BaseModel):
rejection_reason: str rejection_reason: str
@@ -237,6 +332,9 @@ class InstructorQueueItem(BaseModel):
id: UUID id: UUID
call_id: int call_id: int
application_id: int application_id: int
sequence_number: int = 1
period_label: Optional[str] = None
suggested_instructor_id: Optional[int] = None
company_id: int company_id: int
status: str status: str
amount_erogato: Decimal amount_erogato: Decimal
@@ -288,3 +386,76 @@ class ApiResponse(BaseModel):
status: str = "SUCCESS" status: str = "SUCCESS"
message: Optional[str] = None message: Optional[str] = None
data: Optional[Any] = None data: Optional[Any] = None
# ====================== v2 Custom checks ======================
class CustomCheckDeclareBody(BaseModel):
beneficiary_declared: bool
class CustomCheckVerifyBody(BaseModel):
verification_status: str # PENDING | VALIDO | NON_VALIDO
verification_notes: Optional[str] = None
class CustomCheckOut(BaseModel):
"""Vista merged di definition (da schema) + value (dal DB)."""
code: str
label: str
description: Optional[str] = None
requires_document: bool = False
required: bool = False
# valori
beneficiary_declared: bool = False
declared_at: Optional[datetime] = None
filename_original: Optional[str] = None
storage_path: Optional[str] = None
size_bytes: Optional[int] = None
document_uploaded_at: Optional[datetime] = None
verification_status: str = "PENDING"
verification_notes: Optional[str] = None
verified_by: Optional[int] = None
verified_at: Optional[datetime] = None
# ====================== v2 Reassign istruttore ======================
class PracticeReassignBody(BaseModel):
new_instructor_id: Optional[int] = None # None = unassign ritorno in coda
reassignment_reason: Optional[str] = None
# ====================== v2 Tranches ======================
class ApplicationTranchesSummary(BaseModel):
"""Riepilogo pratiche/tranche per una application."""
application_id: int
call_id: int
call_name: Optional[str] = None
company_id: int
company_name: Optional[str] = None
amount_erogato: float
max_tranches: int = 1
# summary tranche esistenti
tranches: List[PracticeListItem] = []
# stato apertura nuova tranche
can_start_new: bool = False
start_blocked_reason: Optional[str] = None
# importi cumulativi
already_approved_sum: float = 0
max_remission_global: float = 0
max_remission_next_tranche: float = 0
class CopyUlaOption(BaseModel):
"""Dipendente copiabile da tranche precedente."""
codice_fiscale: str
full_name: str
contract_type: str
role_description: Optional[str] = None
fte_pct: float
period_start_date: date
period_end_date: date
supporting_doc_type: Optional[str] = None

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

View File

@@ -1,11 +1,14 @@
""" """
Template schemi precompilati per bandi noti. Template schemi precompilati per bandi noti.
RE-START: il bando del xlsx di Cecilia, base per la prima iterazione. RE-START: il bando del xlsx di Cecilia, base per la prima iterazione.
v2 (2026-04-18): schema_version=2, max_tranches, custom_checks[]
""" """
RESTART_TEMPLATE = { RESTART_TEMPLATE = {
"version": "1.0", "version": "2.0",
"template_id": "RESTART_V1", "schema_version": 2,
"template_id": "RESTART_V2",
"template_label": "RE-START (fondo prestiti con remissione del debito)", "template_label": "RE-START (fondo prestiti con remissione del debito)",
"sections": [ "sections": [
{ {
@@ -115,6 +118,22 @@ RESTART_TEMPLATE = {
], ],
}, },
], ],
"custom_checks": [
{
"code": "antiriciclaggio",
"label": "Dichiarazione antiriciclaggio",
"description": "Dichiaro che il beneficiario rispetta la normativa antiriciclaggio (D.Lgs. 231/2007 e s.m.i.) e che i soggetti coinvolti non sono iscritti in liste sanzionatorie.",
"requires_document": False,
"required": True,
},
{
"code": "polizza_fidejussoria",
"label": "Polizza fidejussoria",
"description": "Allegare copia della polizza fidejussoria a garanzia dell'importo erogato (se richiesta da bando).",
"requires_document": True,
"required": False,
},
],
"gate_rules": { "gate_rules": {
"amount_range": {"min": 5000, "max": 25000}, "amount_range": {"min": 5000, "max": 25000},
"cap_pct_erogato": 0.5, "cap_pct_erogato": 0.5,
@@ -125,5 +144,161 @@ RESTART_TEMPLATE = {
"require_at_least_one_invoice_per_nonzero_category": True, "require_at_least_one_invoice_per_nonzero_category": True,
"require_ula_above_threshold": True, "require_ula_above_threshold": True,
"require_all_documents_resolved": True, "require_all_documents_resolved": True,
"max_tranches": 2, # v2: superadmin configurabile, default 1
}, },
} }
def upgrade_schema_to_v2(schema_json: dict) -> dict:
"""Upgrade in-place di schema v1 a v2.
- Aggiunge schema_version=2 se mancante
- Aggiunge gate_rules.max_tranches=1 se mancante
- Aggiunge custom_checks=[] se mancante
- Assicura ula_section.enabled presente (default True se ula_block esiste)
Idempotente: se lo schema e gia v2, no-op.
"""
if not isinstance(schema_json, dict):
return schema_json
changed = False
if schema_json.get("schema_version", 1) < 2:
schema_json["schema_version"] = 2
changed = True
gate = schema_json.setdefault("gate_rules", {})
if "max_tranches" not in gate:
gate["max_tranches"] = 1
changed = True
if "custom_checks" not in schema_json:
schema_json["custom_checks"] = []
changed = True
# ula_section.enabled esplicito
for sec in schema_json.get("sections", []):
if sec.get("type") == "ula_block" and "enabled" not in sec:
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; padding-bottom: 10pt;
margin-bottom: 14pt; margin-bottom: 14pt;
} }
.hdr__logo { .hdr__logo-img {
font-size: 22pt; font-weight: 900; color: #1a365d; letter-spacing: 1pt; height: 38pt; width: auto;
display: inline-block; display: inline-block;
} }
.hdr__subtitle { .hdr__subtitle {
@@ -182,10 +182,11 @@
<div class="hdr__right"> <div class="hdr__right">
<div><strong>Verbale di istruttoria</strong></div> <div><strong>Verbale di istruttoria</strong></div>
<div>Rendicontazione bando</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>
<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 class="hdr__subtitle">Finanziaria regionale dell'Umbria</div>
</div> </div>
</div> </div>
@@ -237,6 +238,15 @@
{% else %}—{% endif %} {% else %}—{% endif %}
</div> </div>
</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="row">
<div class="cell label">Data presentazione</div> <div class="cell label">Data presentazione</div>
<div class="cell val">{{ practice.submitted_at|datetimefmt if practice.submitted_at else '—' }}</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> <p class="text-secondary">Nessun documento richiesto dallo schema del bando.</p>
{% endif %} {% 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 ============ #} {# ============ SOCCORSI ============ #}
{% if amendments %} {% if amendments %}
<h2>Soccorso istruttorio</h2> <h2>Soccorso istruttorio</h2>
@@ -406,42 +455,93 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{# ============ TOTALI ============ #} {# ============ STORICO TRANCHES PRECEDENTI ============ #}
<h2>Riepilogo finanziario</h2> {% 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="totals-summary">
<div class="row"> <div class="row">
<div class="cell"> <div class="cell" style="width:50%">
<div class="lbl">Totale dichiarato</div> <div class="lbl">(1) Importo massimo ammissibile (cap globale)</div>
<div class="val">{{ totals.grand_total_declared|euro }}</div> <div class="val">{{ totals.max_remission_global|euro }}</div>
</div> {% if totals.already_approved_previous_tranches > 0 %}
<div class="cell"> <div class="lbl" style="margin-top:4pt">già approvato nelle tranche precedenti</div>
<div class="lbl">Totale ammesso</div> <div style="font-size:10pt; font-weight:700; color:#744210"> {{ totals.already_approved_previous_tranches|euro }}</div>
<div class="val">{{ totals.grand_total_verified|euro }}</div> <div class="lbl" style="margin-top:4pt">max. disponibile per questa tranche</div>
</div> <div style="font-size:11pt; font-weight:700; color:#2b6cb0">= {{ totals.max_remission_this_tranche|euro }}</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>
{% endif %} {% endif %}
</div> </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 ============ #} {# ============ CHECKLIST + NOTE ============ #}
{% set checklist = practice.instructor_checklist or {} %} {% set checklist = practice.instructor_checklist or {} %}

View File

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

View File

@@ -36,6 +36,7 @@ from app.models import (
RemissionUlaEmployee, RemissionUlaEmployee,
RemissionDocument, RemissionDocument,
RemissionAmendmentRequest, RemissionAmendmentRequest,
RemissionCustomCheckValue,
) )
from app.storage import save_upload, BASE_PATH from app.storage import save_upload, BASE_PATH
from app.templates import RESTART_TEMPLATE from app.templates import RESTART_TEMPLATE
@@ -44,6 +45,7 @@ CALL_ID = 1
COMPANY_ID = 1 COMPANY_ID = 1
BENEFICIARY_USER_ID = 9 # beneficiario@sandbox.local BENEFICIARY_USER_ID = 9 # beneficiario@sandbox.local
INSTRUCTOR_USER_ID = 10 # istruttore@sandbox.local INSTRUCTOR_USER_ID = 10 # istruttore@sandbox.local
MANAGER_USER_ID = 11 # manager@sandbox.local
APPLICATION_ID = 1 APPLICATION_ID = 1
@@ -110,6 +112,27 @@ def attach_pdf(db, entity, entity_type: str, application_id: int,
entity.uploaded_at = datetime.now(timezone.utc) entity.uploaded_at = datetime.now(timezone.utc)
def ensure_assigned_application(db):
"""Popola gepafin_schema.assigned_applications per abilitare suggested_instructor_id
alla creazione della prima tranche. Idempotente."""
from sqlalchemy import text
existing = db.execute(text("""
SELECT id FROM gepafin_schema.assigned_applications
WHERE application_id = :aid AND user_id = :uid AND is_deleted = false
"""), {"aid": APPLICATION_ID, "uid": INSTRUCTOR_USER_ID}).scalar()
if existing:
print(f"[assigned_applications] gia presente (id={existing})")
return
db.execute(text("""
INSERT INTO gepafin_schema.assigned_applications
(user_id, assigned_by, application_id, status, is_deleted, assigned_at, created_date, updated_date)
VALUES (:uid, :admin, :aid, 'ASSIGNED', false, NOW(), NOW(), NOW())
"""), {"uid": INSTRUCTOR_USER_ID, "admin": 8, "aid": APPLICATION_ID})
db.commit()
print(f"[assigned_applications] user={INSTRUCTOR_USER_ID} assegnato a application={APPLICATION_ID}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Reset # Reset
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -120,6 +143,7 @@ def do_reset(scope: str):
if scope == 'all': if scope == 'all':
conn.execute(text(""" conn.execute(text("""
TRUNCATE TRUNCATE
gepafin_rendic.remission_custom_check_value,
gepafin_rendic.remission_amendment_request, gepafin_rendic.remission_amendment_request,
gepafin_rendic.remission_document, gepafin_rendic.remission_document,
gepafin_rendic.remission_ula_employee, gepafin_rendic.remission_ula_employee,
@@ -396,6 +420,207 @@ def scenario_napoli_sas(db, advance='draft'):
return practice.id return practice.id
def scenario_napoli_sas_multi(db):
"""Scenario multi-tranche:
- tranche 1 APPROVED con 1 fattura B3 524.50€, rettifica 57.36€ assicurazione, ammesso 467.14€
(caso reale pratica 5888 di Cecilia)
- tranche 2 DRAFT vuota, pronta per la demo
Popola anche:
- assigned_applications (istruttore originariamente assegnato)
- 2 custom_checks dichiarati + polizza con PDF allegato su tranche 1
"""
schema_row = ensure_schema_published(db)
ensure_assigned_application(db)
# ---------- Tranche 1 APPROVED ----------
practice1 = RemissionPractice(
call_id=CALL_ID,
application_id=APPLICATION_ID,
company_id=COMPANY_ID,
user_id=BENEFICIARY_USER_ID,
status="APPROVED",
schema_snapshot=schema_row.schema_json,
iva_regime="ORDINARIO",
amount_erogato=Decimal("17000"),
sequence_number=1,
period_label="I fase 2021",
suggested_instructor_id=INSTRUCTOR_USER_ID,
assigned_instructor_id=INSTRUCTOR_USER_ID,
approved_remission=Decimal("467.14"),
reviewed_at=datetime.now(timezone.utc),
reviewed_by=INSTRUCTOR_USER_ID,
instructor_final_notes="Pratica tranche I: ammessa 1 fattura B3 con rettifica quota assicurativa.",
submitted_at=datetime.now(timezone.utc),
)
db.add(practice1)
db.flush()
print(f"[practice] tranche 1 APPROVED id={practice1.id}")
# 1 fattura B3 con PARZIALE (storno 57.36)
inv1 = RemissionInvoice(
practice_id=practice1.id,
category_code="B3",
invoice_number="2021/042",
invoice_date=date(2021, 4, 15),
payment_date=date(2021, 4, 30),
supplier_name="Formazione Digitale S.r.l.",
supplier_vat="IT03521460542",
description="Corso di formazione digitale 40h + quota assicurazione partecipanti",
taxable=Decimal("524.50"),
vat=Decimal("115.39"),
total=Decimal("639.89"),
taxable_verified=Decimal("467.14"),
vat_verified=Decimal("102.77"),
total_verified=Decimal("569.91"),
verification_status="PARZIALE",
verification_notes="Storno di 57.36 EUR per quota assicurazione partecipanti non ammissibile (non rientra nelle spese formative dirette).",
verified_by=INSTRUCTOR_USER_ID,
verified_at=datetime.now(timezone.utc),
)
db.add(inv1)
db.flush()
attach_pdf(
db, inv1, "invoice", APPLICATION_ID,
filename="ft_2021_042_formazione.pdf",
title=f"Fattura n. {inv1.invoice_number}",
subtitle=f"{inv1.supplier_name}",
lines=[
f"Fornitore: {inv1.supplier_name} P.IVA {inv1.supplier_vat}",
f"Descrizione: {inv1.description}",
f"Imponibile: EUR {inv1.taxable}",
f"IVA 22%: EUR {inv1.vat}",
f"Totale: EUR {inv1.total}",
],
uploader_id=BENEFICIARY_USER_ID,
)
print(f"[invoice T1] B3 2021/042 PARZIALE + PDF {inv1.size_bytes}b")
# 1 ULA T_IND 1.0 AMMESSA
emp1 = RemissionUlaEmployee(
practice_id=practice1.id,
codice_fiscale="RSSMRA85T10H501Z",
full_name="Mario Rossi",
contract_type="T_IND",
role_description="Sviluppatore senior",
fte_pct=Decimal("1.0000"),
fte_pct_verified=Decimal("1.0000"),
period_start_date=date(2021, 1, 27),
period_end_date=date(2021, 12, 31),
supporting_doc_type="LUL",
verification_status="AMMESSA",
verified_by=INSTRUCTOR_USER_ID,
verified_at=datetime.now(timezone.utc),
)
db.add(emp1)
db.flush()
attach_pdf(
db, emp1, "ula", APPLICATION_ID,
filename="lul_rossi_2021_t1.pdf",
title=f"LUL {emp1.full_name}",
subtitle=f"{emp1.period_start_date} to {emp1.period_end_date}",
lines=[f"CF: {emp1.codice_fiscale}", f"FTE: 1.00", "Contratto: T_IND"],
uploader_id=BENEFICIARY_USER_ID,
)
# Documenti validati
for code, label in [("DURC", "DURC"), ("VISURA_CAMERALE", "Visura"),
("BILANCIO", "Bilancio 2021"), ("ANTIRICICLAGGIO", "Antiriciclaggio")]:
doc = RemissionDocument(
practice_id=practice1.id,
doc_code=code,
verification_status="VALIDO",
verified_by=INSTRUCTOR_USER_ID,
verified_at=datetime.now(timezone.utc),
)
db.add(doc)
db.flush()
attach_pdf(
db, doc, "document", APPLICATION_ID,
filename=f"{code.lower()}_napoli_t1.pdf",
title=label,
subtitle="Tranche I — NAPOLI SAS",
lines=["Documento valido", "Approvato dall istruttore"],
uploader_id=BENEFICIARY_USER_ID,
)
# Custom checks tranche 1: antiriciclaggio dichiarato + polizza con PDF
cc_antir = RemissionCustomCheckValue(
practice_id=practice1.id,
check_code="antiriciclaggio",
beneficiary_declared=True,
declared_at=datetime.now(timezone.utc),
verification_status="VALIDO",
verified_by=INSTRUCTOR_USER_ID,
verified_at=datetime.now(timezone.utc),
)
db.add(cc_antir)
cc_polizza = RemissionCustomCheckValue(
practice_id=practice1.id,
check_code="polizza_fidejussoria",
beneficiary_declared=True,
declared_at=datetime.now(timezone.utc),
verification_status="VALIDO",
verified_by=INSTRUCTOR_USER_ID,
verified_at=datetime.now(timezone.utc),
)
db.add(cc_polizza)
db.flush()
# Genero PDF polizza e lo salvo direttamente in custom_checks/
from pathlib import Path as _P
pdf = make_pdf_bytes(
"Polizza fidejussoria tranche I",
"NAPOLI SAS Sandbox — garanzia bando RE-START",
[
"Compagnia: Generali Assicurazioni",
"Importo garantito: EUR 17.000",
"Data emissione: 15/01/2021",
"Scadenza: 31/12/2022",
"N. polizza: FID-2021-NS-0042",
],
)
import hashlib
digest = hashlib.sha256(pdf).hexdigest()
target_dir = BASE_PATH / "custom_checks" / str(practice1.id) / "polizza_fidejussoria"
target_dir.mkdir(parents=True, exist_ok=True)
target_file = target_dir / f"{digest[:12]}-polizza_fidejussoria.pdf"
target_file.write_bytes(pdf)
cc_polizza.storage_path = str(target_file.relative_to(BASE_PATH))
cc_polizza.mime = "application/pdf"
cc_polizza.size_bytes = len(pdf)
cc_polizza.sha256 = digest
cc_polizza.document_uploaded_at = datetime.now(timezone.utc)
cc_polizza.uploaded_by = BENEFICIARY_USER_ID
db.commit()
print(f"[custom_checks T1] antiriciclaggio VALIDO, polizza VALIDO + PDF {len(pdf)}b")
# ---------- Tranche 2 DRAFT vuota ----------
practice2 = RemissionPractice(
call_id=CALL_ID,
application_id=APPLICATION_ID,
company_id=COMPANY_ID,
user_id=BENEFICIARY_USER_ID,
status="DRAFT",
schema_snapshot=schema_row.schema_json,
iva_regime="ORDINARIO",
amount_erogato=Decimal("17000"),
sequence_number=2,
period_label="II fase 2021",
suggested_instructor_id=INSTRUCTOR_USER_ID,
assigned_instructor_id=None, # non ancora assegnata (simulo workflow capo)
)
db.add(practice2)
db.flush()
print(f"[practice] tranche 2 DRAFT id={practice2.id} (vuota, pronta demo)")
db.commit()
return practice1.id, practice2.id
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main # Main
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -403,7 +628,7 @@ def main():
ap = argparse.ArgumentParser(description="Seed sandbox Gepafin rendicontazione") ap = argparse.ArgumentParser(description="Seed sandbox Gepafin rendicontazione")
ap.add_argument('--reset', action='store_true', ap.add_argument('--reset', action='store_true',
help='Cancella tutti i dati remission_* e pulisci storage prima del seed') help='Cancella tutti i dati remission_* e pulisci storage prima del seed')
ap.add_argument('--scenario', choices=['napoli-sas', 'full'], default='napoli-sas') ap.add_argument('--scenario', choices=['napoli-sas', 'napoli-sas-multi', 'full'], default='napoli-sas')
ap.add_argument('--advance', choices=['draft', 'submitted', 'under_review'], default='under_review', ap.add_argument('--advance', choices=['draft', 'submitted', 'under_review'], default='under_review',
help='Stato finale della pratica dopo il seed') help='Stato finale della pratica dopo il seed')
args = ap.parse_args() args = ap.parse_args()
@@ -417,11 +642,17 @@ def main():
pid = scenario_napoli_sas(db, advance=args.advance) pid = scenario_napoli_sas(db, advance=args.advance)
print(f"\n✓ Scenario napoli-sas completato. practice_id={pid}") print(f"\n✓ Scenario napoli-sas completato. practice_id={pid}")
print(f" Accedi a: http://78.46.41.91:18072/istruttoria/{pid}") print(f" Accedi a: http://78.46.41.91:18072/istruttoria/{pid}")
elif args.scenario == 'napoli-sas-multi':
pid1, pid2 = scenario_napoli_sas_multi(db)
print(f"\n✓ Scenario napoli-sas-multi completato")
print(f" tranche 1 APPROVED id={pid1}")
print(f" tranche 2 DRAFT id={pid2}")
print(f" Istruttoria T1: http://78.46.41.91:18072/istruttoria/{pid1}")
print(f" Rendicontazione T2: http://78.46.41.91:18072/rendicontazioni/{pid2}")
elif args.scenario == 'full': elif args.scenario == 'full':
pid = scenario_napoli_sas(db, advance=args.advance) pid1, pid2 = scenario_napoli_sas_multi(db)
# placeholder per futuri scenari ROMA-SRL / BOLOGNA-SPA print(f"\n✓ Scenario full = napoli-sas-multi (solo questo disponibile).")
print(f"\n✓ Scenario 'full' eseguito (solo napoli-sas disponibile).") print(f" tranche 1={pid1} tranche 2={pid2}")
print(f" practice_id={pid}")
finally: finally:
db.close() db.close()