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/.
This commit is contained in:
BFLOWS
2026-04-18 17:35:56 +02:00
parent 86681678c4
commit c19b2aa0b1
2 changed files with 241 additions and 8 deletions

View File

@@ -15,7 +15,7 @@ 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 .routers import health, schemas, practices, debug, instructor, files, verbale, custom_checks, assignment
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")
@@ -42,7 +42,7 @@ async def lifespan(app: FastAPI):
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 +61,15 @@ 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.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

@@ -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()