Files
rar-autopass/docs/plans/plan-treapta2.md
Claude Agent c38807d88c docs(plan): adauga plan-treapta2.md (planul Treapta 2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:51:39 +00:00

44 KiB
Raw Blame History

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 <data> 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_enccoloană 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 trimiseFINALIZATA 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 <data> #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)

  • U1import_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.
  • T7batch_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 3openpyxl 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-1auto_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 ÎNCHISbatch_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-8validation.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-3UNIQUE 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 1already_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-5purge_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=TrueNone 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 IMMEDIATECOMMIT + 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ă). <label> vizibil + focus tastatură.
    • Banner declarant (D12): variantă .banner cu --warn (avertisment, nu eroare roșie), plasat DIRECT deasupra input-ului N: „Confirmând, TU ești declarantul acestor N prezentări la RAR (ireversibil)". Inevitabil la momentul confirmării (Krug). Anunțat la screen-reader.

13.6 Responsive & a11y (Pass 6)

  • Mobil: .maprow deja se rupe (flex-wrap); tabel preview în .tablewrap (scroll în card, existent). Bara de confirmare devine sticky bottom, nu fixed-overlap.
  • Touch ≥44px (deja .cardlink min-height 36px → ridică la 44 pe butoanele de acțiune upload/confirm).
  • a11y: drop zone are și buton (nu doar drag — drag nu e accesibil la tastatură); dropdown-urile de mapare au <label> vizibil (nu placeholder-as-label); stările au și text, nu doar culoare (pill cu cuvânt, nu doar pastilă colorată — daltonism); contrast ≥4.5:1 (tokenii existenți trec).
  • Gate confirmare accesibil: input N cu label, eroare anunțată, focus pe el la deschidere.

13.7 Decizii de design (Pass 7, rezolvate)

  • Mapare coloane = listă .maprow cu dropdown + eșantion (D8-A). Nu wizard, nu dropdown-pe-antet.
  • Preview = rezumat pills + filtru pe stare + problemele primele + bară confirmare cu tastare N (D9-A).
  • D10 (post eng review): .s-duplicate_in_file = muted + text „dublă cu rândul N" (grupat cu already_sent; amberul rămâne doar „verifică valori"). Fără culoare nouă — disciplină de sistem.
  • D11: atestare = .chk per-rând pe needs_review, obligatoriu pentru includere în N (atestare pe valori).
  • D12: banner declarant = .banner --warn direct deasupra input-ului N (răspundere legală inevitabilă).

GSTACK REVIEW REPORT

Review Trigger Why Runs Status Findings
CEO Review /plan-ceo-review Scope & strategy 1 issues_open SELECTIVE EXPANSION: 6 propuneri, 5 acceptate, 2 deferate/taiate
Eng Review /plan-eng-review Architecture & tests (required) 2 clean Pass 1: 12 findings foldate (R1 INCHIS, T7 P1). Pass 2: 11 findings noi (1 arh + 2 cod + 1 perf + 5 voce externa + 2 test), TOATE acceptate cu optiunea completa (Lake 11/11)
Outside Voice subagent Claude (Codex la cuota) Independent 2nd opinion 2 issues_found Pass 1: 10 findings. Pass 2: 5 noi (OV-1 auto_send mort, OV-2 skew account_id, OV-3 reconcile op-blind, OV-5 fara job purjare, OV-8 data col-level), TOATE absorbite
Design Review /plan-design-review UI/UX gaps 2 clean full 4→9 (9 decizii) + delta 5→9 pe 3 stari UI noi post-eng (D10/D11/D12)
  • CROSS-MODEL: Pass 2 — Issue 2 (Claude) == OV-6 (voce externa) au ajuns INDEPENDENT la aceeasi concluzie (openpyxl read_only nu vede merged cells → parser 2-treceri): consens, confidenta ridicata. Fara tensiune Claude-vs-voce-externa in pass 2. (Pass 1: vocea externa contrazisese design D9-A + CEO D6, rezolvate.)
  • PASS 2 — corectitudine pre-existenta: OV-1/OV-2/OV-3/OV-5 sunt locuri unde planul presupunea o schimbare aditiva dar codul EXISTENT contrazicea presupunerea: auto_send coloana moarta (citita nicaieri), skew account_id la hash (None vs rezolvat), reconcile op-blind by design (T11 nu trebuie sa-l atinga), purge_after setat de nimeni + zero job purjare (PII nelimitat). Toate foldate in T6/T9/T11/T16.
  • VERDICT: CEO + DESIGN + ENG (×2) CLEARED — gata de implementare. R1 INCHIS (T7 P1, predecesor HARD U3). Niciun critical gap silent ramas. Ordine livrare: U1 → U2/T1 → T7 → U3 → U4 → U5; T16 (purjare) in paralel.

NO UNRESOLVED DECISIONS