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

475 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_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 <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)
- [ ] **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ă). `<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