44 KiB
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 + fallbackcp1250/latin2(RO) + BOM.- Parsare:
openpyxl(xlsx) /csvstdlib. Limită (ex. 5 MB / ~5000 rânduri) → semnal explicit, nu trunchiere tăcută. - openpyxl
read_only=Truestreaming (Eng#6):load_workbook(read_only=True, data_only=True); verificămax_row/dimensiune ÎNAINTE de parsare →FileTooLargefă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.Snifferpe{; , \t}sau probă explicită; alege delimiter-ul care dă >1 coloană consistent. 1 coloană →HeaderErrorclar, NU mapare oarbă. - Coercion Excel (R3): odometru numeric →
123456.0; VIN/nr cu zerouri tăiate; date cadatetime. Normalizarea e centralizată înidempotency.canonicalize_row(vezi 3.4bis); coercion nerecuperabilă → stareaneeds_review(3.4). - Dată dezambiguizată (Voce#2): celulă
datetimenativă din openpyxl → folosită DIRECT (neambiguă). Celulă STRING → aplicădate_fmtmapat, 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 (footerTOTAL/Întocmit de:); rând fără VIN = skip structural, nuneeds_datafantomă. - 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 cuapp/crypto.py,purge_aftercasubmissions).
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ă.
- DRY (Eng#4): refolosește
- Format dată configurabil per mapare (
DD.MM.YYYYRO vs ISO) → normalizat laYYYY-MM-DD(vezi dezambiguizarea în 3.1).
3.3 Mapare operații (reuse Treapta 1)
- Eticheta operației din fișier →
codPrestatieprinoperations_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
FINALIZATAeronat e permanent).
3.4 Preview + commit (gate HARD)
GET /v1/import/{id}/preview: fiecare rând cu stare derivată (ruleazăvalidation.py+resolve_prestatiiFĂ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 =FINALIZATApermanent greșit.already_sent(acceptat D5) — cheia idempotency există deja. Preview arată „deja trimis pe<data>caidPrezentare 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_mappingo singură dată ca POST.
- Lookup batch, nu N+1 (Eng#5): calculează toate cheile, apoi
duplicate_in_file(Voce#3, NOU) — coliziune INTRA-batch. Grupare pe cheie în fișierul parsat:|grup|>1identice → „rândul 12 și 88 identice"; acelașivin+dată+odometrucu operație diferită → „rândul 12 și 41 diferă doar prin operație, confirmă".already_sentverifică doar batch-uri anterioare; aceasta prinde coliziunile din ACELAȘI fișier (altfelUNIQUEglobal 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-DDcum o vede RAR, km); rândurileneeds_reviewtrebuie bifate explicit „verificat" ca să intre în N.Ndovedeș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
submissionsDOAR rândurileokconfirmate → 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).
- Log atestare (Voce#9): la commit scrie
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_sentratează → al doileaFINALIZATA. Fix: extrage normalizarea canonică (odometru strip.0, VIN upper/strip, datăYYYY-MM-DD) într-un helper publicidempotency.canonicalize_row(raw) -> dict+build_key(account_id, canon). Parser-ul de import ȘIPOST /v1/prezentariapelează 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)
- API (există) — ROAAUTO / soft propriu.
- Upload manual în browser (3.1-3.4) — service fără cod. Acesta e scope-ul acestei trepte.
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 pesubmissions; NU tabel nou — Eng#1). O singură sursă per cont.- Worker re-login (fallback):
claim_onerămâne; la login worker-ul facecreds = submission.creds_enc OR SELECT rar_creds_enc FROM accounts WHERE id=?. Submission web fără creds → ia dinaccounts→ 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 CUaccounts.rar_creds_encdurabil: purjarea devine inofensivă (worker re-citește dinaccounts). 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).
- Worker re-login (fallback):
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 pentrureresolve_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 —
FINALIZATAterminal, 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_idscoping promovat la P1-blocking (T7), predecesor HARD al U3. Nu mai e deschis. - R2: fișiere mari (mii rânduri) → upload sincron +
openpyxl read_onlystreaming + 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.
b64Imageră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 purjareworker:271(fix D4 + Voce#5). Mutat înainte — dependență hard end-to-end. - T7 —
batch_id/row_indexpesubmissions+ scopereresolve_accountla 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_sentbatch (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_encdurabilă + worker re-login fallback + gate purjareworker:271- Surfaced by: voce externă #1 (D4) + Voce#5 —
worker/__main__.py:271purjază 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
- Surfaced by: voce externă #1 (D4) + Voce#5 —
- 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
- Surfaced by: voce externă #2 (D5) + Eng#5 —
- 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întoarceNonepe celule cu formule necalculate (export soft RO ≠ Excel) → indistinct de gol → cad înneeds_datacu mesaj generic confuz pe un fișier care arată plin - Issue 3 (P2): când o coloană obligatorie întoarce
Nonepe 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-ulneeds_dataprevenea deja trimiterea greșită; asta e doar claritate UX. - Ordonare critică (Eng#2/§3.4bis):
canonicalize_rowrulează ÎNAINTE devalidate_prezentare—_parse_int(validation.py:44,isdigit()) respinge"123456.0"; coercion-ul trebuie să taie.0înainte ca validarea să-l vadă (altfelneeds_dataîn loc deneeds_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, nuneeds_datageneric; (c) odometru123456.0→ canonicalizat la123456înainte de validare
- Surfaced by: voce externă #8 (D6, R3) + Eng pass 2 Issue 3 —
- 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_reviewnebifate → 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_sende SCRIS (save_mapping) și afișat (_mapari.html:49) dar CITIT de niciun codepath;reresolve_accountși bucla POST resolve trec pequeuedignorâ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ă consulteauto_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ă cuauto_send=1tot se requeue ca azi
- Surfaced by: review D3 + plan.md P2 + Eng pass 2 OV-1 —
- T7 (P1, human ~1zi / CC ~30min) — R1 ÎNCHIS —
batch_id/row_indexpe submissions + scopereresolve_account(predecesor HARD al U3)- Surfaced by: voce externă #3+#5 + Voce#10 —
mapping.py:253account-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
- Surfaced by: voce externă #3+#5 + Voce#10 —
- 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_keypartajat (parser + POST), DRY + normalizare account_id- Surfaced by: Eng#2 + Eng pass 2 OV-2 — coercion înainte de cheie → divergență
already_sent;idempotency.py:23hash-uieșteaccount_idAS-PASSED (Nonepe canal API,router.py:66) dar rândurile se stochează subaccount_or_default=1 → același rând logic capătă cheie diferită cross-canal →already_sentratează → al doileaFINALIZATA - OV-2 (P1):
canonicalize_row/build_keyaplicăaccount_or_defaultÎNAINTE de hash (None și 1 colapsează la o cheie). Tensiune cu §3.4bis „cheie identică": rândurile vechi cheie-Nonetrebuie 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ă)
- Surfaced by: Eng#2 + Eng pass 2 OV-2 — coercion înainte de cheie → divergență
- 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≤12per-rând ratează coloana uniform MM.DD (rândurilezi>12par neambigue și trec caok) - 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ândzi≤12. - Files: parser import, preview resolve
- Verify: test — (a)
03.04.2026string ambiguu → needs_review; (b) celulă datetime nativă → folosită direct; (c) coloană uniform MM.DD cu rândurizi>12→ format detectat la nivel de coloană, nu trec orb caok
- Surfaced by: Voce#2 + Eng pass 2 OV-8 —
- 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 —
UNIQUEglobal înghite dup intra-fișier;reconcile.pye 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 editareconcile.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_finalizatarămâne op-blind, recuperarea răspuns-pierdut neschimbată
- Surfaced by: Voce#3+#4 + Eng pass 2 OV-3 —
- 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_sentla 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:100e INSERT simplu, nu OR IGNORE) → utilizatorul a tastat N, confirmat, primește eroare opacă, iarrows_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_confirmedacoperă 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_attestationscu rows_hash + n_confirmed; apare în/v1/audit/export; (b) TOCTOU: cheie inserată de canal concurent după preview → rând reclasificatalready_sent, atestarea acoperă doar rândurile puse în coadă
- Surfaced by: Voce#9 + Eng pass 2 Issue 1 —
- 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_onlystreaming + 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
- Surfaced by: Eng#3+#6 —
- 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_afterSET la insert (ambele canale)- Surfaced by: Eng pass 2 OV-5 —
purge_aftere exportat în audit dar SETAT de niciun INSERT și NICIUN job de purjare nu există (grep purge_after→ doar SELECT). Planul presupunea paritatesubmissions/import_rowscare 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_afterpopulat (sent+90z); (b) rând expirat → șters de tick-ul de purjare; (c)import_rowspurjate cu batch-ul
- Surfaced by: Eng pass 2 OV-5 —
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=Truenu 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→Nonepe formule necalculate → mesaj specific (euristică rată-None). - Issue 4 (P3, U1):
openpyxllipsește dinrequirements.txt→ adaugă PINNED (ex.openpyxl==3.1.x) explicit în U1. - Issue 5 (P2, U1/U3): teste explicite — (a)
import_rows.raw_jsoncriptat la rest (ciphertext pe disc, plaintext după decrypt); (b) fuzzy coloane refoloseștemapping.normalize_for_match(fără normalizator duplicat). - Issue 6 (P2, U1/U4): scrieri bulk sub autocommit (
db.py:17isolation_level=None) →BEGIN IMMEDIATE…COMMIT+executemany(modelclaim_one); 5000 fsync → 1. - OV-1 (P1, T6):
auto_sendcoloană moartă (citită nicăieri) → T6 modificăreresolve_account+ resolve POST, nu doar adaugă. - OV-2 (P1, T9): skew
account_idla hash → normalizareaccount_or_defaultîncanonicalize_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_afterla 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ă
openpyxlpinned (Issue 4); test PII-at-rest (Issue 5a); scrieri staging în tranzacție explicită +executemany(Issue 6). NOTE U3: test reusenormalize_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(:roottokens) — 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_filediferențiat de already_sent prin TEXT explicit + referință încrucișată („dublă cu rândul 88"), nu doar culoare (daltonism — pill poartă cuvântul).
- Semantica culorii (D10, post eng review): amber
- Mapare coloane =
.maprow+.mapcol.grow+select(exact ca_mapari.html). - Drop zone:
.cardcu bordura--linedashed la hover; fără estetică nouă. - Bara confirmare:
.cardfix jos, buton--accentexistent,input[type=text]pentru N.- Checkbox atestare (D11): rândurile
needs_reviewau.chkexistent 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ă
.bannercu--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.
- Checkbox atestare (D11): rândurile
13.6 Responsive & a11y (Pass 6)
- Mobil:
.maprowdeja se rupe (flex-wrap); tabel preview în.tablewrap(scroll în card, existent). Bara de confirmare devine sticky bottom, nu fixed-overlap. - Touch ≥44px (deja
.cardlinkmin-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ă
.maprowcu 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 =
.chkper-rând peneeds_review, obligatoriu pentru includere în N (atestare pe valori). - D12: banner declarant =
.banner--warndirect 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_onlynu 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_sendcoloana moarta (citita nicaieri), skewaccount_idla hash (None vs rezolvat),reconcileop-blind by design (T11 nu trebuie sa-l atinga),purge_aftersetat 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