Files
gepafin-rendicontazione-api/app/migrations.py
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

122 lines
5.3 KiB
Python

"""
Migration idempotente per sandbox.
Base.metadata.create_all() crea solo tabelle mancanti, non colonne aggiuntive.
Qui gestiamo ALTER TABLE ADD COLUMN IF NOT EXISTS per l'evoluzione dello schema.
Ogni migration è una stringa SQL che puo essere eseguita piu volte senza errori.
"""
from sqlalchemy import text
import logging
log = logging.getLogger("rendicontazione-api.migrations")
MIGRATIONS = [
# 2026-04-18: colonne file upload su remission_invoice
"""
ALTER TABLE gepafin_rendic.remission_invoice
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
ADD COLUMN IF NOT EXISTS mime varchar(128),
ADD COLUMN IF NOT EXISTS size_bytes bigint,
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
ADD COLUMN IF NOT EXISTS uploaded_by integer,
ADD COLUMN IF NOT EXISTS uploaded_at timestamptz;
""",
# 2026-04-18: colonne file upload su remission_ula_employee
"""
ALTER TABLE gepafin_rendic.remission_ula_employee
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
ADD COLUMN IF NOT EXISTS mime varchar(128),
ADD COLUMN IF NOT EXISTS size_bytes bigint,
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
ADD COLUMN IF NOT EXISTS uploaded_by integer,
ADD COLUMN IF NOT EXISTS uploaded_at timestamptz;
""",
# 2026-04-18: colonne file upload su remission_document
"""
ALTER TABLE gepafin_rendic.remission_document
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
ADD COLUMN IF NOT EXISTS mime varchar(128),
ADD COLUMN IF NOT EXISTS size_bytes bigint,
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
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);
""",
]
def run_migrations(engine) -> None:
"""Esegue tutte le migration in transazione. Log su ciascuna."""
with engine.begin() as conn:
for i, sql in enumerate(MIGRATIONS, 1):
try:
conn.execute(text(sql))
log.info(f"migration {i}/{len(MIGRATIONS)} OK")
except Exception as e:
log.error(f"migration {i} FAILED: {e}")
raise