Files
rar-autopass/docs/plans/plan-eng-review.md
Marius Mutu 78d21d5a38 Initial commit: baza VFP ROAAUTO + planuri migrare Web API
Arhiva clasei RarAutoPass (VFP) care declara prestatiile la RAR AUTOPASS,
ca baza pentru rescrierea ca gateway central Python/FastAPI.

Include:
- sursa VFP (.prg) + datele necesare migrarii (mapare_prestatii, prestatii_rar)
- spec-ul oficial RAR (txt)
- docs/plans/: plan-design-review + plan-eng-review
- docs/CONTEXT.md: handoff pentru continuarea in alta sesiune
- .gitignore specific Visual FoxPro (ignora artefacte compilate + credentiale)

settings.xml (cu parola de test in clar) EXCLUS; vezi settings.xml.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:10:28 +03:00

16 KiB

Plan implementare: Gateway RAR AUTOPASS (migrare ROAAUTO din VFP în web)

Sursă: review CEO (SELECTIVE EXPANSION) peste design-ul vreau-sa-migrez-acest-precious-mochi.md. Grounded pe codul VFP existent + spec-ul oficial RAR (Documentatie Serviciu AutoPass_Final.txt, Document informativ RAR- Autopass.txt).

Context

ROAAUTO (Visual FoxPro + Oracle, la fiecare service client) declară azi prestațiile la RAR AUTOPASS direct din clasa RarAutoPass (rar_autopass.prg), prin MSXML2.ServerXMLHTTP. Obligație legală (L.142/2023, OM 210/2024). Integrarea e testată doar pe endpoint-ul de test RAR, nepusă la clienți.

Problema reală e de ISV: nu vrei să redistribui un .exe VFP la fiecare corecție. Muți logica (mapare + login RAR + jurnal + retry) pe un gateway central depanabil o dată pentru toți, iar ROAAUTO rămâne client subțire. Un client real a cerut automatizarea — primul plătitor, nu ipoteză.

Rezultat țintă (treapta 1): o prezentare reală trimisă din ROAAUTO prin gateway apare la RAR, vizibilă în dashboard, cu retry pe erori tranzitorii și fără a stoca parole.

Decizii blocate în review

# Decizie Alegere
Mod Postura review SELECTIVE EXPANSION — bulletproof treapta 1 + 4 cherry-picks acceptate
Idempotency Anti-dublură Hash de conținut pe server, UNIQUE; "nu se acceptă 2 prezentări identice". nr_comanda NU e cerut (RAR n-are câmpul; SaaS n-are comenzi)
Defensibilitate Dovadă vs privacy Reținere temporară 90 zile a payload-ului criptat, apoi purjare
Poză odometru b64Image obligatoriu? Se validează întâi la „The Assignment" pe endpoint test; nu construim orbește
Odometru repair Validare Strict + stare needs_data (nu trimite incomplet)
Cherry-picks Adăugate în v1 Alertă submission-uri blocate; /healthz+/metrics; sugestie fuzzy mapare; export audit CSV
Import DBF Migrare date import_dbf.py cu dry-run + raport înainte de scriere

