From c19b2aa0b1b22d192120407fbe10ba8d2d1f6e30 Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Sat, 18 Apr 2026 17:35:56 +0200 Subject: [PATCH] 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//polizza_fidejussoria/. --- app/main.py | 8 +- scripts/seed_sandbox.py | 241 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 241 insertions(+), 8 deletions(-) diff --git a/app/main.py b/app/main.py index 9493971..a0a11d5 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ from sqlalchemy import text from .config import get_settings from .db import engine, Base from .migrations import run_migrations -from .routers import health, schemas, practices, debug, instructor, files, verbale +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") log = logging.getLogger("rendicontazione-api") @@ -42,7 +42,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="rendicontazione-api", description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS", - version="0.3.0", + version="0.4.0", lifespan=lifespan, ) @@ -61,13 +61,15 @@ app.include_router(debug.router) app.include_router(instructor.router) app.include_router(files.router) app.include_router(verbale.router) +app.include_router(custom_checks.router) +app.include_router(assignment.router) @app.get("/", tags=["root"]) def root(): return { "service": "rendicontazione-api", - "version": "0.3.0", + "version": "0.4.0", "docs": "/docs", "health": "/health", } diff --git a/scripts/seed_sandbox.py b/scripts/seed_sandbox.py index 199be4d..7263407 100644 --- a/scripts/seed_sandbox.py +++ b/scripts/seed_sandbox.py @@ -36,6 +36,7 @@ from app.models import ( RemissionUlaEmployee, RemissionDocument, RemissionAmendmentRequest, + RemissionCustomCheckValue, ) from app.storage import save_upload, BASE_PATH from app.templates import RESTART_TEMPLATE @@ -44,6 +45,7 @@ CALL_ID = 1 COMPANY_ID = 1 BENEFICIARY_USER_ID = 9 # beneficiario@sandbox.local INSTRUCTOR_USER_ID = 10 # istruttore@sandbox.local +MANAGER_USER_ID = 11 # manager@sandbox.local 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) + + +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 # --------------------------------------------------------------------------- @@ -120,6 +143,7 @@ def do_reset(scope: str): if scope == 'all': conn.execute(text(""" TRUNCATE + gepafin_rendic.remission_custom_check_value, gepafin_rendic.remission_amendment_request, gepafin_rendic.remission_document, gepafin_rendic.remission_ula_employee, @@ -396,6 +420,207 @@ def scenario_napoli_sas(db, advance='draft'): 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 # --------------------------------------------------------------------------- @@ -403,7 +628,7 @@ def main(): ap = argparse.ArgumentParser(description="Seed sandbox Gepafin rendicontazione") ap.add_argument('--reset', action='store_true', 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', help='Stato finale della pratica dopo il seed') args = ap.parse_args() @@ -417,11 +642,17 @@ def main(): pid = scenario_napoli_sas(db, advance=args.advance) print(f"\n✓ Scenario napoli-sas completato. practice_id={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': - pid = scenario_napoli_sas(db, advance=args.advance) - # placeholder per futuri scenari ROMA-SRL / BOLOGNA-SPA - print(f"\n✓ Scenario 'full' eseguito (solo napoli-sas disponibile).") - print(f" practice_id={pid}") + pid1, pid2 = scenario_napoli_sas_multi(db) + print(f"\n✓ Scenario full = napoli-sas-multi (solo questo disponibile).") + print(f" tranche 1={pid1} tranche 2={pid2}") finally: db.close()