diff --git a/docs/plans/plan-treapta2.md b/docs/plans/plan-treapta2.md new file mode 100644 index 0000000..0d79bfc --- /dev/null +++ b/docs/plans/plan-treapta2.md @@ -0,0 +1,474 @@ +# Plan Treapta 2 — Import xlsx/csv + mapare coloane (canal non-ROA) + +> **Plan executabil, post-review CEO (SELECTIVE EXPANSION).** Continuă `plan.md` (Treapta 1 = LIVE). +> Acoperă funcționalitățile de integrare care AZI nu există: upload fișier, mapare coloane, +> spectru de integrare. Motor identic cu Treapta 1 (mapare op→cod + coadă + worker + monitorizare). +> Ultima actualizare: 2026-06-16. Review: `/plan-ceo-review` + voce externă (subagent independent). + +## 1. Problema (de ce acum) + +Treapta 1 servește clienții ROA prin ROAAUTO (API JSON). Dar obligația legală (L.142/2023) +apasă pe **mii de service-uri non-ROA** care AZI introduc manual în interfața web AUTOPASS, +prezentare cu prezentare (2-4 min × 60-100/lună = 3-6 ore/lună de tastare). Nu au ROAAUTO și nu +vor scrie cod. Au deja datele în Excel/export din propriul soft. + +**Rezultat țintă:** un service non-ROA, fără instalare, încarcă un fișier (xlsx/csv), mapează +coloanele o singură dată (reținut), vede preview cu rândurile cu probleme flag-uite, confirmă +explicit, apasă „Trimite la RAR" și prezentările apar `FINALIZATA`. A doua oară: drop fișier → trimite. + +## 2. Ce există deja (reuse, NU se rescrie) + +| Sub-problemă | Reuse din Treapta 1 | Atenție la review | +|---|---|---| +| Validare conținut (VIN/dată/odometru/nrInm) | `app/validation.py` | OK, se compune cu batch | +| Mapare operație→codPrestatie + fuzzy | `app/mapping.py`, `operations_mapping` | ⚠️ `reresolve_account` e account-GLOBAL (vezi Risc R1) | +| Coadă + idempotency | `submissions`, `app/idempotency.py` | ⚠️ cheia exclude obs/op-denumire (vezi 3.4) | +| Worker login RAR + postPrezentare + retry | `app/worker`, `app/rar_client.py` | ⚠️ purjează creds (vezi 3.6, decizie D4) | +| Reconciliere anti-duplicat | `app/reconcile.py` | OK | +| Monitorizare + audit CSV | `/v1/prezentari`, `/v1/audit/export`, dashboard | OK | +| Auth API-key per cont | `app/auth.py` | OK | +| Criptare PII (Fernet) | `app/crypto.py` | refolosit pt. `import_rows` | + +Nou = **doar stratul de INTRARE** (parsare + mapare coloane + preview) + **un fix de model creds pe web**. + +## 3. Funcționalități noi (scope confirmat la review) + +### 3.1 Upload fișier — `POST /v1/import` +- `multipart/form-data`: `.xlsx`, `.xls`, `.csv`. Encoding: UTF-8 + **fallback `cp1250`/`latin2` (RO)** + BOM. +- Parsare: `openpyxl` (xlsx) / `csv` stdlib. Limită (ex. 5 MB / ~5000 rânduri) → semnal explicit, nu trunchiere tăcută. +- **openpyxl `read_only=True` streaming (Eng#6):** `load_workbook(read_only=True, data_only=True)`; verifică + `max_row`/dimensiune ÎNAINTE de parsare → `FileTooLarge` fără parse parțial. Memorie marginală, rămâne sincron + (acceptabil la volumul țintă, R2). `wb.close()` la final. +- **CSV delimiter sniff (Eng#3):** export Excel RO folosește `;` (virgula = separator zecimal). `csv.Sniffer` + pe `{; , \t}` sau probă explicită; alege delimiter-ul care dă >1 coloană consistent. 1 coloană → `HeaderError` + clar, NU mapare oarbă. +- **Coercion Excel (R3):** odometru numeric → `123456.0`; VIN/nr cu zerouri tăiate; date ca `datetime`. Normalizarea + e centralizată în `idempotency.canonicalize_row` (vezi 3.4bis); coercion nerecuperabilă → starea `needs_review` (3.4). +- **Dată dezambiguizată (Voce#2):** celulă `datetime` nativă din openpyxl → folosită DIRECT (neambiguă). Celulă + STRING → aplică `date_fmt` mapat, DAR dacă `zi<=12` (deci și MM.DD ar fi valid) → forțează `needs_review`, + nu trimite orb. Acoperă fișierul mixt datetime/string (cazul real RO). +- **Robustețe export RO real (Voce#6/#7):** dacă workbook-ul are >1 sheet non-gol → cere alegerea sheet-ului + (nu presupune `active`); rezolvă celulele header **îmbinate** (un-merge logic → nume reale sau flag, nu nume goale); + taie rândurile trailing unde coloanele-cheie (VIN+dată) sunt goale (footer `TOTAL`/`Întocmit de:`); rând fără VIN + = **skip structural**, nu `needs_data` fantomă. +- Detectează header (primul rând non-gol), întoarce `{import_id, columns, sample_rows}`. NU trimite nimic la RAR. +- Stochează în `import_batches` / `import_rows` (PII **criptat** cu `app/crypto.py`, `purge_after` ca `submissions`). + +### 3.2 Mapare coloane (NOUĂ — stratul care lipsește azi) +- **Schemă fișier → câmpuri canonice**: `vin`, `nr_inmatriculare`, `data_prestatie`, `odometru_final`, + `odometru_initial?`, `operatie` (denumire/cod), `obs?`. +- **Reținută per cont** (`column_mappings`), cu **semnătură de coloane**. Map once, reuse forever. +- **Detectie drift (acceptat D3):** maparea reținută se aplică DOAR dacă semnătura coloanelor se potrivește + exact. Coloane mutate/redenumite → NU aplica orb, cere re-confirmare. Previne maparea tăcută greșită la upload 2. +- Auto-sugestie fuzzy pe nume coloană („VIN"/„Serie sasiu"→vin; „KM"→odometru_final). + - **DRY (Eng#4):** refolosește `mapping.normalize_for_match` (NFKD+lowercase+strip) + `fuzz.token_sort_ratio` + (rapidfuzz) — ACELAȘI primitiv ca editorul de operații. Map `{camp_canonic: [sinonime]}`, zero dependință nouă. +- Format dată configurabil per mapare (`DD.MM.YYYY` RO vs ISO) → normalizat la `YYYY-MM-DD` (vezi dezambiguizarea în 3.1). + +### 3.3 Mapare operații (reuse Treapta 1) +- Eticheta operației din fișier → `codPrestatie` prin `operations_mapping` + fuzzy. +- **Gate auto_send pe coduri noi (acceptat D3):** o operație nou-mapată sau cod neobișnuit NU se trimite + automat → review manual o dată (un `FINALIZATA` eronat e permanent). + +### 3.4 Preview + commit (gate HARD) +- `GET /v1/import/{id}/preview`: fiecare rând cu stare derivată (rulează `validation.py` + `resolve_prestatii` + FĂRĂ enqueue). **Cinci stări:** + - `ok` — gata de trimis. + - `needs_mapping` — operație fără cod. + - `needs_data` — validare RAR eșuată / odometru lipsă. + - `needs_review` (acceptat D6, R3) — coercion Excel suspectat (VIN numeric, odometru float). **Blochează + auto-send chiar dacă validarea trece** — VIN stricat = `FINALIZATA` permanent greșit. + - `already_sent` (acceptat D5) — cheia idempotency există deja. Preview arată „deja trimis pe `` ca + `idPrezentare X`". **Niciodată dedup tăcut într-un commit în masă** — decizie explicită per-rând. + - **Lookup batch, nu N+1 (Eng#5):** calculează toate cheile, apoi `SELECT idempotency_key FROM submissions + WHERE account_id=? AND idempotency_key IN (chunk)` (chunk-uri ~900 param SQLite). O(1) interogări, nu 5000. + `load_mapping` o singură dată ca POST. + - `duplicate_in_file` (Voce#3, NOU) — coliziune INTRA-batch. Grupare pe cheie în fișierul parsat: + `|grup|>1` identice → „rândul 12 și 88 identice"; același `vin+dată+odometru` cu operație diferită → + „rândul 12 și 41 diferă doar prin operație, confirmă". `already_sent` verifică doar batch-uri anterioare; + aceasta prinde coliziunile din ACELAȘI fișier (altfel `UNIQUE` global le înghite/erează mid-batch). +- **Gate HARD de confirmare (acceptat D3 + Voce#1):** rezumat dry-run (X gata, Y date lipsă, Z nemapate, W deja + trimise) + confirmare explicită (tastezi numărul de prezentări). **Plus atestare pe VALORI, nu doar pe total:** + preview-ul arată per-rând valorile FINALE rezolvate (VIN, dată ca `YYYY-MM-DD` cum o vede RAR, km); rândurile + `needs_review` trebuie bifate explicit „verificat" ca să intre în N. `N` dovedește numărul; bifa dovedește + conținutul. Oprește atât count-error cât și content-error (VIN coercionat / dată swap în rândul individual). +- Commit = enqueue în `submissions` DOAR rândurile `ok` confirmate → worker → monitorizare standard. + - **Log atestare (Voce#9):** la commit scrie `import_attestations` (batch_id, account_id, confirmed_by, ts, + `rows_hash`=sha256 peste valorile rezolvate confirmate, n_confirmed). Apare în `/v1/audit/export`. UI sub bară: + „Confirmând, TU ești declarantul acestor N prezentări la RAR (ireversibil)". Apărare legală + trasabilitate + (L.142/2023 — operatorul e declarantul de rol). + +### 3.4bis Cheie idempotency canonică partajată (Eng#2) +- Coercion-ul Excel (`123456.0`) calculat ÎNAINTE de cheie poate da cheie diferită de POST live (`123456`) → + `already_sent` ratează → al doilea `FINALIZATA`. Fix: extrage normalizarea canonică (odometru strip `.0`, + VIN upper/strip, dată `YYYY-MM-DD`) într-un helper public `idempotency.canonicalize_row(raw) -> dict` + + `build_key(account_id, canon)`. **Parser-ul de import ȘI `POST /v1/prezentari` apelează ACELAȘI helper** înainte + de cheie ȘI de validare. O sursă de adevăr; cele două canale nu pot diverge. + - **REGRESIE CRITICĂ:** cheia produsă de refactor trebuie să fie IDENTICĂ cu cea de azi pentru input-uri + existente, altfel rândurile deja trimise capătă cheie nouă → re-trimise. Test de regresie obligatoriu. + +### 3.5 Spectru de integrare (același backend) +1. **API** (există) — ROAAUTO / soft propriu. +2. **Upload manual în browser** (3.1-3.4) — service fără cod. **Acesta e scope-ul acestei trepte.** +3. ~~Drop fișier (SFTP/email-to-import)~~ — **CUT din această treaptă** (vezi NOT in scope, decizie D6). + +### 3.6 Acces web + creds RAR (model creds CORECTAT — decizie D4 + Eng#1/Voce#5) +- Login web (email + parolă cont) → folosește/emite API-key existent. +- Creds RAR introduse în UI, **criptate-at-rest per-cont** pe **coloana `accounts.rar_creds_enc`** (ALTER aditiv, + exact ca migrarea existentă a aceleiași coloane pe `submissions`; NU tabel nou — Eng#1). O singură sursă per cont. + - **Worker re-login (fallback):** `claim_one` rămâne; la login worker-ul face `creds = submission.creds_enc + OR SELECT rar_creds_enc FROM accounts WHERE id=?`. Submission web fără creds → ia din `accounts` → login OK. + - **De ce abatere de la zero-storage Treptei 1:** canalul web **nu are re-pusher** (ROAAUTO re-trimitea + la creds lipsă; aici nu există). Worker-ul trebuie să poată re-login oricând, altfel o serie încărcată + zile mai târziu, după un restart worker, rămâne blocată permanent → declarație legală netrimisă, tăcut. + - **Gate purjare `worker:271` (Voce#5, P1):** purjarea existentă `UPDATE submissions SET rar_creds_enc=NULL + WHERE account_id=?` e ACCOUNT-scoped → la primul login web ar șterge creds de pe TOATE submission-urile + contului, inclusiv cele API-channel efemere. Conturile CU `accounts.rar_creds_enc` durabil: purjarea devine + inofensivă (worker re-citește din `accounts`). Conturile FĂRĂ durabil (API-channel pur Treapta 1): purjarea + rămâne neschimbată. **Test = coadă MIXTĂ API+web** (după login web, submission-urile API tot se trimit), nu doar web. + - Compensare risc: creds tot criptate (Fernet), tot redactate din loguri; doar **persistate**, nu efemere. + Blast-radius mai mare la scurgere cheie Fernet (creds durabile vs. doar in-flight) — acceptat conștient (D4). + +## 4. Date noi (SQLite) +- `column_mappings` (account_id, signature_coloane, json mapare, format_data, created_at). +- `import_batches` (id, account_id, filename, status, total/ok/needs_*/already_sent/duplicate_in_file, created_at, purge_after). +- `import_rows` (batch_id, row_index, raw_json **criptat**, resolved_status, error). Purjate cu batch-ul. +- `accounts.rar_creds_enc` — **coloană durabilă per-cont** (ALTER aditiv, NU tabel nou) pentru canalul web (D4, Eng#1). +- `import_attestations` (batch_id, account_id, confirmed_by, ts, rows_hash, n_confirmed) — log atestare legală (Voce#9). +- `submissions += batch_id, row_index` (T7, P1) — scope pentru `reresolve_account` + trasabilitate export rânduri eșuate. + **Închide R1** (bulk-send tăcut cross-batch). T7 e predecesor HARD al U3 (vezi Roadmap). + +## 5. NOT in scope (amânat / tăiat, cu motiv) +- **Drop-fișier SFTP / email-to-import** (era U7) — TĂIAT. Trei mecanisme de intrare înainte ca un singur + service non-ROA să fi încărcat manual un fișier. Validează întâi upload-ul manual, apoi decide. (D6) +- **Contor volum + prag freemium** (era U6) — DEFER. Metrici de preț înainte să existe useri; a contoriza o + obligație legală e delicat. Contorul e trivial de adăugat post-validare. (D6) +- **Wedge auto-drop (SFTP/email-to-import)** — DEFER, confirmat de user la eng review: „verific manual upload-ul + întâi". Manual upload e wedge-ul; auto-drop se re-evaluează post-validare (tensiune Voce#8 vs CEO D6, rezolvată + în favoarea manual). +- **Mapare AI / conector MCP** — Treapta 3. +- **Editare/anulare prezentări trimise** — `FINALIZATA` terminal, neschimbat. +- **Billing complet (Stripe etc.)** — după validarea pragului. + +## 6. Mașina de stări (rând de import → submission) + +``` + ┌─────────── POST /v1/import (parsare, staging, NU trimite) ──────────┐ + ▼ │ + import_row.resolved_status: │ + ok ─────────────┐ │ + needs_mapping │ (preview: rezolva fara enqueue) │ + needs_data │ │ + needs_review ───┤ (coercion suspectat → blocheaza auto-send) │ + already_sent ───┘ (cheie idempotency exista → decizie per-rand) │ + │ │ + ▼ GATE HARD confirmare (tastezi N prezentari) │ + commit: enqueue DOAR rinduri `ok` confirmate ──▶ submissions (queued) ──▶ worker (Treapta 1, neatins) + login RAR → postPrezentare → FINALIZATA +``` + +## 7. Error & Rescue Map (stratul nou) + +``` + CODEPATH | CE POATE MERGE PROST | EXCEPTIE / STARE + -------------------------------|-----------------------------------|---------------------- + POST /v1/import parse xlsx | fisier corupt / non-xlsx | BadZipFile/InvalidFile + | encoding RO (cp1250) | UnicodeDecodeError + | >5MB / >5000 randuri | FileTooLarge (custom) + | header lipsa / coloane duplicate | HeaderError (custom) + parse cell | VIN/odometru coercion Excel | → stare needs_review + | data DD.MM.YYYY | → normalizare, altfel needs_data + apply column_mapping | semnatura coloane != reținuta | → cere re-confirmare (drift) + preview resolve | cheie idempotency exista | → stare already_sent + commit | confirmare numar gresit | reject, nu enqueue + | worker fara creds (restart) | → REZOLVAT D4 (creds durabile) + + STARE / EXCEPTIE | RESCUED? | ACTIUNE | USER VEDE + ------------------------|----------|----------------------------------|--------------------------- + BadZipFile/InvalidFile | Y | 422, mesaj „fisier invalid" | „Fisier nerecunoscut (xlsx/csv)" + UnicodeDecodeError | Y | retry cp1250/latin2, apoi 422 | „Encoding nesuportat" + FileTooLarge | Y | 413, fara parsare partiala | „Max 5000 randuri / 5MB" + HeaderError | Y | 422 + ce coloane s-au gasit | „Antet neclar, verifica fisierul" + needs_review (coercion) | Y | blocheaza auto-send, cere uman | rind galben „verifica VIN/km" + already_sent | Y | NU dedup tacut, decizie per-rind | „deja trimis pe #X" + drift semnatura coloane | Y | nu aplica orb, re-mapare | „coloanele difera, confirma maparea" + worker fara creds | Y (D4) | re-login din creds durabile | nimic (transparent) +``` + +## 8. Failure Modes Registry + +``` + CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER VEDE | LOGGED? + --------------------------------|-------------------------------|----------|-------|------------------|-------- + upload parse | encoding/format RO | Y | Y | mesaj clar | DA + cell coercion (VIN/odo Excel) | VIN stricat trece validarea | Y(D6) | Y | needs_review | DA + column_mapping drift | mapare tacuta gresita upload2 | Y(D3) | Y | re-confirmare | DA + commit in masa | trimite 100 randuri gresite | Y(D3) | Y | gate confirmare | DA + re-export (idempotency) | duplicat / corectie inghitita | Y(D5) | Y | already_sent | DA + worker restart, creds purjate | serie blocata permanent tacut | Y(D4) | Y | nimic (re-login) | DA + mapare salvata → re-resolve | trimite tacut randuri cross- | Y(T7) | Y | gate confirmare | DA + | batch / feed API live | | | (batch scoped) | + data string zi<=12 (DD vs MM) | data gresita-dar-valida trece | Y(V#2) | Y | needs_review | DA + duplicat in ACELASI fisier | UNIQUE global inghite/ereaza | Y(V#3) | Y | duplicate_in_file| DA + multi-op same vin+data+odo | reconcile dropa rand netrimis | Y(V#4) | Y | confirma manual | DA + creds durabile, login web | purjare account-scoped sterge | Y(V#5) | Y | nimic (fallback) | DA + | creds API-channel efemere | | | | + 100 declaratii dintr-un N | raspundere fara atestare/rol | Y(V#9) | Y | UI declarant+log | DA + export RO: sheet 2 / merged hdr | HeaderError pe fisier valid | Y(V#6) | Y | alege sheet/flag | DA + export RO: footer TOTAL parsat | prestatie fantoma needs_data | Y(V#7) | Y | skip structural | DA +``` + +**R1 ÎNCHIS (T7, P1):** `reresolve_account` (mapping.py:253) primește `batch_id` și se scope-ază la seria comitată; +T7 e predecesor HARD al U3. Salvarea unei mapări nu mai poate auto-queue rânduri cross-batch / din feed API live. +Niciun failure mode silent rămas neacoperit. + +## 9. Riscuri / open questions +- **R1 (ÎNCHIS la eng review):** mapare account-global → bulk-send tăcut cross-batch. Fix `batch_id` scoping + promovat la P1-blocking (T7), predecesor HARD al U3. Nu mai e deschis. +- **R2:** fișiere mari (mii rânduri) → upload sincron + `openpyxl read_only` streaming + cap hard înainte de + parsare (Eng#6). Async amânat până apare nevoia reală. +- **R4 (nou, blast-radius):** creds durabile-at-rest (D4) → la scurgerea cheii Fernet, toate parolele RAR sunt + decriptabile (vs. doar in-flight azi). Acceptat conștient; mitigare = rotație cheie + redactare loguri (existent). +- **R3:** coercion Excel nerecuperabilă la parsare → stare `needs_review` (acceptat D6). +- Un cont = un agent RAR sau mai mulți (afectează maparea creds în UI) — open question moștenit din plan.md. +- `b64Image` rămâne opțional, omis în upload v2. + +## 10. Roadmap (reordonat — eng review: T7 înainte de U3) +- [ ] **U1** — `import_batches`/`import_rows`/`column_mappings` + parser xlsx/csv (`POST /v1/import`), cu + encoding RO + delimiter sniff + openpyxl read_only + dezambiguizare dată + robustețe sheet/merged/footer. + PII criptat în staging. (T3 + Voce#2/#6/#7 + Eng#3/#6) +- [ ] **U2** — creds RAR durabile per-cont pe web (`accounts.rar_creds_enc`, ALTER aditiv) + worker re-login + fallback + gate purjare `worker:271` (fix D4 + Voce#5). **Mutat înainte** — dependență hard end-to-end. +- [ ] **T7** — `batch_id`/`row_index` pe `submissions` + scope `reresolve_account` la seria comitată. + **Predecesor HARD al U3** — închide R1 (bulk-send cross-batch) ÎNAINTE ca save-mapare să ajungă live. +- [ ] **U3** — mapare coloane + reținere per cont + semnătură + detectie drift + auto-sugestie fuzzy (reuse + `normalize_for_match`, Eng#4). Nu se livrează până T7 nu e merged. +- [ ] **U4** — preview (6 stări: ok/needs_mapping/needs_data/needs_review/already_sent/duplicate_in_file) + + lookup `already_sent` batch (Eng#5) + canonicalize partajat (Eng#2) + gate HARD confirmare cu atestare pe + valori (Voce#1) + log atestare (Voce#9) + commit selectiv → coadă. +- [ ] **U5** — UI web upload (Jinja2+HTMX în dashboard): drop → mapează → preview → confirmă → trimite. +- [ ] **U6 (P2)** — export rânduri eșuate (CSV) pentru corecție + re-upload (acceptat D3; depinde de T7). +- [ ] ~~contor freemium~~ — DEFER (D6). ~~drop-fișier SFTP~~ — CUT (D6, re-eval post-validare). + +## 11. Diagrame + +### Arhitectură (componente noi vs existente) +``` + [Fisier xlsx/csv] GATEWAY (existent, neatins sub linie) + │ upload ┌──────────────────────────────────────┐ + ▼ │ app/validation.py app/mapping.py │ + POST /v1/import ──parse──▶ import_batches │ app/idempotency.py app/crypto.py │ + (NOU) (cp1250, coercion) │ │ app/reconcile.py │ + │ ▼ └──────────────┬───────────────────────┘ + │ import_rows (PII cript) │ commit (rinduri ok) + ▼ │ preview ▼ + column_mappings (NOU) ──semnatura──▶ │ resolve submissions+batch_id ──▶ WORKER (existent) + (mapare retinuta+drift) │ (6 stari) queued (T7 scope) login RAR → postPrezentare + ▲ ▼ └─▶ FINALIZATA (permanent) + accounts.rar_creds_enc (NOU, D4) ───creds durabile──▶ worker re-login fallback (fara re-pusher) +``` + +### Data flow + shadow paths (upload → commit) +``` + FISIER ──▶ PARSE ──▶ MAP COLOANE ──▶ RESOLVE ──▶ CONFIRM ──▶ ENQUEUE + │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ + [gol?] [coercion?] [drift?] [already_ [N gresit?] [dup key? + [non-xlsx?][encoding?] [nemapat?] sent?] reject → already_sent] + [>5MB?] [needs_ [auto_send [needs_ [creds? → D4] + review] gate] data?] +``` + +## 12. Implementation Tasks +Sintetizate din findings. P1 blochează ship; P2 = aceeași treaptă; P3 = follow-up. + +- [ ] **T1 (P1, human ~1zi / CC ~30min)** — schema — coloană `accounts.rar_creds_enc` durabilă + worker re-login fallback + **gate purjare `worker:271`** + - Surfaced by: voce externă #1 (D4) + Voce#5 — `worker/__main__.py:271` purjază account-scoped, web n-are re-pusher + - Files: `app/schema.sql` (ALTER aditiv), `app/db.py:_migrate`, `app/worker/__main__.py`, `app/crypto.py` + - Verify: test — (a) serie web, worker restart, token expirat → re-login din `accounts` → trimite; + (b) **coadă MIXTĂ** API(efemer)+web(durabil) → după login web, submission-urile API tot se trimit +- [ ] **T2 (P1, human ~half zi / CC ~20min)** — preview — stare `already_sent` + lookup **batch IN(...)** (no N+1, no silent dedup) + - Surfaced by: voce externă #2 (D5) + Eng#5 — `idempotency.py:23`; 5000 randuri = N+1 dacă per-rând + - Files: `app/api/v1/` import preview, `app/idempotency.py` + - Verify: test — (a) re-upload cu typo odometru corectat → already_sent, nu al doilea FINALIZATA; (b) 5000 randuri → ≤7 interogări +- [ ] **T3 (P1, human ~half zi / CC ~20min)** — parse — coercion guard + stare `needs_review` (blochează auto-send) + **mesaj formule-None** + - Surfaced by: voce externă #8 (D6, R3) + **Eng pass 2 Issue 3** — `openpyxl data_only=True` întoarce `None` pe celule cu formule necalculate (export soft RO ≠ Excel) → indistinct de gol → cad în `needs_data` cu mesaj generic confuz pe un fișier care arată plin + - **Issue 3 (P2):** când o coloană obligatorie întoarce `None` pe o pondere mare de rânduri (euristică pe rata de None, fără `data_only=False`), emite mesaj țintit: „fișier cu formule fără valori salvate — deschide și re-salvează în Excel". Gate-ul `needs_data` prevenea deja trimiterea greșită; asta e doar claritate UX. + - **Ordonare critică (Eng#2/§3.4bis):** `canonicalize_row` rulează ÎNAINTE de `validate_prezentare` — `_parse_int` (`validation.py:44`, `isdigit()`) respinge `"123456.0"`; coercion-ul trebuie să taie `.0` înainte ca validarea să-l vadă (altfel `needs_data` în loc de `needs_review`). + - Files: parser import, preview resolve + - Verify: test — (a) VIN `0123…` numeric din xlsx → needs_review, nu se trimite; (b) xlsx cu coloană de formule fără cache → mesaj specific, nu `needs_data` generic; (c) odometru `123456.0` → canonicalizat la `123456` înainte de validare +- [ ] **T4 (P1, human ~half zi / CC ~20min)** — mapare — semnătură coloane + detectie drift + - Surfaced by: review D3 + - Files: `column_mappings`, mapare coloane + - Verify: test — upload 2 cu coloane mutate → cere re-confirmare, nu aplică orb +- [ ] **T5 (P1, human ~half zi / CC ~25min)** — preview — gate HARD confirmare (tastezi N) + **atestare pe valori rezolvate** (Voce#1) + - Surfaced by: review D3 + Voce#1 — N dovedește totalul, bifa dovedește conținutul (VIN/dată/km finale) + - Files: UI preview, commit endpoint + - Verify: test — (a) commit fără N corect → reject; (b) rând `needs_review` nebifate → exclus din N, nu se trimite +- [ ] **T6 (P1, human ~half zi / CC ~15min)** — mapare — gate auto_send pe coduri nou-mapate (**NU e additiv — schimbă cod existent**) + - Surfaced by: review D3 + plan.md P2 + **Eng pass 2 OV-1** — `auto_send` e SCRIS (`save_mapping`) și afișat (`_mapari.html:49`) dar CITIT de niciun codepath; `reresolve_account` și bucla POST resolve trec pe `queued` ignorând flag-ul → AZI codurile nou-mapate se auto-trimit deja (bug latent Treapta 1) + - **OV-1 (P1):** T6 trebuie să MODIFICE `reresolve_account` ȘI resolve-ul POST/import să consulte `auto_send` (`auto_send=0` → stare ținută/`needs_review`), nu doar să adauge un gate nou. + - Files: `app/mapping.py` (reresolve_account), `app/api/v1/router.py` (POST resolve), commit + - Verify: test — (a) cod nou-mapat cu `auto_send=0` → nu auto-send, review manual; (b) **regresie:** mapare existentă cu `auto_send=1` tot se requeue ca azi +- [ ] **T7 (P1, human ~1zi / CC ~30min)** — **R1 ÎNCHIS** — `batch_id`/`row_index` pe submissions + scope `reresolve_account` (**predecesor HARD al U3**) + - Surfaced by: voce externă #3+#5 + Voce#10 — `mapping.py:253` account-global (PROMOVAT la P1-blocking la eng review) + - Files: `app/schema.sql`, `app/db.py:_migrate`, `app/mapping.py`, commit + - Verify: test — (a) salvare mapare în batch A NU trimite rânduri din batch B / feed API; (b) canal API (batch_id NULL) tot se re-rezolvă ca azi +- [ ] **T8 (P2, human ~half zi / CC ~15min)** — export rânduri eșuate CSV (depinde de T7 pt. trasabilitate) + - Surfaced by: review D3 + - Files: import export endpoint + - Verify: descarci needs_data/needs_mapping ca CSV, corectezi, re-upload +- [ ] **T9 (P1, human ~half zi / CC ~20min)** — idempotency — `canonicalize_row` + `build_key` partajat (parser + POST), DRY + **normalizare account_id** + - Surfaced by: Eng#2 + **Eng pass 2 OV-2** — coercion înainte de cheie → divergență `already_sent`; `idempotency.py:23` hash-uiește `account_id` AS-PASSED (`None` pe canal API, `router.py:66`) dar rândurile se stochează sub `account_or_default`=1 → același rând logic capătă cheie diferită cross-canal → `already_sent` ratează → al doilea `FINALIZATA` + - **OV-2 (P1):** `canonicalize_row`/`build_key` aplică `account_or_default` ÎNAINTE de hash (None și 1 colapsează la o cheie). Tensiune cu §3.4bis „cheie identică": rândurile vechi cheie-`None` trebuie reconciliate (recompute o-singură-dată SAU dual-lookup), documentat explicit. + - Files: `app/idempotency.py`, parser import, `app/api/v1/router.py` + - Verify: test — (a) **cross-canal:** cheie(API canal-None) == cheie(import canal-rezolvat) pt. același rând logic; (b) regresie: strategia de reconciliere a cheilor vechi acoperită de test (fără re-trimitere tăcută) +- [ ] **T10 (P1, human ~half zi / CC ~20min)** — parse — dezambiguizare dată **la nivel de coloană** (datetime nativ direct; string ambiguu → needs_review) + - Surfaced by: Voce#2 + **Eng pass 2 OV-8** — `validation.py:81` (`date.fromisoformat`) acceptă orice ISO valid în interval → un DD/MM swap valid-dar-greșit trece. `zi≤12` per-rând ratează coloana uniform MM.DD (rândurile `zi>12` par neambigue și trec ca `ok`) + - **OV-8 (P3):** detectează formatul din ÎNTREAGA coloană — dacă ORICE rând are token poziția-1 `>12`, coloana e DD-first; aplică formatul la toate rândurile, nu doar flag per-rând `zi≤12`. + - Files: parser import, preview resolve + - Verify: test — (a) `03.04.2026` string ambiguu → needs_review; (b) celulă datetime nativă → folosită direct; (c) coloană uniform MM.DD cu rânduri `zi>12` → format detectat la nivel de coloană, nu trec orb ca `ok` +- [ ] **T11 (P1, human ~half zi / CC ~20min)** — preview — detecție coliziuni intra-batch (DOAR la preview/commit, NU în worker) + - Surfaced by: Voce#3+#4 + **Eng pass 2 OV-3** — `UNIQUE` global înghite dup intra-fișier; `reconcile.py` e op-blind PRIN DESIGN (recuperare răspuns pierdut, worker:184/217) + - **OV-3 (P1):** detecția coliziunilor intra-fișier trăiește EXCLUSIV la preview/commit (`duplicate_in_file`). NU edita `reconcile.py` / `worker/__main__.py` — a face reconcile op-aware regresează T2 (recuperarea POST-ului pierdut pe timeout legitim). Intra-file dedup (preview-time) ≠ reconcile stare-RAR (worker-time): probleme diferite. + - Files: preview resolve (NU reconcile.py, NU worker) + - Verify: test — (a) 2 rânduri identice în fișier → `duplicate_in_file`; (b) batch cu vin+data+odo colidant → flag la preview, cere manual; (c) **regresie T2:** `match_finalizata` rămâne op-blind, recuperarea răspuns-pierdut neschimbată +- [ ] **T12 (P2, human ~half zi / CC ~15min)** — commit — log `import_attestations` + UI „ești declarantul" + **commit per-rând ON CONFLICT (TOCTOU)** + - Surfaced by: Voce#9 + **Eng pass 2 Issue 1** — `already_sent` la preview e un snapshot; gardianul real e indexul UNIQUE la INSERT, minute mai târziu. Un canal concurent (API live / al 2-lea import) poate insera cheia colidant în fereastra preview→commit → un INSERT multi-rând într-o tranzacție rollback-uiește TOT batch-ul (`router.py:100` e INSERT simplu, nu OR IGNORE) → utilizatorul a tastat N, confirmat, primește eroare opacă, iar `rows_hash`(N) nu mai corespunde cu ce s-a inserat. + - **Issue 1 (P1):** commit inserează per-rând cu `INSERT … ON CONFLICT(idempotency_key) DO NOTHING`; rândurile care colidează se reclasifică `already_sent` în rezultatul commit-ului; `import_attestations.rows_hash` + `n_confirmed` acoperă DOAR rândurile efectiv puse în coadă (nu N inițial). Respectă principiul planului „niciodată dedup tăcut". + - Files: `app/schema.sql`, commit endpoint, `app/api/v1/router.py` (audit export), UI preview + - Verify: test — (a) commit → rând `import_attestations` cu rows_hash + n_confirmed; apare în `/v1/audit/export`; (b) **TOCTOU:** cheie inserată de canal concurent după preview → rând reclasificat `already_sent`, atestarea acoperă doar rândurile puse în coadă +- [ ] **T13 (P2, human ~1zi / CC ~25min)** — parse — robustețe export RO (multi-sheet + merged header + trim footer) + - Surfaced by: Voce#6+#7 — sheet 2 / celule îmbinate → HeaderError pe fișier valid; footer TOTAL → prestatie fantomă + - Files: parser import + - Verify: test — (a) workbook 2 sheets → cere alegerea; (b) header merged → nume reale; (c) footer fără VIN → skip, nu needs_data +- [ ] **T14 (P2, human ~half zi / CC ~15min)** — perf — CSV delimiter sniff + openpyxl `read_only` streaming + cap înainte de parse + - Surfaced by: Eng#3+#6 — `;` RO dă 1 coloană tăcut; DOM întreg = vârf memorie + - Files: parser import + - Verify: test — (a) CSV `;` → coloane corecte; 1 coloană → HeaderError; (b) >5000 rânduri → FileTooLarge fără parse parțial +- [ ] **T15 (P2, human ~half zi / CC ~20min)** — test — **E2E integrare** import→commit→worker (RAR mock) + - Surfaced by: Test review — mock-urile per-unit ascund cheia idempotency + re-login + batch scoping + - Files: `tests/test_import_e2e.py` + - Verify: upload fixture → mapează → preview → commit N → worker run_once(MockRar) → FINALIZATA; re-upload corectat → already_sent +- [ ] **T16 (P1, human ~2h / CC ~20min)** — retenție — job purjare + `purge_after` SET la insert (ambele canale) + - Surfaced by: **Eng pass 2 OV-5** — `purge_after` e exportat în audit dar SETAT de niciun INSERT și NICIUN job de purjare nu există (`grep purge_after` → doar SELECT). Planul presupunea paritate `submissions`/`import_rows` care nu există → PII criptat (Fernet) trăiește la nesfârșit. Decalaj GDPR/L.142. + - Files: `app/worker/__main__.py` (tick purjare), commit/insert (`submissions` + `import_batches`/`import_rows`) + - Verify: test — (a) insert → `purge_after` populat (sent+90z); (b) rând expirat → șters de tick-ul de purjare; (c) `import_rows` purjate cu batch-ul + +### 12bis. Eng Review Pass 2 — sinteză (2026-06-16) +A doua trecere `/plan-eng-review` pe planul deja CLEARED: 6 findings noi (Claude) + 5 din vocea externă (subagent — Codex la cuotă), TOATE acceptate cu opțiunea completă (Lake 11/11). Detalii foldate în taskuri: +- **Issue 1 (P1, T12):** commit TOCTOU → per-rând `ON CONFLICT DO NOTHING`, atestare doar pe rândurile puse în coadă. +- **Issue 2 (P2, T13/T14) = vocea externă OV-6 (consens cross-model):** `openpyxl read_only=True` nu vede celule îmbinate → parser în 2 treceri (read_only dim-check + body; normal-mode header+merged DUPĂ cap-check). +- **Issue 3 (P2, T3):** `data_only=True` → `None` pe formule necalculate → mesaj specific (euristică rată-None). +- **Issue 4 (P3, U1):** `openpyxl` lipsește din `requirements.txt` → adaugă PINNED (ex. `openpyxl==3.1.x`) explicit în U1. +- **Issue 5 (P2, U1/U3):** teste explicite — (a) `import_rows.raw_json` criptat la rest (ciphertext pe disc, plaintext după decrypt); (b) fuzzy coloane refolosește `mapping.normalize_for_match` (fără normalizator duplicat). +- **Issue 6 (P2, U1/U4):** scrieri bulk sub autocommit (`db.py:17` `isolation_level=None`) → `BEGIN IMMEDIATE`…`COMMIT` + `executemany` (model `claim_one`); 5000 fsync → 1. +- **OV-1 (P1, T6):** `auto_send` coloană moartă (citită nicăieri) → T6 modifică `reresolve_account` + resolve POST, nu doar adaugă. +- **OV-2 (P1, T9):** skew `account_id` la hash → normalizare `account_or_default` în `canonicalize_row` + test cross-canal. +- **OV-3 (P1, T11):** intra-file dedup DOAR la preview/commit; NU atinge `reconcile.py`/worker (op-blind by design, T2). +- **OV-5 (P2, T16):** job purjare + `purge_after` la insert (nou T16, mai sus). +- **OV-8 (P3, T10):** dezambiguizare dată la nivel de coloană, nu per-rând `zi≤12`. +- **NOTE U1:** parserul = 2 treceri (Issue 2); adaugă `openpyxl` pinned (Issue 4); test PII-at-rest (Issue 5a); scrieri staging în tranzacție explicită + `executemany` (Issue 6). **NOTE U3:** test reuse `normalize_for_match` (Issue 5b). **NOTE U4:** enqueue în tranzacție explicită (Issue 6). +- **Constrângere asset offline (learning):** UI upload (U5) NU introduce assets din CDN — gateway rulează offline; refolosește htmx vendorizat local (`app/web/static/`). +- **Ordine livrare actualizată:** U1 → U2/T1 → T7 → U3 → U4 → U5; **T16 (purjare) poate merge în paralel** (independent de T7). + +## 13. Design spec UI (post `/plan-design-review`) + +> Clasificare: **APP UI** (tool intern, data-dense). Extinde sistemul existent din +> `app/web/templates/base.html` (`:root` tokens) — NU introduce limbaj nou. Refolosește: +> `--ok/--warn/--err/--accent`, `.card`, pills `.s-*`, `.maprow`, `.tablewrap`, empty states. + +### 13.1 Information architecture (Pass 1) +``` + DASHBOARD (existent) + ├─ card UPLOAD (NOU, sus; primar/CTA cand coada e goala) + │ „Incarca fisier (xlsx/csv)" → drop zone + buton + ├─ [dupa upload] → ecran/sectiune MAPARE + │ 1. mapare COLOANE (.maprow: camp canonic ← dropdown coloane) + │ 2. mapare OPERATII (editorul fuzzy existent) + ├─ [dupa mapare] → PREVIEW (tabel dominant, 5 stari) + │ rezumat pills sus + filtru + bara confirmare jos + └─ coada submissions (existent, neatins) +``` +Ierarhie preview: 1) rezumat stari (ce e gata/cu probleme), 2) tabelul, 3) bara de trimitere. + +### 13.2 Tabel stari interacțiune (Pass 2) +``` + ECRAN | LOADING | EMPTY | ERROR | SUCCESS | PARTIAL + ---------------|--------------------|-----------------------|--------------------|--------------------|-------------------- + Upload | spinner „se incarca| drop zone + „trage | „fisier invalid | → trece la mapare | n/a + | / se parseaza…" | fisierul aici" + CTA | (xlsx/csv)" rosu | | + Mapare coloane | — | „nicio coloana | dropdown rosu pe | toate verzi → next | unele campuri + | | detectata" | camp obligatoriu | | nemapate (galben) + Preview | „se valideaza N | „0 randuri in fisier" | rand rosu + motiv | „N gata de trimis" | rezumat: X ok, + | randuri…" | | per rand | verde | Y probleme + Trimitere | bara progres N/M | n/a | rand → error in | „N trimise" flash | „M din N trimise, + | | | coada | (.flash existent) | restul in coada" +``` +Empty state upload = feature: warmth („Primul fisier? Trage-l aici.") + CTA + context („xlsx sau csv, max 5000 randuri"). + +### 13.3 User journey / arc emoțional (Pass 3) +``` + PAS | USER FACE | USER SIMTE | UI SUSTINE + ----|--------------------|-------------------------|--------------------------------- + 1 | incarca fisier | nesiguranta („trimite | mesaj clar „NU se trimite nimic + | | acum la RAR?") | pana confirmi" sub drop zone + 2 | mapeaza coloane | efort prima data | auto-sugestie fuzzy pre-selectata; + | | | a 2-a oara: „mapare retinuta aplicata" + 3 | vede preview | control / verificare | rezumat pills + problemele primele + 4 | confirma (tastezi N| frica de greseala | gate explicit; „needs_review" galben + | | permanenta | blocheaza VIN suspect + 5 | vede „N trimise" | usurare / incredere | .flash verde + rand sent in coada +``` +5s: „inteleg ca nu trimite nimic inca". 5min: „maparea s-a retinut". 5 luni: „drop + trimite, sub 1 min". + +### 13.4 AI slop (Pass 4) — 8/10 +APP UI, refolosește sistemul calm existent. Fără card-mosaic decorativ, fără gradients, fără +3-column grid, fără border-left colorat ornamental. Pills semantice = funcționale, nu decor. OK. + +### 13.5 Design system (Pass 5) +- Stări rând: refolosește `.s-queued/.s-sent/.s-error`; adaugă `.s-needs_review` (galben `--warn`), + `.s-already_sent` (muted), **`.s-duplicate_in_file` (muted, D10)**. Pills numerice rezumat = aceleași culori. + - **Semantica culorii (D10, post eng review):** amber `--warn` = „verifică valori" (DOAR needs_review); + muted `--muted` = „informațional / decizie per-rând" (already_sent + duplicate_in_file); roșu `--err` = blocat; + verde `--ok` = ok; albastru `--accent` = în coadă. `duplicate_in_file` diferențiat de already_sent prin TEXT + explicit + referință încrucișată („dublă cu rândul 88"), nu doar culoare (daltonism — pill poartă cuvântul). +- Mapare coloane = `.maprow` + `.mapcol.grow` + `select` (exact ca `_mapari.html`). +- Drop zone: `.card` cu bordura `--line` dashed la hover; fără estetică nouă. +- Bara confirmare: `.card` fix jos, buton `--accent` existent, `input[type=text]` pentru N. + - **Checkbox atestare (D11):** rândurile `needs_review` au `.chk` existent **per-rând** („verificat") — trebuie + bifate ca să intre în N (forțează privirea pe fiecare valoare rezolvată). `