Constatări din spec-ul oficial (corecturi față de design)

  1. Prezentari n-are câmp de număr comandă (spec model #11, l.387-391). RAR acceptă duplicate (fără constrângere unică). → cheia de idempotență e doar pentru retry-urile tale, hash pe conținut.
  2. Toate câmpurile sunt obligatorii except obs (doc informativ l.47). Asta include b64Image (poză odometru, l.40) — VFP-ul trimite azi gol. Posibil gap de conformitate în producție. De validat.
  3. Înlocuire/reparație odometru (l.37, l.39): cer ambele valori odometruInitial + odometruFinal. VFP trimite azi odometruInitial: null. Anti-fraudă (penal până la 5.000 lei).
  4. sistemReparat e „codificat în lista de prestații" (l.45) → probabil derivabil din codurile codPrestatie prin mapare, nu input liber separat. Reduce Open Question #2.
  5. URL-uri: spec-ul are typo-uri de copy/paste (postPrezentare listat ca /patchPrezentare/{id} l.244; markAnulata ca /getPrezentare/{id} l.227). Sursa de adevăr = URL-urile testate din VFP (/prezentari/postPrezentare, /prezentari/markPrezentareAnulataById/{id}).
  6. Monitorizare: spec-ul are getAllPrezentari (prezentări active); VFP folosește getAllPrezentariFinalizate (nedocumentat). De ales deliberat — vezi „Monitorizare" mai jos.

⚠️ Prerequisite blocant — „The Assignment" (spike, ~1h, ÎNAINTE de orice cod)

Pe endpoint-ul de test RAR, măsoară pe /public/login + postPrezentare:

  • Durata de viață a JWT-ului (login, apoi postPrezentare la intervale crescătoare până la 401) → dimensionează fereastra de retry autonom din worker.
  • Dacă postPrezentare trece FĂRĂ b64Image și fără odometruInitial → decide dacă poza e obligatorie în prod (constatare #2) și dacă ROAAUTO trebuie să atașeze poza.
  • Valorile acceptate pentru tipPrestatie / sistemReparat (enum nedocumentat) — probează câteva.

Rezultatul acestui spike decide robustețea cozii și scopul real al ROAAUTO. Nu pornește implementarea worker-ului înainte de el.

Arhitectură

ROAAUTO (VFP, la client)                         GATEWAY FastAPI (central, 1 container)
  citește comanda + creds RAR din Oracle    POST /v1/prezentari {comanda + RAR creds + idempotency implicit}
  ──HTTPS──────────────────────────────────────▶ API
                                                   ├─ valid Pydantic (vin, odometru, prestatii)
                                                   ├─ rezolvă op→codPrestatie (operations_mapping)
                                                   ├─ derivă sistemReparat din coduri
                                                   ├─ calc idempotency_key = hash(conținut canonic)
                                                   ├─ INSERT submission (PII criptat tranzitoriu, queued)
  ◀── {submissionId, status: queued|needs_mapping|needs_data} ──┘  (UNIQUE → dedup, întoarce id existent)

                       WORKER (proces separat, restart:always, poll SQLite WAL)
                         claim atomic: BEGIN IMMEDIATE; UPDATE…SET sending WHERE id=? AND status='queued'
                         login RAR → JWT → postPrezentare → retry/backoff ÎN fereastra JWT
                         succes: scrie idPrezentare; PURJEAZĂ creds; PII criptat rămâne max 90 zile

  Browser ─▶ Dashboard (Jinja2+HTMX): monitorizare + coadă curentă + editor mapări + nomenclator + audit CSV
  ROAAUTO (timer re-push) ─▶ GET /v1/prezentari?status=error → retrimite cu creds proaspete (durabilitate pene lungi)

Data flow + shadow paths (postPrezentare)

INPUT ──▶ VALIDARE ──▶ MAPARE op→cod ──▶ ENQUEUE ──▶ WORKER login+send ──▶ RAR
  │          │              │               │              │
  nil/empty  vin invalid    cod lipsă →     idempotency    JWT expirat → error (re-push ROAAUTO)
  vin?       odometru<0     needs_mapping   key dublu →     RAR 4xx → needs_data/error (logat, NU silent)
  creds?     repair fără    sistemReparat   întoarce id     RAR 5xx/timeout → retry backoff
             init → needs_data nederivabil  existent        b64Image lipsă (dacă obligatoriu) → needs_data

Mașina de stări submission

queued → sending → { sent | needs_mapping | needs_data | error }
  needs_mapping : operație fără codPrestatie mapat → ținut gateway-side, NU trimis incomplet
  needs_data    : repair odometru fără init/final SAU poză lipsă (dacă obligatorie) → ținut, NU trimis
  error         : eligibil re-push din ROAAUTO (GET ?status=error)
  sent          : are idPrezentare RAR; creds purjate; PII criptat max 90 zile; terminal

Componente (un repo, docker compose up)

  1. API app/api/v1 (FastAPI):
    • POST /v1/prezentari (una/mai multe) → Pydantic, mapare, enqueue, răspuns submissionId.
    • GET /v1/prezentari?status=&data= și /{id} — monitorizare programatică + re-push ROAAUTO.
    • GET /v1/nomenclator, POST /v1/nomenclator/refresh.
    • GET/PUT /v1/mapari — CRUD mapare per cont, cu sugestie fuzzy pe denumire (cherry-pick).
    • PATCH /v1/prezentari/{id}/anulare, /corectie — proxy markPrezentareAnulataById / patchPrezentare.
    • GET /v1/audit/export?from=&to=CSV cu ce s-a trimis (cherry-pick, leagă reținerea 90 zile).
    • Auth gateway: API key per cont ROA (separată de creds RAR), cu emitere/rotire/revocare.
    • Redactare credențiale (CORE, nu opțional): middleware care garantează că body-ul pe /v1/prezentari NU se loghează niciodată și password se scrubează din excepții/APM.
    • (Amânat: POST /v1/import xlsx/csv — treapta 2.)
  2. Client RAR app/rar_client.py — portare din rar_autopass.prg + rar-forms.prg: login+JWT, getNomenclatorPrestatii, postPrezentare, getAllPrezentari, getPrezentare, markPrezentareAnulataById, patchPrezentare. httpx + retry/backoff. URL-uri din VFP testat, nu din spec.
  3. Worker app/worker — proces separat sub Docker restart: always (NU asyncio în uvicorn dacă scalezi workeri — un singur scriitor pe coadă ca să nu dublezi un record legal). Buclă claim atomic → login → send → retry → scrie status+idPrezentare. b64Image mare → BLOB/path pe disc, nu RAM.
  4. Dashboard app/web — Jinja2 + HTMX (server-rendered, zero build): monitorizare, stare coadă, editor mapări (cu fuzzy), browser nomenclator, banner alertă submission-uri blocate (cherry-pick).
  5. SQLite (WAL) — un fișier .db:
    • accounts, api_keys.
    • operations_mapping (cod_op_service → codPrestatie, auto_send) ← mapare_prestatii.DBF.
    • nomenclator_rar (cache {codPrestatie, numePrestatie}) ← prestatii_rar.DBF.
    • submissions: idempotency_key UNIQUE, status, statusCode RAR, eroare, idPrezentare, retry, timestamps. PII (vin, odometru, dataPrestatie, prestatii, b64Image) criptat + purge_after (sent+90z).
    • Niciun câmp pentru parole RAR.

Securitate & raza de explozie (constatare review #5)

Gateway-ul vede parolele RAR ale tuturor clienților în memorie/tranzit. Zero-storage reduce riscul la rest, dar o greșeală de logging scurge parole live, iar AGPL = codul e public. Controale hard:

  • Middleware de redactare (mai sus) — body-uri cu parole niciodată în loguri/APM/excepții.
  • HTTPS obligatoriu; recomandare TLS pinning din ROAAUTO către gateway.
  • Parola folosită doar pentru login în worker, apoi ștearsă din itemul de coadă.

Error & rescue map (extras)

Codepath Ce poate eșua Excepție Rescued? Acțiune User vede
rar_client.login timeout/5xx httpx.TimeoutException Y retry backoff în fereastra JWT submission error, re-push
rar_client.login 401 creds greșite AuthError Y NU retry; marchează error+motiv „credențiale RAR invalide"
rar_client.postPrezentare 4xx validare RAR RarValidationError Y needs_data + payload RAR logat rând flag-uit în dashboard
rar_client.postPrezentare JSON malformat/empty JSONDecodeError Y error + raw response logat (scrub) submission error
API enqueue idempotency dublu IntegrityError(UNIQUE) Y întoarce submissionId existent „deja înregistrat"
worker claim două procese (prevenit) BEGIN IMMEDIATE Y un singur scriitor n/a
mapare cod lipsă (control de flux) Y needs_mapping, NU trimite „necesită mapare"

Regulă: fără except Exception generic. Fiecare rescue: retry / degradare cu mesaj / re-raise cu context.

Failure modes registry (gap-uri critice de evitat)

Codepath Failure Rescued Test User vede Logged
postPrezentare repair fără odometru init record fraud-sensibil incomplet Y (needs_data) DA flag dashboard DA
dublu-send din 2 bucle retry duplicat la RAR Y (idempotency UNIQUE) DA nimic (transparent) DA
poză lipsă dacă obligatorie RAR respinge Y (needs_data după spike) DA flag dashboard DA
submission blocat tăcut declarație legală pierdută Y (alertă cherry-pick) DA banner + webhook DA

Client ROAAUTO (VFP) — refactor minim

  • settings.xml păstrează doar URL gateway + API key (rotește parola de test expusă acum în SVN!).
  • Creds RAR ale clientului se citesc din Oracle și se trimit în payload la gateway peste HTTPS.
  • export_comenzi.prg rămâne, dar construiește JSON și face POST /v1/prezentari (nu XML + apel RAR direct).
  • Dispar din VFP: Login, UpdateNomenclator, GetCodRarPentruOperatie, maparea, rar_log → în web.
  • Job periodic „re-push pending" — reutilizează timer-ul existent (OnAutoProcessTimer/nTimerHandle din rar-forms.prg).
  • Dacă spike-ul confirmă poza obligatorie: ROAAUTO atașează poza odometrului (de clarificat sursa).

Migrare date

tools/import_dbf.py (cu dbfread) — dry-run + raport întâi: rânduri valide, mapări orfane, coduri necunoscute în nomenclator. Confirmi, apoi scrie în SQLite. Surse: mapare_prestatii.DBF, prestatii_rar.DBF. (rar_log.DBF NU se migrează — jurnalul nou e submissions + live din RAR.)

Observabilitate (cherry-picks)

  • /healthz — worker viu + ultimul login RAR reușit + adâncime coadă.
  • /metrics — submissions pe status, latență send, retry count, backlog needs_mapping/needs_data.
  • Alertă submission-uri blocate — banner dashboard + webhook/email peste prag (plasă de siguranță legală).

Monitorizare — sursa de adevăr

Citit live din RAR + stare coadă locală. Atenție: getAllPrezentariFinalizate (VFP) întoarce doar FINALIZATA; getAllPrezentari (spec) întoarce active. Alege getAllPrezentari dacă vrei și draft/anulate. Cache scurt (ex. 30-60s) + UX „RAR indisponibil, arăt ultima stare a cozii" (nu lega dashboard-ul de uptime RAR).

Deploy

  • Start: LXC Proxmox + Cloudflare Tunnel (0 €, teste). Producție: VPS mic always-on (~5 €/lună).
  • Un container: uvicorn (API) + worker (proces 2), restart: always, volum SQLite. Mutare = copiezi container + .db.
  • Open-source pe github.com/romfast, AGPL-3.0. ⚠️ Vezi „Decizie one-way door" mai jos.
  • romfast.ro/hosting.com = doar landing (ASGI + worker daemon nu merg pe shared hosting).

Verificare (end-to-end)

  1. Spike „The Assignment" rulat, JWT TTL + cerințe b64Image/odometru documentate.
  2. import_dbf.py --dry-run produce raport corect pe DBF-urile reale; apoi import confirmat.
  3. docker compose up; /healthz verde.
  4. POST /v1/prezentari cu o comandă reală din ROAAUTO (test) → submissionId, worker trimite, apare FINALIZATA la RAR (test) și în dashboard.
  5. Re-trimite aceeași comandă identică → întoarce același submissionId (idempotency), NU dublă la RAR.
  6. Trimite operație nemapată → needs_mapping; repair odometru fără init → needs_data; nu se trimit incomplet.
  7. Oprește RAR (sau forțează 5xx) → submission error, ROAAUTO re-push recuperează; nimic blocat tăcut.
  8. Verifică: SQLite n-are câmp parolă; după sent, PII e criptat și are purge_after; logurile n-au parole.
  9. Teste automate: unit (mapare, idempotency hash, validare odometru), integration (worker claim atomic, retry/backoff), E2E pe endpoint test RAR.

Decizie one-way door de semnalat (CEO)

Licență AGPL fără CLA poate bloca un viitor strat SaaS comercial: odată ce intră PR-uri externe sub AGPL, relicențierea cere acordul tuturor contributorilor. Dacă vrei să păstrezi opțiunea de dual-license (open core + hosted comercial — exact teza ta de la treapta 2), adoptă CLA / copyright assignment din ziua 1. AGPL în sine e bună pentru moat (forțează competitorii care-l găzduiesc să-și deschidă modificările). Decizia: deliberat, acum.

NOT in scope (amânat, cu motiv)

  • POST /v1/import xlsx/csv + UX mapare coloane — treapta 2 (piață non-ROA). Motor identic, fără rescriere.
  • Modelul de conturi RAR (addClient/roluri) — nu-l replicăm; rămâne la RAR.
  • Outbox în Oracle (Approach C) — pentru clienți non-ROA / viitor, cere acces gateway→Oracle.
  • Agregare/produse din datele service-urilor — niciodată default; doar opt-in + anonimizare + lawyered.
  • Redis/arq/Postgres — SQLite WAL + un worker acoperă volumul (60-100 prezentări/lună/client).

Open questions rămase

  1. Sursa pozei odometrului în fluxul ROAAUTO (dacă spike-ul confirmă b64Image obligatoriu).
  2. tipPrestatie — valori acceptate (de probat la spike).
  3. Un singur user RAR per agent economic sau mai mulți (afectează idUser/filtrare monitorizare).
  4. Monetizare/direcție SaaS — de reluat după ce prima prezentare reală merge la primul client.

What already exists (reuse)

Sub-problemă Reuse din VFP
Contract RAR (login/JWT, nomenclator, postPrezentare, cancel) rar_autopass.prg, rar-forms.prg:655,720rar_client.py
Mapare op→codPrestatie + auto_send GetCodRarPentruOperatieoperations_mapping
Timer re-push OnAutoProcessTimer/nTimerHandle (rar-forms.prg)
Export (oglindă treapta 2) btnExportExcel.Click (rar_advanced.prg)
Migrare DBF import_dbf.py citește direct cele 3 .DBF