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

242 lines
16 KiB
Markdown

# 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,720``rar_client.py` |
| Mapare op→codPrestatie + `auto_send` | `GetCodRarPentruOperatie``operations_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` |