Compare commits

...

33 Commits

Author SHA1 Message Date
Claude Agent
0a9ad71cdd fix(ping): raporteaza mediul RAR efectiv al contului, nu ancora globala
GET /v1/ping intorcea settings.rar_env (mediul global al serverului, ex.
"prod") in campul `mediu`, desi contul poate opera pe alt mediu. Un cont cu
doar creds de test primea "prod", iar clientul ROAAUTO afisa badge rosu
PRODUCTIE desi prezentarile ruleaza in test. Acum `mediu` = rar_env_efectiv
al contului (doar din mediile disponibile = activate + cu creds), cu fallback
pe ancora globala doar cand contul nu are niciun mediu disponibil.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:03:23 +00:00
Claude Agent
37172ae2f9 fix(nrinmatriculare): normalizeaza spatii/cratime/puncte consecvent pe toate canalele
Doar 13.8% din numerele de inmatriculare istorice treceau validarea stricta
^[A-Z0-9]{1,10}$ (spike SQL), majoritatea din cauza formatelor reale cu
spatii/cratime ("B 99 XYZ", "B-99-XYZ") pe care normalizarea actuala
(strip+upper) nu le curata.

Adauga normalize_nr_inmatriculare() in app.idempotency (deja punctul comun
API+import pentru canonicalize_row) si o foloseste consecvent in:
- models.PrezentareIn (validare shape / normalizare API)
- validation.validate_prezentare (validare continut)
- idempotency.canonicalize_row (cheia de dedup)

Fara aceasta consecventa, validarea si idempotency ar diverge pe acelasi
numar de inmatriculare in formate echivalente.
2026-07-10 13:43:37 +00:00
1091745789 feat(api): filtru perioada data_de/data_pana pe GET /v1/prezentari
data_prestatie traieste in payload_json, nu e coloana SQL: filtrarea se aplica
in Python dupa derivarea prezentarii (fetch scope+status fara LIMIT, filtreaza,
apoi LIMIT dupa filtrare), acelasi pattern deja folosit de dashboard-ul web
(/_fragments/submissions). Extrage helper-ul iso_date_prefix in payload_view.py
ca sa fie partajat intre canalul web si cel API (elimina duplicarea).

Format invalid pe data_de/data_pana -> 422. Fara parametri, comportamentul
raspunsului ramane identic cu inainte.
2026-07-10 12:53:21 +00:00
Claude Agent
cc8c712ed2 feat(landing): butonul hero 'Creeaza cont gratuit' duce la #pret + copy trial nou
Textul din sectiunea AUTH devine 'Ai Pro gratuit primele 30 de zile,
apoi pachetul ales'; testul de trial verifica noul copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:20:47 +00:00
Claude Agent
251666feb6 feat(landing): pagini publice Termeni/GDPR/Documentatie API + fix link-uri moarte
Antet/footer/stil extrase in include-uri comune (_lp_*.html) folosite de
landing si de shell-ul paginilor statice (landing_static.html). Butoanele
'Vezi cum functioneaza' si 'Vezi documentatia API' devin link-uri reale;
footer-ul si consimtamantul de la inregistrare leaga paginile noi;
Contact indica spre romfast.ro/menu/contact.html.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:01:56 +00:00
Claude Agent
9b5006f26c exemple integrare 2026-07-09 13:42:55 +00:00
Claude Agent
e80a20c856 feat(landing): copy nou sectiunea Problem + slidere cu trepte discrete
- headline Problem: "Pierzi 6 zile pe an / introducand manual in Auto-Pass"
- hero: "Declara operatiile de service la RAR Auto-Pass, automat" (accent pe "automat")
- slidere calculator pe trepte fixe (50-500 trimiteri, 30-150 lei/h) cu repere vizibile
- denumirea oficiala "RAR Auto-Pass" peste tot in landing (ROA AUTOPASS ramane brand)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:34:15 +00:00
Claude Agent
dc6c1db439 docs(deploy): reguli Dokploy — env vars obligatorii inainte de push, chown volum la schimbare uid
Recapitulare incident 2026-07-09: deploy Error la 0s = interpolare env
(variabile ${VAR:?} nesetate in Dokploy), apoi 404 din volum SQLite
detinut de root fata de userul app uid 10001.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:17:14 +00:00
Claude Agent
ec5ca5ba84 feat(landing): fragment wizard import in loc de sectiunea 'calculator' + preturi pe 3 planuri
- sectiunea 'Cum functioneaza': mockup al wizard-ului real (stepper 4 pasi,
  incarcare, potrivire coloane, verificare cu stari si sugestie preselectata,
  bara de confirmare 'Trimite la RAR')
- carduri pret: chips canal de intrare (CSV/XLSX vs +API), banda de economie
  lunara, liste compacte 'Tot ce este la X +'
- cardul Premium eliminat din grila si din select-ul de plan (landing + /signup);
  tier-ul 'premium' ramane valid in backend, alocat doar de admin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:38:38 +00:00
Claude Agent
3ba35abed2 feat(api): raspuns minim implicit + corelare cerere-raspuns + sugestie cod in raspuns; coloana # in lista trimiteri
- POST /v1/prezentari: camp optional 'raspuns' (minim implicit / complet);
  forma minima = 6 chei fixe (index, submission_id, vehicul, status, motiv,
  id_prezentare), formatul detaliat ramane la cerere
- corelare rezultat<->cerere: index 0-based + ecou vin/nr_inmatriculare
- sugestia principala de cod RAR pe nemapate (GOLD partajat > SILVER >
  embeddings > fuzzy peste prag), in motiv si in nemapate[].sugestie
  (si pe dry-run /valideaza); suggestion-only, nu se aplica automat
- lista Trimiteri: coloana # cu id-ul trimiterii (desktop + mobil)
- README: sectiune 'Mapari si sugestii de cod RAR'; contract actualizat

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:32:55 +00:00
Claude Agent
c2beede084 fix(web): detaliul trimiterii arata toate codurile RAR cand prima prestatie e exclusa
cod_rar se deriva acum din toate prestatiile (primul cod mapat), nu doar din
primul item; modalul afiseaza lista completa de coduri. Randurile importate
editate pastreaza operatia originala ca item 0 exclus fara cod, iar codurile
adaugate sunt itemii urmatori — modalul afisa fals "nemapat".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 19:42:25 +00:00
Claude Agent
56ebe1f7cb feat(editor): editor prestatii unificat in modal - un select, un rand de chips (PRD 5.23)
Inlocuieste cele 3 controale de asociere cod RAR cu un singur rand de chips
+ un singur select care adauga instant la change. Stare = un hidden chips_state
JSON versionat; post_form_chips redus la 2 actiuni (add/remove). Optgroup
Sugestii (fuzzy/k-NN) + optiune "Nu se declara la RAR" in select, cu tinta
implicita evidentiata si placeholder care o numeste. exclus persistat prin
payload_json (treapta noua de precedenta in resolve_prestatii, round-trip complet).

Siguranta: itemii exclusi sunt scosi din payload la momentul trimiterii
(worker split_prestatii_excluse inainte de build_rar_payload + filtru defensiv),
ca sa nu ajunga NICIODATA la RAR ca codPrestatie:null.

Suita: 1680 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:45:34 +00:00
Claude Agent
0bc618763e feat(mapari): regula 'exclude de la declarare' per operatie + stare preview Nedeclarat
Operatiile care nu se declara la RAR (ex. ITP facturat in service) primesc o
regula exclus=1 in operations_mapping, setabila din panoul de mapare al
preview-ului de import si din tab-ul Mapari (optiunea 'Nu se declara la RAR').

- resolve_prestatii(excluded_ops): item nemapat cu op exclusa -> adnotat exclus,
  nu mai e needs_mapping; precedenta: cod explicit > exclus > mapare > reguli text
- split_prestatii_excluse: itemii exclusi nu intra niciodata in payload/cheie
- preview import: rand cu toate operatiile excluse -> stare 'excluded'
  (eticheta Nedeclarat), necomis; operatia dispare din panoul de mapat
- reresolve/corectie/API: submission cu toate operatiile excluse -> needs_data
  cu motiv explicit; ingestia API trateaza excluderea la clasificare
- migrare: coloana operations_mapping.exclus + rebuild import_rows pentru
  CHECK-ul resolved_status cu 'excluded' (o singura data, gardat pe sqlite_master)
- fix flake: clamp similaritate embeddings la [-1,1] (float32 dadea 1.0000001)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:49:47 +00:00
Claude Agent
c6e29aa65d fix(import): persista chips prestatii la salvarea din modalul de editare rand
Editorul de rand din preview afisa chips-urile operatie<->cod RAR dar salvarea
extragea doar EDIT_FIELDS — codurile alese se pierdeau si randul ramanea
needs_mapping. Starea chips se reconstruieste din form (conventia /form-chips),
se valideaza fata de nomenclator si se persista in override_json.

Caile de commit (API + web) aplica acum override-ul INAINTE de resolve_prestatii,
ca in preview — altfel prestatiile editate ajungeau nerezolvate in payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:20:01 +00:00
Claude Agent
5a2c976d46 feat(web): UI compact Trimiteri + bara bulk contextuala + fix buton "+" editor (PRD 5.22)
Trimiteri desktop = tabel grid cu header, un rand per trimitere: status =
bulina cu tooltip, mediu = litera T/P, multi-cod = primul cod + badge +N,
sub-linie de eroare cu textul brut RAR doar pe error/needs_data. Mobil:
2 linii prin grid-areas, fara header. Actiunile bulk apar doar la selectie
(bara contextuala cu contor + texte explicite). Mapari: exemplu concret in
loc de descriere la Reguli automate.

Fix buton "+" chips (nu facea nimic, fara eroare): hx-disabled-elt="find
button" mostenit de la form-ul parinte crapa htmx inainte de request ->
hx-disabled-elt="this" explicit pe butoanele chips; separat, no-op-urile
silentioase din post_form_chips au acum mesaje (cod invalid/duplicat) si
confirmare cu chip evidentiat la succes.

Suita: 1622 passed. Verify E2E in browser (desktop 1280 + mobil 390).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:27:42 +00:00
Claude Agent
6674d0e3e1 fix(sugestii): inlocuirea de anvelope -> OE-8 + vot k-NN ponderat pe rang
Seed-ul Haiku eticheta sistematic "INLOCUIT ANVELOPE ..." cu OE-1 (223 vs 17
OE-8), desi RAR are cod dedicat OE-8 (inlocuire sezoniera anvelope), asa ca
votul k-NN propunea OE-1 pentru orice inlocuire de anvelope.

- tools/mapare-llm/fix_anvelope_oe8.py: re-etichetare deterministica (187
  randuri DB + seed JSON -> OE-8; regula exclude singular cu pozitie, D/R,
  janta, vulcanizare) + 13 intrari canonice curate manual pentru formularile
  sezoniere absente din corpus ("MONTAT CAUCIUCURI VARA" etc.); idempotent
- enrich_suggestions: ponderea votului = EMB_VOTE_DECAY^rang (0.7) in loc de
  similaritatea bruta — anizotropia modelului lasa zgomotul de coada (ex.
  INLOCUIT BECURII la 0.934 de INLOCUIRE ANVELOPE) sa invinga vecinii corecti
  de rang 1-2; LOO: precizie egala (93.1%), cod-gresit 4.67% -> 4.63%
- test_fix_anvelope_oe8: gardii pe regula + seed-ul din repo (regresie la
  regenerare); auditul k-NN al dezacordurilor nu a gasit alte erori
  sistematice (curatat=OE-2, reglat=OE-4, bujii/ulei=OE-3 raman apararabile)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:26:30 +00:00
Claude Agent
809823a8ae perf+calitate sugestii k-NN: matvec numpy, vot top-5 cu prag calibrat, indicatori import
- embeddings: corpus ca matrice numpy cu norme precalculate; suggest_nearest
  = un matvec (~0.6ms/query fata de ~500ms cosine pur-Python la 17k vectori)
- enrich_suggestions: vot ponderat cu similaritatea pe top-5 vecini (NUL =
  eticheta proprie); prag 0.5 -> 0.88, calibrat LOO pe corpusul SILVER
  (tools/mapare-llm/knn_calibrate.py): precizie 90.5% -> 93.1%, cod gresit
  preselectat 7.2% -> 4.7%; sub prag abtinere -> preselectie fuzzy
- UI: codul sugerat de sistem afisat explicit cu sursa si scorul, separat de
  lista fuzzy ("potrivire text"); indicator de progres reparat pe upload
  (display:inline anula .htmx-indicator) si adaugat pe pasii 2->3 si
  "Salveaza maparile"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:10:58 +00:00
Claude Agent
65b6e4694d feat(web): deep-link-uri pe sub-taburile Setari + Primii pasi deasupra importului
Link-urile catre configurare (Acasa, Integrare, Upload) duc direct pe sub-tabul
relevant (?tab=cont&subtab=rar|api); pe Acasa, ghidajul "Primii pasi" apare
inaintea containerului de import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:42:47 +00:00
Claude Agent
705ad030fe feat(embeddings): cache persistent de vectori in SQLite + warmup in fundal
Vectorii corpusului k-NN persista in tabela embedding_cache (PK model+text_hash,
blob float32 LE); la warmup se vectorizeaza doar textele lipsa din cache, deci
restartul cu corpus neschimbat nu mai plateste ~1-2 min de embed (embed=0).

- app/embedding_cache.py: serializare array('f'), load/save/purge chunk 500 cu
  BEGIN/COMMIT explicit (conexiuni autocommit), validare dimensiune la scriere
  si citire, orchestrare sync_corpus_vectors cu embed_fn injectat
- index_corpus(vectors=): vectori precalculati cu validare aliniere; mismatch
  -> fallback embed complet
- ensure_embeddings_corpus: warmup in thread la startup (block=True), calea de
  request ne-blocanta (acquire non-blocking pe lock; warmup in curs -> return
  imediat); purjare orfane + modele vechi doar dupa indexare reusita
- log warmup: cache=N embed=M in Xs
- 31 teste noi (cold/warm/incremental, model schimbat, concurenta, ranking
  exact, echivalenta float32); suita completa 1596 passed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:57:49 +00:00
Claude Agent
a46e364594 feat(header): badge mediu RAR compact langa chip-ul RAR
Muta indicatorul de mediu (Testare/PRODUCTIE) din statusbar (randare
periodica, rand intreg) intr-un badge compact din header, langa chipul
RAR scurtat la "RAR". Click pe badge comuta mediul cand exista 2
disponibile; pe mobil (<=560px) eticheta scurta PROD/TEST ramane mereu
vizibila (semnal de risc L.142). Toggle-ul din header si schimbarea
mediului implicit din Setari actualizeaza badge-ul via OOB swap.

Co-Authored-By: Claude Agent <noreply@anthropic.com>
2026-07-06 21:15:12 +00:00
Claude Agent
d7adeaf382 feat(setari): pagina Setari compacta — 3 sub-taburi, plan clarificat, copy scurt
- Date firma mutat in sub-tabul Cont (date firma + plan + parola); 3 taburi in loc de 4
- Plan afisat o singura data (tier bold + consum) cu bara de progres; fara dublarea 'Plan curent'/'Plan:'
- Copy Credentiale RAR redus la un rand; confirmare Productie scurtata; parola setata are placeholder distinct
- Formulare compacte pe 2 coloane (setari-fgrid), titluri de sectiune uppercase mici, microcopy langa butoane
- activ_subtab implicit 'firma' -> 'cont'; 'firma' vechi mapat defensiv la 'cont'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:53:05 +00:00
Claude Agent
e2627c1aa4 test(web): actualizeaza 4 teste pentru meniul grupat + pagina Setari
- badge: cauta elementul randat (class="tab-badge"), nu substringul brut —
  CSS-ul nou din base.html contine mereu selectorul .tab-badge
- meniu mapari: fereastra de context largita — iconita SVG sta intre href si text
- setari: eticheta sub-tabului e "Cheie API", nu "Cheia mea API"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:50:33 +00:00
Claude Agent
44f261e269 refactor: comentarii strict functionale, fara referinte PRD/stories
Curatare globala a comentariilor si docstring-urilor (app, tools, teste,
scripturi): eliminate referintele la PRD-uri, US-xxx, task-uri istorice si
review-uri; pastrata doar informatia functionala, formulata scurt. Regula
adaugata in CLAUDE.md (sectiunea Stil). Fara modificari de cod sau comportament.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:48:38 +00:00
Claude Agent
86408887e5 feat(setari): meniu grupat + pagina Setari cu sub-taburi + schimbare parola
Meniu hamburger reorganizat pe grupuri cu etichete (LUCRU / CONT) cu iconite
SVG discrete; "Cont" redenumit "Setari" (URL ?tab=cont pastrat, zero rute moarte).

Pagina Setari restructurata in sub-taburi interne: Date firma / Credentiale RAR /
Cheie API / Cont (plan + schimbare parola), cu indicator de stare per tab. Sub-tabul
activ e pastrat server-side (activ_subtab) dupa fiecare swap HTMX; comutarea intre
taburi e client-side, delegata pe document (supravietuieste swap-urilor).

Credentiale RAR: mediile Testare/Productie ca doua carduri paralele.

Schimbare parola de acces din sub-tabul Cont (functie noua, lipsea):
- users.change_password (scrypt): verifica parola actuala constant-time, valideaza
  noua (10..128, diferita), rescrie hash+salt nou;
- ruta POST /cont/schimba-parola, scoped pe user_id din sesiune, CSRF enforce,
  parola niciodata re-pusa in raspuns, jurnal 'parola_schimbata'.

Referinte copy "Meniu cont > Cont" -> "Setari".

Teste: schimbare parola (succes / parola actuala gresita / confirmare nepotrivita /
lungime) + izolare rate-limit login in fixture-ul clientului.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:40:38 +00:00
Claude Agent
164f90d603 fix(securitate): inchide nit-urile din review-ul hardening (N1-N3)
- N1: test HSTS pe monkeypatch.setenv (fara env var scursa la assert picat)
- N2: backup_db.sh refuza AUTOPASS_BACKUP_KEEP < 1 (retentia ar fi sters
  backup-ul abia creat)
- N3: teste ASGI directe pentru Content-Length malformat/negativ in
  BodyCapMiddleware (comportamentul defensiv exista deja, acum e fixat in teste)

Suita completa: 1559 passed, 1 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:01:00 +00:00
Claude Agent
0a96bd6a8e docs: ROADMAP actualizat cu hardening 80/20 LIVRAT (2026-07-06)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:50:53 +00:00
Claude Agent
63b6cbc01d fix(securitate): hardening prod — headere, body-cap, non-root, backup
Findings security-review P1/P2 (2026-07-03):
- headere de securitate pe toate raspunsurile (nosniff, X-Frame-Options,
  Referrer-Policy, HSTS doar pe HTTPS) + teste
- body-cap global 10MB ca middleware ASGI pur (413 inainte de parserul
  multipart/JSON; verificarea per-endpoint ramane strat 2)
- imagine Docker non-root (uid 10001), port 8010 aliniat, loguri pe
  volumul /data
- fail-fast la boot cu rar_env=prod fara AUTOPASS_REQUIRE_API_KEY sau
  AUTOPASS_SESSION_SECRET
- compose: env-uri critice obligatorii (:?) ca api/worker sa nu diverga
  tacit; FORWARDED_ALLOW_IPS ca rate-limit-ul sa vada IP-ul real dupa
  Traefik
- signup fara PII in stdout: log_event in loc de print cu email (idem
  notify degradat)
- ratelimit: sterge cheile fara timestamp-uri valide (crestere monotona
  a memoriei pe IP-uri reale)
- backup criptat SQLite (backup online API, gpg AES256) + verificare
  restore + docs/backup.md

Suita completa verde: 1557 passed, 1 skipped (live).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:46:49 +00:00
Claude Agent
fa52468a80 fix(ux): mesaje mediu RAR mai clare + referinta corecta 'Meniu cont › Cont'
Banner upload compact/directiv (fara 'mediu global' derutant), text Cont
onest (importul web fara mediu activ nu are creds -> esueaza), si aliniere
referinta navigare la 'Meniu cont › Cont' in erori/integrare (nu exista
'tab-ul Cont' in UI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 12:45:16 +00:00
Claude Agent
39d2ba3dd0 docs: ROADMAP actualizat cu 5.19 LIVRAT + findings /code-review rezolvate (2026-07-06)
Intrare noua in "Ultima actualizare": 5.19 (auto-send toggle + coada tinuta held)
livrat + push (main la eade702), cu cele doua runde de corectii /code-review high:
finding #1 (re-snapshot held pe caile de repunere, corectitudine) + findings #2-#6
(UX/retentie). Nota PLAN 5.19 marcata LIVRAT. 1541 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:55:57 +00:00
Claude Agent
eade702a52 fix(5.19): corectii UX/retentie held (findings #2-#6 /code-review high)
#2 Filtrul "doar tinute" (held=1) devine persistent: hidden #f-held in
   #filtre-trimiteri sincronizat prin OOB din _submissions.html + paginare
   pastreaza held=1; chip "Doar tinute manual / Arata tot" ca off-switch.
   Param `held` schimbat la str (held= gol nu mai da 422 la coercion).
#3 Banner bulk "Trimite toate (N)" extras in _bulk_held_banner.html si
   re-randat prin OOB (#bulk-held-banner-wrap) la fiecare reincarcare a listei
   -> nu mai ramane stale dupa eliberari (count revine la 0).
#4 tools/carantina_held seteaza purge_after (blocked_retention_days) pe randurile
   carantinate -> PII nu mai sta la nesfarsit (purge_expired le poate sterge).
#5 Panoul de detaliu marcheaza randul tinut (eticheta amber) si ofera buton
   "Trimite acum la RAR" (paritate cu randul din lista).
#6 expire_held seteaza purge_after=now-1s -> randul expirat e purjabil in ACELASI
   ciclu (purge_expired foloseste comparatie stricta), nu la ciclul urmator.

Teste noi: stickiness held + banner OOB + detaliu buton (test_web_auto_send),
purge_after carantina, purge acelasi-ciclu (test_worker_held). 1541 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:19:00 +00:00
Claude Agent
789e3f54be merge: PRD 5.19 auto-send toggle + tinere manuala randuri (held) 2026-07-06 10:13:02 +00:00
Claude Agent
458c3fd43c fix(5.19): re-snapshot held pe caile de re-punere in coada
Finding #1 (/code-review high): create_prezentari (reactivare) si reresolve_account
re-calculau `held` din comutatorul contului la tranzitia -> queued, dar caile de
re-punere din dashboard/admin lasau `held` pe valoarea VECHE:
  - submissions_admin.requeue_submission (API /repune + web)
  - web post_corectie_trimitere
  - web post_repune_trimitere (calea cod_prestatie)
  - web post_bulk_fix

Consecinta: un rand ingerat pe Auto ON (held=0) care esueaza, apoi contul trecut pe
Auto OFF, la re-punere pastra held=0 -> worker-ul (claim_one AND held=0) il auto-trimitea
la RAR (FINALIZATA ireversibil) desi contul e Auto OFF. Directia inversa: rand held=1
repus pe cont trecut Auto ON ramanea blocat.

Fix: held=held_for_account(conn, account_or_default(account_id)) pe toate cele 4 UPDATE-uri
-> queued (paritate cu caile deja corecte). Bulk-fix hoisteaza snapshot-ul o data inainte
de bucla. Worker requeue_with_backoff neatins (opereaza doar pe randuri deja claim-uite,
held=0 -> corect).

Test: tests/test_held_requeue_snapshot.py (ambele directii). 1535 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:12:57 +00:00
Claude Agent
3b5cf7a7d9 feat(5.19): auto-send toggle per cont + tinere manuala randuri (held)
Comutator accounts.auto_send_enabled per cont: Auto OFF (default) tine randurile
la ingestie (submissions.held=1), worker-ul (claim_one AND held=0) le sare pana la
eliberare umana (per rand/bulk/auto-release OFF->ON). Snapshot held prin chokepoint
unic held_for_account pe toate caile de ingestie (API, import, reresolve, reactivare).

- schema/migrare: coloana held + index partial idx_submissions_held; auto_send_enabled
- API: echo onest held+motiv (US-010), ruta /prezentari/{id}/trimite-acum
- web: toggle header, modal confirmare tipata, buton Trimite per rand + Trimite toate,
  banner coada tinuta imbatranita (L.142), contor "In asteptare (manual)"
- worker: expire_held (US-008, inchide gaura retentie PII), metrics held gauges
- ops: tools/carantina_held + runbook rollback (R4)

Nota review (/code-review high): re-snapshot held lipseste pe caile repune/corectie
(requeue_submission, post_corectie, bulk-fix) — de aliniat separat cu create_prezentari.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 08:34:40 +00:00
280 changed files with 14025 additions and 3445 deletions

View File

@@ -8,20 +8,55 @@ AUTOPASS_CREDS_KEY=
# --- Auth API-key ---
# true = orice /v1/* cere cheie valida (prod). false = dev (fara cheie -> cont id=1).
# OBLIGATORIU in docker-compose.yml (:?) — vezi nota din compose.
AUTOPASS_REQUIRE_API_KEY=false
# --- Worker ---
# Send catre RAR. false = nu trimite (default, sigur pentru probe). true = end-to-end.
# OBLIGATORIU in docker-compose.yml (:?) — vezi nota din compose.
AUTOPASS_WORKER_SEND_ENABLED=false
# Dev: foloseste creds <test> din settings.xml cand submission-ul nu are creds criptate.
AUTOPASS_WORKER_USE_TEST_CREDS=false
# --- RAR ---
# test | prod
# test | prod. Ancora globala, NU tinta trimiterilor: dupa PRD 5.20 mediul RAR e
# per submission/cont (worker-ul trimite dupa submissions.rar_env). Variabila asta
# e doar fallback API pentru conturi fara medii disponibile, backfill la migrare
# si afisaj dashboard. OBLIGATORIE in docker-compose.yml (:?) — vezi nota din compose.
AUTOPASS_RAR_ENV=test
# --- Sesiuni web ---
# Secret semnat cookie sesiune, PARTAJAT intre restart-uri (fara el, fiecare
# redeploy delogheaza toti userii). Genereaza:
# python3 -c "import secrets; print(secrets.token_hex(32))"
# OBLIGATORIU in docker-compose.yml (:?).
AUTOPASS_SESSION_SECRET=
# True (prod, implicit in compose): cookie de sesiune cu flag Secure (necesita HTTPS).
# False: doar pentru smoke-test local pe HTTP simplu (browserul dropeaza cookie-ul
# Secure pe HTTP, deci login-ul ar esua silentios cu true).
AUTOPASS_SESSION_HTTPS_ONLY=true
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
# false = dezactivat (default; /mapari instant, sugestii din GOLD/SILVER + fuzzy).
# true = sugestii semantice. Prima cerere /mapari lazy-load-eaza modelul fastembed/ONNX
# (~230MB pe disc) sincron -> hang la prima cerere. Doar API-ul il incarca.
AUTOPASS_EMBEDDINGS_ENABLED=false
# --- Backup SQLite criptat (T2/P0-4, vezi docs/backup.md) ---
# TRIGGER DUR: backup-ul trebuie configurat INAINTE de prima declaratie reala in
# prod. Baza e sistemul legal de evidenta (L.142/2023); FINALIZATA e terminal la
# RAR, deci pierderea volumului fara backup produce duplicate necorectabile.
#
# Parola de criptare (gpg AES256) a backup-urilor — obligatorie, tools/backup_db.sh
# refuza sa scrie un backup necriptat. Foloseste fisierul (secret montat), NU variabila
# inline, in prod:
AUTOPASS_BACKUP_PASSPHRASE_FILE=
# Alternativa (ex. teste locale) — parola inline, evita in prod:
AUTOPASS_BACKUP_PASSPHRASE=
# Director destinatie backup-uri (pe volumul persistent, NU efemer).
AUTOPASS_BACKUP_DIR=/data/backups
# Cate backup-uri locale criptate se pastreaza (cele mai vechi se sterg).
AUTOPASS_BACKUP_KEEP=14
# Tinta rclone optionala pentru upload off-site (ex. remote:bucket/autopass). Fara ea,
# backup-ul ramane doar local pe volum — sarit explicit cu log, nu esec silentios.
AUTOPASS_BACKUP_RCLONE_REMOTE=

114
CLAUDE.md
View File

@@ -1,104 +1,88 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Ghid Claude Code pentru acest repo.
## Ce este
Gateway web (Python / FastAPI) care preia prezentari de service-auto si le declara la **RAR AUTOPASS** (Legea 142/2023, OM 210/2024). Inlocuieste integrarea Visual FoxPro `RarAutoPass` (ROAAUTO), arhivata in `legacy-vfp/` (doar referinta).
Gateway web (Python/FastAPI) care preia prezentari service-auto si le declara la **RAR AUTOPASS** (L.142/2023, OM 210/2024). Inlocuieste integrarea VFP `RarAutoPass` (ROAAUTO), arhivata in `legacy-vfp/` (doar referinta).
Limba proiectului este **romana**: cod, comentarii, commit-uri, documentatie. Fara emoji in raspunsuri sau fisiere noi (preferinta proiect).
## Stil
## Surse de adevar (citeste-le inainte sa modifici comportament)
- Limba proiectului: **romana** (cod, comentarii, commit-uri, docs). Fara emoji.
- **Comentarii scurte, strict functionale.** Doar ce nu reiese din cod. FARA referinte la PRD-uri, stories (US-xxx), review-uri sau istoricul deciziei.
- `docs/api-rar-contract.md` — contractul RAR. **Unde un plan/cod difera de contract, contractul are dreptate.**
- `docs/ROADMAP.md` — singura sursa de progres + procesul de lucru. O sesiune noua porneste de aici. Doar sectiunea "Stadiu Implementare" se modifica pe parcurs; detaliile stau in PRD-uri (`docs/prd/`).
## Surse de adevar
- `docs/api-rar-contract.md` — contractul RAR; la divergenta, contractul are dreptate.
- `docs/ROADMAP.md` — progres + proces de lucru; sesiunile noi pornesc de aici. Se modifica doar "Stadiu Implementare"; detaliile stau in `docs/prd/`.
## Comenzi
```bash
pip3 install -r requirements.txt # Python 3.12+
# Rulare locala (dev): API + worker sunt PROCESE SEPARATE
uvicorn app.main:app --reload --port 8010 # API: dashboard /, Swagger /docs, /healthz, /metrics
python3 -m app.worker # worker (necesar doar pentru a procesa coada)
# Dev: API + worker = procese SEPARATE
uvicorn app.main:app --reload --port 8010 # dashboard /, /docs, /healthz, /metrics
python3 -m app.worker # proceseaza coada
# Wrapper-ul start.sh ambaleaza mediu (test/prod) + rol (api/worker/both/finalizate)
./start.sh test both --send # API + worker, trimite efectiv la RAR test (loguri in .run/)
./start.sh test finalizate # listeaza prezentarile inregistrate la RAR (verificare independenta)
./start.sh status # stare procese + /healthz
./start.sh stop # opreste procesele pornite cu "both"
# start.sh = mediu (test/prod) + rol (api/worker/both/finalizate); loguri in .run/
./start.sh test both --send # trimite efectiv la RAR test
./start.sh test finalizate # listeaza prezentarile inregistrate la RAR
./start.sh status | stop
./start-test.sh / ./start-prod.sh # fixeaza mediul, forwardeaza rolul
# Teste (pytest; folosesc FastAPI TestClient + SQLite temporar). Testele live RAR sunt
# skip implicit (marker `live`) — `pytest -q` nu atinge endpointul real.
# Teste (TestClient + SQLite temporar); testele live RAR sunt skip implicit (marker `live`)
python3 -m pytest -q
python3 -m pytest tests/test_worker_reconcile.py -q # un fisier
python3 -m pytest tests/test_worker_reconcile.py::test_x -q # un singur test
python3 -m pytest -q -m "not live" # exclude explicit testele live
python3 -m pytest tests/test_worker_reconcile.py::test_x -q
AUTOPASS_LIVE_RAR=1 python3 -m pytest tests/test_live_rar.py -q # LIVE opt-in: creeaza FINALIZATA; cere settings.xml cu creds <test>
# Test LIVE pe RAR test (opt-in, skip implicit; atinge endpointul real -> creeaza FINALIZATA):
# reproduce lantul mapare inline -> queued -> worker -> sent -> verificare in finalizate.
AUTOPASS_LIVE_RAR=1 python3 -m pytest tests/test_live_rar.py -q # necesita settings.xml cu creds <test>
# Chei API (admin, doar CLI); cheia rfak_... se afiseaza O SINGURA DATA
python3 -m tools.apikey create --account 2 | list | rotate | revoke
# Lifecycle chei API (admin, doar din CLI — nu exista suprafata HTTP)
python3 -m tools.apikey create --account 2 # cheie afisata O SINGURA DATA (rfak_...)
python3 -m tools.apikey list|rotate|revoke
# Docker (deploy): api + worker + autoheal, acelasi image + volum SQLite
docker compose up --build
docker compose up --build # deploy: api + worker + autoheal, acelasi image + volum SQLite
# prod = Dokploy cu autodeploy la push; reguli in docs/deploy-dokploy.md
```
## Arhitectura
**Doua procese peste acelasi SQLite (WAL) persistent**, care comunica EXCLUSIV prin tabela `submissions`:
**Doua procese peste acelasi SQLite (WAL)**, comunica EXCLUSIV prin tabela `submissions`:
- **API** (`app.main:app`) — API v1 (`app/api/v1/router.py` + `import_router.py`), dashboard web HTMX (`app/web/routes.py` + `templates/`), `/healthz`, `/metrics`. Worker-ul **nu** ruleaza ca task aici: un worker mort nu trebuie sa lase containerul "sanatos".
- **Worker** (`app/worker/__main__.py`) — bucla: heartbeat → recupereaza orfane → claim atomic (`BEGIN IMMEDIATE`) → login RAR (per cont)`postPrezentare` → update. Retry/backoff exponential, reconciliere anti-duplicat, lease pe randuri `sending` orfane, re-login la JWT expirat (TTL 30h). Send DEZACTIVAT implicit (`AUTOPASS_WORKER_SEND_ENABLED=false`) — sigur pentru probe.
- **API** (`app.main:app`) — API v1 (`app/api/v1/`), dashboard HTMX (`app/web/` + `templates/`), `/healthz`, `/metrics`. Worker-ul NU ruleaza aici: un worker mort nu trebuie sa lase containerul "sanatos".
- **Worker** (`app/worker/__main__.py`) — bucla: heartbeat → recuperare orfane → claim atomic (`BEGIN IMMEDIATE`) → login RAR per cont → `postPrezentare` → update. Retry/backoff exponential, reconciliere anti-duplicat, lease pe `sending` orfane, re-login la JWT expirat (TTL 30h). Send OFF implicit (`AUTOPASS_WORKER_SEND_ENABLED=false`).
**Doua canale de intrare** convergent in coada `submissions`:
- Canal API (Treapta 1): `POST /v1/prezentari` pentru ROAAUTO / soft propriu.
- Import web (Treapta 2): upload xlsx/csv → mapare coloane → preview → commit (`app/import_parse.py`, `import_router.py`, dashboard).
**Doua canale de intrare** in coada: `POST /v1/prezentari` (ROAAUTO/soft propriu) si import web xlsx/csv → mapare coloane → preview → commit (`app/import_parse.py`, `import_router.py`).
Flux: validare (`validation.py`) → mapare operatie→cod (`mapping.py`) → enqueue cu PII criptat → worker trimite → dashboard monitorizeaza live.
Flux: validare (`validation.py`) → mapare operatie→cod (`mapping.py`) → enqueue cu PII criptat → worker trimite → dashboard live.
### Invariante critice (usor de stricat)
### Invariante critice
- **`AUTOPASS_CREDS_KEY` trebuie sa fie ACEEASI intre API si worker.** API cripteaza creds RAR (Fernet), worker le decripteaza. Chei diferite → worker nu poate decripta → trimiterile esueaza. `start.sh both` genereaza o cheie efemera partajata; pentru prod pune una persistenta in `.env`. (`crypto.py`)
- **Idempotenta = hash de continut canonic** server-side (`idempotency.py`), pentru ca RAR accepta duplicate si nu are nr. comanda. `build_key` normalizeaza INTOTDEAUNA `account_id` la `account_or_default` (None == 1) INAINTE de hash altfel acelasi rand logic primeste chei diferite pe canalele API vs import (OV-2). `canonicalize_row` normeaza VIN/nr/odometru (strip ".0" din coercion Excel) inainte de validare si de cheie.
- **`FINALIZATA` e terminal la RAR** fara anulare/corectie prin API. De aceea reconcilierea anti-duplicat: pe eroare **ambigua** (timeout / TransportError / 502/503/504 / 429 / 408) sau rand `sending` orfan, worker-ul cauta in finalizate (match pe vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara a re-trimite (`reconcile.py`). **EXCEPTIE: un RAR 500 cu mesaj** (`RarError.rar_message`, ex. `ORA-12899`) e un esec DEFINITIV (RAR a raspuns „am esuat", nu o pierdere de raspuns) → worker-ul NU reconciliaza si NU reincearca, marcheaza `error` cu mesajul RAR (`RAR_EROARE_SERVER`). Altfel ar marca fals `sent` pe un record PARTIAL pe care RAR (ne-tranzactional) il lasa la esec.
- **Creds RAR per cont**: durabile in `accounts.rar_creds_enc` (canal web, fallback re-login) SAU efemere in `submissions.rar_creds_enc` (canal API, sterse dupa primul login reusit). Worker incearca submission-ul intai, apoi fallback la cont. Purjarea sterge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_enc`.
- **Auth API-key** (`auth.py`): identifica CONTUL ROAAUTO, separat de credentialele RAR. Stocam doar SHA-256 al cheii. Enforcement prin `AUTOPASS_REQUIRE_API_KEY`: `false` (dev) → fara cheie merge pe cont id=1, cheie invalida → 401; `true` (prod) → cheie obligatorie pe `/v1/*` protejat. POST-urile + rutele de import sunt account-scoped; GET-urile de listare sunt si ele account-scoped (5.15/US-011: fragmentele `_fragments/submissions|trimitere|mapari|status|jurnal|nomenclator|trimiteri-versiune` sub `require_login` + scope, 404-before-leak pe id strain; `GET /v1/prezentari(/{id})`/`/v1/mapari`/`/v1/audit/export` filtrate pe cont). `GET /v1/nomenclator` ramane public intentionat (coduri RAR publice, fara PII).
- **Admin bootstrap fara hardcodare**: la `/signup`, primul user din TOATA baza (`count_admins()==0`, verificat in tranzactie) primeste `is_admin=1` automat; orice signup ulterior e neadmin. `is_admin` da acces la `/admin` (panou global, toate conturile) — verificat pe `account_id`-ul sesiunii, dar neconditionat DE care `account_id` a primit userul (semnul de "cine e admin" nu are legatura cu contul `id=1`). Contul `id=1` e doar bucket-ul de fallback pentru trafic API neautentificat in dev (`AUTOPASS_REQUIRE_API_KEY=false`) — separat de conceptul de admin. Fix manual daca primul signup s-a consumat deja gresit: `python3 -m tools.account set-admin --account N`.
- **Mapare coloane retinuta per `(account_id, signature_coloane)`** (`column_mappings`): la urmatorul fisier cu aceleasi coloane, pentru acelasi cont, maparea se reaplica automat. Un cont poate avea mai multe formate memorate simultan.
- **Mapare operatie→cod**: prestatie poate veni cu `cod_prestatie` (cod RAR direct) sau `cod_op_service` (cod intern) + `denumire`. Nerezolvat → submission `needs_mapping` (nu se trimite), apare in editorul web cu sugestie fuzzy; la salvarea maparii se re-rezolva automat submission-urile blocate.
- **`cod_prestatie` e VALIDAT fata de nomenclator la ingestie** (`resolve_prestatii(..., valid_codes)`): un cod direct NECUNOSCUT in nomenclator NU se mai trimite raw — e promovat la `cod_op_service` (denumire=cod) si tratat ca operatie de mapat. Motiv (confirmat live 2026-06-23): RAR accepta NUMAI coduri din nomenclator (coloana `COD_PRESTATIE` max 5 car.); un cod necunoscut da **HTTP 500** (`ORA-12899`), iar RAR **NU e tranzactional** → lasa un record PARTIAL `FINALIZATA` (terminal) chiar pe esec, pe care reconcilierea worker-ului l-ar marca fals `sent`. Comportamentul la cod necunoscut/nemapat: `on_unmapped_error` (camp boolean top-level pe `POST /v1/prezentari` + `/valideaza`) = `false` (intra in editor, `needs_mapping`) sau `true` (respinge fara enqueue → `submission_id=null` + `erori`). Default = `accounts.on_unmapped_error_default` (implicit `false`/`0`); precedenta cerere > cont > `false`.
- **WAF RAR da 403 fara User-Agent de browser** — toate apelurile httpx trimit `User-Agent: Mozilla/5.0` (`config.py`, confirmat live).
- **422 fara echo de credentiale**: handler-ul global de validare in `main.py` pastreaza type/loc/msg dar DROP-a `input`/`ctx` (altfel ar reflecta `rar_credentials.password`).
- **Retentie**: `submissions` sent + `import_batches` primesc `purge_after = now + 90 zile`; worker-ul purjeaza odata pe ora (T16, GDPR/L.142).
- **`AUTOPASS_CREDS_KEY` identica intre API si worker** (Fernet, `crypto.py`): chei diferite → worker nu decripteaza creds → trimiterile esueaza. `start.sh both` genereaza cheie efemera partajata; in prod, cheie persistenta in `.env`.
- **Idempotenta = hash de continut canonic** server-side (`idempotency.py`) RAR accepta duplicate, fara nr. comanda. `build_key` normalizeaza `account_id` la `account_or_default` (None == 1) INAINTE de hash, altfel acelasi rand primeste chei diferite pe API vs import. `canonicalize_row` normeaza VIN/nr/odometru (strip ".0" din Excel) inainte de validare si cheie.
- **`FINALIZATA` e terminal la RAR** (fara anulare/corectie). Pe eroare **ambigua** (timeout/TransportError/502/503/504/429/408) sau `sending` orfan, worker-ul cauta in finalizate (vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara re-trimitere (`reconcile.py`). **EXCEPTIE: RAR 500 cu mesaj** (`RarError.rar_message`, ex. `ORA-12899`) = esec DEFINITIV → fara reconciliere/retry, `error` cu mesajul RAR (`RAR_EROARE_SERVER`); altfel ar marca fals `sent` un record PARTIAL lasat de RAR (ne-tranzactional).
- **Creds RAR per cont**: durabile in `accounts` (canal web, fallback re-login) SAU efemere in `submissions.rar_creds_enc` (canal API, sterse dupa primul login reusit). Worker incearca submission-ul intai, apoi contul. Purjarea sterge DOAR creds efemere din `submissions`.
- **Auth API-key** (`auth.py`): identifica contul ROAAUTO (separat de creds RAR); stocam doar SHA-256. `AUTOPASS_REQUIRE_API_KEY`: `false` (dev) → fara cheie = cont id=1, cheie invalida → 401; `true` (prod) → obligatorie pe `/v1/*` protejat. POST-urile, importul si GET-urile de listare (API + fragmente dashboard) sunt account-scoped, 404-before-leak pe id strain. `GET /v1/nomenclator` public intentionat (coduri RAR publice, fara PII).
- **Admin bootstrap**: primul user din TOATA baza la `/signup` (`count_admins()==0`, in tranzactie) devine `is_admin=1`; restul nu. `is_admin` da acces la `/admin` (panou global) si e independent de contul id=1 (acela e doar fallback dev pentru API neautentificat). Fix manual: `python3 -m tools.account set-admin --account N`.
- **Mapare coloane memorata per `(account_id, signature_coloane)`** (`column_mappings`): reaplicata automat la fisiere cu aceleasi coloane; un cont poate avea mai multe formate.
- **Mapare operatie→cod**: prestatia vine cu `cod_prestatie` (cod RAR) sau `cod_op_service` + `denumire`. Nerezolvat → `needs_mapping` (nu se trimite), editor web cu sugestie fuzzy; salvarea maparii re-rezolva automat submission-urile blocate.
- **Excludere de la declarare** (`operations_mapping.exclus=1`; optiunea "Nu se declara la RAR" = sentinel `__NEDECLARAT__` in select-urile de mapare): operatia NU pleaca la RAR. Precedenta in `resolve_prestatii`: cod explicit valid pe rand > exclus > mapare exacta > reguli text. Itemii adnotati `exclus` sunt scosi din payload/cheie cu `split_prestatii_excluse` inainte de enqueue; rand de import cu toate operatiile excluse → stare `excluded` ("Nedeclarat", nu se comite); submission cu toate excluse → `needs_data` cu motiv explicit. `save_mapping` peste o regula exclusa reseteaza `exclus=0`.
- **`cod_prestatie` VALIDAT fata de nomenclator la ingestie** (`resolve_prestatii(..., valid_codes)`): cod necunoscut NU se trimite raw — e promovat la `cod_op_service` (denumire=cod) si intra la mapat. Motiv (confirmat live): RAR accepta doar coduri din nomenclator (max 5 car.); cod necunoscut → HTTP 500 `ORA-12899`, iar RAR ne-tranzactional lasa record PARTIAL `FINALIZATA` pe care reconcilierea l-ar marca fals `sent`. La cod nemapat: `on_unmapped_error` (boolean top-level pe `POST /v1/prezentari` + `/valideaza`) = `false` → editor/`needs_mapping`; `true` → respins fara enqueue (`submission_id=null` + `erori`). Precedenta: cerere > `accounts.on_unmapped_error_default` > `false`.
- **WAF RAR da 403 fara User-Agent de browser** — httpx trimite mereu `User-Agent: Mozilla/5.0` (`config.py`).
- **422 fara echo de credentiale**: handler-ul din `main.py` pastreaza type/loc/msg dar DROP-a `input`/`ctx` (altfel reflecta `rar_credentials.password`).
- **Retentie**: `submissions` sent + `import_batches``purge_after = now + 90 zile`; worker purjeaza orar (GDPR/L.142).
### Masina de stari submissions
`queued → sending → sent` (succes, cu `id_prezentare` de la RAR). Ramuri: `needs_mapping` (cod nerezolvat), `needs_data` (RAR 400, validare continut), `error` (max retries / 4xx nerecuperabil / **RAR 500 cu mesaj — esec definitiv** / creds invalide / login 401 — NU se face retry pe creds gresite). Backoff: `next_attempt_at = now + base*2^retry`, plafonat. Schema completa: `app/schema.sql`.
`queued → sending → sent` (cu `id_prezentare` de la RAR). Ramuri: `needs_mapping`, `needs_data` (RAR 400), `error` (max retries / 4xx nerecuperabil / RAR 500 cu mesaj / creds invalide sau login 401 — fara retry pe creds gresite). Backoff: `next_attempt_at = now + base*2^retry`, plafonat. Schema: `app/schema.sql`.
## Mod non-interactiv
Vezi `/workspace/CLAUDE.md` (workspace-level): cand esti lansat cu `claude -p`, creeaza fisiere noi DOAR in `/workspace/.claude-work/<task>/`, nu in `/workspace`. Modificarile la fisiere existente se fac in locatia originala.
Vezi `/workspace/CLAUDE.md`: cu `claude -p`, fisierele noi merg DOAR in `/workspace/.claude-work/<task>/`; modificarile la fisiere existente raman in locatia originala.
## Skill routing
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
Cand cererea se potriveste unui skill, invoca-l prin Skill tool; la indoiala, invoca-l.
Key routing rules:
- Product ideas/brainstorming → invoke /office-hours
- Strategy/scope → invoke /plan-ceo-review
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
- Ship/deploy/PR → invoke /ship or /land-and-deploy
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
- Author a backlog-ready spec/issue → invoke /spec
- Idei produs → /office-hours · Strategie → /plan-ceo-review · Arhitectura → /plan-eng-review
- Design system/plan → /design-consultation, /plan-design-review · Pipeline complet → /autoplan
- Bug-uri → /investigate · QA site → /qa, /qa-only · Code review → /review · Polish vizual → /design-review
- Ship/deploy/PR → /ship, /land-and-deploy · Salvare/reluare context → /context-save, /context-restore · Spec → /spec

View File

@@ -11,9 +11,19 @@ ENV PYTHONUNBUFFERED=1 \
WORKDIR /app
# tzdata = necesar pentru ca 'localtime' din SQLite sa rezolve Europe/Bucharest.
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
# gnupg = criptare backup SQLite (tools/backup_db.sh, T2/P0-4) — NU e inclus implicit
# in imaginea slim (doar gzip e pachet esential Debian). rclone NU e instalat aici
# intentionat: upload-ul remote e optional (AUTOPASS_BACKUP_RCLONE_REMOTE) si scriptul
# sare peste el explicit daca binarul lipseste.
RUN apt-get update && apt-get install -y --no-install-recommends tzdata gnupg \
&& rm -rf /var/lib/apt/lists/*
# User neprivilegiat (uid/gid stabil): orice RCE viitor are raza de explozie
# limitata, nu root in container. HOME e necesar explicit — fastembed descarca
# modelul (~230MB) in $HOME la prima folosire (AUTOPASS_EMBEDDINGS_ENABLED=true).
RUN groupadd -g 10001 app && useradd -u 10001 -g app -d /home/app -m app
ENV HOME=/home/app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
@@ -22,9 +32,19 @@ COPY tools ./tools
# Date persistente (SQLite WAL) pe volum montat.
ENV AUTOPASS_DB_PATH=/data/autopass.db
# Log text rotativ (app/config.py:log_dir, RotatingFileHandler) pe acelasi volum
# persistent, NU in /app/.run — altfel non-root n-are voie sa scrie in /app.
ENV AUTOPASS_LOG_DIR=/data/logs
# chown INAINTE de VOLUME — schimbarile de proprietar dupa directiva VOLUME se
# pierd la build. Migrare one-off pentru un volum EXISTENT deja detinut de root:
# docker compose run --rm --user root api chown -R app:app /data
RUN mkdir -p /data && chown app:app /data
VOLUME ["/data"]
EXPOSE 8000
USER app
EXPOSE 8010
# Default = API. Worker-ul suprascrie command in compose.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]

View File

@@ -59,6 +59,24 @@ pe semnatura coloanelor, per cont) → **preview** (fiecare rand: `ok` / `needs_
Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, `Odometru final`,
`Odometru initial`, `Operatie`, `Observatii`. Fiecare cont poate avea mai multe formate memorate.
## Mapari si sugestii de cod RAR
O operatie fara cod RAR valid (din import sau din API) opreste randul in **Lipsa cod**
(`needs_mapping`) — nimic nu pleaca la RAR pana nu confirma un om codul. Sistemul propune
insa singur un cod ("sugestia principala"), calculat pe loc din denumirea operatiei:
- **Cand:** de fiecare data cand un rand ajunge in `needs_mapping` — sugestia apare imediat
in raspunsul API (`motiv` + `nemapate[].sugestie`), in tab-ul **Mapari** (precompletata
in select) si in `GET /v1/mapari/pending`.
- **Cum (in ordinea increderii):** mapari confirmate de oameni in alte conturi (GOLD partajat) →
clasificari LLM pre-calculate (SILVER) → similaritate semantica (embeddings k-NN) →
potrivire fuzzy pe numele din nomenclatorul RAR. Prima sursa care raspunde castiga;
textele care nu sunt operatii (ITP, taxe, discounturi) nu primesc sugestie.
- **Sugestia NU se aplica singura** — declararea la RAR e definitiva, deci prima asociere
operatie→cod cere confirmarea ta (tab Mapari sau `POST /v1/mapari`). Din acel moment maparea
e memorata: aparitiile viitoare se rezolva automat, iar **toate** trimiterile deja blocate
pe acel cod se deblocheaza pe loc si intra in coada.
## API v1 (curl)
Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`.

View File

@@ -2,6 +2,68 @@
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare.
## Din /autoplan PRD 5.23 (2026-07-07)
- [ ] **Bulk in preview: "aplica maparea la toate cele N randuri identice"** — o decizie de mapare
repetata per rand e adevaratul consumator de timp; editorul unificat nu o rezolva. Ce: dupa o
mapare in preview/modal, ofera aplicarea la toate randurile din batch cu aceeasi operatie
nemapata. De ce: reduce deschiderile de modal (drumul 10x — vocea externa CEO). Pro: taie direct
timpul de corectie pe importuri mari. Contra: atinge panoul de preview (non-goal in 5.23).
Context: `reresolve_account` exista deja pentru re-rezolvare dupa `save_mapping` — de verificat
daca nu acopera deja cazul la nivel de cont; daca da, TODO-ul devine doar UI de confirmare.
Effort: M→S (CC). Prioritate: P2. Depinde de: 5.23 livrat.
- [ ] **Metrici de baza pentru bucla de mapare** — cate deschideri de modal per import, % randuri
auto-rezolvate, % sugestii acceptate. De ce: fara ele nu stim daca editorul (5.23) sau motorul
(5.14/5.18) merita urmatoarea investitie. Pro: decizie de produs pe date. Contra: telemetrie
noua. Context: contorizare in `/form-chips` + import commit; expunere in /metrics existent.
Effort: S. Prioritate: P3.
- [ ] **A11y touch targets pe chips (× sub 44px)** — UI-ul compact 11-12px face tintele de atins
mici pe mobil. De ce: operare de pe telefon in service. Pro: mai putine mis-tap-uri. Contra:
chips mai inalti = mai putine pe rand. Context: decizie 5.23 = consecventa cu designul compact
existent; de revizitat la un audit a11y global. Effort: S. Prioritate: P3.
## Din executie + code-review 5.23 (2026-07-08)
- [ ] **Curata calea legacy `/trimitere/{id}/mapeaza`** — formular per-operatie separat, cu propriile
"Sugestii:", care coexista cu editorul unificat de chips. Nu interfereaza (dispare cand tinta e
setata), dar e cod mort de UX vechi. Context: semnalat de worker la fix-ul de randare initiala.
Effort: S. Prioritate: P3.
- [ ] **Autocomplete/cautare in selectul de coduri RAR** — non-goal explicit in 5.23 (select nativ
pastrat). De reevaluat daca nomenclatorul creste peste ~18 coduri fixe. Effort: M. Prioritate: P3.
- [ ] **Race buton Salveaza in timpul unui `/form-chips` in zbor** — sectiunea chips e inerta pe
`.htmx-request`, dar butonul Salveaza al formularului parinte nu e blocat; un submit foarte rapid
ar putea trimite un `chips_state` neactualizat. Improbabil (dedup + fereastra mica). Context:
finding PLAUSIBLE din code-review. Effort: S. Prioritate: P3.
- [ ] **Conflict mapare din tab Mapari vs item marcat `exclus` din chips** — un item marcat exclus
explicit din editor domina o mapare salvata ulterior din tab-ul Mapari (`resolve_prestatii`
precedenta item-exclus > mapare cont); utilizatorul poate sa nu inteleaga de ce maparea "nu tine".
De clarificat semantica sau de adaugat un semnal in UI. Context: finding PLAUSIBLE din code-review.
Effort: S. Prioritate: P3.
## Din /autoplan PRD 5.21 (2026-07-06)
- [ ] **CLI administrare cache embeddings (`python3 -m tools.embcache stats|clear|rebuild`)** — v1 foloseste
`DELETE FROM embedding_cache` manual (decizie Open Q1). De construit cand operarea manuala devine
frecventa sau cand apare al doilea operator. Effort: S. (CEO, low.)
- [ ] **Precompute vectori la build (artefact in imaginea Docker)** — modelul e pinned si corpusul seed e
comis, deci vectorii pot fi precalculati la build; ar face si PRIMUL start pe volum proaspat ieftin
(azi doar al doilea beneficiaza). Effort: M (pipeline build + fallback la runtime). (Outside voice CEO, medium.)
- [ ] **Graceful reload / zero-downtime restart** — procesul vechi serveste pana cel nou e cald; ar elimina
COMPLET fereastra post-restart, inclusiv incarcarea modelului (~10-20s) pe care cache-ul n-o poate evita.
Infra (Dokploy/compose), nu app. Effort: M. (Outside voice CEO, medium.)
- [ ] **Latenta per-query `suggest_nearest` (cosine pur Python)** — bucla Python peste 17k x 384 la fiecare
cautare; de MASURAT intai; daca >100ms, matrice numpy + dot product (efort mic). Designul curent e validat
pana la ~50k randuri de corpus (prag documentat in PRD 5.21 A12). (Eng F5, medium.)
## Din hardening 80/20 (/autoplan, 2026-07-03)
- [ ] **Traefik IP-allowlist pe /v1 + /metrics in fereastra de lansare** (T4) — cu zero clienti API,
suprafata anonima de ingestie n-are apelant legitim extern (ROAAUTO propriu vine de la IP cunoscut).
O regula Traefik declarativa (allowlist IP ROAAUTO + admin) elimina complet ingestia anonima in
perioada cea mai riscanta. Deferat: P0-1 (auth obligatoriu pe /v1) + T3 (startup guard) acopera deja
gaura principala. De activat cand: primesc trafic de scanare pe /v1 SAU inainte de expunere cu presa.
Effort: S (config Traefik). Depinde de: acces la config Traefik in Dokploy.
## Din PRD 5.12 (2026-06-26)
- [ ] **Mai multi utilizatori per firma (flux de invitatie / alaturare la cont)** — azi CUI e unic, deci

View File

@@ -67,7 +67,7 @@ def create_account(
# Planul cerut: pastram doar valori valide; orice altceva -> NULL (defensiv).
req_plan = requested_plan if requested_plan in VALID_TIERS else None
try:
# Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z.
# Trial Pro automat la creare: tier='free' + trial_until=now+30z.
trial_until = (
(datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
)
@@ -150,6 +150,40 @@ def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None:
)
def get_auto_send(conn: sqlite3.Connection, account_id: int) -> bool:
"""Comutatorul Auto per cont: `accounts.auto_send_enabled` ca bool.
Cont inexistent -> False (Auto OFF: sigur, tine randurile). Nu comita (ca set_active).
"""
row = conn.execute(
"SELECT auto_send_enabled FROM accounts WHERE id=?", (account_id,)
).fetchone()
if not row:
return False
return bool(row["auto_send_enabled"])
def set_auto_send(conn: sqlite3.Connection, account_id: int, enabled: bool) -> None:
"""Comuta `accounts.auto_send_enabled`. Idempotent (set pe aceeasi
valoare nu arunca). Scoped pe cont. Nu comita — respecta conventia fisierului (apelantul
comita, ca la set_active/set_status). Cont inexistent -> UPDATE fara efect (no-op sigur).
"""
conn.execute(
"UPDATE accounts SET auto_send_enabled=? WHERE id=?",
(1 if enabled else 0, account_id),
)
def held_for_account(conn: sqlite3.Connection, account_id: int) -> int:
"""Snapshot-ul `held` la ingestie. SURSA UNICA (chokepoint) folosita de
TOATE situ-rile de ingestie care scriu `status='queued'`.
Intoarce 0 (nu tine) daca `accounts.auto_send_enabled=1`, altfel 1 (tine randul). Cont
inexistent -> 1 (sigur: tine randul, nu-l trimite fara comutator explicit).
"""
return 0 if get_auto_send(conn, account_id) else 1
def set_tier(
conn: sqlite3.Connection,
account_id: int,
@@ -181,7 +215,7 @@ def set_tier(
"UPDATE accounts SET tier=?, trial_until=? WHERE id=?",
(tier, trial_until, account_id),
)
# Audit in app_events (decizie PRD 5.17 US-008, fara PII nou)
# Audit in app_events (fara PII nou)
try:
from .observ import log_event
log_event(

View File

@@ -1,4 +1,4 @@
"""Router import Treapta 2 — upload fisier xlsx/csv, preview, commit, export.
"""Router import — upload fisier xlsx/csv, preview, commit, export.
Endpointuri:
POST /v1/import — upload fisier, staging in import_batches/import_rows
@@ -32,6 +32,7 @@ from pydantic import BaseModel, Field
from datetime import datetime, timezone
from ... import errors
from ...accounts import held_for_account
from ...auth import require_api_access, resolve_account_id
from ...crypto import decrypt_creds, encrypt_creds
from ...db import get_connection
@@ -48,11 +49,13 @@ from ...mapping import (
_emite_text_rule_hits,
account_or_default,
has_no_auto_send,
load_excluded_ops,
load_mapping_meta,
load_nomenclator_codes,
load_text_rules,
normalize_for_match,
resolve_prestatii,
split_prestatii_excluse,
)
from ...validation import validate_prezentare
from ...rar_env import MediuIndisponibil, rar_env_efectiv_cont, rezolva_rar_env
@@ -134,11 +137,12 @@ def _resolve_row_for_preview(
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
reviewed: bool = False,
excluded_ops: set[str] | None = None,
) -> dict[str, Any]:
"""Rezolva un rand din import pentru preview: aplica mapare coloane + validare.
Intoarce un dict cu:
resolved_status: ok/needs_mapping/needs_data/needs_review
resolved_status: ok/needs_mapping/needs_data/needs_review/excluded
resolved: valorile finale rezolvate (VIN, data, km, prestatii)
errors: lista erori validare
flags: motive needs_review
@@ -189,7 +193,7 @@ def _resolve_row_for_preview(
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
# obs derive-on-empty (D7/E3 PRD 5.15): daca obs e gol si avem operatie,
# obs derive-on-empty: daca obs e gol si avem operatie,
# copiem denumirea operatiei in obs (nu o mutam — op_service ramane neatins).
# DERIVE-ON-EMPTY: doar cand obs e gol, ca sa fie idempotent la re-preview/re-editare.
obs_curent = str(mapped.get("obs") or "").strip()
@@ -217,7 +221,7 @@ def _resolve_row_for_preview(
# Rezolvare prestatii
prestatii = mapped.get("prestatii") or []
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
mapped["prestatii"] = resolved
# Determinare stare
@@ -229,13 +233,27 @@ def _resolve_row_for_preview(
"flags": all_flags,
}
# Validare continut
errors = validate_prezentare(mapped)
# Operatii excluse de la declarare: ies DOAR din payload-ul trimis la RAR (si din
# cheia de idempotenta), NU din `mapped["prestatii"]` persistat/afisat — editorul
# (chips modal) trebuie sa arate chip-ul Nedeclarat la redeschidere (round-trip).
# Toate excluse -> rand 'excluded' (nu se trimite, nu blocheaza).
declarabile, excluse = split_prestatii_excluse(resolved)
if excluse and not declarabile:
return {
"resolved_status": "excluded",
"resolved": mapped,
"errors": [],
"flags": all_flags,
}
# Validare continut: doar partea declarabila conteaza (validate_prezentare
# ignora oricum itemii fara cod_prestatie, dar `mapped` ramane intact pentru afisare).
errors = validate_prezentare({**mapped, "prestatii": declarabile} if excluse else mapped)
if all_flags and not reviewed:
# needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala.
# Daca reviewed=True (operatorul a confirmat explicit valorile in modal), sarim
# acest return si continuam spre ok/needs_data (US-007, PRD 5.12).
# acest return si continuam spre ok/needs_data.
return {
"resolved_status": "needs_review",
"resolved": mapped,
@@ -243,8 +261,6 @@ def _resolve_row_for_preview(
"flags": all_flags,
}
# US-001 (PRD 5.11): ramura auto_send eliminata din preview.
if errors:
return {
"resolved_status": "needs_data",
@@ -267,8 +283,9 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any], rar
return build_key(account_id, canon, rar_env)
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
# aici (raman in panoul de mapare). obs = text liber, se trateaza ca non-canonic
# Campuri de continut editabile in preview. Prestatiile (chips operatie<->cod RAR)
# se editeaza tot in modal, dar se persista separat (param `prestatii` din
# apply_row_override), nu prin EDIT_FIELDS. obs = text liber, se trateaza ca non-canonic
# (doar .strip(), fara canonicalize_row) — urmeaza ramura `else` din _merge_override.
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs")
@@ -319,9 +336,13 @@ def apply_row_override(
account_id: int | None,
row_index: int,
fields: dict[str, str | None],
prestatii: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
"""Persista override-ul canonic pentru un rand de preview (mutatie PURA de stocare).
`prestatii`: None = neatins; lista = inlocuieste integral prestatiile randului
(chips editate in modal, deja validate fata de nomenclator de apelant).
NU recalculeaza statusul si NU atinge `submissions` — preview-ul rederiva statusul
prin `_resolve_row_for_preview` (un singur clasificator, fara drift).
@@ -359,8 +380,10 @@ def apply_row_override(
current = dec
new_override = _merge_override(current, fields)
if prestatii is not None:
new_override["prestatii"] = prestatii
enc = encrypt_creds(new_override) if new_override else None
# D#9 (PRD 5.12): resetam reviewed=0 la orice schimbare de valoare — operatorul
# Resetam reviewed=0 la orice schimbare de valoare — operatorul
# trebuie sa reconfirme dupa editare. NU conditionam pe reviewed curent: orice override
# (chiar si revert la valoarea initiala) anuleaza confirmarea implicita.
conn.execute("UPDATE import_rows SET override_json=?, reviewed=0 WHERE id=?", (enc, row["rid"]))
@@ -764,9 +787,10 @@ def preview_import(
# Incarca maparea de operatii o singura data
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
# Validare nomenclator + reguli text + excluderi incarcate O DATA, inainte de bucla.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
# Mediul RAR efectiv al contului — folosit la calculul cheii de idempotenta
# la preview (trebuie sa coincida cu ce va folosi commit-ul fara rar_env explicit).
@@ -822,13 +846,22 @@ def preview_import(
override=overrides[i] or None,
valid_codes=valid_codes,
text_rules=text_rules,
excluded_ops=excluded_ops,
)
# Calculeaza cheia de idempotenta pentru randurile ok/needs_review
key = None
if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"):
try:
key = _build_idempotency_key(account_id, resolved_info["resolved"], preview_env)
# Cheia foloseste DOAR partea declarabila (itemii exclusi nu intra in
# hash) — identic cu cheia calculata la commit (linia cu
# split_prestatii_excluse din commit_import), altfel preview-ul
# arata alt duplicat decat cel real.
_key_resolved = resolved_info["resolved"]
_decl_key, _exc_key = split_prestatii_excluse(_key_resolved.get("prestatii"))
if _exc_key:
_key_resolved = {**_key_resolved, "prestatii": _decl_key}
key = _build_idempotency_key(account_id, _key_resolved, preview_env)
keys_for_lookup.append(key)
if key not in key_to_index:
key_to_index[key] = []
@@ -964,7 +997,7 @@ def commit_import(
if batch["status"] == "committed":
raise HTTPException(status_code=409, detail="batch deja comis")
# D#8 (PRD 5.12): gate commit derivat din DB `reviewed` pe AMBELE canale.
# Gate commit derivat din DB `reviewed` pe AMBELE canale.
# API: reviewed_rows pastrat (contract stabil) dar seteaza reviewed=1 in DB inainte
# de interogare. Randurile needs_review cu reviewed=1 sunt incluse in comit.
if req.reviewed_rows:
@@ -1031,7 +1064,7 @@ def commit_import(
if n_total_ok == 0:
raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.")
# Rezolva mediul RAR tinta al lotului (US-009): cerut > default cont > ancora globala.
# Rezolva mediul RAR tinta al lotului: cerut > default cont > ancora globala.
try:
env = rezolva_rar_env(conn, account_id, req.rar_env)
except ValueError as e:
@@ -1043,8 +1076,8 @@ def commit_import(
"disponibile": e.disponibile,
})
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
# Enforce volum plan — INAINTE de enqueue (invariant idempotenta).
# Respingere TOTALA a lotului (nu enqueue partial tacut).
from ...config import get_settings as _get_settings
from ...plans import PLANS, effective_tier, monthly_usage
from ...observ import log_event as _log_event_plan
@@ -1112,9 +1145,10 @@ def commit_import(
# Incarca maparea de operatii
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
# Validare nomenclator + reguli text + excluderi incarcate O DATA, inainte de bucla.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
# Construieste payload-urile submissions
enqueued: list[dict] = []
@@ -1125,6 +1159,9 @@ def commit_import(
conn.execute("BEGIN IMMEDIATE")
try:
purge_after_sql = "datetime('now', '+90 days')"
# Snapshot `held` din comutatorul contului (chokepoint unic
# held_for_account) — 0 daca Auto ON, 1 daca Auto OFF. Per-cont -> o data.
held_val = held_for_account(conn, acct)
for ok_row in ok_rows:
row_dict = ok_row["data"]
@@ -1158,33 +1195,37 @@ def commit_import(
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
# obs derive-on-empty (D7/E3 PRD 5.15): copiere denumire in obs daca obs e gol.
# obs derive-on-empty: copiere denumire in obs daca obs e gol.
# Identic cu logica din _resolve_row_for_preview (override aplicat tot ultimul).
obs_curent = str(mapped.get("obs") or "").strip()
if not obs_curent and operatie_val:
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["obs"] = obs_denumire
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
prestatii = mapped.get("prestatii") or []
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
mapped["prestatii"] = resolved
# Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie)
canon = canonicalize_row(mapped)
mapped.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
"odometru_final": canon["odometru_final"],
})
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
# Override editat in preview — aplicat INAINTE de resolve_prestatii,
# ca in _resolve_row_for_preview: prestatiile din override (chips
# editate in modal) trec prin ACELASI resolve, altfel raman raw si
# hash-ul/telemetria ar folosi prestatiile pre-editare.
override = ok_row.get("override") or {}
if override:
mapped.update(override)
# Re-canonicalizeaza pentru a obtine cheia IDENTICA cu cea din preview
# (_build_idempotency_key = canonicalize_row + build_key peste mapped).
canon = canonicalize_row(mapped)
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
prestatii = mapped.get("prestatii") or []
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
# Prestatiile excluse de la declarare NU pleaca la RAR si nu intra in
# cheia de idempotenta, dar RAMAN in payload_json persistat (round-trip
# in editor, ca in preview) — ingustarea la declarabile se face doar
# local, pentru canon/cheie, si la worker, la momentul trimiterii.
declarabile, _excluse = split_prestatii_excluse(resolved)
# rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip.
if not declarabile:
continue
mapped["prestatii"] = resolved
# Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie;
# cheia IDENTICA cu preview: _build_idempotency_key = canonicalize_row + build_key).
canon = canonicalize_row({**mapped, "prestatii": declarabile})
mapped.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
@@ -1194,13 +1235,13 @@ def commit_import(
# Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env)
key = build_key(account_id, canon, env)
# Hash row pentru atestare (valori rezolvate)
# Hash row pentru atestare (valori rezolvate, doar partea declarabila)
rows_for_hash.append(json.dumps({
"row_index": row_index,
"vin": mapped.get("vin"),
"data_prestatie": mapped.get("data_prestatie"),
"odometru_final": mapped.get("odometru_final"),
"prestatii": [str(p.get("cod_prestatie") or p.get("cod_op_service") or "") for p in resolved],
"prestatii": [str(p.get("cod_prestatie") or p.get("cod_op_service") or "") for p in declarabile],
}, sort_keys=True, ensure_ascii=False))
payload_json = json.dumps(mapped, ensure_ascii=False)
@@ -1208,9 +1249,9 @@ def commit_import(
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
cur = conn.execute(
"INSERT OR IGNORE INTO submissions "
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) "
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?)",
(key, acct, payload_json, import_id, row_index, env),
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env, held) "
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?, ?)",
(key, acct, payload_json, import_id, row_index, env, held_val),
)
if cur.rowcount == 0:
@@ -1268,7 +1309,7 @@ def commit_import(
class RandEditIn(BaseModel):
"""Campuri de continut editabile in preview. None = ne-trimis (neschimbat);
"" = sterge override-ul (revine la valoarea din fisier).
obs = text liber fara validare de continut (US-005 PRD 5.15)."""
obs = text liber fara validare de continut."""
vin: str | None = None
nr_inmatriculare: str | None = None
data_prestatie: str | None = None

View File

@@ -21,6 +21,7 @@ from ...auth import _extract_key, account_for_key, resolve_account_id
from ...config import get_settings
from ...db import get_connection
from ...mapping import account_or_default
from ...rar_env import rar_env_efectiv
router = APIRouter(prefix="/v1", tags=["integrare"])
@@ -35,7 +36,8 @@ def ping(
Intoarce:
account_id — contul rezolvat din cheie (sau 1 in dev fara cheie)
mediu — "test" / "prod" (settings.rar_env)
mediu — mediul RAR efectiv al contului ("test"/"prod"),
nu ancora globala settings.rar_env
autentificat_cu_cheie — True daca cererea a venit cu o cheie API reala valida
are_creds_rar — True daca contul are creds RAR stocate pe cel putin un mediu (test sau prod)
are_creds_test — True daca contul are creds RAR pentru mediul Testare
@@ -57,12 +59,13 @@ def ping(
conn.close()
autentificat_cu_cheie = acct is not None
# Verificam daca contul are creds RAR stocate (per-env, US-013).
# Verificam daca contul are creds RAR stocate (per mediu).
aid = account_or_default(account_id)
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=?", (aid,)
"SELECT rar_test_enabled, rar_prod_enabled, rar_creds_test_enc, "
"rar_creds_prod_enc, rar_env_default FROM accounts WHERE id=?", (aid,)
).fetchone()
finally:
conn.close()
@@ -71,9 +74,15 @@ def ping(
are_creds_prod = bool(row and row["rar_creds_prod_enc"])
are_creds_rar = are_creds_test or are_creds_prod
# mediu = mediul RAR EFECTIV al contului (REQ-DEFAULT), nu ancora globala
# settings.rar_env: un cont cu doar creds de test trebuie sa raporteze "test"
# chiar daca serverul ruleaza global pe "prod". Fallback pe ancora globala doar
# cand contul nu are niciun mediu disponibil (rar_env_efectiv -> None).
mediu_efectiv = (rar_env_efectiv(row) if row else None) or settings.rar_env
return JSONResponse({
"account_id": aid,
"mediu": settings.rar_env,
"mediu": mediu_efectiv,
"autentificat_cu_cheie": autentificat_cu_cheie,
"are_creds_rar": are_creds_rar,
"are_creds_test": are_creds_test,

View File

@@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from ...accounts import held_for_account
from ...auth import require_api_access, resolve_account_id
from ...crypto import encrypt_creds
from ...db import get_connection
@@ -30,23 +31,27 @@ from ...mapping import (
account_or_default,
account_scope_clause,
classify_prezentare,
load_excluded_ops,
load_mapping_meta,
load_nomenclator,
load_nomenclator_codes,
load_text_rules,
pending_unmapped,
reresolve_account,
save_mapping,
sugestie_pentru_op,
)
from ...models import (
PrezentareRequest,
PrezentariResponse,
SubmissionResult,
SubmissionResultMinim,
ValidarePrezentariRequest,
ValidareResponse,
ValidareResult,
)
from ...observ import log_event
from ...payload_view import prezentare_din_payload
from ...payload_view import iso_date_prefix, prezentare_din_payload
from ...submissions_admin import (
SubmissionNotFound,
SubmissionStateConflict,
@@ -68,13 +73,13 @@ def _effective_on_unmapped_error(conn, acct: int, req_value: bool | None) -> boo
return bool(row["on_unmapped_error_default"]) if row else False
def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules=None) -> dict:
def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules=None, excluded_ops=None) -> dict:
"""classify_prezentare + aplicarea modului on_unmapped_error.
Cand exista coduri nemapate si error_mode=True, marcheaza outcome-ul ca respingere
(blocked_error=True): rutele NU mai fac enqueue, ci intorc o eroare per-element.
"""
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, text_rules)
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, text_rules, excluded_ops)
cl["blocked_error"] = bool(cl["unmapped"]) and error_mode
return cl
@@ -87,13 +92,36 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
]
def _ataseaza_sugestii(conn, cl: dict, nomenclator: list[dict]) -> None:
"""Adauga `sugestie` ({cod_prestatie, nume_prestatie, sursa} | None) pe fiecare
cod nemapat din clasificare. Suggestion-only: informeaza raspunsul API, nu
schimba statusul si nu se aplica automat."""
for u in cl.get("unmapped") or []:
u["sugestie"] = sugestie_pentru_op(
conn, u.get("denumire") or u.get("cod_op_service"), nomenclator
)
# Mesaj uman pentru un rand tinut manual (queued AND held=1).
# Reutilizeaza vocabularul existent AUTO_SEND_OPRIT (errors.py) — nu inventam al treilea
# vocabular "auto_send". Onestitate: raspunsul NU trebuie sa arate un "queued" curat fals.
MOTIV_HELD = (
"In asteptare — tinut pentru verificare; NU trimis la RAR (Auto OFF). "
"Elibereaza manual (trimite-acum) sau activeaza Trimiterea automata."
)
def _motiv_held(status: str, held: bool) -> str | None:
"""Motiv uman pentru un rand tinut manual; None cand nu e cazul (status!=queued sau held=0)."""
return MOTIV_HELD if (held and status == "queued") else None
def _motiv_clasificare(cl: dict) -> str | None:
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
None cand status='queued'. Acopera ramurile de blocaj: erori de continut
(needs_data) si coduri fara mapare RAR (needs_mapping).
Dupa US-001: needs_mapping apare EXCLUSIV cand unmapped e non-gol
(ramura auto_send_oprit era inaccesibila si a fost eliminata).
needs_mapping apare EXCLUSIV cand unmapped e non-gol.
"""
if cl["status"] == "queued":
return None
@@ -102,23 +130,35 @@ def _motiv_clasificare(cl: dict) -> str | None:
(e.get("problema") or e.get("message") or "") for e in cl["errors"]
).strip("; ") or "Date incomplete (respinse de RAR)."
if cl["unmapped"]:
coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"])
return f"Coduri fara mapare RAR: {coduri}"
parti = []
for u in cl["unmapped"]:
op = u.get("cod_op_service") or ""
s = u.get("sugestie")
if s and s.get("cod_prestatie"):
nume = f" {s['nume_prestatie']}" if s.get("nume_prestatie") else ""
parti.append(f"{op} (sugestie: {s['cod_prestatie']}{nume})")
else:
parti.append(op)
return f"Coduri fara mapare RAR: {', '.join(parti)}"
return None
def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult:
def _rezultat_enqueue(submission_id: int | None, cl: dict, held: int = 0, **extra) -> SubmissionResult:
"""SubmissionResult onest dintr-un rezultat de clasificare.
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None.
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None, EXCEPTAND
randurile tinute manual (`held=1` -> held=true + motiv non-null).
"""
tinut = bool(held)
motiv = _motiv_clasificare(cl) or _motiv_held(cl["status"], tinut)
return SubmissionResult(
submission_id=submission_id,
status=cl["status"],
erori=list(cl["errors"]),
nemapate=_erori_nemapate(cl["unmapped"]),
motiv=_motiv_clasificare(cl),
motiv=motiv,
held=tinut,
**extra,
)
@@ -160,22 +200,25 @@ def create_prezentari(
conn = get_connection()
results: list[SubmissionResult] = []
try:
# load_mapping_meta incarca maparea op->cod RAR; dupa US-001, auto_send nu mai tine randuri.
# load_mapping_meta incarca maparea op->cod RAR; auto_send nu mai tine randuri.
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
valid_codes = load_nomenclator_codes(conn) or None
# Nomenclator complet (cod+nume) pentru sugestiile atasate codurilor nemapate.
nomenclator = load_nomenclator(conn)
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
# US-005: rezolva mediul RAR tinta (cerut > default cont > ancora globala).
# Rezolva mediul RAR tinta (cerut > default cont > ancora globala).
# MediuIndisponibil -> 422 inainte de orice enqueue (respinge tot lotul).
try:
env = rezolva_rar_env(conn, acct, req.rar_env)
except MediuIndisponibil as e:
# US-012: audit blocare mediu indisponibil (tip='rar_env_blocat').
# Audit blocare mediu indisponibil (tip='rar_env_blocat').
log_event(
"rar_env_blocat",
nivel="WARNING",
@@ -201,8 +244,8 @@ def create_prezentari(
detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"),
)
# T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
# Enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
# Respingere TOTALA a lotului (nu enqueue partial tacut).
from ...config import get_settings as _get_settings
from ...plans import PLANS, effective_tier, monthly_usage
_settings = _get_settings()
@@ -240,6 +283,11 @@ def create_prezentari(
),
)
# Snapshot `held` din comutatorul contului (chokepoint unic held_for_account).
# 0 daca Auto ON, 1 daca Auto OFF (tine randul). Depinde de
# cont, nu de rand -> calculat o data. NU intra in payload/idempotenta.
held_val = held_for_account(conn, acct)
for prez in req.prezentari:
content = prez.model_dump()
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
@@ -254,7 +302,7 @@ def create_prezentari(
"odometru_final": canon["odometru_final"],
})
existing = conn.execute(
"SELECT id, status, id_prezentare FROM submissions WHERE idempotency_key=?",
"SELECT id, status, id_prezentare, held FROM submissions WHERE idempotency_key=?",
(key,),
).fetchone()
if existing:
@@ -262,22 +310,25 @@ def create_prezentari(
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
if existing["status"] == "error":
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
_ataseaza_sugestii(conn, cl, nomenclator)
if cl["blocked_error"]:
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
continue
# La reactivare re-snapshot-am `held` din comutatorul contului. Fara
# asta randul pastra held VECHI si se auto-trimitea desi contul e Auto OFF.
cur = conn.execute(
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, "
"rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'",
"held=?, rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'",
(cl["status"], json.dumps(cl["content"], ensure_ascii=False),
cl["rar_error"], creds_enc, env, existing["id"]),
cl["rar_error"], creds_enc, held_val, env, existing["id"]),
)
if cur.rowcount == 1:
# Creds noi se propaga si in slotul durabil per-env al contului
# — ambele canale converg pe parola corectata (US-013, env-aware).
# — ambele canale converg pe parola corectata (env-aware).
if req.rar_credentials is not None:
conn.execute(
f"UPDATE accounts SET rar_creds_{env}_enc=?, rar_{env}_enabled=1 WHERE id=?",
@@ -286,41 +337,57 @@ def create_prezentari(
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
# needs_data/needs_mapping, expune motivul (nu doar status).
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True, rar_env=env))
results.append(_rezultat_enqueue(existing["id"], cl, held=held_val, reactivated=True, rar_env=env))
continue
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
existing = conn.execute(
"SELECT id, status, id_prezentare FROM submissions WHERE id=?",
"SELECT id, status, id_prezentare, held FROM submissions WHERE id=?",
(existing["id"],),
).fetchone()
# Echo onest pe dedup: propaga `held` al randului
# existent — un rand tinut NU trebuie sa apara ca un "queued" curat fals.
dedup_held = bool(existing["held"])
results.append(
SubmissionResult(
submission_id=existing["id"],
status=existing["status"],
id_prezentare=existing["id_prezentare"],
deduped=True,
held=dedup_held,
motiv=_motiv_held(existing["status"], dedup_held),
rar_env=env,
)
)
continue
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
# (canonicalize + mapare op->cod + validare; auto_send gate eliminat dupa US-001).
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
# (canonicalize + mapare op->cod + validare; fara gate auto_send).
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
_ataseaza_sugestii(conn, cl, nomenclator)
if cl["blocked_error"]:
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
results.append(_rezultat_respins(None, cl, rar_env=env))
continue
# `held` = snapshot comutator cont (chokepoint held_for_account).
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env),
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env, held) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env, held_val),
)
sub_id = int(cur.lastrowid)
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
results.append(_rezultat_enqueue(sub_id, cl, rar_env=env))
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv;
# pe queued tinut manual (held=1) expune held=true + motiv.
results.append(_rezultat_enqueue(sub_id, cl, held=held_val, rar_env=env))
# Corelare raspuns<->cerere: fiecare prezentare produce EXACT un rezultat,
# in ordine, deci pozitia din lista e si indexul cererii. Ecoul vehiculului
# foloseste valorile normalizate de Pydantic (upper/strip).
for i, (rez, prez) in enumerate(zip(results, req.prezentari)):
rez.index = i
rez.vin = prez.vin
rez.nr_inmatriculare = prez.nr_inmatriculare
# Audit cerere API per cont. Doar metadate (count + distributie status),
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
@@ -342,7 +409,21 @@ def create_prezentari(
)
finally:
conn.close()
return PrezentariResponse(results=results)
# Forma implicita = minim (6 chei fixe per rezultat); formatul complet
# (erori/nemapate/deduped/held/...) doar la cerere explicita.
if req.raspuns == "complet":
return PrezentariResponse(results=list(results))
return PrezentariResponse(results=[
SubmissionResultMinim(
index=r.index if r.index is not None else 0,
submission_id=r.submission_id,
vehicul=r.nr_inmatriculare or "",
status=r.status,
motiv=r.motiv,
id_prezentare=r.id_prezentare,
)
for r in results
])
@router.post("/prezentari/valideaza", response_model=ValidareResponse)
@@ -363,15 +444,17 @@ def valideaza_prezentari(
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
nomenclator = load_nomenclator(conn)
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
# US-005 (DX F5): rezolva env identic ca trimiterea reala si ecou-ieste in raspuns.
# Rezolva env identic ca trimiterea reala si ecou-ieste in raspuns.
try:
env = rezolva_rar_env(conn, acct, req.rar_env)
except MediuIndisponibil as e:
# US-012: audit blocare mediu indisponibil pe dry-run (tip='rar_env_blocat').
# Audit blocare mediu indisponibil pe dry-run (tip='rar_env_blocat').
log_event(
"rar_env_blocat",
nivel="WARNING",
@@ -397,7 +480,8 @@ def valideaza_prezentari(
for i, prez in enumerate(req.prezentari):
content = prez.model_dump()
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
_ataseaza_sugestii(conn, res, nomenclator)
if res["blocked_error"]:
res = {**res, "status": "error"}
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
@@ -419,40 +503,88 @@ def valideaza_prezentari(
return ValidareResponse(results=results)
def _valida_data_filtru(nume_param: str, valoare: str | None) -> str | None:
"""Valideaza formatul YYYY-MM-DD al unui parametru de filtrare pe perioada.
None/gol -> None (filtru inactiv). Format invalid -> 422, consecvent cu restul
rutelor API (vezi celelalte `status_code=422` din acest fisier).
"""
v = (valoare or "").strip()
if not v:
return None
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise HTTPException(
status_code=422,
detail=f"{nume_param} trebuie sa fie in format YYYY-MM-DD",
)
return v
@router.get("/prezentari")
def list_prezentari(
status: str | None = None,
data_de: str | None = None,
data_pana: str | None = None,
limit: int = 100,
account_id: int = Depends(resolve_account_id),
) -> dict:
data_de = _valida_data_filtru("data_de", data_de)
data_pana = _valida_data_filtru("data_pana", data_pana)
conn = get_connection()
try:
scope_sql, scope_params = account_scope_clause(account_id)
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
# rar_env inclus (US-005): badge mediu in lista.
# rar_env inclus: badge mediu in lista.
cols = (
"id, status, id_prezentare, rar_status_code, retry_count, "
"created_at, updated_at, payload_json, rar_env"
"created_at, updated_at, payload_json, rar_env, held"
)
where = [scope_sql]
params: list = list(scope_params)
if status:
where.append("status=?")
params.append(status)
where_sql = " AND ".join(where)
# data_prestatie traieste in payload_json, nu e coloana SQL (vezi
# payload_view.prezentare_din_payload) — nu poate intra in clauza WHERE.
# Cu filtru de perioada activ: fetch scope+status FARA LIMIT, filtreaza in
# Python dupa derivarea prezentarii, apoi LIMIT-eaza DUPA filtrare (altfel
# LIMIT SQL ar trunchia gresit inainte de filtru). Acelasi pattern ca
# /_fragments/submissions (dashboard web, app/web/routes.py).
filtru_perioada = bool(data_de or data_pana)
if filtru_perioada:
rows = conn.execute(
f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? "
f"ORDER BY id DESC LIMIT ?",
scope_params + [status, limit],
f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC",
params,
).fetchall()
else:
rows = conn.execute(
f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
scope_params + [limit],
f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC LIMIT ?",
params + [limit],
).fetchall()
out = []
for r in rows:
d = dict(r)
# Campuri afisabile derivate din payload (acelasi helper ca dashboardul web);
# payload_json brut nu se intoarce in raspuns.
d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None))
prezentare = prezentare_din_payload(d.pop("payload_json", None))
if filtru_perioada:
d_prefix = iso_date_prefix(prezentare["data_prestatie"])
if d_prefix is None:
continue
if data_de and d_prefix < data_de:
continue
if data_pana and d_prefix > data_pana:
continue
d["prezentare"] = prezentare
out.append(d)
if filtru_perioada and len(out) >= limit:
break
return {"submissions": out}
finally:
conn.close()
@@ -468,8 +600,10 @@ _PREZENTARE_FIELDS = frozenset({
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
"rar_error",
# US-005: mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API.
# Mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API.
"rar_env",
# Flag `held` (tinut manual) — onestitate GET (dev vede ca NU a plecat).
"held",
})
@@ -548,6 +682,38 @@ def repune_prezentare(
conn.close()
@router.post("/prezentari/{submission_id}/trimite-acum")
def trimite_acum_prezentare(
submission_id: int,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Elibereaza manual un rand tinut (paritate API cu /repune).
Scoped strict pe contul cheii API (account_id din sesiune/cheie, NICIODATA din body).
404-before-leak pe id strain/inexistent. `held: 1 -> 0` DOAR daca randul e
`queued AND held=1` (no-op sigur altfel: un rand deja `sending`/`sent` ramane neatins).
Worker-ul preia randul la urmatorul poll (trimitere asincrona).
"""
conn = get_connection()
try:
scope_sql, scope_params = account_scope_clause(account_id)
row = conn.execute(
f"SELECT id FROM submissions WHERE id=? AND {scope_sql}",
[submission_id] + scope_params,
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="submission inexistent")
cur = conn.execute(
f"UPDATE submissions SET held=0, updated_at=datetime('now') "
f"WHERE id=? AND {scope_sql} AND status='queued' AND held=1",
[submission_id] + scope_params,
)
conn.commit()
return {"ok": True, "eliberat": cur.rowcount == 1}
finally:
conn.close()
@router.get("/nomenclator")
def get_nomenclator() -> dict:
conn = get_connection()
@@ -571,7 +737,7 @@ AUDIT_COLUMNS = [
"odometru_final",
"prestatii",
"rar_status_code",
# US-010 (PRD 5.20): mediul RAR tinta per trimitere.
# Mediul RAR tinta per trimitere.
"rar_env",
"created_at",
"updated_at",
@@ -626,7 +792,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
"odometru_final": p.get("odometru_final") or "",
"prestatii": codes,
"rar_status_code": r["rar_status_code"] or "",
# US-010 (PRD 5.20): mediul RAR tinta — coloana audit.
# Mediul RAR tinta — coloana audit.
"rar_env": r["rar_env"] or "",
"created_at": r["created_at"],
"updated_at": r["updated_at"],
@@ -758,7 +924,7 @@ def set_rar_creds(
req: RarCredsIn,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Seteaza creds RAR durabile per-cont, in slotul per-mediu (US-013, env-aware).
"""Seteaza creds RAR durabile per-cont, in slotul per-mediu (env-aware).
Slotul tinta: `req.rar_target` ('test'/'prod') sau ancora globala (AUTOPASS_RAR_ENV).
Activeaza mediul selectat (`rar_{env}_enabled=1`). Worker-ul le foloseste ca
@@ -787,7 +953,7 @@ def delete_rar_creds(
account_id: int = Depends(resolve_account_id),
env: str | None = None,
) -> dict:
"""Sterge creds RAR durabile per-cont (revenire la modelul efemer Treapta 1).
"""Sterge creds RAR durabile per-cont (revenire la modelul efemer per submission).
`env` (query param): 'test' | 'prod' -> sterge DOAR slotul acelui mediu + dezactiveaza-l.
Absent -> sterge AMBELE sloturi (revenire completa). Schimbare aditiva (back-compat).

View File

@@ -168,7 +168,7 @@ def resolve_account_id(
def require_api_access(
account_id: int = Depends(resolve_account_id),
) -> int:
"""Dependency FastAPI (T4, PRD 5.17): verifica ca tier-ul efectiv permite accesul la API.
"""Dependency FastAPI: verifica ca tier-ul efectiv permite accesul la API.
Reguli:
- enforce_plans=False (kill-switch): sare verificarea.

View File

@@ -35,6 +35,12 @@ class Settings(BaseSettings):
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
# ale `sent` — un blocat n-are valoare de audit.
blocked_retention_days: int = 30
# Prag zile: randurile tinute manual (`queued AND held=1`) mai vechi de
# atat declanseaza bannerul de avertizare L.142 (declararea are termen legal).
held_warn_days: int = 7
# Retentie randuri tinute manual: un `queued AND held=1` mai vechi de atat
# expira la `error` (`TINUT_EXPIRAT`) + purge_after direct. Aliniat retentiei `sent` (90z).
held_retention_days: int = 90
# --- Securitate ---
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->
@@ -61,7 +67,7 @@ class Settings(BaseSettings):
# False (dev): cookie fara Secure, functioneaza pe HTTP.
session_https_only: bool = False
# --- Contact suport (US-001, PRD 5.12) ---
# --- Contact suport ---
# Email/canal de contact afisat in mesaje catre utilizatori (ex. CUI duplicat la signup).
# Nesetat -> fallback la formularea generica fara canal concret.
support_email: str | None = None
@@ -109,14 +115,13 @@ class Settings(BaseSettings):
worker_retry_max_s: int = 300
worker_max_retries: int = 8 # peste atat -> error + banner
# --- Planuri de cont (PRD 5.17) ---
# --- Planuri de cont ---
# Enforcement DUR al limitelor de plan (volum + acces API). True (implicit) = activ.
# False = kill-switch de operare: sare toate gate-urile de plan (util pentru debugging
# sau rollback rapid fara revert de cod). Enforcement DUR e activ implicit de la deploy
# (decizie user 2026-06-28, decizia #22 autoplan): nu exista conturi legacy, produs in TESTE.
# sau rollback rapid fara revert de cod). Nu exista conturi legacy, produs in teste.
enforce_plans: bool = True
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
# --- Embeddings (sugestie mapare) ---
# ACTIVAT implicit: editorul de mapari ofera sugestii semantice (model fastembed/ONNX).
# Cost: prima folosire lazy-load-eaza modelul (~230MB pe disc) sincron in thread-ul de
# cerere -> prima cerere /mapari poate dura 30-120s pana modelul intra in memorie; cererile
@@ -125,7 +130,7 @@ class Settings(BaseSettings):
# /mapari instant la prima cerere sau suita de teste rapida (cade pe GOLD/SILVER+fuzzy).
embeddings_enabled: bool = True
# --- Seed corpus operatii etichetate (SILVER, PRD 5.18 US-004) ---
# --- Seed corpus operatii etichetate (SILVER) ---
# ACTIVAT implicit: la init_db, populeaza mapping_suggestions din artefactul comis
# `app/data/operatii-etichetate.json` (INSERT OR IGNORE). Asa SILVER nu mai e gol in
# productie -> sugestii exact-match + corpus k-NN reale. SUGGESTION-ONLY.
@@ -133,6 +138,12 @@ class Settings(BaseSettings):
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
seed_operatii_enabled: bool = True
# --- Limita globala corp cerere (P1-4/P1-5, hardening 2026-07-03) ---
# Plafon aplicat de un middleware ASGI pur (app/web/body_cap.py) pe TOATE rutele,
# INAINTE ca FastAPI sa parseze body-ul (multipart e spooled pe disc altfel).
# Verificarea per-endpoint existenta (import_parse.MAX_BYTES, 5MB) ramane strat 2.
max_request_bytes: int = 10 * 1024 * 1024 # 10 MB
@property
def rar_base_url(self) -> str:
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test
@@ -143,6 +154,32 @@ def get_settings() -> Settings:
return Settings()
def validate_prod_invariants(settings: Settings) -> None:
"""Fail-fast la startup: refuza boot-ul in productie cand lipsesc invariantele
de securitate obligatorii, in loc sa lase aplicatia sa porneasca intr-o
configuratie nesigura descoperita abia dupa deploy (mirror `crypto.validate_creds_key`).
Conditionat STRICT pe `rar_env=="prod"` — altfel ar opri suita de teste, care
ruleaza implicit cu `rar_env="test"` si fara `session_secret`/`require_api_key`.
"""
if settings.rar_env != "prod":
return
if not settings.require_api_key:
raise RuntimeError(
"AUTOPASS_RAR_ENV=prod dar AUTOPASS_REQUIRE_API_KEY nu e activat: in "
"productie cheia API e obligatorie pe /v1/*, altfel orice cerere "
"neautentificata ajunge pe contul implicit id=1. Seteaza "
"AUTOPASS_REQUIRE_API_KEY=true."
)
if not settings.session_secret:
raise RuntimeError(
"AUTOPASS_RAR_ENV=prod dar AUTOPASS_SESSION_SECRET nu este setat: fara el, "
"secretul de sesiune e efemer per restart si toti userii sunt delogati la "
"fiecare redeploy. Genereaza unul persistent cu:\n"
" python3 -c \"import secrets; print(secrets.token_hex(32))\""
)
def load_test_credentials(settings_xml: Path | None = None) -> dict | None:
"""Citeste credentialele <test> din settings.xml (dev local / probe test).

File diff suppressed because it is too large Load Diff

116
app/db.py
View File

@@ -37,7 +37,7 @@ def init_db() -> None:
from .mapping import seed_nomenclator_if_empty
seed_nomenclator_if_empty(conn)
# Seed corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
# Seed corpus operatii etichetate -> mapping_suggestions (SILVER).
# Gated: OFF in teste (conftest), ON in productie. INSERT OR IGNORE -> idempotent.
# DOAR daca mapping_suggestions e gol: seedul are ~17k randuri; re-rularea lui pe
# FIECARE boot (API + worker concurent) tinea write-lock-ul indelung -> al doilea
@@ -72,9 +72,9 @@ def _migrate(conn: sqlite3.Connection) -> None:
if "row_index" not in sub_cols:
conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER")
if "rar_env" not in sub_cols:
# PRD 5.20 US-001. Mediul RAR tinta pe submission. Pe DB existent NU lasam
# Mediul RAR tinta pe submission. Pe DB existent NU lasam
# randurile pe DEFAULT 'test': un rand prod pre-migrare etichetat 'test' ar fi
# reconciliat de worker (US-006) contra endpoint TEST -> no-match -> re-send prod
# reconciliat de worker contra endpoint TEST -> no-match -> re-send prod
# = DUPLICAT REAL IREVERSIBIL. Backfill din AUTOPASS_RAR_ENV global (ancora de
# migrare) + recompute idempotency_key env-aware. Ruleaza O SINGURA DATA (in
# blocul de adaugare a coloanei); pe DB fresh coloana vine din schema.sql (fara rows).
@@ -83,14 +83,21 @@ def _migrate(conn: sqlite3.Connection) -> None:
"CHECK (rar_env IN ('test', 'prod'))"
)
_backfill_submissions_rar_env(conn)
if "held" not in sub_cols:
# Flag rand tinut manual. Default 0 (randurile pre-migrare nu erau
# tinute — pastreaza comportamentul; comutatorul de cont e OFF/held la ingestie NOUA).
conn.execute(
"ALTER TABLE submissions ADD COLUMN held INTEGER NOT NULL DEFAULT 0 "
"CHECK (held IN (0, 1))"
)
# Coloane accounts
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
# AUTO-FIX 6a ELIMINAT (US-013): NU mai adaugam accounts.rar_creds_enc — coloana e dropata.
# _migrate_accounts_medii gestioneaza absenta coloana (guard la ~219: if "rar_creds_enc" not in acc_cols: return).
# Medii RAR per cont (PRD 5.20 US-001): activare + slot creds + default, per mediu.
# NU mai adaugam accounts.rar_creds_enc — coloana e dropata.
# _migrate_accounts_medii gestioneaza absenta coloana (guard: if "rar_creds_enc" not in acc_cols: return).
# Medii RAR per cont: activare + slot creds + default, per mediu.
_migrate_accounts_medii(conn, acc_cols)
# US-013: DROP coloana legacy accounts.rar_creds_enc dupa backfill complet.
# DROP coloana legacy accounts.rar_creds_enc dupa backfill complet.
_drop_legacy_accounts_rar_creds(conn, acc_cols)
if "active" not in acc_cols:
# Conturi existente raman active (default 1).
@@ -113,11 +120,19 @@ def _migrate(conn: sqlite3.Connection) -> None:
"ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 "
"CHECK (on_unmapped_error_default IN (0, 1))"
)
if "auto_send_enabled" not in acc_cols:
# Comutator Auto per cont. Default 0 (Auto OFF) — conturile existente
# trec pe „tine randurile" la ingestie noua pana cand operatorul porneste Auto explicit.
# Contul id=1 ramane pe default (0), fara tratament special.
conn.execute(
"ALTER TABLE accounts ADD COLUMN auto_send_enabled INTEGER NOT NULL DEFAULT 0 "
"CHECK (auto_send_enabled IN (0, 1))"
)
if "email" not in acc_cols:
# Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy.
# Email canonic de contact al firmei. Nullable pt. conturi legacy.
conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT")
if "tier" not in acc_cols:
# Plan de cont (US-001, PRD 5.17). Legacy -> 'free' fara trial (enforcement DUR la deploy).
# Plan de cont. Legacy -> 'free' fara trial (enforcement DUR la deploy).
conn.execute(
"ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT 'free' "
"CHECK (tier IN ('free','standard','pro','premium'))"
@@ -158,11 +173,31 @@ def _migrate(conn: sqlite3.Connection) -> None:
if "override_json" not in irows_cols:
conn.execute("ALTER TABLE import_rows ADD COLUMN override_json TEXT")
if "reviewed" not in irows_cols:
# Marcaj confirmare umana (US-007, PRD 5.12). NU intra in payload/idempotenta.
# Marcaj confirmare umana. NU intra in payload/idempotenta.
# NOT NULL DEFAULT 0: valoare clara (0=neconfirmat), fara ambiguitate NULL vs 0.
conn.execute(
"ALTER TABLE import_rows ADD COLUMN reviewed INTEGER NOT NULL DEFAULT 0"
)
# CHECK-ul resolved_status trebuie sa accepte 'excluded' (operatii excluse de la
# declarare). SQLite nu poate altera CHECK -> rebuild o singura data (detectat
# pe textul CREATE din sqlite_master).
create_sql = str(conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='import_rows'"
).fetchone()["sql"] or "")
if "'excluded'" not in create_sql:
_rebuild_import_rows_cu_excluded(conn)
# Coloana operations_mapping.exclus: regula "nu se declara la RAR" per operatie.
om_tbl = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='operations_mapping'"
).fetchone()
if om_tbl:
om_cols = {r["name"] for r in conn.execute("PRAGMA table_info(operations_mapping)").fetchall()}
if "exclus" not in om_cols:
conn.execute(
"ALTER TABLE operations_mapping ADD COLUMN exclus INTEGER NOT NULL DEFAULT 0 "
"CHECK (exclus IN (0, 1))"
)
# Index batch_id pe submissions (poate lipsi pe DB veche)
existing_idx = {r["name"] for r in conn.execute(
@@ -178,10 +213,55 @@ def _migrate(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_submissions_account_status "
"ON submissions(account_id, status)"
)
if "idx_submissions_held" not in existing_idx:
# CREATE TABLE IF NOT EXISTS nu se declanseaza pe DB
# existent -> indexul partial trebuie creat si aici, nu doar in schema.sql.
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_submissions_held ON submissions(held) WHERE held=1"
)
def _rebuild_import_rows_cu_excluded(conn: sqlite3.Connection) -> None:
"""Rebuild import_rows cu 'excluded' in CHECK-ul resolved_status.
Ruleaza O SINGURA DATA pe DB-uri create inainte de starea 'excluded' (gardat de
apelant pe textul CREATE). Copiaza toate randurile 1:1 si recreeaza indexul.
"""
conn.execute("BEGIN IMMEDIATE")
try:
conn.execute(
"CREATE TABLE import_rows_new ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" batch_id INTEGER NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,"
" row_index INTEGER NOT NULL,"
" raw_json TEXT NOT NULL,"
" override_json TEXT,"
" reviewed INTEGER NOT NULL DEFAULT 0,"
" resolved_status TEXT NOT NULL DEFAULT 'pending'"
" CHECK (resolved_status IN ("
" 'pending','ok','needs_mapping','needs_data',"
" 'needs_review','already_sent','duplicate_in_file','excluded'"
" )),"
" error TEXT"
")"
)
conn.execute(
"INSERT INTO import_rows_new "
" (id, batch_id, row_index, raw_json, override_json, reviewed, resolved_status, error) "
"SELECT id, batch_id, row_index, raw_json, override_json, reviewed, resolved_status, error "
"FROM import_rows"
)
conn.execute("DROP TABLE import_rows")
conn.execute("ALTER TABLE import_rows_new RENAME TO import_rows")
conn.execute("CREATE INDEX IF NOT EXISTS idx_import_rows_batch ON import_rows(batch_id)")
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
"""PRD 5.20 US-001: coloane medii RAR per cont + backfill din ancora globala.
"""Coloane medii RAR per cont + backfill din ancora globala.
Adauga (idempotent): rar_test_enabled/rar_prod_enabled (bife activare),
rar_creds_test_enc/rar_creds_prod_enc (sloturi creds), rar_env_default.
@@ -191,7 +271,7 @@ def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> Non
momentul migrarii (ancora) — il copiem in slotul acelui mediu, activam DOAR acel
mediu (celalalt dezactivat) si fixam default-ul pe el. Conturile fara creds raman
pe default-urile coloanei (prod on / test off). Migrarea NU presupune env-ul; se
bazeaza pe ancora globala, exact cum opera contul inainte de 5.20.
bazeaza pe ancora globala.
"""
newly_added = "rar_env_default" not in acc_cols
if "rar_test_enabled" not in acc_cols:
@@ -231,12 +311,12 @@ def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> Non
def _drop_legacy_accounts_rar_creds(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
"""PRD 5.20 US-013: DROP coloana legacy `accounts.rar_creds_enc` dupa backfill complet.
"""DROP coloana legacy `accounts.rar_creds_enc` dupa backfill complet.
Idempotent si sigur la fiecare boot (garda one-way: coloana absenta = nimic de facut).
La eroare LOGHEAZA si lasa coloana pe loc (fail-safe — nu crapa boot-ul ambelor procese).
Structura separata pentru testabilitate: `_garda_si_drop(conn)` expune pasul de
assert+backup+DROP izolat (fara re-backfill 6c), apelabil direct din teste.
assert+backup+DROP izolat (fara re-backfill), apelabil direct din teste.
"""
if "rar_creds_enc" not in acc_cols:
return # garda one-way: coloana deja dropata sau DB fresh
@@ -251,10 +331,10 @@ def _drop_legacy_accounts_rar_creds(conn: sqlite3.Connection, acc_cols: set[str]
def _drop_legacy_rar_creds_impl(conn: sqlite3.Connection) -> None:
"""Re-backfill (AUTO-FIX 6c) + delegate la _garda_si_drop.
"""Re-backfill + delegate la _garda_si_drop.
Re-backfill-ul 6c acopera creds setate via POST /v1/conturi/rar-creds intre US-001
si US-013 (pot fi DOAR in coloana veche). Ancora globala: AUTOPASS_RAR_ENV.
Re-backfill-ul acopera creds setate via POST /v1/conturi/rar-creds intre adaugarea
coloanei noi si drop-ul celei vechi (pot fi DOAR in coloana veche). Ancora globala: AUTOPASS_RAR_ENV.
"""
if sqlite3.sqlite_version_info < (3, 35, 0):
print(
@@ -323,7 +403,7 @@ def _garda_si_drop(conn: sqlite3.Connection) -> None:
def _backfill_submissions_rar_env(conn: sqlite3.Connection) -> None:
"""PRD 5.20 US-001 (AUTO-FIX G + E4/3): backfill rar_env + recompute idempotency_key.
"""Backfill rar_env + recompute idempotency_key.
Ruleaza O SINGURA DATA, imediat dupa ce coloana `submissions.rar_env` a fost adaugata
pe un DB existent. Toate randurile pre-migrare au fost trimise (sau urmeaza) catre

View File

@@ -11,6 +11,7 @@ import textwrap
from email.message import EmailMessage
from .config import get_settings
from .observ import log_event
def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
@@ -23,10 +24,11 @@ def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
settings = get_settings()
if not settings.smtp_host or not admin_emails:
print(
f"SIGNUP-NOTIFY degradat (fara SMTP) cont={account_id} "
f"email={email} admins={len(admin_emails)}",
flush=True,
log_event(
"signup_notify_degradat",
account_id=account_id,
mesaj="fara SMTP configurat",
context={"admins": len(admin_emails)},
)
return

210
app/embedding_cache.py Normal file
View File

@@ -0,0 +1,210 @@
"""Cache persistent de vectori embeddings in SQLite (tabela `embedding_cache`).
Design:
- Cheie (model, text_hash): schimbarea modelului nu foloseste vectori vechi.
- Vectorii raman `array('f')` (float32) end-to-end -- fara conversie la list[float].
- Scrieri/stergeri in tranzactii scurte, chunk-uite (conexiunile sunt autocommit,
vezi app/db.py -- BEGIN/COMMIT explicit per chunk, altfel fiecare INSERT/DELETE
e propria tranzactie).
- Degradare gratioasa: orice eroare SQLite -> log.warning, fara exceptie propagata
din load/save/purge (caller-ul, ensure_embeddings_corpus, ramane neschimbat).
"""
from __future__ import annotations
import hashlib
import logging
import sqlite3
import sys
from array import array
from typing import Callable, Iterable, Sequence
log = logging.getLogger(__name__)
EMB_DIM = 384 # paraphrase-multilingual-MiniLM-L12-v2 (app/embeddings.py::FASTEMBED_MODEL)
_CHUNK_SIZE = 500 # A2/A3: tranzactii scurte vs worker BEGIN IMMEDIATE pe submissions
def text_hash(text: str) -> str:
"""SHA-256 hex al textului dat (apelantul normalizeaza inainte de hash)."""
return hashlib.sha256(str(text).encode("utf-8")).hexdigest()
def vector_to_blob(vector: Sequence[float]) -> bytes:
"""Serializeaza un vector la float32 little-endian (independent de platforma)."""
arr = array("f", vector)
if sys.byteorder != "little":
arr = array("f", arr)
arr.byteswap()
return arr.tobytes()
def blob_to_vector(blob: bytes) -> array:
"""Deserializeaza un blob float32 little-endian la `array('f')`."""
arr = array("f")
arr.frombytes(blob)
if sys.byteorder != "little":
arr.byteswap()
return arr
def _chunks(seq: Sequence, size: int = _CHUNK_SIZE) -> Iterable[Sequence]:
for i in range(0, len(seq), size):
yield seq[i : i + size]
def load_cached_vectors(conn: sqlite3.Connection, model: str, hashes: Sequence[str]) -> dict[str, array]:
"""Citeste vectorii existenti pentru `model` + `hashes`. Blob corupt (lungime
gresita) = tratat ca miss (nu apare in rezultat), cu log.warning.
Degradare gratioasa: orice eroare SQLite -> dict gol/partial, fara exceptie.
"""
out: dict[str, array] = {}
if not hashes:
return out
expected_bytes = EMB_DIM * 4
unique_hashes = list(dict.fromkeys(hashes))
try:
for chunk in _chunks(unique_hashes):
placeholders = ",".join("?" for _ in chunk)
rows = conn.execute(
f"SELECT text_hash, vector FROM embedding_cache "
f"WHERE model = ? AND text_hash IN ({placeholders})",
(model, *chunk),
).fetchall()
for row in rows:
blob = row["vector"]
if len(blob) != expected_bytes:
log.warning(
"embedding_cache: blob lungime gresita pentru hash=%s (asteptat %d, primit %d) -- tratat ca miss",
row["text_hash"], expected_bytes, len(blob),
)
continue
out[row["text_hash"]] = blob_to_vector(blob)
except sqlite3.OperationalError as exc:
log.warning("embedding_cache: load_cached_vectors esuat (%s) -- fallback embed complet", exc)
return out
def save_vectors(conn: sqlite3.Connection, model: str, items: Sequence[tuple[str, Sequence[float]]]) -> None:
"""INSERT OR REPLACE in chunk-uri de 500 randuri, BEGIN/COMMIT explicit per chunk
(conexiunile sunt autocommit -- conn.commit() singur e no-op).
Valideaza `len(vector) == EMB_DIM` la scriere: vector gresit = respins (log.warning),
NU scris. Degradare gratioasa: eroare SQLite pe un chunk -> log.warning, chunk-urile
ramase continua (cache partial e idempotent, se completeaza la urmatorul warmup).
Invarianta: `conn` trebuie sa fie in autocommit (fara tranzactie deschisa de
apelant) -- ROLLBACK-ul din except ar anula altfel tranzactia apelantului.
"""
valid = []
for h, vec in items:
if len(vec) != EMB_DIM:
log.warning(
"embedding_cache: vector dimensiune gresita pentru hash=%s (asteptat %d, primit %d) -- respins",
h, EMB_DIM, len(vec),
)
continue
valid.append((h, model, vector_to_blob(vec)))
for chunk in _chunks(valid):
try:
conn.execute("BEGIN")
conn.executemany(
"INSERT OR REPLACE INTO embedding_cache (text_hash, model, vector) VALUES (?, ?, ?)",
chunk,
)
conn.execute("COMMIT")
except sqlite3.OperationalError as exc:
try:
conn.execute("ROLLBACK")
except sqlite3.OperationalError:
pass
log.warning("embedding_cache: save_vectors esuat pe un chunk (%s) -- cache ramane partial", exc)
def purge_stale(conn: sqlite3.Connection, model: str, corpus_hashes: Iterable[str]) -> None:
"""Sterge intrarile care nu mai apartin corpusului curent: orfane ale
modelului curent (text_hash absent din `corpus_hashes`) SI toate intrarile
modelelor VECHI (model != curent). Diff calculat in Python, DELETE chunk-uit
pe PK (model, text_hash) -- tranzactii scurte.
Degradare gratioasa: eroare SQLite -> log.warning, orfanele raman pana la
urmatoarea trecere.
Invarianta: `conn` trebuie sa fie in autocommit (fara tranzactie deschisa de
apelant) -- ROLLBACK-ul din except ar anula altfel tranzactia apelantului.
Apelantul trebuie sa cheme aceasta functie DOAR dupa o indexare reusita
(vezi `ensure_embeddings_corpus`) -- un esec de indexare nu trebuie sa goleasca
cache-ul de randuri inca valide.
"""
keep = set(corpus_hashes)
try:
rows = conn.execute("SELECT model, text_hash FROM embedding_cache").fetchall()
except sqlite3.OperationalError as exc:
log.warning("embedding_cache: purge_stale citire esuata (%s)", exc)
return
to_delete = [
(r["model"], r["text_hash"])
for r in rows
if r["model"] != model or r["text_hash"] not in keep
]
if not to_delete:
return
for chunk in _chunks(to_delete):
try:
conn.execute("BEGIN")
conn.executemany(
"DELETE FROM embedding_cache WHERE model = ? AND text_hash = ?",
chunk,
)
conn.execute("COMMIT")
except sqlite3.OperationalError as exc:
try:
conn.execute("ROLLBACK")
except sqlite3.OperationalError:
pass
log.warning("embedding_cache: purge_stale esuat pe un chunk (%s) -- orfanele raman", exc)
def sync_corpus_vectors(
conn: sqlite3.Connection,
model: str,
texts: Sequence[str],
embed_fn: Callable[[list[str]], Sequence[Sequence[float]]],
) -> list[array]:
"""Orchestreaza hash -> load -> embed(doar miss-uri) -> save -> vectori aliniati.
Returneaza o lista de `array('f')` aliniata pozitional cu `texts`.
`embed_fn` primeste lista textelor lipsa din cache si intoarce vectorii lor
(aceeasi ordine). Daca `embed_fn` esueaza (arunca), exceptia se propaga;
apelantul (ensure_embeddings_corpus) are deja degradare gratioasa (except -> pass).
Un save partial esuat (lock SQLite) NU opreste intoarcerea vectorilor din RAM
(A13c): vectorii noi raman in `cached` indiferent de rezultatul persistarii.
NU purjeaza orfanele: purjarea e responsabilitatea apelantului, DUPA ce corpusul
a fost indexat cu succes (`index_corpus`) -- un esec de indexare nu trebuie sa
goleasca din greseala cache-ul de randuri inca valide.
"""
hashes = [text_hash(t) for t in texts]
cached = load_cached_vectors(conn, model, hashes)
missing_positions = [i for i, h in enumerate(hashes) if h not in cached]
if missing_positions:
new_texts = [texts[i] for i in missing_positions]
new_vecs = embed_fn(new_texts)
if len(new_vecs) != len(new_texts):
raise ValueError(
f"embed_fn a intors {len(new_vecs)} vectori pentru {len(new_texts)} texte"
)
to_save = []
for pos, vec in zip(missing_positions, new_vecs):
arr = array("f", vec)
cached[hashes[pos]] = arr
to_save.append((hashes[pos], arr))
save_vectors(conn, model, to_save)
return [cached[h] for h in hashes]

View File

@@ -1,16 +1,16 @@
"""Modul embedding in-proces pentru sugestie cod RAR -- L14-S4.
"""Modul embedding in-proces pentru sugestie cod RAR.
Design (PRD 5.14, Decision #16/#16b):
Design:
- Model multilingv via fastembed/ONNX (~230MB pe disc, quantizat, fara torch)
- Lazy load la prima folosire, NU la import si NU pe /healthz
- Worker NU incarca modelul (API-only)
- Degradare gratioasa: daca modelul nu se incarca -> is_available()=False,
suggest_nearest() -> [] fara exceptie, ingestia NU e blocata
- Embeddings = DOAR sugestie (nu intra in lantul de enqueue/resolve_prestatii)
- NU apelat din resolve_prestatii/load_mapping (wiring vine in L14-S6 DUPA 5.15)
- NU apelat din resolve_prestatii/load_mapping
API public (nivel modul):
index_corpus(items) -> None
index_corpus(items, signature, vectors) -> None
suggest_nearest(text, top_k) -> [{cod, is_nul, similaritate}]
is_available() -> bool
@@ -22,13 +22,19 @@ from __future__ import annotations
import logging
import math
import threading
from typing import Protocol, runtime_checkable
try:
import numpy as _np # garantat de fastembed/onnxruntime; fallback pur-Python daca lipseste
except ImportError: # pragma: no cover
_np = None
log = logging.getLogger(__name__)
# Modelul ales: paraphrase-multilingual-MiniLM-L12-v2
# ~230MB pe disc (ONNX quantizat), 384 dim, multilingv (ro/en/etc.), suportat de
# fastembed, fara torch. (Estimarea initiala din PRD de ~50MB a fost gresita.)
# fastembed, fara torch.
FASTEMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
@@ -93,6 +99,10 @@ class EmbeddingEngine:
self._corpus_vecs: list[list[float]] = []
self._corpus_items: list[dict] = []
self._corpus_sig: str | None = None
# Matrice numpy + norme precalculate: cautarea NN devine un matvec
# (~ms) in loc de cosine pur-Python per item (~0.5s la 17k vectori).
self._corpus_matrix = None
self._corpus_norms = None
def is_available(self) -> bool:
"""True daca backend-ul e disponibil si gata de folosire."""
@@ -107,8 +117,19 @@ class EmbeddingEngine:
doar cand semnatura nomenclatorului s-a schimbat (evita re-embed inutil)."""
return self._corpus_sig
def index_corpus(self, items: list[dict], signature: str | None = None) -> None:
"""Vectorizeaza corpus [{denumire, cod}] si il pastreaza in memorie.
def index_corpus(
self,
items: list[dict],
signature: str | None = None,
vectors: list | None = None,
) -> None:
"""Indexeaza corpus [{denumire, cod}] si il pastreaza in memorie.
`vectors`: vectori precalculati, aliniati POZITIONAL cu `items` (ex. din
embedding_cache). `None` (default) = comportamentul existent, embed complet
prin backend. Daca `vectors` e furnizat dar lungimea nu corespunde cu `items`
sau contine `None`, se ignora (log.warning) si se cade pe embed complet (A8) --
o dezaliniere silentioasa ar produce coduri sugerate GRESITE.
Ignora silentios daca backend-ul lipseste, corpus-ul e gol sau apare
orice exceptie la vectorizare (degradare gratioasa).
@@ -116,20 +137,63 @@ class EmbeddingEngine:
self._corpus_vecs = []
self._corpus_items = []
self._corpus_sig = None
self._corpus_matrix = None
self._corpus_norms = None
if not items or not self.is_available():
return
if vectors is not None and (len(vectors) != len(items) or any(v is None for v in vectors)):
log.warning(
"embeddings: index_corpus vectors (%d) nealiniat cu items (%d) sau contine None -- fallback embed complet",
len(vectors), len(items),
)
vectors = None
try:
if vectors is not None:
self._corpus_vecs = list(vectors)
else:
texts = [str(item["denumire"]) for item in items]
vecs = self._backend.embed(texts)
self._corpus_vecs = vecs
self._corpus_vecs = self._backend.embed(texts)
self._corpus_items = list(items)
self._corpus_sig = signature
self._build_matrix()
except Exception as exc:
log.warning("embeddings: index_corpus esuat: %s", exc)
# corpus ramane gol -- suggest_nearest va returna []
def _build_matrix(self) -> None:
"""Precalculeaza matricea numpy + normele corpusului pentru cautarea NN.
Esecul (numpy lipsa, vectori neregulati) lasa matricea None --
suggest_nearest cade pe scanarea pur-Python, corpusul ramane valid.
"""
if _np is None:
return
try:
matrix = _np.asarray(self._corpus_vecs, dtype=_np.float32)
if matrix.ndim != 2:
raise ValueError(f"vectori corpus neregulati (ndim={matrix.ndim})")
norms = _np.linalg.norm(matrix, axis=1)
norms[norms == 0.0] = 1.0 # vector nul -> similaritate 0, fara div/0
self._corpus_matrix = matrix
self._corpus_norms = norms
except Exception as exc:
log.warning("embeddings: matrice numpy esuata, fallback pur-Python: %s", exc)
self._corpus_matrix = None
self._corpus_norms = None
def embed(self, texts: list[str]) -> list[list[float]]:
"""Vectorizeaza texte brute prin backend (folosit la miss-uri de cache).
Arunca daca backend-ul lipseste sau embed() esueaza -- apelantul
(sync_corpus_vectors) propaga eroarea, fara degradare gratioasa aici.
"""
if not self.is_available():
raise RuntimeError("embeddings: backend indisponibil")
return self._backend.embed(texts)
def suggest_nearest(
self,
denumire: str,
@@ -137,7 +201,7 @@ class EmbeddingEngine:
) -> list[dict]:
"""Returneaza top_k vecini cosine [{cod, is_nul, similaritate}].
`is_nul` (PRD 5.18 US-005): cand corpusul include exemple NUL (non-operatii),
`is_nul`: cand corpusul include exemple NUL (non-operatii),
un vecin NUL = semnal de SUPRESIE, nu cod. Default False pe corpusuri vechi
fara `is_nul` in itemi. Returneaza [] daca backend-ul lipseste, corpus-ul e gol
sau apare orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
@@ -148,6 +212,26 @@ class EmbeddingEngine:
try:
query_vecs = self._backend.embed([str(denumire)])
query_vec = query_vecs[0]
if self._corpus_matrix is not None:
q = _np.asarray(query_vec, dtype=_np.float32)
qn = float(_np.linalg.norm(q)) or 1.0
sims = (self._corpus_matrix @ q) / (self._corpus_norms * qn)
k = min(top_k, sims.shape[0])
if k <= 0:
return []
# argpartition + sort doar pe top_k: O(n) in loc de O(n log n)
idx = _np.argpartition(-sims, k - 1)[:k]
idx = idx[_np.argsort(-sims[idx])]
return [
{
"cod": self._corpus_items[i]["cod"],
"is_nul": bool(self._corpus_items[i].get("is_nul", False)),
# clamp la [-1, 1]: eroarea de rotunjire float32 poate da
# 1.0000001 pe text identic (ar afisa >100% in UI).
"similaritate": min(1.0, max(-1.0, float(sims[i]))),
}
for i in idx
]
scored = [
{
"cod": item["cod"],
@@ -168,6 +252,7 @@ class EmbeddingEngine:
# --------------------------------------------------------------------------- #
_engine: EmbeddingEngine | None = None
_engine_lock = threading.Lock()
def _load_engine() -> EmbeddingEngine:
@@ -194,13 +279,24 @@ def _load_engine() -> EmbeddingEngine:
def _get_engine() -> EmbeddingEngine:
"""Returneaza engine-ul global (lazy-init)."""
"""Returneaza engine-ul global (lazy-init, thread-safe).
Lock-ul previne incarcarea dubla a modelului cand warmup-ul de la startup
si un request concurent ajung aici simultan.
"""
global _engine
if _engine is None:
with _engine_lock:
if _engine is None:
_engine = _load_engine()
return _engine
def is_loaded() -> bool:
"""True daca engine-ul global a fost deja construit. NU forteaza incarcarea."""
return _engine is not None
# --------------------------------------------------------------------------- #
# API public la nivel de modul (wiring L14-S6) #
# --------------------------------------------------------------------------- #
@@ -233,12 +329,22 @@ def corpus_signature() -> str | None:
return _engine.corpus_signature()
def index_corpus(items: list[dict], signature: str | None = None) -> None:
def embed_texts(texts: list[str]) -> list[list[float]]:
"""Vectorizeaza texte brute prin motorul global (folosit la miss-uri de cache).
Arunca daca engine-ul e indisponibil -- apelantul (embedding_cache.sync_corpus_vectors)
propaga eroarea catre ensure_embeddings_corpus (degradare gratioasa acolo).
"""
return _get_engine().embed(texts)
def index_corpus(items: list[dict], signature: str | None = None, vectors: list | None = None) -> None:
"""Vectorizeaza corpus [{denumire, cod}] in motorul global.
`vectors`: vezi EmbeddingEngine.index_corpus (precalculati, aliniati cu `items`).
Silentios pe eroare (degradare gratioasa).
"""
_get_engine().index_corpus(items, signature=signature)
_get_engine().index_corpus(items, signature=signature, vectors=vectors)
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]:

View File

@@ -114,7 +114,7 @@ CATALOG: dict[str, dict[str, str]] = {
"RAR_CREDS_INVALIDE": {
"problema": "Credentiale RAR invalide",
"fix": (
"Verifica email-ul si parola contului RAR in tab-ul Cont;"
"Verifica email-ul si parola contului RAR in Meniu cont Cont;"
" trimiterea nu se reincearca automat la credentiale gresite."
),
},
@@ -124,6 +124,13 @@ CATALOG: dict[str, dict[str, str]] = {
"Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand."
),
},
"CERERE_PREA_MARE": {
"problema": "Corpul cererii depaseste limita permisa",
"fix": (
"Trimite o cerere mai mica (imparte fisierul/lista in bucati mai mici"
" si retrimite)."
),
},
"IMPORT_ANTET_NECLAR": {
"problema": "Antet de coloane neclar",
"fix": (
@@ -178,7 +185,7 @@ CATALOG: dict[str, dict[str, str]] = {
" cererii (request_id) afisat."
),
},
# Coduri de plan (PRD 5.17)
# Coduri de plan
"PLAN_LIMITA_LUNARA": {
"problema": "Ai atins limita planului Gratuit (60 prestatii/luna)",
"fix": (
@@ -197,7 +204,7 @@ CATALOG: dict[str, dict[str, str]] = {
"RAR_MEDIU_INDISPONIBIL": {
"problema": "Mediul RAR cerut nu e disponibil",
"fix": (
"Activeaza mediul si introdu credentialele RAR in tab-ul Cont."
"Activeaza mediul si introdu credentialele RAR in Meniu cont Cont."
" Mediile disponibile acum sunt in campul cauza."
),
},

View File

@@ -19,9 +19,25 @@ from __future__ import annotations
import hashlib
import json
import re
from typing import Any
NRINM_STRIP_RE = re.compile(r"[\s\-.]+")
def normalize_nr_inmatriculare(value: object) -> str:
"""Normalizare canonica nr. inmatriculare: strip, upper, elimina spatii/cratime/puncte.
Formate reale ("B 99 XYZ", "B-99-XYZ") nu treceau NRINM_RE (app.validation) fara
aceasta curatare (spike SQL: doar 13.8% din numerele istorice treceau strict
^[A-Z0-9]{1,10}$). Folosita consecvent in models.PrezentareIn, validation._norm
si canonicalize_row de mai jos ca sa nu diverga validarea de cheia de dedup.
"""
s = str(value or "").strip().upper()
return NRINM_STRIP_RE.sub("", s)
def _op_identity(p: Any) -> str:
"""Cod RAR (normalizat) daca exista, altfel codul intern ROAAUTO."""
get = p.get if isinstance(p, dict) else (lambda k, d=None: getattr(p, k, d))
@@ -41,7 +57,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
"""
vin = (raw.get("vin") or "").strip().upper()
nr = (raw.get("nr_inmatriculare") or "").strip().upper()
nr = normalize_nr_inmatriculare(raw.get("nr_inmatriculare"))
# Odometru: strip ".0" Excel float coercion
odo_raw = raw.get("odometru_final")
@@ -71,7 +87,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
def build_key(account_id: int | None, canon: dict[str, Any], rar_env: str = "test") -> str:
"""SHA-256 partajat canal-API + canal-import, env-aware (PRD 5.20 US-003).
"""SHA-256 partajat canal-API + canal-import, env-aware.
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.

View File

@@ -1,4 +1,4 @@
"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2).
"""Parser fisiere xlsx/csv pentru import prezentari.
Arhitectura 2-treceri:
Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet.

View File

@@ -7,6 +7,7 @@ un worker mort nu trebuie sa lase containerul "sanatos".
from __future__ import annotations
import secrets
import threading
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
@@ -25,12 +26,13 @@ from . import errors
from .api.v1.import_router import router as import_v1_router
from .api.v1.integrare_router import router as integrare_v1_router
from .api.v1.router import router as api_v1_router
from .config import get_settings
from .config import get_settings, validate_prod_invariants
from .crypto import validate_creds_key
from .db import get_connection, init_db, queue_depth, read_heartbeat
from .observ import log_event, request_id_var
from .security import install_log_redaction, scrub_text
from .web.middleware import RequestIDMiddleware
from .web.body_cap import BodyCapMiddleware
from .web.middleware import RequestIDMiddleware, SecurityHeadersMiddleware
from .web.routes import router as web_router
from .web.auth_routes import router as auth_router
from .web.admin_routes import router as admin_router
@@ -38,13 +40,37 @@ from .web.csrf import CsrfError
from .web.session import AdminRequired, LoginRequired
def _warmup_embeddings() -> None:
"""Incarca modelul de embeddings si indexeaza corpusul SILVER, in fundal.
Ruleaza intr-un thread daemon la startup: incarcarea modelului (~230MB) plus
vectorizarea corpusului dureaza zeci de secunde si NU are voie sa blocheze
primul request pe /mapari. Pana termina, sugestiile embeddings lipsesc
(degradare gratioasa); GOLD/SILVER/fuzzy functioneaza normal.
"""
from .mapping import ensure_embeddings_corpus
try:
conn = get_connection()
try:
ensure_embeddings_corpus(conn, block=True)
finally:
conn.close()
except Exception:
pass # best-effort: esecul warmup-ului nu opreste API-ul
@asynccontextmanager
async def lifespan(app: FastAPI):
install_log_redaction()
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
# in loc de 500 brut la primul POST /v1/prezentari.
validate_creds_key()
# Fail-fast (prod-only): elimina clasa "am uitat env var in Dokploy" pentru
# cheia API si secretul de sesiune, in loc de o instanta descoperita post-deploy.
validate_prod_invariants(get_settings())
init_db()
if get_settings().embeddings_enabled:
threading.Thread(target=_warmup_embeddings, name="emb-warmup", daemon=True).start()
yield
@@ -63,6 +89,12 @@ app.add_middleware(
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
# inclusiv 401/404/422/500 produse mai in interior.
app.add_middleware(RequestIDMiddleware)
# Headere de securitate (P1-4) pe TOATE raspunsurile.
app.add_middleware(SecurityHeadersMiddleware)
# Body-cap (P1-5): ADAUGAT ULTIMUL -> ruleaza CEL MAI OUTERMOST (add_middleware
# prepend), deci intercepteaza `receive` inaintea oricarui parser FastAPI/Starlette
# (multipart, JSON) — vezi docstring body_cap.py pentru motivul ASGI-pur.
app.add_middleware(BodyCapMiddleware)
@app.exception_handler(LoginRequired)
@@ -177,9 +209,21 @@ def metrics() -> str:
conn = get_connection()
try:
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
# Coada tinuta: observabilitate ops pt. default Auto OFF.
# Gauge-uri DERIVATE (zero stare noua): total randuri tinute + varsta celui
# mai vechi (din created_at), scoped global. Semnaleaza esecul silentios
# cand prezentari raman nedeclarate tacit (risc L.142).
held = conn.execute(
"SELECT COUNT(*) AS n, "
"COALESCE(MAX(strftime('%s','now') - strftime('%s', created_at)), 0) AS oldest "
"FROM submissions WHERE status='queued' AND held=1"
).fetchone()
finally:
conn.close()
lines = ["# submissions pe status"]
for r in rows:
lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}')
# randuri tinute (queued AND held=1)
lines.append(f'autopass_held_submissions {held["n"]}')
lines.append(f'autopass_held_oldest_age_seconds {held["oldest"]}')
return "\n".join(lines) + "\n"

View File

@@ -16,16 +16,22 @@ from __future__ import annotations
import hashlib
import json
import logging
import re
import threading
import time
import unicodedata
from typing import Any
from rapidfuzz import fuzz, process
from . import errors as err_mod
from .accounts import held_for_account
from .nomenclator_seed import FALLBACK_NOMENCLATOR
from .validation import validate_prezentare
log = logging.getLogger(__name__)
# Cont implicit cat timp auth API-key (CORE) nu e implementat: ingestiile vin cu
# account_id NULL si le atribuim contului seed-at in schema (id=1).
DEFAULT_ACCOUNT_ID = 1
@@ -33,6 +39,10 @@ DEFAULT_ACCOUNT_ID = 1
# Sub acest scor (0..100) nu preselectam nicio sugestie — userul alege manual.
SUGGEST_MIN_SCORE = 60
# Sentinel pentru optiunea "nu se declara la RAR" din select-urile web de mapare.
# NU e cod RAR (codurile au max 5 caractere) — nu poate coliziona cu nomenclatorul.
EXCLUDE_SENTINEL = "__NEDECLARAT__"
# --------------------------------------------------------------------------- #
# Pur: normalizare + fuzzy + rezolvare #
@@ -51,7 +61,7 @@ def normalize_for_match(value: object) -> str:
# --------------------------------------------------------------------------- #
# Pre-filtru determinist non-operatii (NUL) — US-001 PRD 5.18 #
# Pre-filtru determinist non-operatii (NUL) #
# --------------------------------------------------------------------------- #
#
# Masuratoarea k-NN (memorie test-precizie-knn-embeddings) arata recall NUL doar
@@ -87,7 +97,7 @@ _NUL_CTX_PIESA = re.compile(
def prefiltru_nul(denumire: object) -> bool:
"""True daca operatia e gunoi evident (non-operatie de service) -> NUL determinist.
Ruleaza INAINTE de k-NN/embeddings in `enrich_suggestions` (US-006). Pur, fara DB.
Ruleaza INAINTE de k-NN/embeddings in `enrich_suggestions`. Pur, fara DB.
Zero fals-pozitiv pe operatii reale (vezi comentariul de mai sus + tests).
"""
text = normalize_for_match(denumire)
@@ -237,11 +247,15 @@ def resolve_prestatii(
mapping: dict[str, str],
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
excluded_ops: set[str] | None = None,
) -> tuple[list[dict], list[dict]]:
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
Reguli (hibrid):
- item cu `cod_prestatie` valid (in nomenclator) -> pastrat ca atare.
- item fara cod, cu `cod_op_service` in `excluded_ops` -> adnotat `exclus=True`
(NU e nemapat; nu se declara la RAR — apelantii il scot din payload cu
`split_prestatii_excluse` inainte de enqueue).
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
- item fara cod, nemapat exact, dar al carui text da match pe o regula text
(substring) -> umplem cod_prestatie din prima regula care potriveste.
@@ -252,9 +266,16 @@ def resolve_prestatii(
COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
la RAR (terminal) -> nu-l trimitem niciodata raw.
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service`
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista
cod valid SI op nu e in `mapping`.
Precedenta (stricta): item-exclus EXPLICIT (`exclus=1` venit pe item, ex. din
editorul de chips) > `cod_prestatie` direct valid > exclus de la declarare
(regula de cont) > mapare exacta `cod_op_service` in `mapping` > reguli text >
nemapat. Un item marcat explicit `exclus` de utilizator (editor) domina TOT —
inclusiv un `cod_prestatie` deja valid pe rand — pentru ca alegerea explicita
"Nu se declara la RAR" e intentia cea mai recenta si trebuie sa supravietuiasca
re-rezolvarii (altfel un item exclus ar reveni needs_mapping sau, mai rau, ar fi
declarat la RAR). Restul precedentei (cod valid > exclus de cont > mapare >
reguli text) e NESCHIMBAT. Regulile text se incearca DOAR cand nu exista cod
valid SI op nu e in `mapping`.
`valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e
None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece
@@ -279,10 +300,21 @@ def resolve_prestatii(
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
it.pop("cod_sursa", None)
it.pop("regula_fara_autosend", None)
exclus_explicit = bool(it.pop("exclus", None))
cod = (it.get("cod_prestatie") or "").strip().upper()
op = (it.get("cod_op_service") or "").strip()
cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes)
if cod_valid:
if exclus_explicit:
# Treapta noua (precedenta maxima): alegerea explicita "Nu se declara la
# RAR" (editor de chips) supravietuieste re-rezolvarii, chiar daca ramane
# (sau a ramas) un cod_prestatie valid pe rand.
if cod and not op:
op = cod
if op and not it.get("cod_op_service"):
it["cod_op_service"] = op
it["cod_prestatie"] = None
it["exclus"] = True
elif cod_valid:
it["cod_prestatie"] = cod
else:
# cod lipsa SAU necunoscut in nomenclator -> ruta de mapare.
@@ -293,7 +325,11 @@ def resolve_prestatii(
it["cod_op_service"] = op
if not it.get("denumire"):
it["denumire"] = cod
if op and op in mapping:
if op and excluded_ops and op in excluded_ops:
# Exclus de la declarare: nu e nemapat, nu se trimite la RAR.
it["cod_prestatie"] = None
it["exclus"] = True
elif op and op in mapping:
it["cod_prestatie"] = mapping[op]
elif op:
# Mapare exacta absenta -> incearca regulile text (substring).
@@ -305,7 +341,7 @@ def resolve_prestatii(
# Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul
# sursa. Payload-harmless (RAR citeste doar cod_prestatie).
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
# US-001 (PRD 5.11): regula_fara_autosend nu se mai seteaza;
# regula_fara_autosend nu se mai seteaza;
# auto_send nu mai tine randul (has_no_auto_send neutralizat).
else:
it["cod_prestatie"] = None
@@ -316,6 +352,17 @@ def resolve_prestatii(
return resolved, unmapped
def split_prestatii_excluse(prestatii: list[dict] | None) -> tuple[list[dict], list[dict]]:
"""Separa prestatiile declarabile de cele adnotate `exclus` de resolve_prestatii.
Payload-ul trimis la RAR (si cheia de idempotenta) se construieste NUMAI din
declarabile; cele excluse nu parasesc niciodata sistemul.
"""
declarabile = [p for p in (prestatii or []) if not p.get("exclus")]
excluse = [p for p in (prestatii or []) if p.get("exclus")]
return declarabile, excluse
# --------------------------------------------------------------------------- #
# Persistenta (conn) #
# --------------------------------------------------------------------------- #
@@ -398,10 +445,11 @@ def load_nomenclator_codes(conn) -> set[str]:
def load_mapping(conn, account_id: int | None) -> dict[str, str]:
"""{cod_op_service -> cod_prestatie} pentru un cont."""
"""{cod_op_service -> cod_prestatie} pentru un cont. Fara regulile de excludere."""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT cod_op_service, cod_prestatie FROM operations_mapping WHERE account_id=?",
"SELECT cod_op_service, cod_prestatie FROM operations_mapping "
"WHERE account_id=? AND exclus=0",
(acct,),
).fetchall()
return {r["cod_op_service"]: r["cod_prestatie"] for r in rows}
@@ -411,10 +459,13 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
Varianta extinsa care include si flagul auto_send per operatie.
Fara regulile de excludere (cod_prestatie gol) — consumatorii construiesc din ea
dict-uri de mapare op->cod; excluderile se incarca separat (load_excluded_ops).
"""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT cod_op_service, cod_prestatie, auto_send FROM operations_mapping WHERE account_id=?",
"SELECT cod_op_service, cod_prestatie, auto_send FROM operations_mapping "
"WHERE account_id=? AND exclus=0",
(acct,),
).fetchall()
return {
@@ -423,12 +474,23 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
}
def load_excluded_ops(conn, account_id: int | None) -> set[str]:
"""Setul de operatii excluse de la declarare pentru un cont."""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT cod_op_service FROM operations_mapping WHERE account_id=? AND exclus=1",
(acct,),
).fetchall()
return {r["cod_op_service"] for r in rows}
def classify_prezentare(
content: dict,
mapping: dict[str, str],
mapping_meta: dict[str, dict],
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
excluded_ops: set[str] | None = None,
) -> dict:
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
@@ -448,7 +510,7 @@ def classify_prezentare(
"odometru_final": canon["odometru_final"],
})
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules)
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
c["prestatii"] = resolved
if unmapped:
@@ -460,12 +522,25 @@ def classify_prezentare(
)
errors: list[dict] = []
else:
# Prestatiile excluse de la declarare NU intra in payload-ul trimis (nici in
# cheia de idempotenta). Toate excluse -> needs_data cu motiv explicit;
# payload-ul pastreaza itemii adnotati `exclus` ca detaliul sa arate operatiile.
declarabile, excluse = split_prestatii_excluse(resolved)
if not declarabile and excluse:
ops_excluse = ", ".join((p.get("cod_op_service") or "") for p in excluse)
errors = [{
"field": "prestatii",
"message": f"Toate operatiile sunt excluse de la declarare ({ops_excluse}) — randul nu se trimite la RAR.",
}]
else:
if excluse:
c["prestatii"] = declarabile
errors = validate_prezentare(c)
if errors:
status = "needs_data"
rar_error = json.dumps(errors, ensure_ascii=False)
else:
# US-001 (PRD 5.11): ramura AUTO_SEND_OPRIT eliminata.
# Ramura AUTO_SEND_OPRIT eliminata.
# Un cod rezolvat (mapare exacta sau regula text) -> queued direct.
status = "queued"
rar_error = None
@@ -481,7 +556,7 @@ def classify_prezentare(
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
"""Neutralizat dupa US-001 (PRD 5.11): auto_send nu mai tine randuri in needs_mapping.
"""Neutralizat: auto_send nu mai tine randuri in needs_mapping.
Simbolul este PASTRAT (importat in routes.py si import_router.py); stergerea
ar produce ImportError la boot. Functia intoarce mereu False — codul rezolvat
@@ -516,6 +591,7 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
).fetchall()
agg: dict[tuple[int, str], dict[str, Any]] = {}
excluded_by_acct: dict[int, set[str]] = {}
for r in rows:
acct = r["account_id"] if r["account_id"] is not None else DEFAULT_ACCOUNT_ID
try:
@@ -530,6 +606,11 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
op = (item.get("cod_op_service") or "").strip()
if not op:
continue
# Operatiile excluse de la declarare nu sunt "de mapat" — nu apar in editor.
if acct not in excluded_by_acct:
excluded_by_acct[acct] = load_excluded_ops(conn, acct)
if op in excluded_by_acct[acct]:
continue
key = (acct, op)
entry = agg.setdefault(
key,
@@ -557,21 +638,44 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestatie: str, auto_send: bool) -> None:
"""Upsert o mapare op->cod (UNIQUE pe account_id+cod_op_service)."""
"""Upsert o mapare op->cod (UNIQUE pe account_id+cod_op_service).
Reseteaza `excluded=0`: maparea unei operatii excluse anterior o readuce
in fluxul de declarare (aceeasi cheie UNIQUE, o operatie = o regula).
"""
acct = account_or_default(account_id)
op = (cod_op_service or "").strip()
cod = (cod_prestatie or "").strip().upper()
if not op or not cod:
raise ValueError("cod_op_service si cod_prestatie sunt obligatorii")
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?) "
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send, exclus) "
"VALUES (?, ?, ?, ?, 0) "
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send",
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send, exclus=0",
(acct, op, cod, 1 if auto_send else 0),
)
def save_exclusion(conn, account_id: int | None, cod_op_service: str) -> None:
"""Upsert o regula 'nu se declara' pentru o operatie (UNIQUE pe account_id+op).
cod_prestatie ramane gol — regula nu mapeaza, doar exclude de la declarare.
Suprascrie o mapare existenta pe aceeasi operatie (o operatie = o regula).
"""
acct = account_or_default(account_id)
op = (cod_op_service or "").strip()
if not op:
raise ValueError("cod_op_service este obligatoriu")
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send, exclus) "
"VALUES (?, ?, '', 0, 1) "
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
"cod_prestatie='', auto_send=0, exclus=1",
(acct, op),
)
def load_text_rules(conn, account_id: int | None) -> list[dict]:
"""Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC.
@@ -625,10 +729,31 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
)
# Prag minim de similaritate cosine pentru sugestia din embeddings NN.
# Sub acest scor, sugestia NN e prea incerta si nu o afisam (previne recomandari
# irelevante cand corpus-ul e mic sau neindexat corect).
EMB_MIN_SIMILARITATE = 0.5
# Prag minim de similaritate cosine pentru un vecin k-NN luat in calcul la vot.
# Calibrat prin leave-one-out pe corpusul SILVER (tools/mapare-llm/knn_calibrate.py):
# modelul e anizotrop (perechi nelegate scoreaza 0.79-0.87), deci sub ~0.88 vecinul
# e zgomot. La 0.88: coverage 83%, precizie 93%; la 0.5 (vechiul prag): coverage
# 100% dar 7.4% cod gresit preselectat.
EMB_MIN_SIMILARITATE = 0.88
# Cati vecini intra in votul ponderat pe rang. Vot > top-1: corpusul SILVER
# are etichete contradictorii pe denumiri aproape identice; votul e imun la ele
# (+~1pp precizie la acelasi coverage, masurat LOO).
EMB_VOTE_TOP_K = 5
# Ponderea unui vecin = EMB_VOTE_DECAY^rang (1-indexat). Ponderarea cu similaritatea
# bruta e pacalita de anizotropia modelului: vecini fara legatura scoreaza aproape
# cat cei buni (ex. INLOCUIT BECURII la 0.934 de INLOCUIRE ANVELOPE), deci coada
# de zgomot poate invinge 2 vecini corecti de rang 1-2. Ordinea rangurilor ramane
# informativa; decay 0.7 = cel mai mic cod-gresit la precizie egala (LOO).
EMB_VOTE_DECAY = 0.7
# Protejeaza secventa hash->load->embed->save->purge->index (embedding_cache) de
# executie concurenta intre warmup-ul de fundal (block=True) si calea de request
# (block=False, dupa ce modelul e deja incarcat) -- altfel purjarea uneia ar sterge
# randuri tocmai scrise de cealalta. Calea de request obtine lock-ul neblocant
# (nu asteapta warmup-ul in curs); doar warmup-ul asteapta normal.
_embeddings_lock = threading.Lock()
def _corpus_signature_silver(rows: list) -> str:
@@ -645,8 +770,8 @@ def _corpus_signature_silver(rows: list) -> str:
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
"""Construieste/actualizeaza corpusul embeddings din corpusul ETICHETAT (PRD 5.18 US-005).
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None, *, block: bool = False) -> None:
"""Construieste/actualizeaza corpusul embeddings din corpusul ETICHETAT.
Sursa corpusului = `mapping_suggestions` (SILVER): exemple reale etichetate
{denumire_normalizata -> cod, is_nul}, NU cele 18 categorii generice din
@@ -657,16 +782,37 @@ def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> Non
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default ON; OFF in teste): cand e
dezactivat, e un no-op total -> /mapari instant + suita de teste rapida.
Cand e activat: indexeaza corpusul o singura data (lazy-load modelul ~230MB la
prima chemare), re-indexeaza doar cand semnatura corpusului SILVER s-a schimbat.
Itemii NUL (is_nul=1, cod NULL) raman in corpus: un vecin NUL e semnal de supresie
(US-006). Degradare gratioasa: orice eroare lasa corpusul gol -> enrich cade pe restul.
Cand e activat: indexeaza corpusul o singura data, re-indexeaza doar cand
semnatura corpusului SILVER s-a schimbat. Itemii NUL (is_nul=1, cod NULL) raman
in corpus: un vecin NUL e semnal de supresie.
`block=False` (default, calea de request): daca modelul NU e inca incarcat,
return imediat — incarcarea modelului (~230MB, zeci de secunde) NU are voie sa
blocheze un request HTTP; o face warmup-ul de la startup (block=True, in thread).
Odata modelul incarcat insa, warmup-ul mai poate fi INCA in curs de vectorizare
a corpusului (~1-2 min): calea de request NU asteapta dupa lock in acest caz —
incearca sa il obtina neblocant, iar daca e ocupat, iese imediat (degradare
gratioasa, sugestii lipsa pana termina warmup-ul). block=True (warmup) asteapta
normal dupa lock.
Cache persistent (embedding_cache): hash-ul se calculeaza pe lista FILTRATA de
`denumire` (EXACT ce intra in `index_corpus`), citeste vectorii existenti pentru
modelul curent, vectorizeaza doar miss-urile si salveaza-i. Purjarea orfanelor
ruleaza DUPA indexare, doar daca indexarea a reusit efectiv (semnatura noua
confirmata) — un esec de indexare nu trebuie sa goleasca cache-ul.
Degradare gratioasa: orice eroare lasa corpusul gol -> enrich cade pe restul.
"""
from .config import get_settings
if not get_settings().embeddings_enabled:
return
try:
from . import embeddings as _emb
if not block and not _emb.is_loaded():
return # warmup-ul din fundal nu a terminat inca; nu bloca request-ul
if not _embeddings_lock.acquire(blocking=block):
return # warmup in curs; calea de request nu asteapta (nu bloca request-ul)
try:
rows = conn.execute(
"SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions"
).fetchall()
@@ -684,9 +830,34 @@ def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> Non
for r in rows
if r["denumire_normalizata"]
]
_emb.index_corpus(items, signature=sig)
if not items:
return
from . import embedding_cache as _cache
texts = [item["denumire"] for item in items]
hashes = [_cache.text_hash(t) for t in texts]
miss_count = 0
def _embed_fn(missing_texts: list[str]) -> list:
nonlocal miss_count
miss_count += len(missing_texts)
return _emb.embed_texts(missing_texts)
t0 = time.monotonic()
vectors = _cache.sync_corpus_vectors(conn, _emb.FASTEMBED_MODEL, texts, _embed_fn)
_emb.index_corpus(items, signature=sig, vectors=vectors)
if _emb.corpus_signature() == sig and _emb.has_corpus():
_cache.purge_stale(conn, _emb.FASTEMBED_MODEL, set(hashes))
eticheta = "warmup ok" if block else "corpus reindexat"
log.info(
"embeddings: %s cache=%d embed=%d in %.1fs",
eticheta, len(texts) - miss_count, miss_count, time.monotonic() - t0,
)
finally:
_embeddings_lock.release()
except Exception:
pass # degradare gratioasa (#16b): esecul indexarii nu blocheaza editorul
pass # degradare gratioasa: esecul indexarii nu blocheaza editorul
def enrich_suggestions(
@@ -697,39 +868,43 @@ def enrich_suggestions(
) -> dict:
"""Imbogateste sugestiile cu GOLD partajat, SILVER LLM si embeddings NN.
Precedenta Eng-F2 (pentru sugestie-only, nu auto-send):
Precedenta (pentru sugestie-only, nu auto-send):
shared GOLD > SILVER > embeddings
(Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping;
nu apare in needs_mapping, deci nu e in precedenta de sugestie.)
Ordine completa (PRD 5.18 US-006):
Ordine completa:
pre-filtru NUL determinist -> (daca NUL: fara cod, `surse['nul']=True`)
altfel GOLD partajat > exact (SILVER) > k-NN embeddings.
Returneaza:
{
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None, 'nul': bool}
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None,
'embedding_similaritate': float|None, 'nul': bool}
}
INVARIANTE:
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13).
- Pre-filtru NUL (US-001) ruleaza PRIMUL: gunoiul evident (ITP/plata/discount...) e
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping.
- Pre-filtru NUL ruleaza PRIMUL: gunoiul evident (ITP/plata/discount...) e
marcat non-operatie INAINTE de k-NN, fara sugestie de cod.
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie (#4); vecin k-NN NUL idem.
- Degradare gratioasa pe embeddings (#16b): daca motorul nu e disponibil sau arunca,
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie; vecin k-NN NUL idem.
- Degradare gratioasa pe embeddings: daca motorul nu e disponibil sau arunca,
returneaza sugestia disponibila din celelalte surse, fara exceptie.
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store
importa normalize_for_match din mapping).
"""
sugestie_principala: dict | None = None
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
surse: dict = {
"gold_partajat": None, "silver": None,
"embedding": None, "embedding_similaritate": None, "nul": False,
}
if not denumire:
return {"sugestie_principala": sugestie_principala, "surse": surse}
# 0. Pre-filtru NUL determinist (US-001) INAINTE de orice k-NN/lookup: non-operatie
# 0. Pre-filtru NUL determinist INAINTE de orice k-NN/lookup: non-operatie
# evidenta -> fara cod, scurtcircuit (nu interogheaza embeddings/SILVER pe gunoi).
if prefiltru_nul(denumire):
surse["nul"] = True
@@ -757,7 +932,7 @@ def enrich_suggestions(
except Exception:
pass # degradare gratioasa
# 3. Embeddings NN (similaritate semantica, degradare gratioasa #16b)
# 3. Embeddings NN (similaritate semantica, degradare gratioasa)
if include_embeddings:
try:
from . import embeddings as _emb
@@ -767,18 +942,31 @@ def enrich_suggestions(
# ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand
# flagul e off, has_corpus() ramane False si calea e un no-op real.
if _emb.has_corpus():
# F1 (US-005): corpusul k-NN e text NORMALIZAT (denumire_normalizata),
# Corpusul k-NN e text NORMALIZAT (denumire_normalizata),
# deci query-ul TREBUIE normalizat la fel — altfel cosine degradeaza si
# nu mai e configul sub care s-a masurat 94.3%.
nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=1)
# Prag minim: similaritate prea mica = sugestie inutila.
# Evita recomandari irelevante cand corpus-ul e mic/partial.
if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
if nn[0].get("is_nul"):
# Vecin NUL (non-operatie) = semnal de SUPRESIE, nu cod (US-006).
nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=EMB_VOTE_TOP_K)
# Vot ponderat pe rang (decay^rang) pe vecinii peste prag; NUL e eticheta
# proprie (castiga -> supresie, nu cod). Vecinii sub prag nu voteaza.
scoruri: dict[str, float] = {}
sim_max: dict[str, float] = {}
for rang, v in enumerate(nn, 1):
sim = float(v.get("similaritate", 0))
if sim < EMB_MIN_SIMILARITATE:
continue
lab = "NUL" if v.get("is_nul") else (str(v["cod"]) if v.get("cod") else None)
if lab is None:
continue
scoruri[lab] = scoruri.get(lab, 0.0) + EMB_VOTE_DECAY ** rang
sim_max[lab] = max(sim_max.get(lab, 0.0), sim)
if scoruri:
castigator = max(scoruri, key=lambda k: scoruri[k])
if castigator == "NUL":
# Vecinatate NUL (non-operatie) = semnal de SUPRESIE, nu cod.
surse["nul"] = True
elif nn[0].get("cod"):
surse["embedding"] = str(nn[0]["cod"])
else:
surse["embedding"] = castigator
surse["embedding_similaritate"] = sim_max[castigator]
except Exception:
pass # degradare gratioasa (#16b): motorul absent nu blocheaza
@@ -793,6 +981,40 @@ def enrich_suggestions(
return {"sugestie_principala": sugestie_principala, "surse": surse}
# Prag fuzzy sub care nu propunem nimic (evita sugestii de zgomot in raspunsul API).
FUZZY_SUGESTIE_MIN_SCORE = 60.0
def sugestie_pentru_op(conn, denumire: str | None, nomenclator: list[dict]) -> dict | None:
"""Sugestia principala de cod RAR pentru o operatie nemapata.
Aceeasi precedenta ca editorul de mapari (enrich_suggestions: GOLD partajat >
SILVER > embeddings), cu fallback fuzzy pe nomenclator peste prag. SUGGESTION-ONLY:
nu se aplica automat, doar se afiseaza (raspuns API / editor).
Returneaza {cod_prestatie, nume_prestatie, sursa} sau None (inclusiv pe
pre-filtrul NUL: non-operatiile nu primesc sugestie de cod).
"""
if not denumire:
return None
nume = {r.get("cod_prestatie"): r.get("nume_prestatie") for r in nomenclator}
enriched = enrich_suggestions(conn, denumire)
sp = enriched.get("sugestie_principala")
if sp and sp.get("cod_prestatie"):
cod = str(sp["cod_prestatie"])
return {"cod_prestatie": cod, "nume_prestatie": nume.get(cod), "sursa": sp.get("sursa")}
if enriched.get("surse", {}).get("nul"):
return None
fuzzy = suggest_codes(denumire, nomenclator, limit=1)
if fuzzy and float(fuzzy[0].get("score") or 0) >= FUZZY_SUGESTIE_MIN_SCORE:
return {
"cod_prestatie": fuzzy[0]["cod_prestatie"],
"nume_prestatie": fuzzy[0].get("nume_prestatie"),
"sursa": "fuzzy",
}
return None
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
@@ -839,6 +1061,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
valid_codes = load_nomenclator_codes(conn) or None
# Incarca regulile text O DATA, inainte de bucla pe randuri.
text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
if batch_id is not None:
# Scope la batch-ul specificat (import commit explicit).
@@ -864,7 +1087,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
content = json.loads(r["payload_json"])
except (ValueError, TypeError):
continue
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
content["prestatii"] = resolved
payload_json = json.dumps(content, ensure_ascii=False)
@@ -879,7 +1102,29 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
stats["still_blocked"] += 1
continue
# US-001 (PRD 5.11): ramura auto_send eliminata din reresolve.
# Prestatiile excluse ies din payload-ul trimis; toate excluse -> needs_data
# cu motiv explicit (randul nu se mai trimite; operatorul il poate sterge).
# Payload-ul pastreaza itemii adnotati `exclus` DOAR in ramura blocata, ca
# detaliul sa arate operatiile; la queued pleaca numai declarabilele.
declarabile, excluse = split_prestatii_excluse(resolved)
if not declarabile and excluse:
ops_excluse = ", ".join((p.get("cod_op_service") or "") for p in excluse)
motiv = [{
"field": "prestatii",
"message": f"Toate operatiile sunt excluse de la declarare ({ops_excluse}) — randul nu se trimite la RAR.",
}]
conn.execute(
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
"updated_at=datetime('now') WHERE id=?",
(payload_json, json.dumps(motiv, ensure_ascii=False), r["id"]),
)
stats["needs_data"] += 1
continue
if excluse:
content["prestatii"] = declarabile
payload_json = json.dumps(content, ensure_ascii=False)
# Ramura auto_send eliminata din reresolve.
# Un cod rezolvat -> queued direct (review_manual ramane 0).
errors = validate_prezentare(content)
@@ -891,10 +1136,14 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
)
stats["needs_data"] += 1
else:
# Re-snapshot `held` din comutatorul contului la trecerea
# needs_mapping -> queued (chokepoint held_for_account, `acct` deja normalizat
# prin account_or_default la intrarea in reresolve_account). Altfel un rand
# deblocat din needs_mapping ar pleca automat desi contul e Auto OFF.
conn.execute(
"UPDATE submissions SET status='queued', payload_json=?, rar_error=NULL, "
"retry_count=0, next_attempt_at=NULL, updated_at=datetime('now') WHERE id=?",
(payload_json, r["id"]),
"retry_count=0, next_attempt_at=NULL, held=?, updated_at=datetime('now') WHERE id=?",
(payload_json, held_for_account(conn, acct), r["id"]),
)
stats["requeued"] += 1
return stats

View File

@@ -11,6 +11,8 @@ from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from app.idempotency import normalize_nr_inmatriculare
class RarCredentials(BaseModel):
"""Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza."""
@@ -69,11 +71,16 @@ class PrezentareIn(BaseModel):
obs: str | None = None
b64_image: str | None = None
@field_validator("vin", "nr_inmatriculare")
@field_validator("vin")
@classmethod
def _norm_upper(cls, v: str) -> str:
return v.strip().upper()
@field_validator("nr_inmatriculare")
@classmethod
def _norm_nrinm(cls, v: str) -> str:
return normalize_nr_inmatriculare(v)
@field_validator("data_prestatie", "odometru_final")
@classmethod
def _norm_strip(cls, v: str) -> str:
@@ -97,11 +104,37 @@ class PrezentareRequest(BaseModel):
on_unmapped_error: bool | None = None
# Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT).
rar_env: Literal["test", "prod"] | None = None
# Forma raspunsului: 'minim' (implicit) = 6 campuri fixe per rezultat
# (index, submission_id, vehicul, status, motiv, id_prezentare);
# 'complet' = SubmissionResult intreg (erori/nemapate/deduped/held/...).
raspuns: Literal["minim", "complet"] | None = None
class SubmissionResultMinim(BaseModel):
"""Rezultat compact per prezentare — forma implicita a raspunsului.
Chei FIXE (mereu prezente), usor de parsat de clienti simpli (ex. VFP):
`motiv` e null cand randul e curat in coada; `id_prezentare` e non-null
doar pe dedup al unui rand deja trimis la RAR.
"""
index: int
submission_id: int | None = None
vehicul: str
status: str
motiv: str | None = None
id_prezentare: int | None = None
class SubmissionResult(BaseModel):
# submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True).
submission_id: int | None = None
# Corelare cu cererea (campuri aditive): pozitia 0-based in `prezentari`
# + ecoul vehiculului trimis, ca integratorul sa lege fiecare rezultat
# de randul lui fara sa se bazeze doar pe ordine.
index: int | None = None
vin: str | None = None
nr_inmatriculare: str | None = None
status: str
id_prezentare: int | None = None
deduped: bool = False # True daca idempotency a intors un submission existent
@@ -109,6 +142,10 @@ class SubmissionResult(BaseModel):
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
reactivated: bool = False
# Ecou PUR de coada: True cand randul e `queued AND held=1`
# (tinut manual, cont Auto OFF) -> NU a plecat la RAR, asteapta eliberarea umana.
# NU influenteaza payload/idempotenta; onestitate fata de integratorul API (invariant 5.7).
held: bool = False
# Mediul RAR tinta efectiv (ecou din DB / rezolvat la ingestie).
rar_env: str = "test"
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
@@ -123,7 +160,9 @@ class SubmissionResult(BaseModel):
class PrezentariResponse(BaseModel):
results: list[SubmissionResult]
# Uniune per element: instantele sunt deja modelul potrivit (smart union
# pydantic v2 pastreaza tipul exact), deci serializarea nu amesteca formele.
results: list[SubmissionResult | SubmissionResultMinim]
class ValidarePrezentariRequest(BaseModel):

View File

@@ -1,4 +1,4 @@
"""Seeder corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
"""Seeder corpus operatii etichetate -> mapping_suggestions (SILVER).
Artefactul `app/data/operatii-etichetate.json` e produs offline de
`tools/mapare-llm/genereaza_seed.py` (etichetare LM Studio, o singura data) si comis

View File

@@ -1,6 +1,6 @@
"""Constructor payload postPrezentare (T4).
"""Constructor payload postPrezentare.
Reguli din contract (docs/api-rar-contract.md), confirmate live in T1:
Reguli din contract (docs/api-rar-contract.md), confirmate live:
- status mereu "FINALIZATA".
- tipPrestatie NU se trimite (server-generated GENERIC).
- odometruFinal ca string; odometruInitial ca string cand e prezent, altfel null.
@@ -33,6 +33,16 @@ def _cod(p: object) -> str | None:
return str(cod).strip().upper() if cod else None
def _declarabil(p: object) -> bool:
"""Un item pleaca la RAR doar daca are cod si nu e adnotat exclus.
Filtru no-op pentru payload deja ingustat la declarabile; sarit ca ultima
plasa de siguranta — un item fara cod nu produce niciodata {codPrestatie: null}.
"""
exclus = p.get("exclus") if isinstance(p, dict) else getattr(p, "exclus", None)
return not exclus and _cod(p) is not None
def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
"""Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare."""
payload: dict[str, Any] = {
@@ -46,6 +56,7 @@ def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
"prestatii": [
{"codPrestatie": _cod(p), "idPrezentare": None}
for p in (prezentare.get("prestatii") or [])
if _declarabil(p)
],
"sistemReparat": prezentare.get("sistem_reparat") or "null",
"status": "FINALIZATA",

View File

@@ -1,8 +1,8 @@
"""Extragere payload submission -> campuri afisabile.
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
eng review, DRY). Fara DB, fara request — primeste `payload_json` (text JSON
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua.
Fara DB, fara request — primeste `payload_json` (text JSON
plaintext, vezi `submissions.payload_json`) sau un dict deja parsat.
Defensiv prin constructie: nu arunca niciodata pe payload malformat — degradeaza
@@ -13,11 +13,34 @@ la em-dash. Citeste cheile tolerant (canalele API si import pot diferi usor:
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
EMPTY = ""
def iso_date_prefix(value: Any) -> str | None:
"""Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel None.
Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
fara a exclude timestamp-urile. Valori care nu incep cu o data ISO valida
(ex. '05.12.2024') intorc None si sunt excluse din filtru.
Partajat intre canalul web (dashboard Trimiteri, filtrul data_de/data_pana) si
canalul API (`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua.
"""
s = str(value or "").strip()
if len(s) < 10:
return None
prefix = s[:10]
try:
datetime.strptime(prefix, "%Y-%m-%d")
return prefix
except (ValueError, TypeError):
return None
def _clean_str(value: Any) -> str:
"""str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe)."""
if value is None:
@@ -59,6 +82,19 @@ def _vin_scurt(vin: str) -> str:
return "" + vin[-6:]
def _coduri_rar(prestatii: Any) -> list[str]:
"""Codurile RAR (cod_prestatie) din toate prestatiile: ordonate, dedup."""
coduri: list[str] = []
if isinstance(prestatii, list):
for item in prestatii:
if not isinstance(item, dict):
continue
cod = _clean_cod_rar(item.get("cod_prestatie"))
if cod and cod not in coduri:
coduri.append(cod)
return coduri
def _prima_prestatie(prestatii: Any) -> dict[str, Any]:
"""Primul item de prestatie ca dict, defensiv (lista goala/non-dict -> {})."""
if isinstance(prestatii, list):
@@ -83,10 +119,11 @@ def _payload_dict(payload: str | dict | None) -> dict[str, Any]:
return {}
def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
def prezentare_din_payload(payload: str | dict | None) -> dict[str, Any]:
"""Campuri afisabile dintr-un payload de submission.
Intoarce un dict cu chei stabile (toate string-uri, EMPTY cand lipsesc):
Intoarce un dict cu chei stabile (string-uri, EMPTY cand lipsesc; exceptie
`coduri_rar` = list[str]):
vehicul_nr, vin, vin_scurt, operatie, data_prestatie, odometru, cod.
`operatie` = denumire prestatiei daca exista, altfel codul; `cod` = codul RAR
@@ -112,8 +149,11 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
denumire = _clean_str(item.get("denumire"))
operatie = denumire or cod
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
cod_rar = _clean_cod_rar(item.get("cod_prestatie"))
# cod_rar: primul cod_prestatie din TOATE prestatiile (NU doar primul item si NU
# fallback la cod_op_service). Primul item poate fi operatia originala exclusa
# (cod_prestatie null), cu codurile declarate pe itemii urmatori.
coduri_rar = _coduri_rar(data.get("prestatii"))
cod_rar = coduri_rar[0] if coduri_rar else ""
# Operatia de service originala (codul intern + denumire venita prin API/import),
# distincta de operatia RAR mapata (cod_rar).
@@ -126,7 +166,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else ""
# obs: text liber observatii (camp RAR, optional). Conventie goala "" (nu EMPTY).
# US-005 PRD 5.15: obs traieste in payload_json (nu coloana separata).
# Traieste in payload_json, nu in coloana separata.
obs = _clean_str(data.get("obs"))
return {
@@ -138,6 +178,9 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
"odometru": odo or EMPTY,
"cod": cod or EMPTY,
"cod_rar": cod_rar or EMPTY,
# Lista ordonata, fara duplicate, a codurilor RAR din TOATE prestatiile
# (goala cand nimic nu e mapat). Randul compact afiseaza primul + "+N".
"coduri_rar": coduri_rar,
# Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
"op_service_cod": op_service_cod,
"op_service_denumire": op_service_denumire,

View File

@@ -3,11 +3,11 @@
Pattern ca app/errors.py: catalog + helperi. Consumat de rutele de ingestie si dashboard.
Nu importa DB, HTTP, sau orice alt modul intern cu efecte secundare.
Decizii implementare (PRD 5.17 / autoplan 2026-06-28):
- FREE_MONTHLY_LIMIT: constanta unica (T-CEO-2), tunabila fara arqueologie de cod.
- CONSUMED_STATUSES: decizie #20 — prestatie consumata = acceptata in coada.
- effective_tier: `now` injectabil (decizie #2) pentru teste deterministe.
- monthly_usage: pattern E7/5.15 (strftime localtime), `now` injectabil.
Note implementare:
- FREE_MONTHLY_LIMIT: constanta unica, tunabila fara arheologie de cod.
- CONSUMED_STATUSES: prestatie consumata = acceptata in coada.
- effective_tier: `now` injectabil pentru teste deterministe.
- monthly_usage: strftime localtime, `now` injectabil.
"""
from __future__ import annotations
@@ -16,11 +16,10 @@ import sqlite3
from datetime import datetime, timezone
# Limita lunara pentru planul Gratuit.
# Decizie user T-CEO-2 (2026-06-28): o singura constanta, referita din PLANS.
# Tunabila fara a modifica logica de enforcement.
# O singura constanta, referita din PLANS. Tunabila fara a modifica logica de enforcement.
FREE_MONTHLY_LIMIT: int = 60
# Statusurile care consuma din cota lunara (decizie #20, 2026-06-28).
# Statusurile care consuma din cota lunara.
# Prestatie consumata = acceptata in coada (queued/sending/sent), nu cele respinse/blocate.
# Rationale: limita e pe ce trimitem la RAR, nu pe incercari esuate sau blocate.
CONSUMED_STATUSES: tuple[str, ...] = ("queued", "sending", "sent")
@@ -30,7 +29,7 @@ CONSUMED_STATUSES: tuple[str, ...] = ("queued", "sending", "sent")
# monthly_limit -- None = nelimitat; int = limita prestatii/luna
# api_access -- True = acces import prin API (/v1/*); False = doar web dashboard
#
# Aliniat landing-ului comercial (PRD 5.17 US-001):
# Aliniat landing-ului comercial:
# Gratuit: 60/luna, fara API
# Standard: nelimitat, fara API
# Pro: nelimitat, cu API

View File

@@ -1,4 +1,4 @@
"""Medii RAR per cont (PRD 5.20): disponibilitate + default efectiv.
"""Medii RAR per cont: disponibilitate + default efectiv.
Sursa UNICA de adevar pentru REQ-DISP / REQ-DEFAULT: vizibilitatea selector/toggle
in UI, validarea tintei in API si decizia worker-ului citesc TOATE de aici, ca sa
@@ -92,7 +92,7 @@ def rar_env_efectiv_cont(conn: sqlite3.Connection, account_id: int) -> str | Non
# --------------------------------------------------------------------------- #
# Exceptie si rezolvator de mediu tinta (US-004, dependent de US-002) #
# Exceptie si rezolvator de mediu tinta #
# --------------------------------------------------------------------------- #
class MediuIndisponibil(Exception):
@@ -126,7 +126,7 @@ def rezolva_rar_env(
4. `cerut` None -> incearca rar_env_efectiv_cont (default-ul contului).
5. Daca contul nu are niciun mediu disponibil (rar_env_efectiv_cont == None)
-> cade pe ancora globala get_settings().rar_env, normalizata la VALID_ENVS.
Acest fallback e intentionat (PRD 5.20 §2 Non-Goals): AUTOPASS_RAR_ENV ramane
Acest fallback e intentionat: AUTOPASS_RAR_ENV ramane
ancora de migrare si fallback pentru actiuni fara cont (keepalive, canal API cu
creds efemere pe conturi nou-create fara medii configurate).

View File

@@ -1,6 +1,5 @@
-- Schema SQLite (WAL) pentru gateway RAR AUTOPASS.
-- Vezi plan.md sect. 5 + plan-treapta2.md sect. 4.
-- Treapta 2: adauga conturi cu creds RAR durabile, tabele import, atestari.
-- Conturi cu creds RAR durabile, tabele import, atestari.
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
@@ -10,7 +9,7 @@ CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cui TEXT,
email TEXT, -- email canonic de contact al firmei (US-001, PRD 5.12); nullable pt. conturi legacy
email TEXT, -- email canonic de contact al firmei; nullable pt. conturi legacy
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
-- Stare de ciclu de viata explicita (5.5). Superset al lui `active`: mentinem invariantul
-- active=1 <=> status='active' (vezi accounts.set_status / set_active). Worker gate-uieste pe status.
@@ -19,8 +18,8 @@ CREATE TABLE IF NOT EXISTS accounts (
-- vezi accounts.delete_account — randul ramane doar pentru audit).
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('pending','active','blocked','archived','deleted')),
-- Medii RAR per cont (PRD 5.20 US-001). Fiecare mediu = bifa de activare + slot creds.
-- accounts.rar_creds_enc (legacy env-less) a fost dropata in US-013 (SQLite DROP COLUMN).
-- Medii RAR per cont. Fiecare mediu = bifa de activare + slot creds.
-- accounts.rar_creds_enc (legacy env-less) a fost dropata (SQLite DROP COLUMN).
-- medii_disponibile = enabled AND creds prezente (app/rar_env.py). Cont client nou =
-- Productie on / Testare off (clientii declara real); contul operator se pune manual pe Testare.
rar_test_enabled INTEGER NOT NULL DEFAULT 0 CHECK (rar_test_enabled IN (0, 1)),
@@ -33,6 +32,14 @@ CREATE TABLE IF NOT EXISTS accounts (
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
CHECK (on_unmapped_error_default IN (0, 1)),
-- Comutator Auto per cont. AL TREILEA `auto_send` din schema:
-- DISTINCT de operations_mapping.auto_send si operation_text_rules.auto_send, care sunt
-- PER-OPERATIE. Acesta e PER-CONT si guverneaza DOAR: (a) valoarea implicita a lui
-- submissions.held la ingestie (held = 0 daca 1, altfel 1 — vezi accounts.held_for_account);
-- (b) eliberarea in bloc a randurilor tinute la comutarea OFF -> ON. NU e per-operatie si
-- NU intra in payload/idempotenta/RAR. Default 0 = Auto OFF (randuri noi tinute manual).
auto_send_enabled INTEGER NOT NULL DEFAULT 0
CHECK (auto_send_enabled IN (0, 1)),
-- Plan de cont (5.17). Tier de baza al contului (admin aloca manual via CLI set-tier).
-- trial_until: daca != NULL si > now -> effective_tier() intoarce 'pro' (trial Pro activ).
-- Cont nou primeste tier='free' + trial_until=now+30z via create_account.
@@ -75,8 +82,9 @@ CREATE TABLE IF NOT EXISTS operations_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
cod_op_service TEXT NOT NULL,
cod_prestatie TEXT NOT NULL,
cod_prestatie TEXT NOT NULL, -- gol ('') cand exclus=1 (regula nu mapeaza)
auto_send INTEGER NOT NULL DEFAULT 1,
exclus INTEGER NOT NULL DEFAULT 0, -- 1 = operatia NU se declara la RAR
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (account_id, cod_op_service)
);
@@ -96,7 +104,7 @@ CREATE TABLE IF NOT EXISTS submissions (
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
payload_json TEXT NOT NULL,
-- Mediul RAR tinta al acestei trimiteri (PRD 5.20 US-001). DEFAULT 'test' e doar plasa
-- Mediul RAR tinta al acestei trimiteri. DEFAULT 'test' e doar plasa
-- pentru randuri net-noi care nu seteaza explicit; fiecare INSERT (API/import/web) seteaza
-- rar_env explicit. Backfill din AUTOPASS_RAR_ENV global la migrare (NU lasa pe DEFAULT).
rar_env TEXT NOT NULL DEFAULT 'test' CHECK (rar_env IN ('test', 'prod')),
@@ -105,18 +113,28 @@ CREATE TABLE IF NOT EXISTS submissions (
rar_error TEXT,
id_prezentare INTEGER, -- data.id intors de RAR la succes
retry_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT, -- backoff: randul nu se ia inainte de acest moment (T2)
sending_since TEXT, -- pentru lease/timeout pe randuri 'sending' orfane (T2)
purge_after TEXT, -- sent + 90z (T16)
batch_id INTEGER, -- import batch (T7; NULL = canal API)
row_index INTEGER, -- rand in batch (T7; NULL = canal API)
next_attempt_at TEXT, -- backoff: randul nu se ia inainte de acest moment
sending_since TEXT, -- pentru lease/timeout pe randuri 'sending' orfane
purge_after TEXT, -- sent + 90z
batch_id INTEGER, -- import batch (NULL = canal API)
row_index INTEGER, -- rand in batch (NULL = canal API)
-- Randuri tinute manual. rand `queued AND held=1` = "In asteptare
-- (manual)": tinut pana la eliberare umana (trimitere manuala per rand/bulk: held 1 -> 0).
-- Worker-ul (claim_one) ia doar `queued AND held=0`. held NU intra in payload/idempotenta
-- si NU pleaca la RAR — e pur control de coada (ca import_rows.reviewed). Snapshot la
-- ingestie din accounts.auto_send_enabled (vezi accounts.held_for_account).
held INTEGER NOT NULL DEFAULT 0 CHECK (held IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status);
CREATE INDEX IF NOT EXISTS idx_submissions_account_status ON submissions(account_id, status);
-- Nota: idx_submissions_batch se creeaza in _migrate (dupa ALTER care adauga batch_id pe DB veche).
-- Nota: idx_submissions_batch si idx_submissions_held se creeaza in _migrate, NU aici. `init_db`
-- ruleaza executescript(schema.sql) INAINTE de _migrate; pe un DB existent pre-`held`/`batch_id`
-- coloana inca nu exista in momentul executarii schemei, deci un CREATE INDEX pe ea ar esua
-- (no such column). Indexul partial `idx_submissions_held ON submissions(held) WHERE held=1`
-- traieste doar in _migrate, ca `idx_submissions_batch`.
-- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane).
CREATE TABLE IF NOT EXISTS column_mappings (
@@ -144,7 +162,7 @@ CREATE TABLE IF NOT EXISTS import_batches (
already_sent INTEGER NOT NULL DEFAULT 0,
duplicate_in_file INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
purge_after TEXT -- created_at + 90z (T16)
purge_after TEXT -- created_at + 90z
);
-- Randuri din lot de import (PII criptat cu Fernet).
@@ -153,12 +171,13 @@ CREATE TABLE IF NOT EXISTS import_rows (
batch_id INTEGER NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
row_index INTEGER NOT NULL,
raw_json TEXT NOT NULL, -- PII criptat (Fernet, ca submissions)
override_json TEXT, -- patch CANONIC editat in preview, criptat Fernet (3.6, Approach B); NULL = fara editare
reviewed INTEGER NOT NULL DEFAULT 0, -- US-007 (PRD 5.12): 0=neconfirmat, 1=confirmat de operator; NU intra in payload/idempotenta
override_json TEXT, -- patch CANONIC editat in preview, criptat Fernet; NULL = fara editare
reviewed INTEGER NOT NULL DEFAULT 0, -- 0=neconfirmat, 1=confirmat de operator; NU intra in payload/idempotenta
resolved_status TEXT NOT NULL DEFAULT 'pending'
CHECK (resolved_status IN (
'pending','ok','needs_mapping','needs_data',
'needs_review','already_sent','duplicate_in_file'
'needs_review','already_sent','duplicate_in_file',
'excluded'
)),
error TEXT
);
@@ -189,15 +208,15 @@ CREATE TABLE IF NOT EXISTS users (
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Jurnal de aplicatie la nivel de eveniment (PRD 5.6). Dublu canal: aici (vizibil
-- Jurnal de aplicatie la nivel de eveniment. Dublu canal: aici (vizibil
-- in tab "Jurnal") + log text rotativ (depanare). `tip` e text liber documentat
-- (lista extensibila, decizie §5) — adaugam tipuri fara migrare. Toate valorile
-- (lista extensibila) — adaugam tipuri fara migrare. Toate valorile
-- sunt REDACTATE la scriere (app/observ.py via app/security.py): parole/token ->
-- ***REDACTED***, VIN/nr partial. `context_json` = metadate (NU payload PII integral).
CREATE TABLE IF NOT EXISTS app_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL DEFAULT (datetime('now')),
request_id TEXT, -- corelare cu raspunsul clientului (US-002)
request_id TEXT, -- corelare cu raspunsul clientului
account_id INTEGER, -- NULL = eveniment de sistem (fara cont)
sursa TEXT NOT NULL DEFAULT 'api'
CHECK (sursa IN ('api','worker')),
@@ -206,14 +225,14 @@ CREATE TABLE IF NOT EXISTS app_events (
cod TEXT, -- cod din catalogul de erori (app/errors.py) daca aplica
mesaj TEXT, -- mesaj scurt redactat
context_json TEXT, -- JSON metadate redactate (submission_id, count, status...)
purge_after TEXT -- ts + log_retention_days (US-008)
purge_after TEXT -- ts + log_retention_days
);
CREATE INDEX IF NOT EXISTS idx_app_events_ts ON app_events(ts);
CREATE INDEX IF NOT EXISTS idx_app_events_account ON app_events(account_id, ts);
CREATE INDEX IF NOT EXISTS idx_app_events_tip ON app_events(tip);
-- Reguli automate de mapare pe text (substring, per cont). PRD 5.8 US-001.
-- auto_send DEFAULT 0 (decizie CEO 2026-06-24): substring are blast-radius mai mare
-- Reguli automate de mapare pe text (substring, per cont).
-- auto_send DEFAULT 0: substring are blast-radius mai mare
-- decat maparea exacta; o regula noua rezolva codul dar tine randul pentru verificare
-- umana pana cand operatorul activeaza explicit "In coada".
CREATE TABLE IF NOT EXISTS operation_text_rules (
@@ -228,7 +247,7 @@ CREATE TABLE IF NOT EXISTS operation_text_rules (
);
CREATE INDEX IF NOT EXISTS idx_text_rules_account ON operation_text_rules(account_id);
-- Sugestii de mapare (strat SILVER, L14-S3 PRD 5.14).
-- Sugestii de mapare (strat SILVER).
-- Etichete LLM/embedding — bootstrap; citita DOAR de suggest_codes/pending_unmapped,
-- NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
-- Cheia = denumire normalizata (fara diacritice, uppercase, spatii colapsate).
@@ -247,7 +266,7 @@ CREATE TABLE IF NOT EXISTS mapping_suggestions (
CREATE INDEX IF NOT EXISTS idx_mapping_suggestions_cod
ON mapping_suggestions(cod_prestatie) WHERE cod_prestatie IS NOT NULL;
-- Mapari validate de oameni (strat GOLD partajat cross-account, L14-S3 PRD 5.14).
-- Mapari validate de oameni (strat GOLD partajat cross-account).
-- Confirmarile umane din ORICE cont contribuie la acest store (#8).
-- cross-account = suggestion-only (pre-completeaza editorul, F-A/#11), NU auto-send.
-- Auto-send DOAR din operations_mapping (GOLD propriu per-cont, Eng-F2).
@@ -264,6 +283,17 @@ CREATE TABLE IF NOT EXISTS shared_mappings (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Cache persistent vectori embeddings corpus (mapping_suggestions). Cheia (model,
-- text_hash) leaga cache-ul de numele modelului: schimbare model = randuri vechi inerte,
-- fara sa fie folosite (curatate ulterior de purge_stale).
CREATE TABLE IF NOT EXISTS embedding_cache (
text_hash TEXT NOT NULL,
model TEXT NOT NULL,
vector BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (model, text_hash)
);
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
CREATE TABLE IF NOT EXISTS worker_heartbeat (
id INTEGER PRIMARY KEY CHECK (id = 1),

View File

@@ -1,17 +1,17 @@
"""Store partajat pentru sugestii (SILVER) si mapari validate de oameni (GOLD cross-account).
Straturi (L14-S3, PRD 5.14):
Straturi:
- mapping_suggestions (SILVER): sugestii LLM/embedding, citite DOAR de suggest/pending_unmapped,
NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
NICIODATA de load_mapping/resolve_prestatii (separare structurala).
- shared_mappings (GOLD partajat): mapari validate de om din orice cont; pot pre-completa
editorul (suggestion-only cross-account, F-A/#11); auto-send DOAR GOLD propriu
(operations_mapping per-cont, Eng-F2).
editorul (suggestion-only cross-account); auto-send DOAR GOLD propriu
(operations_mapping per-cont).
Invariante:
- INSERT OR IGNORE la seed: nu suprascrie randuri existente (#2).
- NUL = is_nul=1, cod_prestatie NULL; NU se promoveaza la cod RAR (#4).
- source/confidence pe fiecare rand (provenienta + rollback batch model prost, #5).
- Wiring in resolve_prestatii/load_mapping vine in L14-S6; modulul de fata e API pur.
- INSERT OR IGNORE la seed: nu suprascrie randuri existente.
- NUL = is_nul=1, cod_prestatie NULL; NU se promoveaza la cod RAR.
- source/confidence pe fiecare rand (provenienta + rollback batch model prost).
- Wiring in resolve_prestatii/load_mapping nu e cablat inca; modulul de fata e API pur.
"""
from __future__ import annotations

View File

@@ -20,10 +20,12 @@ from __future__ import annotations
import json
from .accounts import held_for_account
from .mapping import (
account_or_default,
account_scope_clause,
classify_prezentare,
load_excluded_ops,
load_mapping_meta,
load_nomenclator_codes,
)
@@ -101,13 +103,18 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict:
mapping_meta = load_mapping_meta(conn, account_id)
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes)
excluded_ops = load_excluded_ops(conn, account_id)
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, excluded_ops=excluded_ops)
# Re-snapshot `held` din comutatorul contului la re-punere in coada
# (paritate cu create_prezentari/reresolve_account). Fara asta un rand repus pastra
# `held` VECHI si s-ar auto-trimite desi contul e Auto OFF (sau ar ramane blocat cand e ON).
held_val = held_for_account(conn, account_or_default(account_id))
conn.execute(
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, retry_count=0, "
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, updated_at=datetime('now') "
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, held=?, updated_at=datetime('now') "
"WHERE id=?",
(cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], sid),
(cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], held_val, sid),
)
log_event(
"submission_repus",

View File

@@ -157,6 +157,53 @@ def verify_password(conn: sqlite3.Connection, email: str, password: str) -> int
return None
def change_password(
conn: sqlite3.Connection,
user_id: int,
current_password: str,
new_password: str,
) -> None:
"""Schimba parola de acces a userului identificat prin user_id.
Verifica parola actuala (scrypt, comparatie constant-time), valideaza parola
noua (10..128, diferita de cea actuala) si rescrie password_hash + salt nou cu
parametrii scrypt curenti. Parola NICIODATA stocata in clar.
Ridica ValueError la: user inexistent, parametri parola corupti, parola actuala
gresita, parola noua invalida. Nu invalideaza sesiunea (apelantul decide).
"""
row = conn.execute(
"SELECT password_hash, salt, scrypt_params FROM users WHERE id=?",
(user_id,),
).fetchone()
if row is None:
raise ValueError("utilizator inexistent")
salt = bytes.fromhex(row["salt"])
expected = bytes.fromhex(row["password_hash"])
params = _parse_scrypt_params(row["scrypt_params"] or "")
if params is None:
raise ValueError("parametri parola corupti")
n, r, p = params
actual = _scrypt_hash(current_password, salt, n=n, r=r, p=p)
if not hmac.compare_digest(actual, expected):
raise ValueError("Parola actuala este incorecta.")
if len(new_password) < _PASSWORD_MIN:
raise ValueError(f"Parola noua e prea scurta (minim {_PASSWORD_MIN} caractere).")
if len(new_password) > _PASSWORD_MAX:
raise ValueError(f"Parola noua e prea lunga (maxim {_PASSWORD_MAX} caractere).")
if new_password == current_password:
raise ValueError("Parola noua trebuie sa difere de cea actuala.")
new_salt = secrets.token_bytes(16)
new_hash = _scrypt_hash(new_password, new_salt)
conn.execute(
"UPDATE users SET password_hash=?, salt=?, scrypt_params=? WHERE id=?",
(new_hash.hex(), new_salt.hex(), SCRYPT_PARAMS, user_id),
)
def get_user_by_email(conn: sqlite3.Connection, email: str) -> dict | None:
"""Metadate user dupa email (FARA password_hash si salt)."""
row = conn.execute(

View File

@@ -1,4 +1,4 @@
"""Validare de domeniu pentru prezentari (T3).
"""Validare de domeniu pentru prezentari.
Replica regulile RAR (docs/api-rar-contract.md sect. "Reguli de validare") ÎNAINTE
de enqueue, ca sa nu primim 4xx de la RAR. Spre deosebire de validarea de SHAPE
@@ -18,6 +18,7 @@ from datetime import date
from zoneinfo import ZoneInfo
from app.errors import eroare as _eroare
from app.idempotency import normalize_nr_inmatriculare
# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
@@ -73,7 +74,7 @@ def validate_prezentare(content: dict) -> list[dict]:
))
# --- nrInmatriculare ---
nrinm = _norm(content.get("nr_inmatriculare"))
nrinm = normalize_nr_inmatriculare(content.get("nr_inmatriculare"))
if not NRINM_RE.match(nrinm):
errors.append(_eroare(
"NR_INMATRICULARE_FORMAT",

View File

@@ -127,7 +127,7 @@ def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
for aid in ids:
try:
if action == "activate":
# Gate US-002: nu activam conturi fara identitate completa (companie+email+CUI)
# Nu activam conturi fara identitate completa (companie+email+CUI)
acct_row = conn.execute(
"SELECT id, name, cui, email FROM accounts WHERE id=?", (aid,)
).fetchone()

View File

@@ -15,6 +15,7 @@ from ..auth import create_api_key
from ..config import get_settings
from ..db import get_connection
from ..email import notify_signup
from ..observ import log_event
from ..users import count_admins, create_user, list_admin_emails, verify_password
from ..web.csrf import get_csrf_token, verify_csrf
from ..web.ratelimit import check_rate_limit
@@ -78,7 +79,6 @@ async def signup_post(
name=name, cui=cui, email=email, plan=requested_plan,
), status_code=422)
# CUI obligatoriu la signup (US-001, PRD 5.12)
cui_norm = cui.strip().upper() if cui else ""
if not cui_norm:
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
@@ -157,7 +157,7 @@ async def signup_post(
conn.close()
set_session(request, account_id, user_id)
print(f"SIGNUP cont={account_id} email={email}", flush=True)
log_event("signup", account_id=account_id, mesaj="cont nou creat")
# Notificare email admin (best-effort, nu blocheaza signup-ul)
try:

93
app/web/body_cap.py Normal file
View File

@@ -0,0 +1,93 @@
"""Middleware ASGI PUR: plafon global pe dimensiunea corpului cererii (P1-5).
`BaseHTTPMiddleware` nu poate refuza devreme: pentru a construi un `Request`
reconstituie intregul flux `receive()`, iar FastAPI parseaza deja COMPLET
multipart-ul (spooled pe disc peste prag) INAINTE sa ruleze endpointul — o
verificare per-endpoint (chiar si citire pe chunk-uri) nu repara cauza, doar
simptomul. Un middleware ASGI pur intercepteaza `receive` inaintea oricarui
parser: Content-Length peste limita -> 413 fara sa citeasca nimic din body;
altfel numara cumulativ octetii mesajelor `http.request` si intrerupe la
depasire. Cererile fara body (GET etc.) trec netaxate.
Verificarea per-endpoint existenta (`import_router.py`, `import_parse.MAX_BYTES`)
ramane NEATINSA ca strat 2 — acopera orice cale care ar ocoli acest middleware.
"""
from __future__ import annotations
import json
from starlette.datastructures import Headers
from .. import errors
from ..config import get_settings
class _BodyTooLarge(Exception):
pass
def _error_body() -> bytes:
payload = errors.eroare(
"CERERE_PREA_MARE",
cauza="Corpul cererii depaseste limita permisa.",
)
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
class BodyCapMiddleware:
"""Limiteaza corpul cererii la `settings.max_request_bytes`, pe TOATE rutele.
Limita se citeste din `get_settings()` LA FIECARE CERERE (nu o data la
construirea middleware-ului): `app.add_middleware` ruleaza o singura data per
proces (modulul `app.main` e importat o singura data si in teste), deci un
`max_bytes` fixat in `__init__` ar ramane inghetat la valoarea de la primul
import si ar ignora `AUTOPASS_MAX_REQUEST_BYTES` schimbat ulterior (teste).
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
max_bytes = get_settings().max_request_bytes
content_length = Headers(scope=scope).get("content-length")
if content_length is not None:
try:
declared = int(content_length)
except ValueError:
declared = None
if declared is not None and declared > max_bytes:
await self._send_413(send)
return
total = 0
async def capped_receive():
nonlocal total
message = await receive()
if message["type"] == "http.request":
total += len(message.get("body") or b"")
if total > max_bytes:
raise _BodyTooLarge()
return message
try:
await self.app(scope, capped_receive, send)
except _BodyTooLarge:
await self._send_413(send)
@staticmethod
async def _send_413(send) -> None:
await send(
{
"type": "http.response.start",
"status": 413,
"headers": [(b"content-type", b"application/json")],
}
)
await send({"type": "http.response.body", "body": _error_body()})

View File

@@ -29,7 +29,7 @@ def _payload_prezentari_dict(account_id: int) -> dict:
omise pentru concizie — nu sunt obligatorii.
`rar_credentials` NU e inclus: cererea trimite doar cheia API + datele prezentarii,
iar worker-ul foloseste credentialele RAR salvate pe cont (tab-ul Cont). Trimiterea
iar worker-ul foloseste credentialele RAR salvate pe cont (Meniu cont Cont). Trimiterea
lor in payload e optionala (suprascrie creds-urile contului pe acea cerere).
"""
# Construim un dict cu toate campurile obligatorii
@@ -69,6 +69,50 @@ def _payload_json_compact(account_id: int) -> str:
return json.dumps(_payload_prezentari_dict(account_id), separators=(",", ":"), ensure_ascii=False)
def _exemplu_cerere_dict() -> dict:
"""Cerere exemplu completa: ambele forme de prestatie + campuri optionale uzuale."""
return {
"prezentari": [
{
"vin": "WVWZZZ1JZXW000001",
"nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-06-22",
"odometru_final": "150000",
"odometru_initial": "149500",
"prestatii": [
{"cod_prestatie": "OE-1"},
{"cod_op_service": "SF01", "denumire": "Schimb filtru ulei"},
],
}
],
"rar_env": "test",
}
def _exemplu_raspuns_dict() -> dict:
"""Raspuns exemplu in forma implicita `minim`: 6 chei fixe per rezultat."""
return {
"results": [
{
"index": 0,
"submission_id": 123,
"vehicul": "B123ABC",
"status": "queued",
"motiv": None,
"id_prezentare": None,
}
]
}
def exemplu_json() -> dict:
"""Perechea cerere/raspuns JSON formatata pentru cardul din pagina Integrare."""
return {
"cerere": json.dumps(_exemplu_cerere_dict(), indent=2, ensure_ascii=False),
"raspuns": json.dumps(_exemplu_raspuns_dict(), indent=2, ensure_ascii=False),
}
def _snippet_curl_prezentari(base_url: str, account_id: int) -> str:
payload = _payload_json_str(account_id)
return f"""curl -X POST "{base_url}/v1/prezentari" \\

View File

@@ -71,13 +71,39 @@ ETICHETE_SCURTE: dict[str, str] = {
}
def eticheta_scurta(status: str) -> str:
# ---------------------------------------------------------------------------
# Randuri tinute manual (held).
#
# `held` NU e o stare noua in masina de stari: un rand cu `status='queued' AND
# held=1` este in coada, dar tinut pana la eliberarea umana. Il afisam distinct
# ("In asteptare (manual)") cu culoare de AVERTIZARE (--warn / amber) — asteptare
# benigna, NU eroare. Reutilizam clasa `s-needs_review` (definita in base.html ca
# `color:var(--warn)`); NU folosim clasele needs_* rosii (s-needs_data /
# s-needs_mapping = --err). CHECK-ul din schema.sql ramane neatins.
# ---------------------------------------------------------------------------
ETICHETA_HELD: Eticheta = (
"In asteptare (manual)",
"Tinuta pentru confirmarea ta; nu pleaca spre RAR pana nu o eliberezi.",
"s-needs_review",
)
ETICHETA_HELD_SCURTA = "Manual"
def eticheta_scurta(status: str, held: bool = False) -> str:
"""
Returneaza eticheta compacta (pill) pentru o stare de submission.
Cand `status='queued'` si `held` este adevarat, randul e tinut manual si
intoarce "Manual" (distinct de "In coada"). Pentru orice alta stare `held`
este ignorat.
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
stari noi adaugate in schema fara mapare corespunzatoare.
"""
if held and status == "queued":
return ETICHETA_HELD_SCURTA
try:
return ETICHETE_SCURTE[status]
except KeyError:
@@ -87,13 +113,19 @@ def eticheta_scurta(status: str) -> str:
)
def eticheta_stare(status: str) -> Eticheta:
def eticheta_stare(status: str, held: bool = False) -> Eticheta:
"""
Returneaza (text, subtext, css_class) pentru o stare de submission.
Cand `status='queued'` si `held` este adevarat, randul e tinut manual si
intoarce eticheta "In asteptare (manual)" cu clasa de avertizare (amber /
--warn). Pentru orice alta stare `held` este ignorat.
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
stari noi adaugate in schema fara mapare corespunzatoare.
"""
if held and status == "queued":
return ETICHETA_HELD
try:
return STARI_SUBMISSION[status]
except KeyError:
@@ -327,6 +359,31 @@ def parse_erori(rar_error: object) -> list[dict]:
return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}]
# ---------------------------------------------------------------------------
# eroare_bruta — textul compact al erorii pentru sub-linia randului de tabel
# ---------------------------------------------------------------------------
# Stari cu sub-linie de eroare pe rand. needs_mapping e EXCLUS intentionat:
# "nemapat" e deja vizibil in coloana Cod RAR, sub-linia ar fi redundanta.
_STARI_CU_EROARE_BRUTA = ("error", "needs_data")
def eroare_bruta(status: str, rar_error: object) -> str:
"""Textul brut, compact, al primei erori — pentru sub-linia randului.
Prefera cauza tehnica (mesajul serverului RAR, ex. ORA-12899) si cade pe
problema. Sir gol in afara starilor error/needs_data. Nu arunca niciodata
(reuse parse_erori, degradare gratioasa).
"""
if status not in _STARI_CU_EROARE_BRUTA:
return ""
erori = parse_erori(rar_error)
if not erori:
return ""
e = erori[0]
return (str(e.get("cauza") or "").strip() or str(e.get("problema") or "").strip())[:200]
# ---------------------------------------------------------------------------
# Etichete stari preview import (vocabular DIFERIT de starile de submission)
#
@@ -343,6 +400,7 @@ STARI_PREVIEW: dict[str, tuple[str, str]] = {
"needs_data": ("Date incomplete", "s-needs_data"),
"already_sent": ("Deja trimis", "s-already_sent"),
"duplicate_in_file": ("Duplicat in fisier", "s-duplicate_in_file"),
"excluded": ("Nedeclarat", "s-excluded"),
}
@@ -351,7 +409,7 @@ def nota_umana_preview(status: str, errors: list, flags: list) -> str:
Primeste ``errors`` ca lista Python (nu JSON string) — NU pasa la motiv_uman
sau parse_erori care asteapta un JSON string si ar produce repr Python brut
prin fallback ``raw[:160]`` (bug documentat in PRD 5.11 US-003).
prin fallback ``raw[:160]``.
Logica de prioritate:
- already_sent / duplicate_in_file -> "" (template le afiseaza separat)
@@ -365,6 +423,8 @@ def nota_umana_preview(status: str, errors: list, flags: list) -> str:
"""
if status in ("already_sent", "duplicate_in_file"):
return ""
if status == "excluded":
return "Operatie exclusa de la declarare — randul nu se trimite la RAR."
# needs_mapping: codul RAR lipseste — prioritizeaza 'unmapped' inaintea flags,
# altfel un rand cu si un flag (ex. VIN numeric) ar afisa textul flag-ului
# si ascunde motivul real (cod lipsa).
@@ -402,7 +462,7 @@ def nota_umana_preview(status: str, errors: list, flags: list) -> str:
# ---------------------------------------------------------------------------
# Etichete mediu RAR (Test / Productie) — US-010 PRD 5.20
# Etichete mediu RAR (Test / Productie)
#
# Conventia de culori (semantica risc L.142):
# prod = env-badge-prod (fill atentie, text alb) — declaratie REALA, ireversibila.

View File

@@ -31,3 +31,23 @@ class RequestIDMiddleware(BaseHTTPMiddleware):
request_id_var.reset(token)
response.headers["X-Request-ID"] = request_id
return response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Headere de securitate pe TOATE raspunsurile (P1-4, hardening 2026-07-03).
HSTS se pune DOAR pe HTTPS: pe HTTP browserul l-ar ignora oricum, dar l-am
omis explicit ca sa nu sugereze o garantie falsa in dev/smoke pe HTTP simplu.
Nu suprascrie un header deja setat explicit de raspuns (setdefault).
"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
if request.url.scheme == "https":
response.headers.setdefault(
"Strict-Transport-Security", "max-age=31536000; includeSubDomains"
)
return response

View File

@@ -7,10 +7,9 @@ Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (conf
from __future__ import annotations
import time
from collections import defaultdict
# ip/key -> lista de timestamps (time.monotonic) ale cererilor din fereastra activa
_hits: dict[str, list[float]] = defaultdict(list)
_hits: dict[str, list[float]] = {}
def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
@@ -19,13 +18,21 @@ def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
Curata timestamp-urile expirate la fiecare apel (O(n) per cheie, acceptabil
pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple;
suficient pentru un singur proces uvicorn.
Cheile fara timestamp-uri valide sunt sterse din `_hits` (nu doar golite), altfel
dictionarul creste monoton pe viata procesului odata ce cheia devine IP-uri reale
de internet (F5). Foloseste `.get` in loc de acces direct pe dict pentru a nu
reintroduce o cheie doar prin citire.
"""
now = time.monotonic()
cutoff = now - window_s
timestamps = _hits[key]
# Sterge intrari expirate
_hits[key] = [t for t in timestamps if t > cutoff]
if len(_hits[key]) >= max_hits:
filtered = [t for t in _hits.get(key, []) if t > cutoff]
if len(filtered) >= max_hits:
if filtered:
_hits[key] = filtered
else:
_hits.pop(key, None)
return False
_hits[key].append(now)
filtered.append(now)
_hits[key] = filtered
return True

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
<div id="acasa-section">
{# === Banner ne-blocant: cont incomplet (US-002) ===
{# === Banner ne-blocant: cont incomplet ===
Apare cand accounts.name / email / CUI sunt necompletate (conturi legacy sau create din CLI).
NU blocheaza importul sau uploadul — doar orienteaza operatorul sa completeze datele.
Dispare automat dupa ce contul devine complet (re-render la urmatoarea navigare/reload).
@@ -13,43 +13,31 @@
</div>
{% endif %}
{# === Container colapsabil: stepper + upload intr-un singur element <details> (US-006).
Serverul seteaza atributul `open` din are_trimiteri:
are_trimiteri=False (first-run) → open (importul e vizibil imediat, fara JS)
are_trimiteri=True (returning) → colapsat (nu ocupa ecranul, dar e accesibil la click)
Degradare fara JS: corecta pe ambele ramuri.
In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul
intern) → <details> ramane neatins → containerul ramane deschis intre pasi. === #}
<details id="import-details"{% if not are_trimiteri %} open{% endif %}>
<summary>+ Importa fisier (XLSX / CSV)</summary>
{% include '_upload.html' %}
</details>
{# === Subordonat: primii pasi pe un singur rand compact === #}
{# === Primii pasi: DEASUPRA importului, ca ghidajul sa fie primul lucru vizibil === #}
{% set toti_esentiali = are_creds and are_trimiteri %}
{% if not toti_esentiali %}
<div class="card" style="margin-top:14px; padding:12px 16px;">
<div class="card" style="margin-bottom:14px; padding:12px 16px;">
<div style="display:flex; gap:20px; flex-wrap:wrap; align-items:center; font-size:13px;">
<span class="muted" style="font-weight:600;">Primii pasi:</span>
{# Pas 1: Cont RAR (esential) #}
{# Pas 1: Cont RAR (esential) — direct pe sub-tabul Mediu RAR din Setari #}
<span style="display:inline-flex; align-items:center; gap:6px;">
{% if are_creds %}
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span>
{% else %}
<span class="muted" aria-hidden="true">&#9675;</span>
{% endif %}
<a href="/?tab=cont">Cont RAR</a>
<a href="/?tab=cont&subtab=rar">Cont RAR</a>
</span>
{# Pas 2: Cheie API (optional) #}
{# Pas 2: Cheie API (optional) — direct pe sub-tabul Cheie API din Setari #}
<span style="display:inline-flex; align-items:center; gap:6px;">
{% if are_cheie_folosita %}
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span>
{% else %}
<span class="muted" aria-hidden="true">&#9675;</span>
{% endif %}
<a href="/?tab=cont">Cheie API</a>
<a href="/?tab=cont&subtab=api">Cheie API</a>
<span class="muted">(optional)</span>
</span>
@@ -60,12 +48,24 @@
{% else %}
<span class="s-queued" aria-hidden="true" style="font-weight:bold;">&#9679;</span>
{% endif %}
<strong>Import</strong> <span class="muted">(incarca fisierul sus)</span>
<strong>Import</strong> <span class="muted">(incarca fisierul jos)</span>
</span>
</div>
</div>
{% endif %}
{# === Container colapsabil: stepper + upload intr-un singur element <details>.
Serverul seteaza atributul `open` din are_trimiteri:
are_trimiteri=False (first-run) → open (importul e vizibil imediat, fara JS)
are_trimiteri=True (returning) → colapsat (nu ocupa ecranul, dar e accesibil la click)
Degradare fara JS: corecta pe ambele ramuri.
In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul
intern) → <details> ramane neatins → containerul ramane deschis intre pasi. === #}
<details id="import-details"{% if not are_trimiteri %} open{% endif %}>
<summary>+ Importa fisier (XLSX / CSV)</summary>
{% include '_upload.html' %}
</details>
{# Sectiunea Trimiteri, permanenta sub upload.
La first-run (zero trimiteri), randam un placeholder <section> gol/ascuns — necesar
ca OOB swap-ul de la confirma sa gaseasca tinta valida in DOM si sa injecteze

View File

@@ -0,0 +1,54 @@
{#
_auto_send_toggle.html — comutatorul "Trimite automat la RAR".
Plasat in clusterul de header (base.html, langa .rar-chip). NU face parte din
swap-ul periodic al #status-bar (every 15s) -> fara flicker / pierdere focus.
Non-optimist: la esec POST bifa revine + toast eroare (hx-on::response-error).
account_id vine din SESIUNE server-side (ruta /auto-send), niciodata din formular.
Raspunsul POST re-randeaza acest fragment (outerHTML pe #auto-send-toggle-wrap):
- confirmare_release=True -> injecteaza modalul de confirmare OOB in #modal-confirma-slot;
- curata_modal=True -> goleste slotul de modal (dupa activare reusita).
#}
<span id="auto-send-toggle-wrap" class="auto-send-wrap"
style="display:inline-flex; align-items:center; gap:6px;">
<input type="hidden" id="auto-send-csrf" name="csrf_token" value="{{ csrf_token | default('') }}">
<label class="auto-send-label"
title="Trimite automat la RAR. Debifat: prezentarile asteapta confirmarea ta."
style="display:inline-flex; align-items:center; gap:6px; cursor:pointer;
font-size:var(--fs-sm); white-space:nowrap; user-select:none;">
<input type="checkbox" name="enabled" value="1"
{% if auto_send_enabled|default(false) %}checked{% endif %}
hx-post="/auto-send"
hx-include="#auto-send-csrf"
hx-target="#auto-send-toggle-wrap"
hx-swap="outerHTML"
hx-on::response-error="this.checked = !this.checked; if(window.arataToast) window.arataToast('Nu am putut salva setarea Auto-send', 's-error');"
aria-label="Trimite automat la RAR"
style="width:16px; height:16px; cursor:pointer;">
<span class="auto-send-text">Trimite automat la RAR</span>
</label>
{% if confirmare_release|default(false) %}
{# OOB: modal de confirmare tipata pentru auto-release OFF->ON (garda F4/D4). #}
<span hx-swap-oob="innerHTML:#modal-confirma-slot">
{% set cf_id = 'modal-auto-release' %}
{% set cf_titlu = 'Activezi trimiterea automata?' %}
{% set cf_count = release_count %}
{% set cf_env = release_env %}
{% set cf_url = '/auto-send' %}
{% set cf_vals = '{"enabled":"1","confirma":"1"}' %}
{% set cf_target = '#auto-send-toggle-wrap' %}
{% set cf_swap = 'outerHTML' %}
{% set cf_confirm_label = 'Activez si trimit ' ~ release_count %}
{% set cf_deschis = true %}
{% include '_modal_confirma_trimitere.html' %}
</span>
{% endif %}
{% if curata_modal|default(false) %}
{# Curata slotul de modal dupa o comutare reusita (fara modal rezidual). #}
<span hx-swap-oob="innerHTML:#modal-confirma-slot"></span>
{% endif %}
</span>

View File

@@ -0,0 +1,42 @@
{#
_bulk_held_banner.html — banner bulk "Trimite toate (N)".
N = randuri tinute ale contului (queued AND held=1). Randat:
- initial in _coada.html (banner_oob nesetat -> fara OOB);
- RE-RANDAT prin OOB (banner_oob=true) din _submissions.html la FIECARE reincarcare a
listei, ca sa nu ramana stale dupa eliberari.
Wrapper cu id STABIL #bulk-held-banner-wrap: OOB outerHTML il inlocuieste mereu (chiar si
gol la N=0), ca id-ul sa ramana tinta pentru urmatorul OOB. Confirmare tipata prin modal
(count + mediu RAR), NU hx-confirm.
#}
<div id="bulk-held-banner-wrap"{% if banner_oob %} hx-swap-oob="outerHTML"{% endif %}>
{% if held_manual_count|default(0) %}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 10px;
padding:8px 10px; border-left:3px solid var(--warn);
background:color-mix(in srgb, var(--warn) 10%, var(--card)); border-radius:6px;">
<span style="font-size:var(--fs-sm);">
<strong>{{ held_manual_count }}</strong>
{{ 'prezentare tinuta' if held_manual_count == 1 else 'prezentari tinute' }} manual.
</span>
<button type="button"
onclick="var m=document.getElementById('modal-trimite-toate'); if(m){m.hidden=false;}"
style="margin-left:auto; padding:4px 12px; font-size:13px; border-radius:6px;
border:1px solid var(--accent); background:var(--accent); color:#fff;
font-weight:600; cursor:pointer;">
Trimite toate ({{ held_manual_count }})
</button>
</div>
{% set cf_id = 'modal-trimite-toate' %}
{% set cf_titlu = 'Trimiti toate prezentarile tinute?' %}
{% set cf_count = held_manual_count %}
{% set cf_env = env_bulk | default('prod') %}
{% set cf_url = '/trimite-toate' %}
{% set cf_vals = '{}' %}
{% set cf_target = '#submissions-wrap' %}
{% set cf_swap = 'innerHTML' %}
{% set cf_confirm_label = 'Trimite toate (' ~ held_manual_count ~ ')' %}
{% set cf_deschis = false %}
{% include '_modal_confirma_trimitere.html' %}
{% endif %}
</div>

View File

@@ -1,17 +1,31 @@
{#
_chips_prestatii.html — sectiunea de prestatii chips (E4, server-driven via /form-chips).
_chips_prestatii.html — control unificat chips prestatii.
Re-randata de endpoint-ul /form-chips la fiecare add/remove de chip.
Inclusa si din _form_editare.html pentru randarea initiala.
UN singur container `.chips`: chips cod (legate de operatie sau libere), chips
warning pentru operatii nemapate, chip `Nedeclarat` pentru itemi exclusi, si
selectul unic `chips_pick` la final. Fara `.op-row`, fara butoane Adauga/+ —
selectul adauga instant la `change` (POST /form-chips, chips_action=add).
Starea chip-urilor traieste in input-uri hidden din form (NU in DB mid-edit).
Fiecare operatie are un picker propriu cand e nemapata (E4 binding op<->cod).
Reveal odometru initial semnalat prin data-has-r-odo="true" si chip-warn pe R-ODO/I-ODO.
Diagrama starii per item (chips_state.items[i]):
cod="" & exclus=0 -> chip warning (warn OP-X . fara cod), click = tinta
cod="" & exclus=1 -> chip Nedeclarat (line-through), x -> readuce warning
cod!="" (op="" sau op!="") -> chip cod (eticheta COD, tooltip = operatia sursa
sau "cod adaugat manual")
Stare persistata intr-UN SINGUR hidden `chips_state` (JSON versionat)
+ hidden `chips_target_index` (tinta curenta; comutata CLIENT-SIDE la click pe
un chip warning, fara round-trip — indexul pleaca la server abia la urmatorul
add) + hidden `chips_submission_id` (ecouat ca sa "salveaza ca regula" ramana
disponibil dupa re-randari via /form-chips).
Context vars (toate cu defaults):
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire}
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru picker
has_r_odo — True daca orice chip e R-ODO sau I-ODO (server-computed)
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire, exclus}
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru select
has_r_odo — True daca vreun chip e R-ODO/I-ODO (server-computed)
chips_added_index — index-ul chip-ului tocmai adaugat (emfaza chip-nou)
chips_target_index — index-ul operatiei tinta curente (sau none)
chips_submission_id — id submission cand chips sunt in modalul de detaliu
(activeaza butonul "salveaza ca regula")
form_chips_url — URL pentru HTMX; default '/form-chips'
chips_section_id — ID div (default 'chips-section')
csrf_token — CSRF (trecut prin hx-include din form parinte)
@@ -19,68 +33,92 @@
{% set _chips_url = form_chips_url or '/form-chips' %}
{% set _sec_id = chips_section_id or 'chips-section' %}
{% set _chips = prestatii_chips or [] %}
{% set _has_ops = _chips | selectattr('cod_op_service') | list | length > 0 %}
{# US-009: chips_submission_id e setat din _detaliu_ctx cand chips sunt randate in modalul de detaliu.
Lipseste cand _chips_prestatii.html e rerandat via /form-chips (stateless, fara submission). #}
{% set _sub_id = chips_submission_id if chips_submission_id is defined else none %}
{% set _sub_id = chips_submission_id if (chips_submission_id is defined and chips_submission_id) else '' %}
{% set _target_idx = chips_target_index if (chips_target_index is defined and chips_target_index is not none) else none %}
{% set _target_op = (_chips[_target_idx].cod_op_service or '') if (_target_idx is not none and _target_idx < _chips | length) else '' %}
<div id="{{ _sec_id }}" data-has-r-odo="{{ 'true' if has_r_odo else 'false' }}"
<div id="{{ _sec_id }}" class="chips-editor" data-has-r-odo="{{ 'true' if has_r_odo else 'false' }}"
aria-live="polite" aria-label="Prestatii cod RAR">
{# ===== Input-uri hidden pentru starea curenta a chip-urilor =====
TOATE itemele emit 3 hidden inputs (cod poate fi "" pentru unmapped).
Paralele index-by-index: cod_prestatie[i], chip_op_service[i], chip_denumire[i].
Filtrate la submit de post_corectie_trimitere (coduri goale = neschimbate). #}
{% for chip in _chips %}
<input type="hidden" name="cod_prestatie" value="{{ chip.cod_prestatie or '' }}">
<input type="hidden" name="chip_op_service" value="{{ chip.cod_op_service or '' }}">
<input type="hidden" name="chip_denumire" value="{{ chip.denumire or '' }}">
{% endfor %}
{# ===== Stare: UN SINGUR hidden JSON versionat + tinta + submission id ===== #}
<input type="hidden" name="chips_state" value='{{ chips_state_json(_chips) }}'>
<input type="hidden" name="chips_target_index" id="chips-target-index"
value="{{ _target_idx if _target_idx is not none else '' }}">
<input type="hidden" name="chips_submission_id" value="{{ _sub_id }}">
<div class="camp-slim" style="margin-bottom:8px;">
<label>Prestatii — cod RAR pe fiecare operatie</label>
<label>Prestatii — cod RAR</label>
{% if _has_ops %}
{# ===== Mod operatii: UN picker PE operatie (E4 binding) ===== #}
<div class="chips" role="group" aria-label="Prestatii cod RAR">
{% for chip in _chips %}
{% if chip.cod_op_service %}
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
{% set _nemapat = not chip.cod_prestatie %}
<div class="op-row {% if _nemapat %}op-row-warn{% endif %}" style="margin-bottom:6px;">
<span class="op-row-name">
{{ chip.cod_op_service }}
{% if chip.denumire and chip.denumire != chip.cod_op_service %}
<span class="muted" style="font-weight:400;font-size:11px;"> — {{ chip.denumire }}</span>
{% endif %}
{% if _nemapat %}
<span style="color:var(--warn);font-size:10px;font-weight:400;"> · lipsa cod</span>
{% endif %}
</span>
<span style="display:flex;align-items:center;gap:8px;">
{% if chip.cod_prestatie %}
{# ===== Operatie mapata: chip cu × ===== #}
<span class="chip {% if _is_warn %}chip-warn{% endif %}"
aria-label="Prestatie {{ chip.cod_prestatie }} adaugata pentru {{ chip.cod_op_service }}">
{{ chip.cod_prestatie }}
{% set _idx = loop.index0 %}
{% set _is_target = _target_idx is not none and _idx == _target_idx %}
{% if chip.cod_op_service and not chip.cod_prestatie and not chip.exclus %}
{# ===== Chip warning: operatie nemapata — click comuta tinta client-side ===== #}
{% set _label = chip.cod_op_service %}
{% set _label_scurt = (_label[:18] ~ '…') if _label | length > 18 else _label %}
{% set _title = chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '') %}
<button type="button"
class="chip chip-op-warn {% if _is_target %}is-target{% endif %}"
data-chip-idx="{{ _idx }}"
data-op-nume="{{ chip.cod_op_service }}"
title="{{ _title }}"
onclick="window.chipsSetTarget && window.chipsSetTarget(this, {{ _idx }})"
aria-pressed="{{ 'true' if _is_target else 'false' }}"
aria-label="Operatia {{ chip.cod_op_service }} nu are cod RAR{% if _is_target %} (tinta curenta){% endif %}">
&#9888; {{ _label_scurt }} &middot; fara cod
</button>
{% elif chip.exclus %}
{# ===== Chip Nedeclarat: vizibil (line-through), x readuce warning-ul ===== #}
<span class="chip chip-nedeclarat"
title="{{ chip.cod_op_service or 'Cod liber' }} — nu se declara la RAR"
aria-label="{{ chip.cod_op_service or 'Operatia' }} nu se declara la RAR">
Nedeclarat
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove","chips_remove_index":{{ loop.index0 }}}'
aria-label="Sterge codul {{ chip.cod_prestatie }} pentru {{ chip.cod_op_service }}">
hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}'
aria-label="Anuleaza Nedeclarat pentru {{ chip.cod_op_service or 'cod liber' }} (readuce avertismentul)">
&times;
</button>
</span>
{# US-009: "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
(in modalul de detaliu, nu la re-randarea stateless via /form-chips).
Reuse EXACT save_mapping + reresolve_account via endpoint dedicat.
hx-include="closest form" propaga csrf_token din form-ul parinte. #}
<span id="save-rule-slot-{{ loop.index0 }}" class="save-rule-slot">
{% elif chip.cod_prestatie %}
{# ===== Chip cod: legat de operatie sau liber ===== #}
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
{% set _e_nou = (chips_added_index is defined) and chips_added_index is not none and _idx == chips_added_index %}
{% set _tooltip = (chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '')) if chip.cod_op_service else 'cod adaugat manual' %}
<span class="chip {% if _is_warn %}chip-warn{% endif %} {% if _e_nou %}chip-nou{% endif %}"
title="{{ _tooltip }}"
aria-label="Prestatie {{ chip.cod_prestatie }}{% if chip.cod_op_service %} pentru {{ chip.cod_op_service }}{% endif %}">
<span class="chip-cod-label">COD</span> {{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}'
aria-label="Sterge codul {{ chip.cod_prestatie }}{% if chip.cod_op_service %} pentru {{ chip.cod_op_service }}{% endif %}">
&times;
</button>
</span>
{# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
(modalul de detaliu, nu la re-randarea stateless via /form-chips fara ecou). #}
{% if _sub_id and chip.cod_op_service and chip.cod_prestatie %}
<span id="save-rule-slot-{{ _idx }}" class="save-rule-slot">
<button type="button"
style="font-size:10px;color:var(--muted);background:none;border:none;cursor:pointer;text-decoration:underline;padding:0;margin-left:4px;line-height:1;"
style="font-size:10px;color:var(--muted);background:none;border:none;cursor:pointer;text-decoration:underline;padding:0;margin-left:-2px;line-height:1;"
hx-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
@@ -88,147 +126,94 @@
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
salveaza ca regula
</button>
{% endif %}
</span>
{% else %}
{# ===== Operatie nemapata: picker galben cu "alege cod RAR" ===== #}
<select name="chips_add_cod_{{ loop.index0 }}"
id="picker-op-{{ loop.index0 }}"
aria-label="Alege cod RAR pentru {{ chip.cod_op_service }}"
style="min-width:160px;font-size:11px;height:26px;">
<option value="">— alege cod RAR —</option>
{% endif %}
{% endif %}
{% endfor %}
{# ===== Selectul unic: adauga instant la schimbare, placeholder numeste tinta ===== #}
{% if nomenclator_rar %}
<select name="chips_pick"
id="chips-picker"
class="chips-picker"
hx-trigger="change"
hx-post="{{ _chips_url }}"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-include="closest form"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-vals='{"chips_action":"add"}'
aria-label="{% if _target_op %}Alege cod RAR pentru {{ _target_op }}{% else %}Adauga cod RAR{% endif %}">
<option value="">{% if _target_op %}Cod pentru {{ _target_op }} — alege{% else %}— adauga cod RAR —{% endif %}</option>
{% if _target_idx is not none %}
{% set _sugestii = sugestii_tinta if (sugestii_tinta is defined and sugestii_tinta) else [] %}
{% if _sugestii %}
<optgroup label="Sugestii">
{% for s in _sugestii %}
<option value="{{ s.cod_prestatie }}">{{ s.cod_prestatie }} — {{ s.nume_prestatie }}</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="{{ EXCLUDE_SENTINEL }}">Nu se declara la RAR (exclude operatia)</option>
{% endif %}
{% for n in (nomenclator_rar or []) %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add","chips_add_op_index":{{ loop.index0 }}}'
aria-label="Adauga cod RAR pentru {{ chip.cod_op_service }}">
+ Adauga
</button>
{% endif %}
</span>
</div>
{% endif %}
{% endfor %}
{# ===== US-005 (5.16): Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #}
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat).
T-7 (5.16): containerul .chips se randeaza DOAR cand exista chips extra — altfel ramanea
un chenar gol nefinisat sub randurile de operatie. #}
{% set _extra_chips = _chips | rejectattr('cod_op_service') | selectattr('cod_prestatie') | list %}
{% if _extra_chips %}
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;">
{% for chip in _extra_chips %}
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %}"
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}">
{{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">&times;</button>
</span>
{% endfor %}
</div>
{% endif %}
{% if nomenclator_rar %}
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;">
<select name="chips_add_cod_flat"
aria-label="Adauga cod RAR suplimentar"
style="min-width:160px;font-size:11px;height:26px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
<option value="">+ Adauga alta operatie / cod RAR</option>
{% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add_extra"}'
aria-label="Adauga cod RAR suplimentar la trimitere">
+
</button>
</span>
{% else %}
{# T-D1/T-E5 (5.16): empty state in mod operatii cand nomenclatorul lipseste #}
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;margin-top:4px;">
Nomenclator indisponibil — adaugarea de coduri suplimentare nu e posibila.
</div>
{% endif %}
{% if chips_extra_error %}
{# T-C1/T-E4 (5.16): semnal vizibil cand add_extra are select gol sau cod invalid #}
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
Selecteaza un cod RAR din lista inainte de a adauga.
</div>
{% endif %}
{% else %}
{# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #}
<div class="chips" role="group" aria-label="Coduri RAR selectate">
{% for chip in _chips %}
{% if chip.cod_prestatie %}
{% set _is_warn_flat = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
<span class="chip {% if _is_warn_flat %}chip-warn{% endif %}"
aria-label="Prestatie {{ chip.cod_prestatie }}">
{{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
aria-label="Sterge codul {{ chip.cod_prestatie }}">&times;</button>
</span>
{% endif %}
{% endfor %}
{# Picker adaugare cod nou in mod plat #}
{% if nomenclator_rar %}
<span style="display:inline-flex;align-items:center;gap:4px;">
<select name="chips_add_cod_flat"
aria-label="Adauga cod RAR nou"
style="font-size:11px;height:22px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
<option value="">+ cod</option>
{% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add_flat"}'
aria-label="Adauga cod RAR selectat in lista">
+
</button>
</span>
{% else %}
{# T-D1/T-E5 (5.16): empty state in mod plat cand nomenclatorul lipseste #}
{# T-D1/T-E5 (5.16): empty state cand nomenclatorul lipseste #}
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;">
Nomenclator indisponibil — nu se pot adauga coduri RAR momentan.
Nomenclator indisponibil — adaugarea de coduri RAR nu e posibila.
</div>
{% endif %}
</div>
{% endif %}
{# Hint discret fara chips (debut) #}
{% if not _chips %}
<div style="font-size:10px;color:var(--muted);padding:4px 0;">
Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare.
{# Rezultatul ultimei adaugari: refuz (rol=alert) sau confirmare.
Clasa chips-extra-error pastrata pe eroare (selector stabil pentru teste/QA). #}
{% if chips_error is defined and chips_error %}
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
{{ chips_error }}
</div>
{% elif chips_ok is defined and chips_ok %}
<div class="chips-adaugat-ok" style="font-size:11px;color:var(--ok);padding:2px 0;" role="status">
{{ chips_ok }}
</div>
{% endif %}
</div>
</div>
<script>
(function () {
if (window.__chipsUnifiedBound) { return; }
window.__chipsUnifiedBound = true;
// Click pe chip warning -> comuta tinta CLIENT-SIDE (fara round-trip). Click
// pe tinta activa = no-op (nu se poate "deselecta" tinta cat exista warnings).
window.chipsSetTarget = function (btn, idx) {
var hidden = document.getElementById('chips-target-index');
if (!hidden) { return; }
if (String(idx) === hidden.value) { return; }
hidden.value = String(idx);
document.querySelectorAll('.chip-op-warn.is-target').forEach(function (el) {
el.classList.remove('is-target');
el.setAttribute('aria-pressed', 'false');
});
btn.classList.add('is-target');
btn.setAttribute('aria-pressed', 'true');
var picker = document.getElementById('chips-picker');
if (picker && picker.options.length) {
picker.options[0].textContent = 'Cod pentru ' + (btn.dataset.opNume || '') + ' — alege';
}
};
// Focus restaurat pe select dupa fiecare swap outerHTML al sectiunii.
document.body.addEventListener('htmx:afterSettle', function (evt) {
var t = evt.target;
if (t && t.classList && t.classList.contains('chips-editor')) {
var el = document.getElementById('chips-picker');
if (el) { el.focus(); }
}
});
})();
</script>

View File

@@ -6,10 +6,15 @@
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
<div class="card">
{# US-002 (5.16): titlul de sectiune vizibil ("Trimiterile tale") a fost eliminat —
lista incepe direct sub filtre. Heading pastrat sr-only pentru a11y (section
{# Titlul de sectiune vizibil ("Trimiterile tale") a fost eliminat — lista incepe
direct sub filtre. Heading pastrat sr-only pentru a11y (section
aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
<h2 id="trimiteri-heading" class="sr-only">Trimiterile tale</h2>
{# Bulk "Trimite toate (N)". Randat initial aici; re-randat prin OOB
din _submissions.html la fiecare reincarcare a listei (evita staleness). #}
{% include '_bulk_held_banner.html' %}
{% if blocate_total %}
<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin:0 0 10px;">
<span class="tab-badge" title="{{ blocate_total }} necesita atentie"
@@ -39,6 +44,9 @@
<input type="hidden" id="f-status" name="status" value="{{ status_filtru | default('', true) }}">
{# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #}
<input type="hidden" id="f-page" name="page" value="1">
{# Filtrul "doar tinute" — persistat prin OOB din _submissions.html, ca sa
nu se piarda la reincarcare (trimiteriChanged) sau paginare. #}
<input type="hidden" id="f-held" name="held" value="{{ f_held | default('') }}">
{# === STANGA: Quick-pills de data (preset interval) + buton Custom ===
Azi / 7 zile / 30 zile → seteaza interval preset si submitr automat.

View File

@@ -1,226 +1,241 @@
<div class="card" id="card-cont">
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
{# Pagina Setari: sub-taburi interne — Cont (date firma + plan + parola) /
Credentiale RAR / Cheie API. `activ_subtab` (server) alege panoul activ dupa fiecare
swap HTMX, ca re-randarea sa nu resete la primul tab. Comutarea intre taburi
fara server = JS delegat in base.html (#card-cont). #}
{% set _firma_ok = account_meta.name and account_meta.email and account_meta.cui %}
{% set _rar_ok = medii_disponibile and medii_disponibile|length > 0 %}
{% set _subtab = activ_subtab|default('cont') %}
{% if _subtab == 'firma' %}{% set _subtab = 'cont' %}{% endif %}
<div class="card" id="card-cont" data-activ-subtab="{{ _subtab }}">
<h2 class="setari-title">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.6 4.6l2.1 2.1M17.3 17.3l2.1 2.1M4.6 19.4l2.1-2.1M17.3 6.7l2.1-2.1"/></svg>
Setari
</h2>
<!-- Sectiunea: Plan curent (US-006 PRD 5.17) -->
{% if plan_linie is defined %}
<div id="sectiune-plan" style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:var(--fs-sm); color:var(--muted); font-weight:500; margin:0 0 10px;
text-transform:uppercase; letter-spacing:.04em;">Plan curent</h3>
<div style="font-size:var(--fs-md); font-weight:600; margin-bottom:6px;
color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--ink){% endif %};">
{{ plan_linie }}
<div class="setari-subtabs" role="tablist" aria-label="Sectiuni setari">
<button type="button" class="setari-subtab{{ ' is-active' if _subtab == 'cont' else '' }}" role="tab" data-subtab="cont"
aria-selected="{{ 'true' if _subtab == 'cont' else 'false' }}">
<span class="st-dot {{ 'ok' if _firma_ok else 'warn' }}" aria-hidden="true"></span>Cont</button>
<button type="button" class="setari-subtab{{ ' is-active' if _subtab == 'rar' else '' }}" role="tab" data-subtab="rar"
aria-selected="{{ 'true' if _subtab == 'rar' else 'false' }}">
<span class="st-dot {{ 'ok' if _rar_ok else 'warn' }}" aria-hidden="true"></span>Credentiale RAR</button>
<button type="button" class="setari-subtab{{ ' is-active' if _subtab == 'api' else '' }}" role="tab" data-subtab="api"
aria-selected="{{ 'true' if _subtab == 'api' else 'false' }}">
<span class="st-dot {{ 'ok' if are_cheie else 'warn' }}" aria-hidden="true"></span>Cheie API</button>
</div>
{% if monthly_limit_val is defined and monthly_limit_val is not none and effective_tier_name|default('') == 'free' %}
<div style="font-size:var(--fs-sm); color:var(--muted); margin-bottom:8px;">
Planul Gratuit include {{ monthly_limit_val }} prestatii/luna prin dashboard-ul web.
{% if plan_limita_atinsa|default(false) %}
Limita lunara a fost atinsa — trimiterile noi sunt blocate pana la inceputul lunii urmatoare.
{% elif plan_warn|default(false) %}
Te apropii de limita lunara.
{% endif %}
{# ================= Sub-tab: Cont (date firma + plan + parola) ================ #}
<div class="setari-pane" data-subpane="cont" role="tabpanel"
{% if _subtab != 'cont' %}hidden{% endif %}>
{# --- Date firma --- #}
<div class="setari-zone-title">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 21V4a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v17"/><path d="M15 9h4a1 1 0 0 1 1 1v11"/><path d="M2 21h20"/><path d="M8 7h3M8 11h3M8 15h3"/></svg>
Date firma
<span class="setari-badge {{ 'sb-ok' if _firma_ok else 'sb-warn' }}">{{ 'complet' if _firma_ok else 'incomplet' }}</span>
</div>
{% endif %}
<div style="font-size:var(--fs-sm); color:var(--muted); padding:8px 10px;
border:1px solid var(--line); border-radius:6px; margin-top:4px;">
Vrei sa treci pe Standard, Pro sau Premium?
Contacteaza-ne pentru alocare manuala — nu exista inca plata self-service.
<strong>Pro</strong> adauga import prin API; <strong>Standard</strong> si
<strong>Premium</strong> ridica limita de volum.
</div>
</div>
{% endif %}
{% if date_firma_mesaj %}<div class="flash" style="margin-bottom:12px;">{{ date_firma_mesaj }}</div>{% endif %}
{% if date_firma_eroare %}<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ date_firma_eroare }}</div>{% endif %}
<!-- Sectiunea: Date firma (US-002) -->
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Date firma</h3>
{% if date_firma_mesaj %}
<div class="flash" style="margin-bottom:12px;">{{ date_firma_mesaj }}</div>
{% endif %}
{% if date_firma_eroare %}
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ date_firma_eroare }}</div>
{% endif %}
<form hx-post="/cont/date-firma"
hx-target="#card-cont"
hx-swap="outerHTML">
<form hx-post="/cont/date-firma" hx-target="#card-cont" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Companie</label><br>
<input type="text" name="companie" required
value="{{ account_meta.name or '' }}"
style="width:100%; max-width:340px;"
placeholder="Numele firmei (ex. Service Auto SRL)">
</p>
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Email contact</label><br>
<input type="email" name="email" required
value="{{ account_meta.email or '' }}"
style="width:100%; max-width:340px;"
placeholder="contact@firma.ro">
</p>
<p style="margin:0 0 12px;">
<label style="font-size:13px; color:var(--muted);">CUI (cod unic de identificare)</label><br>
<input type="text" name="cui" required
value="{{ account_meta.cui or '' }}"
style="width:100%; max-width:340px;"
placeholder="RO12345678">
</p>
<button type="submit">Salveaza datele firmei</button>
</form>
</div>
<!-- Sectiunea: Cheia mea API -->
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Cheia mea API</h3>
{% if api_key %}
<div class="flash" style="margin-bottom:12px;">Cheia a fost rotita. Salveaz-o acum — nu o vei mai putea vedea.</div>
<div class="card" style="font-family:monospace; word-break:break-all; font-size:14px; background:#0f1115; margin:0 0 8px;">
{{ api_key }}
</div>
<button type="button"
data-key="{{ api_key }}"
onclick="navigator.clipboard.writeText(this.dataset.key).then(()=>this.textContent='Copiat!')">
Copiaza cheia
</button>
<p style="font-size:13px; color:var(--warn); margin:10px 0 0;">
Atentie: la urmatoarea vizita aceasta cheie dispare. Daca o pierzi, roteste din nou.
</p>
{% endif %}
{% if rot_eroare %}
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ rot_eroare }}</div>
{% endif %}
<form hx-post="/cont/roteste-cheie"
hx-target="#card-cont"
hx-swap="outerHTML"
style="margin-top:{% if api_key %}12px{% else %}0{% endif %};">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--warn); border-color:var(--warn);">
Roteste cheia API
</button>
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Cheia veche se revoca imediat.</span>
</form>
</div>
<!-- Sectiunea: Credentiale RAR -->
<div class="setari-fgrid">
<div>
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 12px; text-transform:uppercase; letter-spacing:.04em;">Credentiale RAR (portal AUTOPASS)</h3>
<label for="df-companie">Companie</label>
<input id="df-companie" type="text" name="companie" required value="{{ account_meta.name or '' }}"
placeholder="Numele firmei (ex. Service Auto SRL)">
</div>
<div>
<label for="df-cui">CUI</label>
<input id="df-cui" type="text" name="cui" required value="{{ account_meta.cui or '' }}"
placeholder="RO12345678">
</div>
<div class="fg-full">
<label for="df-email">Email contact</label>
<input id="df-email" type="email" name="email" required value="{{ account_meta.email or '' }}"
placeholder="contact@firma.ro">
</div>
</div>
<div class="setari-btn-row">
<button type="submit">Salveaza</button>
<span class="setari-micro">Apar pe declaratiile RAR si pe facturi.</span>
</div>
</form>
<p class="muted" style="font-size:12px; margin:0 0 12px; line-height:1.5;">
Aceasta instanta ruleaza pe mediul global <strong>{{ mediu_instanta() }}</strong>.
Poti configura mai jos ambele medii — Testare si Productie — fiecare validat separat
la salvare pe sistemul RAR corespunzator. Cand un cont nu are niciun mediu activ,
trimiterile cad pe mediul global al instantei ({{ mediu_instanta() }}).
{# --- Plan --- #}
{% if plan_linie is defined %}
<hr class="setari-subzone-sep">
<div id="sectiune-plan" class="setari-zone-title">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 3 8l9 5 9-5z"/><path d="M3 13l9 5 9-5"/></svg>
Plan
</div>
{# plan_linie vine ca "Plan: <tier> · <detaliu>"; aici tier-ul apare O data, bold. #}
{% set _plan_text = plan_linie|replace('Plan: ', '', 1) %}
{% set _plan_parts = _plan_text.split(' · ', 1) %}
<div class="plan-row">
<span class="plan-tier">{{ _plan_parts[0] }}</span>
{% if _plan_parts|length > 1 %}
<span class="plan-usage" style="{% if plan_limita_atinsa|default(false) %}color:var(--err); font-weight:600;{% elif plan_warn|default(false) %}color:var(--warn); font-weight:600;{% endif %}">{{ _plan_parts[1] }}</span>
{% endif %}
</div>
{% if monthly_limit_val|default(none) and usage_lunar is defined %}
{% set _pct = (usage_lunar / monthly_limit_val * 100)|round|int %}
<div class="plan-meter" role="progressbar" aria-valuenow="{{ usage_lunar }}" aria-valuemin="0"
aria-valuemax="{{ monthly_limit_val }}" aria-label="Consum plan luna curenta">
<span style="width:{{ [_pct, 100]|min }}%;{% if plan_limita_atinsa|default(false) %} background:var(--err);{% elif plan_warn|default(false) %} background:var(--warn);{% endif %}"></span>
</div>
{% endif %}
{% if plan_limita_atinsa|default(false) %}
<p class="setari-micro" style="color:var(--err); margin:0 0 6px;">Limita lunara atinsa — trimiterile noi sunt blocate pana luna viitoare.</p>
{% endif %}
<p class="setari-micro" style="margin:0;">Pentru mai mult volum sau acces API, <a href="mailto:contact@romfast.ro">contacteaza-ne</a>.</p>
{% endif %}
{# --- Parola de acces --- #}
<hr class="setari-subzone-sep">
<div class="setari-zone-title">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="15.5" r="1.2"/></svg>
Parola de acces
</div>
<p class="setari-zone-desc">Parola dashboard-ului — separata de credentialele RAR. Minim 10 caractere.</p>
{% if parola_mesaj %}<div class="flash" style="margin-bottom:12px;">{{ parola_mesaj }}</div>{% endif %}
{% if parola_eroare %}<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ parola_eroare }}</div>{% endif %}
<form hx-post="/cont/schimba-parola" hx-target="#card-cont" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="setari-fgrid">
<div class="fg-full" style="max-width:273px;">
<label for="pw-actuala">Parola actuala</label>
<input id="pw-actuala" type="password" name="parola_actuala" autocomplete="current-password">
</div>
<div>
<label for="pw-noua">Parola noua</label>
<input id="pw-noua" type="password" name="parola_noua" autocomplete="new-password">
</div>
<div>
<label for="pw-confirma">Confirma parola noua</label>
<input id="pw-confirma" type="password" name="parola_confirma" autocomplete="new-password">
</div>
</div>
<button type="submit">Schimba parola</button>
</form>
</div>
{# ========================= Sub-tab: Credentiale RAR ========================= #}
<div class="setari-pane" data-subpane="rar" role="tabpanel"
{% if _subtab != 'rar' %}hidden{% endif %}>
<div class="setari-zone-title">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 11l1.5-4A2 2 0 0 1 8.4 5.6h7.2A2 2 0 0 1 17.5 7L19 11"/><path d="M4 11h16v6H4z"/><path d="M6 17v2M18 17v2"/><circle cx="7.5" cy="14" r="1"/><circle cx="16.5" cy="14" r="1"/></svg>
Credentiale RAR
<span class="setari-badge {{ 'sb-ok' if _rar_ok else 'sb-warn' }}">{{ 'configurat' if _rar_ok else 'neconfigurat' }}</span>
</div>
<p class="setari-zone-desc">
Fiecare mediu are propriile credentiale, verificate la salvare. Trimiterile merg doar pe mediile active.
</p>
{% if creds_mesaj %}
<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>
{% endif %}
{% if creds_eroare %}
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ creds_eroare }}</div>
{% endif %}
{% if creds_mesaj %}<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>{% endif %}
{% if creds_eroare %}<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ creds_eroare }}</div>{% endif %}
<form hx-post="/cont/rar-medii"
hx-target="#card-cont"
hx-swap="outerHTML">
<form hx-post="/cont/rar-medii" hx-target="#card-cont" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<!-- Subsectiunea: Testare -->
<div style="margin-bottom:16px; padding-bottom:16px; border-bottom:1px solid var(--line);">
<p style="margin:0 0 6px;">
<label style="font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px;">
<div class="env-cards">
<!-- Testare -->
<div class="env-card{% if test_disponibil %} active-test{% endif %}">
<h4>Testare
{% if test_disponibil %}<span class="setari-badge sb-ok">configurat</span>{% else %}<span class="setari-badge sb-info">inactiv</span>{% endif %}
</h4>
<label class="env-sw">
<input type="checkbox" name="test_enabled" value="1" {% if test_enabled %}checked{% endif %}>
Activare Testare
</label>
{% if test_disponibil %}
<span style="font-size:12px; color:var(--ok);">configurat</span>
{% endif %}
</p>
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Email RAR Testare</label><br>
<input type="email" name="test_email" style="width:100%; max-width:340px;"
placeholder="email@service.ro">
</p>
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Parola RAR Testare</label><br>
<input type="password" name="test_parola" style="width:100%; max-width:340px;"
autocomplete="new-password">
</p>
{% if creds_test_mesaj %}
<div class="flash" style="margin-top:6px;">{{ creds_test_mesaj }}</div>
{% endif %}
{% if creds_test_eroare %}
<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_test_eroare }}</div>
{% endif %}
<label class="env-lbl" for="rar-test-email">Email RAR Testare</label>
<input id="rar-test-email" type="email" name="test_email" placeholder="email@service.ro">
<label class="env-lbl" for="rar-test-parola">Parola RAR Testare</label>
<input id="rar-test-parola" type="password" name="test_parola" autocomplete="new-password"
{% if test_disponibil %}placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull; (setata)"{% endif %}>
{% if creds_test_mesaj %}<div class="flash" style="margin-top:6px;">{{ creds_test_mesaj }}</div>{% endif %}
{% if creds_test_eroare %}<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_test_eroare }}</div>{% endif %}
</div>
<!-- Subsectiunea: Productie -->
<div style="margin-bottom:16px; padding-bottom:16px; border-bottom:1px solid var(--line);">
<p style="margin:0 0 6px;">
<label style="font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px;">
<!-- Productie -->
<div class="env-card{% if not prod_disponibil %} inactive{% endif %}">
<h4>Productie
{% if prod_disponibil %}<span class="setari-badge sb-ok">configurat</span>{% else %}<span class="setari-badge sb-warn">inactiv</span>{% endif %}
</h4>
<label class="env-sw">
<input type="checkbox" name="prod_enabled" value="1" {% if prod_enabled %}checked{% endif %}>
Activare Productie
</label>
{% if prod_disponibil %}
<span style="font-size:12px; color:var(--ok);">configurat</span>
{% endif %}
</p>
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Email RAR Productie</label><br>
<input type="email" name="prod_email" style="width:100%; max-width:340px;"
placeholder="email@service.ro">
</p>
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Parola RAR Productie</label><br>
<input type="password" name="prod_parola" style="width:100%; max-width:340px;"
autocomplete="new-password">
</p>
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted); display:flex; align-items:flex-start; gap:6px;">
<input type="checkbox" name="prod_confirmare" value="1" style="margin-top:2px; flex-shrink:0;">
Inteleg ca trimiterile pe Productie sunt declaratii reale (L.142), finale si fara anulare.
<label class="env-lbl" for="rar-prod-email">Email RAR Productie</label>
<input id="rar-prod-email" type="email" name="prod_email" placeholder="email@service.ro">
<label class="env-lbl" for="rar-prod-parola">Parola RAR Productie</label>
<input id="rar-prod-parola" type="password" name="prod_parola" autocomplete="new-password"
{% if prod_disponibil %}placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull; (setata)"{% endif %}>
<label class="env-confirm">
<input type="checkbox" name="prod_confirmare" value="1">
<span>Confirm: trimiterile pe Productie sunt declaratii reale (L.142), finale si fara anulare.</span>
</label>
</p>
{% if creds_prod_mesaj %}
<div class="flash" style="margin-top:6px;">{{ creds_prod_mesaj }}</div>
{% endif %}
{% if creds_prod_eroare %}
<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_prod_eroare }}</div>
{% endif %}
{% if creds_prod_mesaj %}<div class="flash" style="margin-top:6px;">{{ creds_prod_mesaj }}</div>{% endif %}
{% if creds_prod_eroare %}<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_prod_eroare }}</div>{% endif %}
</div>
</div>
<!-- Selector mediu implicit -->
<div style="margin-bottom:16px;">
<label style="font-size:13px; color:var(--muted);">Mediu implicit pentru trimiteri</label><br>
<div class="default-env">
<label for="rar-env-default" style="font-size:13px; color:var(--muted);">Mediu implicit pentru trimiteri</label>
{% if medii_disponibile %}
<select name="rar_env_default" style="width:100%; max-width:340px; margin-top:4px;">
<select id="rar-env-default" name="rar_env_default">
{% for env in medii_disponibile %}
<option value="{{ env }}"{% if env == rar_env_default %} selected{% endif %}>{{ "Testare" if env == "test" else "Productie" }}</option>
{% endfor %}
</select>
{% else %}
<p style="font-size:13px; color:var(--muted); margin:4px 0 0;">Activeaza si valideaza un mediu intai.</p>
{% endif %}
{% if creds_default_mesaj %}
<div class="flash" style="margin-top:6px;">{{ creds_default_mesaj }}</div>
{% endif %}
{% if creds_default_eroare %}
<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_default_eroare }}</div>
<span style="font-size:13px; color:var(--muted);">Activeaza si valideaza un mediu intai.</span>
{% endif %}
{% if creds_default_mesaj %}<div class="flash" style="margin-top:6px; flex-basis:100%;">{{ creds_default_mesaj }}</div>{% endif %}
{% if creds_default_eroare %}<div class="banner" style="margin-top:6px; padding:8px 12px; flex-basis:100%;">{{ creds_default_eroare }}</div>{% endif %}
</div>
<button type="submit">Salveaza mediile RAR</button>
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Parolele stocate criptat, niciodata in clar.</span>
<div class="setari-btn-row">
<button type="submit">Salveaza</button>
<span class="setari-micro">Parolele se stocheaza criptat.</span>
</div>
</form>
</div>
{# ============================ Sub-tab: Cheie API ============================ #}
<div class="setari-pane" data-subpane="api" role="tabpanel"
{% if _subtab != 'api' %}hidden{% endif %}>
<div class="setari-zone-title">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9"/><path d="M17 6l2.5 2.5M14 9l2.5 2.5"/></svg>
Cheie API
<span class="setari-badge {{ 'sb-ok' if are_cheie else 'sb-warn' }}">{{ 'activa' if are_cheie else 'lipsa' }}</span>
</div>
<p class="setari-zone-desc">
Pentru trimiteri din softul propriu / ROAAUTO. Se afiseaza integral o singura data.
</p>
{% if api_key %}
<div class="flash" style="margin-bottom:12px;">Cheia a fost rotita. Salveaz-o acum — nu o vei mai putea vedea.</div>
<div class="keybox">{{ api_key }}</div>
<button type="button" class="btn-secondary" style="margin-top:8px;"
data-key="{{ api_key }}"
onclick="navigator.clipboard.writeText(this.dataset.key).then(()=>this.textContent='Copiat!')">
Copiaza cheia
</button>
{% endif %}
{% if rot_eroare %}<div class="banner" style="margin:12px 0; padding:8px 12px;">{{ rot_eroare }}</div>{% endif %}
<form hx-post="/cont/roteste-cheie" hx-target="#card-cont" hx-swap="outerHTML"
style="margin-top:{% if api_key %}14px{% else %}0{% endif %};">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="setari-btn-row">
<button type="submit" class="btn-warn">{{ 'Roteste cheia API' if are_cheie else 'Genereaza cheia API' }}</button>
<span class="setari-micro">Rotirea revoca imediat cheia veche.</span>
</div>
</form>
</div>
</div>

View File

@@ -1,7 +1,7 @@
{# _editare_preview_modal.html — fragment de editare rand preview in modalul global.
US-006 (PRD 5.12): swap-uit in #detaliu-modal-body de butonul Editeaza din preview.
US-007 (PRD 5.12): butonul 'Confirma valorile' apare DOAR pe randurile needs_review
(T2): trimite CSRF POST la /confirma-review, inchide modalul via HX-Trigger-After-Settle.
Swap-uit in #detaliu-modal-body de butonul Editeaza din preview.
Butonul 'Confirma valorile' apare DOAR pe randurile needs_review si trimite CSRF
POST la /confirma-review, inchide modalul via HX-Trigger-After-Settle.
Necesita din context:
import_id — id batch import
@@ -52,7 +52,7 @@
</form>
{% if is_needs_review %}
{# T2 (US-007): Butonul 'Confirma valorile' apare DOAR pe randurile needs_review.
{# Butonul 'Confirma valorile' apare DOAR pe randurile needs_review.
POST separat (form propriu) la /confirma-review cu CSRF. Raspunsul inchide
modalul via HX-Trigger-After-Settle: inchideModal + swap OOB randul si countorii. #}
<form hx-post="/_import/{{ import_id }}/rand/{{ row_index }}/confirma-review"

View File

@@ -1,6 +1,6 @@
{# _form_editare.html — partial partajat slim: campurile vehicul/data/odo + obs + chips prestatii.
US-007 (PRD 5.15): redesign slim cu VIN unic, Observatii textarea, chips prestatii (E4),
si reveal dinamic odometru initial cand chips contin R-ODO/I-ODO (D10c, E6 server-driven).
Redesign slim cu VIN unic, Observatii textarea, chips prestatii, si reveal dinamic
odometru initial cand chips contin R-ODO/I-ODO (server-driven).
Inclus cu {% include "_form_editare.html" %} INSIDE un <form> element al
template-ului parinte. Acel parinte pune form-ul, CSRF-ul si orice campuri
@@ -36,7 +36,7 @@
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
</div>
{# === 3. Observatii (obs) — textarea liber, US-005 === #}
{# === 3. Observatii (obs) — textarea liber === #}
<div class="camp-slim">
<label for="c-obs">Observatii (operatiile efectuate)</label>
<textarea id="c-obs" name="obs" rows="2"
@@ -44,7 +44,7 @@
placeholder="ex: Revizie; schimbare placute frana">{{ obs_val or '' }}</textarea>
</div>
{# === 4. Prestatii chips (E4 server-driven, US-007) === #}
{# === 4. Prestatii chips (server-driven) === #}
{% set form_chips_url = form_chips_url or '/form-chips' %}
{% set chips_section_id = 'chips-section' %}
{% include "_chips_prestatii.html" %}

View File

@@ -0,0 +1,29 @@
{# Badge compact "Mediu RAR" din header, langa chip-ul RAR.
0 medii: wrapper gol (id-ul ramane in DOM pentru viitoare OOB swap).
1 mediu: eticheta statica. 2 medii: buton de comutare HTMX (ciclic).
Clasele .env-badge-prod/.env-badge-test sunt cele din listele de trimiteri (consecventa). #}
{% set _medii = medii_disponibile | default([]) %}
<div id="header-env-badge"{% if oob %} hx-swap-oob="outerHTML"{% endif %}>
{% if _medii | length >= 1 %}
{% set _env = env_default | default('prod') %}
{% set _eb = eticheta_env(_env) %}
{% set _scurt = 'PROD' if _env == 'prod' else 'TEST' %}
{% if _medii | length >= 2 %}
<button type="button" class="{{ _eb[1] }}"
style="cursor:pointer;"
hx-post="/_fragments/status/toggle-env"
hx-target="#header-env-badge"
hx-swap="outerHTML"
hx-vals='{"csrf_token": {{ csrf_token | tojson }}}'
title="Comuta la {{ 'Testare' if _env == 'prod' else 'PRODUCȚIE' }}">
<span class="env-tx-full">{{ _eb[0] }}</span>
<span class="env-tx-short">{{ _scurt }}</span>
</button>
{% else %}
<span class="{{ _eb[1] }}">
<span class="env-tx-full">{{ _eb[0] }}</span>
<span class="env-tx-short">{{ _scurt }}</span>
</span>
{% endif %}
{% endif %}
</div>

View File

@@ -14,7 +14,7 @@
{% else %}
Lipseste o cheie API activa.
{% endif %}
Mergi la <a href="/?tab=cont">tab-ul Cont</a> pentru a le configura.
Mergi la <a href="/?tab=cont&subtab={{ 'rar' if not are_creds else 'api' }}">Meniu cont &rsaquo; Setari</a> pentru a le configura.
</div>
{% endif %}
@@ -28,7 +28,7 @@
</div>
<p class="muted" style="font-size:12px; margin:10px 0 0;">
Cererile trimit doar cheia API + datele prezentarii. Credentialele RAR se configureaza
o data in <a href="/?tab=cont">Cont</a> si sunt folosite automat la trimitere. Optional,
o data in <a href="/?tab=cont&subtab=rar">Setari</a> si sunt folosite automat la trimitere. Optional,
poti include <code>rar_credentials</code> in payload ca sa le suprascrii pe acea cerere.
</p>
</div>
@@ -216,6 +216,87 @@
</div>
</div>
{# Card: exemplu cerere + raspuns JSON pentru POST /v1/prezentari #}
<div class="card" style="margin-bottom:16px;">
<h3 style="margin:0 0 6px; font-size:15px;">Cerere si raspuns JSON</h3>
<p class="muted" style="font-size:12px; margin:0 0 12px;">
<code>POST /v1/prezentari</code> — o prezentare cu doua operatii: una cu cod RAR direct
(<code>cod_prestatie</code>), una cu cod intern + denumire (mapata automat de gateway).
</p>
<div style="display:flex; flex-wrap:wrap; gap:12px;">
<div style="flex:1 1 320px; min-width:0;">
<p style="font-size:12.5px; margin:0 0 4px;"><strong>Cerere</strong></p>
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza exemplu cerere JSON" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ exemplu_json["cerere"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
<div style="flex:1 1 320px; min-width:0;">
<p style="font-size:12.5px; margin:0 0 4px;"><strong>Raspuns 200</strong> <span class="muted">(forma implicita <code>minim</code>)</span></p>
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza exemplu raspuns JSON" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ exemplu_json["raspuns"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
</div>
<ul class="muted" style="font-size:12px; margin:8px 0 0; padding-left:18px; line-height:1.6;">
<li>Obligatorii: <code>vin</code>, <code>nr_inmatriculare</code>, <code>data_prestatie</code> (YYYY-MM-DD), <code>odometru_final</code> (string), <code>prestatii</code> (min. 1).</li>
<li>Fiecare prestatie are <code>cod_prestatie</code> (cod RAR din <a href="/?tab=nomenclator">nomenclator</a>) SAU <code>cod_op_service</code> + <code>denumire</code> — gateway-ul le mapeaza la cod RAR.</li>
<li>Optionale: <code>odometru_initial</code>, <code>obs</code>; <code>rar_env</code> (<code>"test"</code> / <code>"prod"</code>) — absent = mediul implicit al contului.</li>
<li>Un rezultat per prezentare, in aceeasi ordine (<code>index</code> = pozitia din cerere). <code>status</code>: <code>queued</code> (in coada), <code>needs_mapping</code> / <code>needs_data</code> / <code>error</code> (blocat — vezi <code>motiv</code>).</li>
<li><code>id_prezentare</code> e completat doar cand randul era DEJA trimis la RAR (duplicat detectat); altfel apare ulterior, in dashboard sau la <code>GET /v1/prezentari/{id}</code>.</li>
<li>Vrei detalii complete (erori pe camp, coduri nemapate, <code>held</code>)? Trimite <code>"raspuns": "complet"</code> in body.</li>
</ul>
</div>
{# Card: De ce nu ajunge la RAR? (onestitate canal API) #}
{# Un POST /v1/prezentari poate intoarce 200 dar randul sa NU plece la RAR. #}
<div class="card" style="margin-bottom:16px;">
<h3 style="margin:0 0 6px; font-size:15px;">De ce nu ajunge la RAR?</h3>
<p class="muted" style="font-size:12px; margin:0 0 12px;">
Un <code>POST /v1/prezentari</code> poate intoarce <strong>200</strong> cu
<code>status: "queued"</code>, dar randul sa NU plece automat la RAR. Cele trei motive:
</p>
<div class="banner warn" role="note" style="margin:0 0 12px; font-size:12.5px;">
<strong>Conturile noi pornesc cu Trimiterea automata OPRITA (Auto OFF).</strong>
Primul <code>POST</code> da 200/<code>queued</code>, dar randul e tinut
(<code>held=true</code>) si asteapta eliberare manuala. Verifica bifa
<em>„Trimite automat la RAR"</em> din dashboard.
</div>
<div class="tablewrap">
<table>
<thead><tr>
<th>Motiv</th>
<th>Ce inseamna</th>
<th>Cum deblochezi</th>
</tr></thead>
<tbody>
<tr>
<td><span class="pill">held</span> <span class="muted">(Auto OFF)</span></td>
<td>Contul are Trimiterea automata OPRITA &rarr; randul e tinut
(<code>held=true</code>), NU pleaca la RAR.</td>
<td>Elibereaza manual din dashboard (butonul <em>Trimite</em> / <em>Trimite toate</em>)
sau activeaza Trimiterea automata. Verifici cu
<code>GET /v1/prezentari/{id}</code> (<code>held=true</code>).</td>
</tr>
<tr>
<td><span class="pill">needs_mapping</span></td>
<td>Cod operatie fara mapare la un cod RAR din nomenclator.</td>
<td>Intra in editorul de mapare din dashboard; la salvarea maparii randul
se re-rezolva automat.</td>
</tr>
<tr>
<td><span class="pill">needs_data</span></td>
<td>Validare de continut esuata (VIN / data prestatie / odometru).</td>
<td>Corectezi datele si repui prezentarea.</td>
</tr>
</tbody>
</table>
</div>
</div>
{# Card: Export & referinta #}
<div class="card" style="margin-bottom:16px;">
<h3 style="margin:0 0 12px; font-size:15px;">Export &amp; referinta</h3>
@@ -246,7 +327,7 @@
<div id="integrare-test-rezultat" style="margin-top:8px;"></div>
</div>
{# Formular test credentiale RAR (US-007, PRD 5.20) #}
{# Formular test credentiale RAR #}
{# Login de proba pe mediul ales — fara efecte secundare, nu salveaza nimic. #}
{# Banner-ul de rezultat include eticheta mediului ("pe TESTARE" / "pe PRODUCTIE"). #}
<div class="card" style="margin-bottom:16px;">

View File

@@ -10,7 +10,7 @@
{% else %}
<span class="muted" style="font-size:12px;">doar evenimentele contului tau</span>
{% endif %}
{# Badge mediu RAR activ (US-010 PRD 5.20) — mediul implicit al contului #}
{# Badge mediu RAR activ — mediul implicit al contului #}
{% if env_default | default('') %}
{% set _eb = eticheta_env(env_default) %}
<span class="{{ _eb[1] }}" style="margin-left:auto;" title="Mediu RAR activ">{{ _eb[0] }}</span>

View File

@@ -0,0 +1,8 @@
{# Footer comun landing + pagini statice publice. #}
<div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;">
<div style="font:700 18px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div>
<div style="display:flex;gap:26px;font:400 14px var(--font-ui);color:var(--sub,#8b93a7);flex-wrap:wrap;">
<a href="/termeni" style="color:inherit;">Termeni</a><a href="/confidentialitate" style="color:inherit;">Confidențialitate / GDPR</a><a href="/documentatie-api" style="color:inherit;">Documentație API</a><a href="https://romfast.ro/menu/contact.html" target="_blank" rel="noopener" style="color:inherit;">Contact</a>
</div>
<div style="font:400 13px var(--font-ui);color:var(--mut,#5c6473);">© 2026 ROMFAST</div>
</div>

View File

@@ -0,0 +1,27 @@
{# Antet comun landing + pagini statice. `lp_root` = '' pe landing, '/' pe paginile statice
(ancorele si butoanele de auth duc inapoi pe landing). #}
{% set lp_root = lp_root | default('') %}
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
<div style="display:flex;align-items:center;gap:14px;">
<a href="/" style="display:flex;align-items:center;gap:14px;color:inherit;">
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
<div style="display:flex;flex-direction:column;line-height:1.05;">
<span style="font:700 17px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);">ROA AUTOPASS</span>
<span style="font:500 11px var(--font-ui);letter-spacing:.04em;color:var(--sub,#8b93a7);">Gateway RAR</span>
</div>
</a>
</div>
<div style="display:flex;align-items:center;gap:28px;">
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
<a href="{{ lp_root }}#cum-functioneaza" style="color:inherit;text-decoration:none;">Cum funcționează</a><a href="{{ lp_root }}#api" style="color:inherit;text-decoration:none;">API</a><a href="{{ lp_root }}#pret" style="color:inherit;text-decoration:none;">Preț</a>
</div>
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
<button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px var(--font-ui);cursor:pointer;">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
<span id="theme-label">Grafit</span>
</button>
<a href="/login" class="auth-login-link" style="display:inline-flex;align-items:center;height:44px;padding:0 18px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;text-decoration:none;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Autentificare</a>
<button data-act="auth" data-tab="register" style="height:44px;padding:0 18px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,51 @@
{# Stiluri comune landing + pagini statice publice (termeni, confidentialitate, contact, documentatie API). #}
<style>
/* IBM Plex eliminat complet — stive font sistem standard web.
Tokenurile --font-ui / --font-mono definite in :root (sursa unica de adevar). */
:root{--font-ui:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--font-mono:ui-monospace,"SF Mono","Cascadia Code","Segoe UI Mono","Roboto Mono",Menlo,Consolas,monospace;}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{font-family:var(--font-ui);-webkit-font-smoothing:antialiased;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);}
body[data-theme="grafit"]{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--hbg:rgba(15,18,24,.88);--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--mut:#5c6473}
body[data-theme="cobalt"]{--bg:#080d1c;--card:#111a33;--card2:#0b1226;--text:#e9ecfb;--sub:#8a93b8;--line:#1d2747;--line2:#161f3a;--accent:#4068FF;--hbg:rgba(8,13,28,.9);--okt:#2fd0a6;--infot:#8aa0ff;--errt:#f06a7a;--mut:#5a6390}
body[data-theme="cupru"]{--bg:#15110b;--card:#211a12;--card2:#15110b;--text:#efe6d6;--sub:#a89a85;--line:#36291c;--line2:#281e14;--accent:#D98A3D;--hbg:rgba(21,17,11,.9);--okt:#67b98c;--infot:#dfa45c;--errt:#e2685a;--mut:#6d5f4c}
body[data-theme="hartie"]{--bg:#f3efe6;--card:#fffdf7;--card2:#f3efe6;--text:#1e1a13;--sub:#6a6052;--line:#e2dccc;--line2:#ece6d9;--accent:#1F5FBF;--hbg:rgba(255,253,247,.92);--okt:#1c7d5d;--infot:#1F5FBF;--errt:#bd463c;--mut:#9a8f7d}
/* Teme aliniate cu aplicatia (base.html) — acelasi set de 8; 'auto' se rezolva la light/dark. */
body[data-theme="light"]{--bg:#f5f7fa;--card:#ffffff;--card2:#f5f7fa;--text:#1a1d24;--sub:#5c6473;--line:#e2e5ea;--line2:#eaedf2;--accent:#1F66C9;--hbg:rgba(245,247,250,.9);--okt:#15803d;--infot:#1F66C9;--errt:#dc2626;--mut:#8a92a0}
body[data-theme="dark"]{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--hbg:rgba(15,18,24,.88);--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--mut:#5c6473}
body[data-theme="petrol"]{--bg:#0e1416;--card:#161e20;--card2:#0e1416;--text:#e6e9ef;--sub:#8b93a7;--line:#232c2e;--line2:#1c2426;--accent:#0E7C7B;--hbg:rgba(14,20,22,.9);--okt:#2FBF8F;--infot:#3fb9b6;--errt:#E05D5D;--mut:#5c6473}
.page{width:100%;max-width:1280px;margin:0 auto;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);overflow:hidden;}
a{text-decoration:none;}
input[type=range]{-webkit-appearance:none;appearance:none;background:transparent;}
input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:99px;background:var(--line,#262b36);}
input[type=range]::-moz-range-track{height:6px;border-radius:99px;background:var(--line,#262b36);}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;margin-top:-6px;border-radius:99px;background:var(--accent,#2E74D6);cursor:pointer;border:none;}
input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:99px;background:var(--accent,#2E74D6);cursor:pointer;border:none;}
@media (max-width:900px){
.lp-nav{display:none!important;}
.lp-header{padding:0 18px!important;}
.lp-h1{font-size:32px!important;line-height:1.1!important;}
.page [style*="grid-template-columns"]{grid-template-columns:1fr!important;}
.page [style*="padding:80px 40px"]{padding:48px 20px!important;}
.page [style*="padding:0 40px 80px"]{padding:0 20px 48px!important;}
.page [style*="padding:56px 40px 80px"]{padding:36px 20px 48px!important;}
.page [style*="padding:44px"]{padding:28px!important;}
.page [style*="padding:56px 40px"]{padding:40px 22px!important;}
.page [style*="height:68px"]{height:60px!important;}
.page [style*="gap:56px"]{gap:32px!important;}
.page [style*="gap:48px"]{gap:28px!important;}
}
@media (max-width:560px){
.lp-h1{font-size:27px!important;}
.page [style*="padding:10px 40px"]{padding:10px 18px!important;}
.lp-header{padding:0 12px!important;}
#theme-label{display:none!important;}
.lp-hactions{gap:8px!important;}
.lp-hactions button{height:38px!important;padding:0 11px!important;font-size:13px!important;}
}
@media (max-width:430px){
.lp-hactions a.auth-login-link{display:none!important;}
}
</style>

View File

@@ -1,12 +1,12 @@
{# Macro-uri partajate intre template-urile de import si mapari. #}
{# US-002 (PRD 5.11): autosend_toggle neutralizat — auto_send nu mai tine randuri (US-001).
{# autosend_toggle neutralizat — auto_send nu mai tine randuri.
Simbolul pastrat (apelat in _mapari.html, _preview_import.html, _trimitere_detaliu.html)
dar intoarce string gol. Coloanele DB raman (default 1, ne-citite pentru hold). #}
{% macro autosend_toggle(form_id='', checked=True, label='') -%}{%- endmacro %}
{# US-005 (PRD 5.12): macro `camp` partajat — extras din _trimitere_detaliu.html si
_preview_rand.html. Suporta tip='date' (calendar nativ, D#10/R3) si tip='text' (default).
{# macro `camp` partajat — extras din _trimitere_detaliu.html si
_preview_rand.html. Suporta tip='date' (calendar nativ) si tip='text' (default).
Parametri:
nome — name="" al input-ului (si cheie in err_map/fix_map)
@@ -19,14 +19,14 @@
id_prefix — prefix pentru id="" al input-ului (default 'c'; preview poate folosi 'e-N')
#}
{% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c', slim=False, mono=False) %}
{# slim=False: randare clasica (neschimbata). slim=True: varianta compacta (.camp-slim) din US-002 PRD 5.15:
{# slim=False: randare clasica (neschimbata). slim=True: varianta compacta (.camp-slim):
label 11px muted deasupra, input ~30px, fundal --card2.
mono=True (valid numai cu slim=True): adauga clasa 'camp-mono' pe input pentru campuri
VIN/odometru/nr (IBM Plex Mono, prin .camp-slim .camp-mono din base.html). #}
<div {% if slim %}class="camp-slim"{% else %}style="margin-bottom:10px;"{% endif %}>
<label for="{{ id_prefix }}-{{ nome }}"{% if not slim %} class="muted" style="font-size:12px; display:block;"{% endif %}>{{ eticheta }}</label>
{% if tip == 'date' %}
{# D#10/R3: degradare grijulie pentru valori ne-YYYY-MM-DD.
{# Degradare grijulie pentru valori ne-YYYY-MM-DD.
Daca valoarea nu e in formatul corect, inputul ramane gol + hint + hidden cu valoarea bruta
(ca sa nu se piarda tacut la submit). #}
{%- set _dp_ok = (valoare and valoare|length == 10 and valoare[4:5] == '-' and valoare[7:8] == '-') -%}
@@ -57,7 +57,7 @@
</div>
{% endmacro %}
{# PRD 5.13 — sistem butoane de actiune responsive.
{# Sistem butoane de actiune responsive.
CSS-ul aferent (.act, .act-tx, .act-ic, .act-save, .act-del, .act-group)
este definit in base.html.
Desktop: se afiseaza textul (.act-tx); mobil: se afiseaza iconita (.act-ic). #}

View File

@@ -54,7 +54,7 @@
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
</td>
<td class="muted" style="font-size:12px;" data-eticheta="Sugestii">
{# 5.18 US-007: badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
{# Badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
{% if e.sugestie_principala %}
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
@@ -62,10 +62,17 @@
{% else %}
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
{% endif %}
{# Codul sugerat de sistem, explicit (nu doar preselectat in dropdown);
pentru k-NN si scorul de similaritate. #}
<strong>{{ e.sugestie_principala.cod_prestatie }}</strong>
{%- if e.sugestie_principala.sursa == 'embedding' and e.surse_sugestie.embedding_similaritate %}
({{ (e.surse_sugestie.embedding_similaritate * 100)|round|int }}%)
{%- endif %}
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
<span class="sugg-sursa sugg-sursa--nul" title="pare non-operatie (ITP/plata/discount...)">non-operatie</span>
{% endif %}
{% if e.suggestions %}
{% if e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul) %}<span class="muted">· potrivire text:</span>{% endif %}
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
@@ -75,6 +82,7 @@
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option>
<option value="__NEDECLARAT__">Nu se declara la RAR (exclude operatia)</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
@@ -122,7 +130,7 @@
<tbody>
{% for m in saved_mappings %}
{# data-dt-row = haystack de cautare (randul contine un <select> cu tot nomenclatorul). #}
<tr data-dt-row="{{ m.cod_op_service }} {{ m.cod_prestatie }} {{ m.nume_prestatie or '' }}">
<tr data-dt-row="{{ m.cod_op_service }} {{ m.cod_prestatie }} {{ m.nume_prestatie or '' }}{% if m.exclus %} nedeclarat exclus{% endif %}">
<td data-eticheta="Operatie">
<form id="map-salv-{{ loop.index }}" hx-post="/mapari/salvate" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
@@ -135,14 +143,16 @@
</form>
<div><strong>{{ m.cod_op_service }}</strong></div>
<div class="muted map-acum" style="font-size:12px;">
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
{% if m.exclus %}acum: <span class="pill s-excluded" style="font-size:11px;">Nu se declara la RAR</span>
{% else %}acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}{% endif %}
</div>
</td>
<td data-eticheta="Cod RAR">
<select name="cod_prestatie" form="map-salv-{{ loop.index }}" required
aria-label="Cod RAR pentru {{ m.cod_op_service }}">
<option value="__NEDECLARAT__" {% if m.exclus %}selected{% endif %}>Nu se declara la RAR (exclude operatia)</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
<option value="{{ n.cod_prestatie }}" {% if not m.exclus and n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
@@ -171,18 +181,19 @@
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2>
<p class="muted" style="margin:0 0 12px; font-size:13px; max-width:680px;">
O regula leaga orice operatie al carei text <strong>contine</strong> (nu egal, ci substring)
un cuvant de un cod RAR. Util pentru operatii fara cod intern: ex. orice operatie care
<em>contine</em> „verificare" primeste codul ales. Match insensibil la majuscule/diacritice.
</p>
{% if not text_rules %}
<div class="empty" style="margin-bottom:12px;">
Inca nu ai reguli. Ex: operatia contine «verificare» &rarr; OE-2.
Mapeaza automat operatii similare fara cod intern. Adauga prima regula mai jos.
{# Un exemplu concret in loc de descriere — arata direct formatul unei reguli. #}
<div class="regula-exemplu"
style="margin:0 0 12px; font-size:13.5px; max-width:680px; padding:10px 12px;
border:1px dashed var(--line); border-radius:8px; background:var(--card2);">
Daca operatia contine
<span style="font-family:var(--font-mono); font-size:12.5px; padding:1px 6px; border-radius:4px;
background:color-mix(in srgb, var(--warn) 18%, transparent); color:var(--warn);">verificare</span>
<span class="muted" aria-hidden="true">&rarr;</span>
Cod RAR =
<span style="font-family:var(--font-mono); font-size:12.5px; font-weight:700; padding:1px 6px; border-radius:4px;
background:color-mix(in srgb, var(--accent) 16%, transparent); color:var(--accent);">OE-2</span>
Intretinere si revizie
</div>
{% endif %}
<div class="tablewrap tabel-card">
<table>

View File

@@ -65,7 +65,8 @@
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
hx-target="#import-section"
hx-swap="outerHTML">
hx-swap="outerHTML"
hx-indicator="#mapcol-spinner">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
@@ -82,7 +83,7 @@
</span>
</div>
{# Tabel mapare: coloana din fisier | exemplu | camp RAR (mockup 5.16 / US-013) #}
{# Tabel mapare: coloana din fisier | exemplu | camp RAR #}
<div class="tablewrap" style="margin-bottom:16px;">
<table style="border-collapse:collapse; width:100%;">
<thead>
@@ -162,6 +163,11 @@
style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}">
Salveaza si continua la preview
</button>
<span id="mapcol-spinner" class="htmx-indicator muted"
style="font-size:var(--fs-sm);" role="status">
<span class="spin-dot" aria-hidden="true"></span>
se verifica randurile si se calculeaza sugestiile...
</span>
{% if not prima_inreg %}
<span style="font-size:var(--fs-xs); color:var(--err);">
Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare.

View File

@@ -0,0 +1,67 @@
{#
_modal_confirma_trimitere.html — modal REAL de confirmare tipata.
`hx-confirm` nativ (doar OK/Cancel) NU e suficient pentru o actiune ireversibila care
trimite N prezentari catre RAR. Acest modal arata numarul + mediul tinta (Testare /
PRODUCTIE) si — pentru PRODUCTIE — cere o confirmare tipata ("TRIMIT") inainte de a
activa butonul. Reutilizat din:
- _auto_send_toggle.html (auto-release OFF->ON), injectat OOB in #modal-confirma-slot;
- _coada.html (bulk "Trimite toate (N)"), randat inline ascuns si aratat la click.
Parametri (context):
cf_id, cf_titlu, cf_count, cf_env ('prod'/'test'), cf_url, cf_vals (json string),
cf_target, cf_swap, cf_confirm_label, cf_deschis (bool), csrf_token.
#}
{% set _prod = (cf_env == 'prod') %}
{% set _eb = eticheta_env(cf_env) %}
<div id="{{ cf_id }}" class="modal-overlay" role="dialog" aria-modal="true"
aria-labelledby="{{ cf_id }}-titlu"{% if not cf_deschis %} hidden{% endif %}>
<div class="modal-backdrop"
onclick="this.closest('.modal-overlay').hidden=true"></div>
<div class="modal-dialog" role="document" style="max-width:440px;">
<button type="button" class="modal-close" aria-label="Anuleaza"
onclick="this.closest('.modal-overlay').hidden=true">&times;</button>
<h2 id="{{ cf_id }}-titlu" style="margin:0 0 10px; font-size:var(--fs-lg);">{{ cf_titlu }}</h2>
<p style="margin:0 0 8px; font-size:var(--fs-sm);">
Se trimit imediat <strong>{{ cf_count }}</strong>
{{ 'prezentare' if cf_count == 1 else 'prezentari' }} catre
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>.
</p>
<p style="margin:0 0 12px; font-size:var(--fs-sm); color:var(--warn); font-weight:600;">
Declararea la RAR este ireversibila (FINALIZATA nu se poate anula).
</p>
{% if _prod %}
{# Type-to-confirm DOAR pe PRODUCTIE: butonul ramane dezactivat pana se scrie TRIMIT. #}
<label for="{{ cf_id }}-typ" style="display:block; font-size:var(--fs-xs); color:var(--muted); margin-bottom:4px;">
Scrie <strong>TRIMIT</strong> pentru a confirma:
</label>
<input type="text" id="{{ cf_id }}-typ" autocomplete="off" autocapitalize="characters"
style="width:100%; margin-bottom:12px; font-size:14px; padding:5px 8px;
border:1px solid var(--line); border-radius:5px; background:var(--card2); color:var(--ink);"
oninput="document.getElementById('{{ cf_id }}-ok').disabled = (this.value.trim().toUpperCase() !== 'TRIMIT');">
{% endif %}
<div style="display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap;">
<button type="button"
onclick="this.closest('.modal-overlay').hidden=true"
style="background:var(--card); color:var(--muted); border:1px solid var(--line);
padding:6px 14px; border-radius:6px; cursor:pointer; font-size:13px;">
Anuleaza
</button>
<button type="button" id="{{ cf_id }}-ok"{% if _prod %} disabled{% endif %}
hx-post="{{ cf_url }}"
hx-vals='{{ cf_vals }}'
hx-target="{{ cf_target }}"
hx-swap="{{ cf_swap }}"
hx-include="#{{ cf_id }}-csrf"
onclick="this.closest('.modal-overlay').hidden=true"
style="background:{% if _prod %}var(--err){% else %}var(--accent){% endif %};
color:#fff; border:none; padding:6px 14px; border-radius:6px;
cursor:pointer; font-size:13px; font-weight:600;">
{{ cf_confirm_label }}
</button>
<input type="hidden" id="{{ cf_id }}-csrf" name="csrf_token" value="{{ csrf_token }}">
</div>
</div>
</div>

View File

@@ -14,7 +14,7 @@
Preview —
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
</h2>
{# Badge mediu RAR (US-009): vizibil intotdeauna in preview (claritate tinta) #}
{# Badge mediu RAR: vizibil intotdeauna in preview (claritate tinta) #}
{% if rar_env %}
<span class="pill" style="font-size:var(--fs-xs);
{% if rar_env == 'prod' %}background:color-mix(in srgb,#B4452F 15%,var(--card)); border-color:#B4452F; color:#B4452F; font-weight:700;
@@ -40,6 +40,7 @@
('needs_data', 'Date incomplete'),
('already_sent', 'Deja trimis'),
('duplicate_in_file','Duplicat in fisier'),
('excluded', 'Nedeclarat'),
] %}
<div id="preview-rezumat" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
{% for status_key, label in status_labels %}
@@ -71,7 +72,7 @@
</div>
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload).
US-004: un singur <form> cu un select per operatie + un singur buton Salveaza. -->
Un singur <form> cu un select per operatie + un singur buton Salveaza. -->
{% if unmapped_ops %}
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
@@ -81,7 +82,8 @@
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
</p>
<form hx-post="/_import/{{ import_id }}/mapare-operatii"
hx-target="#import-section" hx-swap="outerHTML">
hx-target="#import-section" hx-swap="outerHTML"
hx-indicator="#mapop-spinner">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
{% for e in unmapped_ops %}
{%- set top = e.suggestions[0] if e.suggestions else None -%}
@@ -98,7 +100,7 @@
{% if e.suggestions or e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul) %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{# 5.18 US-007: badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
{# Badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
{% if e.sugestie_principala %}
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
@@ -106,9 +108,16 @@
{% else %}
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
{% endif %}
{# Codul sugerat de sistem, explicit (nu doar preselectat in dropdown);
pentru k-NN si scorul de similaritate. #}
<strong>{{ e.sugestie_principala.cod_prestatie }}</strong>
{%- if e.sugestie_principala.sursa == 'embedding' and e.surse_sugestie.embedding_similaritate %}
({{ (e.surse_sugestie.embedding_similaritate * 100)|round|int }}%)
{%- endif %}
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
<span class="sugg-sursa sugg-sursa--nul" title="pare non-operatie (ITP/plata/discount...)">non-operatie</span>
{% endif %}
{% if e.suggestions and (e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul)) %}<span class="muted">· potrivire text:</span>{% endif %}
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
@@ -118,6 +127,7 @@
<div class="mapcol">
<select name="cod_prestatie" aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option>
<option value="__NEDECLARAT__">Nu se declara la RAR (exclude operatia)</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
@@ -127,14 +137,19 @@
</div>
</div>
{% endfor %}
<div style="margin-top:12px;">
<div style="margin-top:12px; display:flex; align-items:center; gap:12px;">
<button type="submit" style="min-height:44px;">Salveaza maparile</button>
<span id="mapop-spinner" class="htmx-indicator muted"
style="font-size:var(--fs-sm);" role="status">
<span class="spin-dot" aria-hidden="true"></span>
se salveaza si se recalculeaza preview-ul...
</span>
</div>
</form>
</div>
{% endif %}
<!-- Banner discoverability (T1, US-007): vizibil cand exista randuri needs_review.
<!-- Banner discoverability: vizibil cand exista randuri needs_review.
Explica operatorului ca randurile cu 'Verifica valori' nu pleaca la RAR
pana le deschide in modal si apasa 'Confirma valorile'. Dispare via OOB
cand summary.needs_review == 0. -->
@@ -213,7 +228,7 @@
hx-target="#import-section"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
{# Mediu RAR ales la upload — propagar la commit (US-009) #}
{# Mediu RAR ales la upload — propagar la commit #}
<input type="hidden" name="rar_env" value="{{ rar_env or '' }}">
<div class="sticky-bar">
<div style="flex:1; min-width:280px;">
@@ -285,7 +300,7 @@ din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
}
/* Actualizeaza N dupa editare/confirmare rand (OOB).
US-007: reviewed_rows (checkboxe) eliminate; N = randurile ok din DB,
reviewed_rows (checkboxe) eliminate; N = randurile ok din DB,
actualizate via OOB (#preview-ok-count[data-ok]) dupa /confirma-review sau /editeaza. */
function updateN() {
var total = getOk();

View File

@@ -1,7 +1,7 @@
{#
_preview_rand.html — un singur rand de preview import.
US-006 (PRD 5.12): editarea inline (tr.preview-edit + mutual-exclusion script)
a fost eliminata. Butonul Editeaza deschide MODALUL global (#detaliu-modal-body).
Editarea inline (tr.preview-edit + mutual-exclusion script) a fost eliminata.
Butonul Editeaza deschide MODALUL global (#detaliu-modal-body).
Parametri:
editing — ELIMINAT (ignorat, pastrat pentru compatibilitate apeluri vechi)
@@ -39,7 +39,7 @@
<span class="pill {{ row.stare_css }}" style="display:inline-flex; align-items:center; gap:5px;"
{% if _nota %}title="{{ _nota }}"{% endif %}>
<span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ row.stare_eticheta }}</span>
{# Badge mediu RAR (US-010 PRD 5.20) — rar_env disponibil din contextul batch-ului. #}
{# Badge mediu RAR — rar_env disponibil din contextul batch-ului. #}
{% if rar_env | default('') %}
{% set _eb = eticheta_env(rar_env) %}
<div style="margin-top:3px;"><span class="{{ _eb[1] }}">{{ _eb[0] }}</span></div>

View File

@@ -14,10 +14,36 @@
</div>
{% endif %}
{# US-006 (5.17) — Banner one-time trial->Gratuit (T-DES-1): afisat la prima incarcare
{# Banner coada tinuta imbatranita — mitigare pt. default OFF.
Cand exista randuri `queued AND held=1` mai vechi de held_aged_days, semnalul de conformitate
L.142 (declarare obligatorie). Derivat (zero stare noua), varsta pe created_at.
Prioritate bannere (un-singur-banner "notice", ca sa nu impinga contoarele sub fold pe
mobil): cont inactiv (sus) + strip RAR jos raman separate; intre notice-uri, held-aged >
trial (risc legal > notita trial) -> trial e suprimat cand held-aged e activ. #}
{% set _held_aged_activ = (held_aged_count|default(0) > 0) and account_active %}
{% if _held_aged_activ %}
<div id="banner-held-aged" role="alert"
style="margin-bottom:10px; padding:8px 12px; border-left:3px solid var(--warn);
background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px;
font-size:var(--fs-sm); display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<span><strong>{{ held_aged_count }}</strong>
{{ 'prezentare tinuta' if held_aged_count == 1 else 'prezentari tinute' }}
de peste {{ held_aged_days }} zile — declarare obligatorie (L.142).</span>
<button type="button"
hx-get="/_fragments/submissions?held=1"
hx-target="#submissions-wrap" hx-swap="innerHTML"
style="margin-left:auto; padding:2px 10px; font-size:var(--fs-xs); border-radius:99px;
border:1px solid var(--warn); background:transparent; color:var(--warn);
cursor:pointer; font-weight:600;">
Vezi tinute
</button>
</div>
{% endif %}
{# Banner one-time trial->Gratuit: afisat la prima incarcare
dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage.
Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #}
{% if trial_expirat_recent|default(false) %}
{% if trial_expirat_recent|default(false) and not _held_aged_activ %}
<div id="banner-trial-expirat"
role="status"
style="margin-bottom:10px; padding:7px 12px;
@@ -34,7 +60,7 @@
<script>(function(){ if(sessionStorage.getItem('tfx')){ var el=document.getElementById('banner-trial-expirat'); if(el) el.style.display='none'; } })();</script>
{% endif %}
{# === US-003 (PRD 5.16): Banda de stare RAR — NUMAI cand BLOCAT (rosu, lat de 100%).
{# === Banda de stare RAR — NUMAI cand BLOCAT (rosu, lat de 100%).
OK = dot verde in antet (base.html); banda nu mai apare cand totul e ok.
Elementul id="strip-sanatate" ramane in DOM mereu, dar goleste continutul cand OK,
astfel "hidden" + fara continut eroare in sursa = nu pica testele de prezenta id-ului.
@@ -58,7 +84,7 @@
</div>
{% endif %}
{# === US-002 (PRD 5.16): 5 carduri-contor separate (desktop) + bara compacta (mobil <=560px).
{# === 5 carduri-contor separate (desktop) + bara compacta (mobil <=560px).
Total / Luna asta / Azi / In coada / De corectat.
#}
{# Desktop: 5 carduri side-by-side. display:flex + layout stau in CSS (.contoare-desktop in
@@ -83,10 +109,17 @@
<div class="contor-label">Azi</div>
</div>
{# In coada #}
{# In coada — contorul "In asteptare (manual)" se pliaza aici: nu adaugam a 6-a
celula, ci o sub-linie amber cand exista randuri tinute (queued AND held=1). #}
<div class="contor-card" style="flex:1; min-width:80px;">
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
<div class="contor-label">In coada</div>
{% if held_manual_count|default(0) %}
<div class="contor-sub" style="font-size:var(--fs-xs); color:var(--warn); font-weight:600; margin-top:2px;"
title="Prezentari tinute manual — asteapta eliberarea ta">
{{ held_manual_count }} manual
</div>
{% endif %}
</div>
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
@@ -115,7 +148,7 @@
</div>
<div class="compact-item">
<div class="compact-nr s-queued">{{ counts_queued }}</div>
<div class="compact-lbl">Coada</div>
<div class="compact-lbl">Coada{% if held_manual_count|default(0) %} <span style="color:var(--warn); font-weight:700;">·{{ held_manual_count }}m</span>{% endif %}</div>
</div>
<a class="compact-item" href="/" style="text-decoration:none; color:inherit;">
<div class="compact-nr {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
@@ -123,45 +156,8 @@
</a>
</div>
{# === US-011 (5.20): Indicator mediu RAR activ per cont.
La 0 medii: nu afisa (cont fara configuratie RAR).
La 1 mediu: eticheta statica (fara toggle).
La 2 medii: buton de comutare HTMX (toggle conditionat).
US-010: culori prin eticheta_env/clase .env-badge-* (consistenta cu badge-urile din liste).
#}
{% set _medii = medii_disponibile | default([]) %}
{% set _env = env_default | default('prod') %}
{% set _eb = eticheta_env(_env) %}
{% if _medii | length >= 1 %}
<div class="env-indicator"
style="display:flex; align-items:center; gap:8px; margin-bottom:10px;
padding:6px 10px; border-radius:6px; font-size:var(--fs-sm);
background:color-mix(in srgb, {% if _env == 'prod' %}var(--err){% else %}var(--line){% endif %} 10%, var(--card));
border:1px solid color-mix(in srgb, {% if _env == 'prod' %}var(--err){% else %}var(--line){% endif %} 30%, transparent);">
<span style="font-weight:600;">Mediu RAR:</span>
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>
{% if _medii | length >= 2 %}
{# Toggle: apare DOAR cand sunt cel putin 2 medii disponibile #}
<form method="post" action="/_fragments/status/toggle-env"
hx-post="/_fragments/status/toggle-env"
hx-target="#status-bar"
hx-swap="outerHTML"
style="margin:0; padding:0; display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token | default('') }}">
<button type="submit"
style="padding:2px 10px; font-size:var(--fs-xs); border-radius:99px;
border:1px solid currentColor; background:transparent; cursor:pointer;
color:var(--muted); line-height:1.4;"
title="Comuta la {{ 'PRODUCȚIE' if _env == 'test' else 'Testare' }}">
Comuta
</button>
</form>
{% endif %}
</div>
{% endif %}
{# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping ===
Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ.
Pastrata exact ca inainte: tab_activ determina marcajul activ.
#}
{% set _tab = tab_activ | default('acasa') %}
<nav class="status-nav" aria-label="Navigatie rapida"
@@ -174,7 +170,7 @@
class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a>
</nav>
{# US-006 (5.17) + T-6 (5.16): linia de plan in CORP apare DOAR in starea de avertizare
{# Linia de plan in CORP apare DOAR in starea de avertizare
(>=80% -> --warn; limita atinsa -> --err). Consumul normal (N/60) traieste in badge-ul
din antet + linia din meniul burger, nu ca rand permanent in corp (densitate redusa).
Ierarhie: nu concureaza cu stripul de sanatate (zero-silent-failures pastrat). #}

View File

@@ -1,5 +1,5 @@
{#
_stepper.html — Antet wizard import COMPACT (PUR vizual). PRD 5.13.
_stepper.html — Antet wizard import COMPACT (PUR vizual).
Parametru: `pas` (integer 1-4). Clasele .stepper-* sunt definite in base.html.
>=1024px: bara slim orizontala (.stepper-track). <1024px: forma colapsata
"Pasul N din 4 - Titlu" + bara de progres (.stepper-collapsed).

View File

@@ -5,6 +5,13 @@
#}
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
{# OOB: sincronizeaza filtrul "doar tinute" (#f-held) cu starea randata acum, ca reincarcarile
(trimiteriChanged / paginare) sa-l pastreze in loc sa revina la lista completa. #}
<input type="hidden" id="f-held" name="held" value="{{ f_held | default('') }}" hx-swap-oob="true">
{# OOB: banner bulk "Trimite toate (N)" re-randat la fiecare reincarcare a listei (fix staleness). #}
{% with banner_oob = true %}{% include '_bulk_held_banner.html' %}{% endwith %}
{# OOB: re-randeaza pill-urile de stare (in bara de filtre, in afara #submissions-wrap) cu
contoarele si starea activa proaspete la fiecare reincarcare a tabelului. #}
<span hx-swap-oob="innerHTML:#pills-categorii">{% include '_pills.html' %}</span>
@@ -13,7 +20,7 @@
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
{% if bulk_message %}
{# Sumar actiune bulk (US-010 PRD 5.15): afisat dupa bulk-fix, disparut la urmatoarea reincarcare. #}
{# Sumar actiune bulk: afisat dupa bulk-fix, disparut la urmatoarea reincarcare. #}
<div class="bulk-message" role="status" aria-live="polite"
style="font-size:13px; color:var(--ink); background:var(--card2);
border:1px solid var(--line); border-radius:6px;
@@ -22,8 +29,25 @@
</div>
{% endif %}
{# Chip filtru activ "doar tinute" cu buton de golire — filtrul held e sticky
prin OOB, deci ii dam un off-switch explicit. #}
{% if f_held %}
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:var(--fs-sm);">
<span style="display:inline-flex; align-items:center; gap:6px; padding:2px 10px; border-radius:99px;
border:1px solid var(--warn); color:var(--warn); font-weight:600;">
Doar tinute manual
</span>
<button type="button"
onclick="var h=document.getElementById('f-held'); if(h){h.value='';} var f=document.getElementById('filtre-trimiteri'); if(f&&window.htmx){htmx.trigger(f,'submit');}"
style="background:none; border:none; color:var(--muted); cursor:pointer;
text-decoration:underline; font-size:var(--fs-sm);">
Arata tot
</button>
</div>
{% endif %}
{% if rows %}
{# Form bulk cu DOUA actiuni: (1) aplica cod RAR la selectate (bulk-fix, US-010),
{# Form bulk cu DOUA actiuni: (1) aplica cod RAR la selectate (bulk-fix),
(2) sterge selectate (sterge-bulk). Selectia opereaza DOAR pe randuri blocate
(gestionabil); sent/sending/queued nu au checkbox (read-only).
Butonul "Aplica cod" foloseste hx-post propriu (override form action).
@@ -36,45 +60,71 @@
hx-disinherit="hx-confirm"
style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="display:flex; justify-content:flex-end; align-items:center;
gap:6px; margin-bottom:8px; flex-wrap:wrap;">
{# Bulk-fix: input cod + buton aplica (US-010 PRD 5.15) #}
{# Bara contextuala bulk (5.22): randata inactiva (ascunsa prin CSS), afisata de
JS (base.html) cand >= 1 checkbox e bifat. Contoarele .bulk-n / .bulk-n-txt
sunt actualizate client-side. Endpoint-urile si CSRF raman neschimbate;
"Sterge" pastreaza hx-confirm-ul form-ului, "Aplica" e exclus (hx-disinherit). #}
<div id="bulk-bar" class="bulk-bar" role="toolbar" aria-label="Actiuni pe randurile selectate">
<span style="font-weight:700;"><span class="bulk-n">0</span> selectate</span>
<span style="color:var(--muted);" aria-hidden="true">·</span>
<input type="text" name="cod_prestatie" id="bulk-fix-cod"
placeholder="Cod RAR (ex: OE-1)"
autocomplete="off" autocapitalize="characters"
style="width:120px; font-size:12px; padding:3px 7px;
border:1px solid var(--line); border-radius:5px;
background:var(--card2); color:var(--ink);"
background:var(--card2); color:var(--ink); font-family:var(--font-mono);"
aria-label="Cod RAR de aplicat la randurile selectate">
<button type="button"
hx-post="/trimiteri/bulk-fix"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="background:var(--card); color:var(--accent); border-color:var(--accent);
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;"
style="background:var(--card); color:var(--accent); border:1px solid var(--accent);
font-size:12.5px; padding:4px 10px; border-radius:5px; cursor:pointer;"
aria-label="Aplica codul RAR la randurile blocate selectate">
Aplica cod
Aplica acest cod la <span class="bulk-n">0</span> <span class="bulk-n-txt">randuri</span>
</button>
{# Separator vizual #}
<span style="color:var(--muted); font-size:11px; padding:0 2px;" aria-hidden="true">|</span>
{# Bulk-delete: pastreaza exact comportamentul existent #}
<button type="submit" id="bulk-sterge-btn"
style="background:var(--card); color:var(--err); border-color:var(--err);
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;">
Sterge selectate
style="background:var(--card); color:var(--err); border:1px solid var(--err);
font-size:12.5px; padding:4px 10px; border-radius:5px; cursor:pointer;">
Sterge <span class="bulk-n">0</span> <span class="bulk-n-txt">randuri</span>
</button>
<button type="button" id="bulk-deselect"
style="margin-left:auto; background:none; border:none; color:var(--muted);
cursor:pointer; text-decoration:underline; font-size:12px;">
Deselecteaza
</button>
</div>
<div class="bulk-hint">
Aplica codul RAR doar operatiilor fara cod din randurile selectate, apoi le repune in coada.
</div>
{# Lista slim trimiteri (US-004, PRD 5.15).
Inlocuieste tabelul cu randuri compacte: VIN mono + operatie·ora + pill.
Nr. inmatriculare, data prestatie si nr. prezentare RAR raman accesibile
pe linia meta discreta (linia 3) si in modalul de detaliu. #}
{# Tabel compact trimiteri (grid CSS, PRD 5.22).
Desktop: un rand per trimitere sub un header de coloane; status = bulina cu
tooltip, mediu = litera T/P, multi-cod = primul cod + badge +N.
Mobil (max-width:767px): header ascuns, randul revine la 2 linii
(placuta / cod·operatie·data) cu litera + bulina in dreapta. #}
<ul class="lista-trimiteri-slim" role="list"
aria-label="Lista trimiteri">
{# Header de coloane — doar desktop (CSS); numele starii/mediului stau aici,
celulele raman semnale compacte (bulina, litera). #}
<li class="trimiteri-head" aria-hidden="true">
<span></span>
<span>#</span>
<span>Vehicul</span>
<span>Cod RAR</span>
<span>Operatie</span>
<span>Data</span>
<span class="th-c">Status</span>
<span class="th-c">Mediu</span>
<span></span>
</li>
{% for r in rows %}
{# Randul slim: stanga = VIN mono scurt (L1) + operatie·ora muted (L2) + meta (L3);
dreapta = pill de stare. Click deschide modalul global (#detaliu-modal-body).
Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
{# Rand clickabil (role=button, Enter/Space in JS din base.html) — deschide
modalul global de detaliu (#detaliu-modal-body). #}
<li id="trimitere-row-{{ r.id }}"
class="trimitere-slim"
data-detaliu-id="{{ r.id }}"
@@ -86,24 +136,21 @@
style="cursor:pointer;"
title="Click pentru detaliul complet">
{# Zona checkbox — nu declanseaza modalul (stopPropagation).
Vizibila DOAR pe randurile gestionabile (error/needs_data/needs_mapping).
Latimea fixa previne reflow la prezenta/absenta checkbox-ului. #}
<div style="flex:0 0 22px; display:flex; align-items:center;" onclick="event.stopPropagation();">
{# Checkbox — nu declanseaza modalul (stopPropagation). Vizibil DOAR pe
randurile gestionabile; latimea fixa previne reflow. #}
<div class="c-check" onclick="event.stopPropagation();">
{% if r.gestionabil %}
<input type="checkbox" name="submission_id" value="{{ r.id }}"
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
{% endif %}
</div>
{# Bloc text principal — stanga, ocupa spatiul ramas. Rand de 2 linii (spec 5.16):
L1 = placuta (identificator primar); L2 = cod RAR · operatie · data prestatie. #}
<div style="flex:1 1 auto; min-width:0;">
{# Numarul trimiterii (id din baza) — aceeasi valoare ca `submission_id`
din raspunsul API si ca titlul modalului de detaliu/editare. #}
<span class="c-id">#{{ r.id }}</span>
{# Linia 1: nr. inmatriculare (placuta) — identificatorul primar pe care il
scaneaza operatorul. .slim-vin reumplut (acelasi nume de clasa, churn minim).
Fallback cand placuta lipseste ('—'): VIN scurt, apoi mesaj neutru
(nu randa em-dash izolat ca identificator). #}
{# Vehicul: placuta (identificator primar); fallback VIN scurt / 'fara numar'
(niciodata em-dash izolat). #}
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
<div class="slim-vin">{{ r.prez.vehicul_nr }}</div>
{% elif r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
@@ -112,38 +159,66 @@
<div class="slim-vin muted">fara numar</div>
{% endif %}
{# Linia 2: cod RAR (sau 'nemapat') · operatie (ink, ellipsis) · data prestatie.
Separatorul "·" e injectat prin CSS intre celule. Operatia primeste ellipsis
ca randul sa NU treaca pe a 3-a linie nici la 390px.
VIN integral, #id_prezentare si secundele traiesc in modalul de detaliu. #}
<div class="slim-meta slim-rand2">
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
<span class="cod-rar-cod">{{ r.prez.cod_rar }}</span>
{# Pe desktop .c-meta e display:contents (copiii devin celule de grid);
pe mobil e linia 2 (flex) cu separatoare "·" din CSS. #}
<div class="c-meta">
{% set _cods = r.prez.coduri_rar or [] %}
<span class="c-cod">
{% if _cods %}
<span class="cod-rar-cod">{{ _cods[0] }}</span>
{%- if _cods | length > 1 -%}
<span class="cod-plus" title="{{ _cods | join(' · ') }}">+{{ _cods | length - 1 }}</span>
{%- endif %}
{% else %}
<span class="cod-rar-cod cod-rar-sub muted">nemapat</span>
{% endif %}
<span class="slim-op">{{ r.prez.operatie }}</span>
{% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %}
<span class="slim-data muted">{{ r.prez.data_prestatie }}</span>
{% endif %}
</span>
<span class="c-op">{{ r.prez.operatie }}</span>
<span class="c-data">{% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %}{{ r.prez.data_prestatie }}{% endif %}</span>
</div>
{# Micro-linie umana a problemei — text mic s-error, DOAR pe stari de problema
(loud-on-exception D6). Randul normal/finalizat ramane strict 2 linii.
Token tipografic --fs-xs (>=12px, scala 5.16). #}
{% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
{% endif %}
</div>
{# Status: doar bulina colorata; numele complet in title + aria-label. #}
<span class="c-status">
<span class="dot-stare {{ r.stare_css }}" title="{{ r.stare_text }}"
role="img" aria-label="{{ r.stare_text }}"></span>
</span>
{# Zona dreapta: pill stare + badge mediu RAR (US-010 PRD 5.20) #}
<div style="flex:0 0 auto; display:flex; flex-direction:column; align-items:flex-end; gap:3px; white-space:nowrap;">
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
{# Mediu: litera compacta — P fill atentie (productie, risc L.142),
T contur discret (testare). Numele complet in title. #}
<span class="c-env">
{% if r.rar_env %}
{% set _eb = eticheta_env(r.rar_env) %}
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>
<span class="env-l {{ 'env-l-prod' if 'prod' in _eb[1] else 'env-l-test' }}"
title="{{ _eb[0] }}" role="img" aria-label="Mediu RAR: {{ _eb[0] }}">{{ _eb[0][0] }}</span>
{% endif %}
</span>
{# Actiuni: buton "Trimite" DOAR pe randurile tinute (queued AND held=1). #}
<div class="c-act">
{% if r.tinut %}
<form hx-post="/trimitere/{{ r.id }}/trimite-acum"
hx-target="#submissions-wrap" hx-swap="innerHTML"
hx-confirm="Trimit aceasta prezentare la RAR? Declararea este ireversibila (FINALIZATA)."
onclick="event.stopPropagation();"
onkeydown="event.stopPropagation();"
style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-trimite-rand"
onclick="event.stopPropagation();"
style="padding:2px 10px; font-size:var(--fs-xs);
border:1px solid var(--accent); border-radius:99px; cursor:pointer;
background:var(--accent); color:#fff; font-weight:600;">
Trimite
</button>
</form>
{% endif %}
</div>
{# Sub-linie de eroare: textul BRUT (mesaj RAR/validare), compact, un rand,
doar pe error/needs_data (eroare_bruta e "" in rest). #}
{% if r.eroare_bruta %}
<div class="c-err" title="{{ r.eroare_bruta }}">{{ r.eroare_bruta }}</div>
{% endif %}
</li>
{% endfor %}
</ul>
@@ -176,6 +251,7 @@
{% if f_vehicul %}{% set pq = pq + "&vehicul=" + f_vehicul %}{% endif %}
{% if f_data_de %}{% set pq = pq + "&data_de=" + f_data_de %}{% endif %}
{% if f_data_pana %}{% set pq = pq + "&data_pana=" + f_data_pana %}{% endif %}
{% if f_held %}{% set pq = pq + "&held=" + f_held %}{% endif %}
<nav aria-label="Paginare trimiteri"
style="display:flex; justify-content:center; gap:4px; flex-wrap:wrap; margin-top:10px;">

View File

@@ -2,12 +2,12 @@
{% import '_macros.html' as ui %}
{# Detaliu editabil in-place. Fragmentul se swap-uieste in corpul modalului global
(#detaliu-modal-body). Heading-ul poarta id-ul folosit de aria-labelledby al dialogului.
Operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
(fallback „nemapat"), fara eticheta separata „Cod RAR". #}
{% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
Operatie + coduri RAR rezolvate apar IMPREUNA, read-only, folosind `prez.coduri_rar`
(toate codurile, fallback „nemapat"), fara eticheta separata „Cod RAR". #}
{% set cod_afis = prez.coduri_rar | join(', ') if prez.coduri_rar else 'nemapat' %}
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
{# === Header — #id + pill stare + badge mediu RAR (US-010 PRD 5.20) + motiv === #}
{# === Header — #id + pill stare + badge mediu RAR + motiv === #}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 8px;">
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
@@ -110,14 +110,13 @@
hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# Cleanup B (US-009 PRD 5.15): vechiul <select name="cod_prestatie"> eliminat.
Chips din _form_editare.html (via _chips_prestatii.html) il inlocuiesc complet:
emit hidden inputs name="cod_prestatie" + picker per-operatie (E4, US-007).
{# Vechiul <select name="cod_prestatie"> eliminat. Chips din _form_editare.html
(via _chips_prestatii.html) il inlocuiesc complet: emit hidden inputs
name="cod_prestatie" + picker per-operatie.
post_corectie_trimitere foloseste form.getlist("cod_prestatie") → compatibil. #}
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent.
RAMANE in _trimitere_detaliu.html (D#5). #}
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. #}
{% if prez.op_service_cod %}
<div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie service</div>
@@ -125,7 +124,7 @@
</div>
{% endif %}
{# === Campurile vehicul/data/odo + erori/fix + buton — partial DRY (US-005). === #}
{# === Campurile vehicul/data/odo + erori/fix + buton — partial DRY. === #}
{% include "_form_editare.html" %}
</form>
{% else %}
@@ -150,6 +149,29 @@
</div>
{% endif %}
{# === Rand tinut manual (queued AND held=1) — afordanta de eliberare.
Buton dedicat „Trimite acum": elibereaza randul (held 1->0); worker-ul il preia la
urmatorul poll. Reincarca lista + inchide modalul. Confirmare 1-linie (ireversibil). === #}
{% if tinut %}
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
<p class="muted" style="margin:0 0 8px; font-size:12.5px;">
Prezentare tinuta manual — nu a plecat la RAR. Elibereaz-o pentru trimitere.
</p>
<form hx-post="/trimitere/{{ id }}/trimite-acum"
hx-target="#submissions-wrap" hx-swap="innerHTML"
hx-confirm="Trimit aceasta prezentare la RAR? Declararea este ireversibila (FINALIZATA)."
hx-on::after-request="document.getElementById('modal-detaliu').hidden=true"
hx-disabled-elt="find button" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit"
style="border:1px solid var(--accent); background:var(--accent); color:#fff;
font-weight:600; padding:6px 16px; border-radius:6px; cursor:pointer;">
Trimite acum la RAR
</button>
</form>
</div>
{% endif %}
{# === Actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT === #}
{% if status == 'error' or gestionabil %}
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">

View File

@@ -32,17 +32,16 @@
hx-indicator="#upload-spinner">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
{# Indicator mediu RAR (US-009, PRD 5.20): vizibil inainte de drop-zone #}
{# Indicator mediu RAR: vizibil inainte de drop-zone #}
{% set medii_rar = medii | default([]) %}
{% if medii_rar | length == 0 %}
{# Banner avertisment — non-blocant (upload continua; commit foloseste ancora globala) #}
<div style="margin-bottom:10px; padding:8px 14px; border-radius:6px;
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
border:1px solid var(--warn, #e6b34a); font-size:13px;" role="note">
<strong>Niciun mediu RAR configurat pentru acest cont.</strong>
Pana activezi un mediu, trimiterile din aceasta instanta folosesc mediul global
<strong>{{ mediu_instanta() }}</strong>. Configureaza Testare si/sau Productie in
<a href="?tab=cont" style="color:var(--accent);">tab-ul Cont</a>.
<strong>Niciun mediu RAR activ.</strong>
Activeaza mediul de Productie sau Testare in
<a href="/?tab=cont&subtab=rar" style="color:var(--accent);">Meniu cont &rsaquo; Setari &rsaquo; Mediu RAR</a>.
</div>
{% elif medii_rar | length == 1 %}
{# Eticheta statica (un singur mediu disponibil) #}
@@ -123,9 +122,12 @@
</p>
{% endif %}
{# FARA display in stilul inline: ar suprascrie .htmx-indicator{display:none}
si indicatorul ar ramane vizibil permanent (deci invizibil ca progres). #}
<span id="upload-spinner" class="htmx-indicator muted"
style="font-size:var(--fs-sm); margin-top:6px; display:inline;">
se parseaza fisierul...
style="font-size:var(--fs-sm); margin-top:6px;" role="status">
<span class="spin-dot" aria-hidden="true"></span>
se parseaza fisierul si se pregatesc sugestiile...
</span>
</form>
</div>

View File

@@ -36,8 +36,8 @@
})();
</script>
<style>
/* US-001 PRD 5.16: stive de font standard web. Toate regulile font-face IBM Plex sterse.
Motiv: decizie user (risc AI-Slop #11 acceptat constient), uniformitate cross-page.
/* Stive de font standard web. Toate regulile font-face IBM Plex sterse.
Motiv: decizie user, uniformitate cross-page.
Fisierele woff2 raman pe disc (curatare = follow-up optional, non-blocant).
Referinte catre directorul de fonturi statice eliminate — font-ui si font-mono sunt stive sistem. */
/* Paleta dark (default) — accent azur ROMFAST.
@@ -45,10 +45,10 @@
--line2: separator subtire (intre --bg si --line). */
:root { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6;
/* US-001 (PRD 5.16): stive font standard web — sursa unica de adevar */
/* Stive font standard web — sursa unica de adevar */
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
/* US-002 (PRD 5.16): scala tipografica uniforma — sursa unica de adevar */
/* Scala tipografica uniforma — sursa unica de adevar */
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px;
--fs-lg:18px; --fs-xl:20px; --fs-2xl:28px; --fs-3xl:34px;
--lh-tight:1.25; --lh-body:1.55; }
@@ -60,7 +60,7 @@
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --card2:#0e1416; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e; --line2:#1c2426;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
/* Paleta Grafit — similara cu dark, accent azur deschis (#6ea2ec = landing --infot).
Distinta de dark la cererea userului (D2 PRD 5.15). */
Distinta de dark la cererea userului. */
[data-theme="grafit"] { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#6ea2ec; }
/* Paleta Cobalt — fundal bleumarin adanc, accent albastru viu (#8aa0ff = landing --infot). */
@@ -70,7 +70,7 @@
[data-theme="cupru"] { --bg:#15110b; --card:#211a12; --card2:#15110b; --ink:#efe6d6; --muted:#a89a85; --line:#36291c; --line2:#281e14;
--ok:#67b98c; --warn:#c97d2e; --err:#e2685a; --accent:#dfa45c; }
/* Paleta Hartie — fundal crem cald, accent albastru clasic (#1F5FBF = landing --infot = --accent).
Similara cu light, distinta la cererea userului (D2 PRD 5.15). */
Similara cu light, distinta la cererea userului. */
[data-theme="hartie"] { --bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052; --line:#e2dccc; --line2:#ece6d9;
--ok:#1c7d5d; --warn:#b45309; --err:#bd463c; --accent:#1F5FBF; }
* { box-sizing:border-box; }
@@ -104,7 +104,7 @@
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
.empty { color:var(--muted); padding:24px; text-align:center; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
/* Badge sursa sugestie (5.18 US-007): de unde vine sugestia de cod in editorul de mapare.
/* Badge sursa sugestie: de unde vine sugestia de cod in editorul de mapare.
confirmat = GOLD validat de om (verde); similar = SILVER/embedding k-NN (azur);
non-operatie = pre-filtru NUL / vecin NUL (gri-cald). Suggestion-only, doar indiciu vizual. */
.sugg-sursa { display:inline-block; font-size:10px; font-weight:700; line-height:1; padding:2px 6px;
@@ -141,8 +141,8 @@
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
.s-ok{color:var(--ok);}
.s-needs_review{color:var(--warn);}
.s-already_sent,.s-duplicate_in_file{color:var(--muted);}
/* Badge mediu RAR (US-010 PRD 5.20) — semantica risc L.142.
.s-already_sent,.s-duplicate_in_file,.s-excluded{color:var(--muted);}
/* Badge mediu RAR — semantica risc L.142.
Productie: fill atentie (ton --err), text alb — declaratie reala, ireversibila.
Testare: outline discret, ton --muted — mediu de proba, low-stakes.
NU se foloseste verde pentru Productie (ar semnala "sigur"). */
@@ -150,6 +150,15 @@
background:color-mix(in srgb, var(--err) 80%, var(--card)); color:#fff; border:1px solid transparent; }
.env-badge-test { display:inline-block; padding:1px 7px; border-radius:99px; font-size:11px;
background:transparent; color:var(--muted); border:1px solid var(--line); }
/* Badge mediu RAR din header: eticheta scurta PROD/TEST ramane mereu vizibila pe
mobil (semnal de risc L.142), eticheta lunga se ascunde sub 560px. Clasele
.env-badge-prod/.env-badge-test isi redefinesc deja border-ul complet, deci
suprascriu stilul global de buton fara reguli suplimentare. */
.env-tx-short { display:none; }
@media (max-width:560px) {
.env-tx-full { display:none; }
.env-tx-short { display:inline; }
}
.muted { color:var(--muted); }
/* Heading/eticheta accesibila doar pentru cititoare de ecran (vizual ascunsa). */
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
@@ -167,7 +176,12 @@
flex-wrap:wrap; z-index:10; }
/* Indicator HTMX — ascuns pana la request */
.htmx-indicator { display:none; }
/* US-011 (PRD 5.16): selector tema stil pill — icon + eticheta temei curente.
/* Cerc rotitor pentru indicatorii de procesare (import: parsare + sugestii) */
.spin-dot { display:inline-block; width:13px; height:13px; border:2px solid var(--line);
border-top-color:var(--accent); border-radius:50%; vertical-align:-2px;
margin-right:6px; animation:spin-dot .7s linear infinite; }
@keyframes spin-dot { to { transform:rotate(360deg); } }
/* Selector tema stil pill — icon + eticheta temei curente.
Eticheta se ascunde pe <=560px (spatiu ingust), ramane iconita. */
.tema-btn { display:inline-flex; align-items:center; gap:6px; height:36px; padding:0 12px;
border-radius:8px; background:transparent; border:1px solid var(--line);
@@ -176,7 +190,7 @@
.tema-btn:hover { border-color:var(--accent); color:var(--ink); }
.tema-btn:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
@media (max-width:560px) { #tema-label { display:none; } }
/* US-003 (PRD 5.16): dot RAR compact in antet.
/* Dot RAR compact in antet.
Stare OK: dot verde pulsant + "RAR online". Stare BLOCAT: dot rosu.
Stilat ca pill; sensul NU depinde de culoare (aria-label + title). */
.rar-chip { display:inline-flex; align-items:center; gap:7px; height:36px; padding:0 12px;
@@ -190,7 +204,7 @@
.rar-dot.live { animation:rar-pulse 2s ease-in-out infinite; }
@keyframes rar-pulse { 0%,100%{opacity:1;} 50%{opacity:.5;} }
@media (max-width:560px) { .rar-chip .rar-tx { display:none; } }
/* US-010 (PRD 5.16): sub-titlu cu numele service-ului (cand autentificat). */
/* Sub-titlu cu numele service-ului (cand autentificat). */
.h-sub { font-size:var(--fs-xs); color:var(--muted); margin-top:2px; line-height:1.2; }
.h-sub .svc { color:var(--ink); font-weight:600; }
/* Badge env (test/prod) si badge tier (plan cont) langa titlu. */
@@ -367,7 +381,7 @@
border-radius:6px; cursor:pointer; min-height:36px; white-space:nowrap; }
.kebab-menu button:hover, .kebab-menu a:hover { background:var(--line); }
.kebab-menu button.danger { color:var(--err); }
/* === Accordion import compact (US-006 — regiune CSS disjuncta) ===
/* === Accordion import compact (regiune CSS disjuncta) ===
<details id="import-details"> invelete stepper + upload pe Acasa.
Atribut `open` setat de server din `are_trimiteri`:
False (first-run) → open; True (returning) → colapsat.
@@ -394,7 +408,7 @@
border-bottom:none; border-radius:0;
margin-bottom:0; }
/* === Sfarsit regiune accordion import === */
/* === Inceput regiune nav links status-bar (US-005) ===
/* === Inceput regiune nav links status-bar ===
Linkuri Trimiteri + Mapari sub contoare; marcaj activ cu aria-current/status-nav-activ. */
.status-nav-link { color:var(--accent); text-decoration:none; padding:2px 6px; border-radius:4px;
transition:background .1s; }
@@ -504,7 +518,7 @@
/* === Preview import: coloane extra fata de tabelul Trimiteri.
SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata —
nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat).
US-007: 8 coloane (coloana de verificare manuala eliminata).
8 coloane (coloana de verificare manuala eliminata).
Suma latimi fixe: col-id(48) + col-stare(104) + col-data(104) +
col-km(76) + col-note(176) + col-actiuni(92) = 600px.
Restul (~680px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
@@ -561,7 +575,7 @@
font-size:12px; margin-bottom:2px; }
.tabel-trimiteri td.col-chk, .tabel-trimiteri td.col-id { display:none; }
/* === Card COMPACT (PRD 5.13, corectie dogfood) ===
/* === Card COMPACT ===
Inlocuieste stiva generica eticheta+valoare (prea inalta) cu un card
scanabil la prima vedere: vehicul = titlu, stare = pill dreapta-sus,
operatie+cod pe rand, meta (data/km/rar) muted mic, nota mica. Fara
@@ -701,10 +715,10 @@
.sticky-bar { padding:10px 12px; gap:10px; }
.sticky-bar button { width:100%; min-height:44px; }
}
/* === SENTINEL-COMPONENTE-SLIM: inceput componente slim US-002 (PRD 5.15).
/* === SENTINEL-COMPONENTE-SLIM: inceput componente slim.
Testele ancoreaza pe acest marker. Nu muta/sterge. === */
/* .contor-card — card cifra contor: fundal --card2, bordura --line, radius 8px.
US-002 PRD 5.16: padding marit (18px), cifra pe --fs-2xl, label pe --fs-sm, sub pe --fs-xs.
Padding marit (18px), cifra pe --fs-2xl, label pe --fs-sm, sub pe --fs-xs.
Variante de culoare a cifrei prin clasele .s-* existente (verde/accent/rosu). */
.contor-card { background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:18px 18px; }
.contor-cifra { font-size:var(--fs-2xl); font-weight:700; line-height:1; }
@@ -727,35 +741,72 @@
.compact-nr { font-size:var(--fs-xl); font-weight:700; line-height:1; }
.compact-lbl { font-size:10px; color:var(--muted); margin-top:3px; white-space:nowrap; }
}
/* .lista-trimiteri-slim + .trimitere-slim — lista compacta cu separator --line2.
Randul e clickabil (rol button), tinta min-height:44px pe mobil. */
/* Bara contextuala bulk (5.22): ascunsa fara selectie; .activ (setat de JS
cand >=1 checkbox e bifat) o afiseaza impreuna cu hint-ul de sub ea. */
.bulk-bar { display:none; align-items:center; gap:8px; flex-wrap:wrap; font-size:13px;
background:color-mix(in srgb, var(--accent) 10%, var(--card));
border:1px solid color-mix(in srgb, var(--accent) 40%, transparent);
border-radius:8px; padding:8px 12px; margin-bottom:4px; }
.bulk-bar.activ { display:flex; }
.bulk-hint { display:none; font-size:12px; color:var(--muted); margin:0 0 8px; }
.bulk-bar.activ + .bulk-hint { display:block; }
/* Tabel compact trimiteri (grid CSS, 5.22).
Desktop: header de coloane + un rand per trimitere; sub-linia de eroare
(.c-err) ocupa coloanele 2..-1 pe un al doilea rand de grid.
Mobil (max-width:767px, mai jos): header ascuns, rand pe 2 linii. */
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
.trimitere-slim { display:flex; align-items:center; justify-content:space-between; gap:12px;
padding:14px 16px; border-bottom:1px solid var(--line2); min-height:44px; cursor:pointer; }
.trimiteri-head, .trimitere-slim {
display:grid; align-items:center; column-gap:10px; row-gap:2px;
grid-template-columns:22px 42px minmax(90px,150px) minmax(64px,auto) minmax(0,1fr) auto 46px 44px 84px; }
.trimiteri-head { padding:2px 16px 8px; border-bottom:1px solid var(--line);
font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase;
color:var(--muted); }
.trimiteri-head .th-c { text-align:center; }
.trimitere-slim { padding:9px 16px; border-bottom:1px solid var(--line2);
min-height:44px; cursor:pointer; }
.trimitere-slim:last-child { border-bottom:none; }
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-md); font-weight:600; color:var(--ink); }
.slim-meta { font-size:var(--fs-sm); color:var(--muted); margin-top:3px; }
/* Linia 2 a randului slim (5.16): cod RAR · operatie (ellipsis) · data, pe UN rand.
Ellipsis-ul pe operatie garanteaza 2 linii MAX si la 390px. */
.slim-rand2 { display:flex; align-items:baseline; gap:6px; min-width:0; }
.slim-rand2 .cod-rar-cod { flex:0 0 auto; font-family:var(--font-mono); font-weight:600;
color:var(--accent); }
.slim-rand2 .cod-rar-cod.muted { color:var(--muted); font-weight:500; }
.slim-rand2 .slim-op { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden;
text-overflow:ellipsis; color:var(--ink); }
.slim-rand2 .slim-data { flex:0 0 auto; }
.slim-rand2 .slim-op::before, .slim-rand2 .slim-data::before {
content:"·"; color:var(--muted); margin-right:6px; }
.lista-trimiteri-slim .eticheta-problema { font-size:var(--fs-xs); line-height:1.3; margin-top:2px; }
/* Pill slim (5.16): fill-tint + dot 7px + text colorat per stare (currentColor din .s-*).
Pastrat pe FIECARE rand inclusiv Finalizat (linistit dar prezent). */
.lista-trimiteri-slim .pill { display:inline-flex; align-items:center; gap:5px; font-weight:600;
background:color-mix(in srgb, currentColor 14%, transparent);
border-color:color-mix(in srgb, currentColor 35%, transparent); }
.lista-trimiteri-slim .pill::before { content:""; width:7px; height:7px; border-radius:99px;
background:currentColor; flex-shrink:0; }
.trimitere-slim .c-check { display:flex; align-items:center; }
/* Numarul trimiterii: id-ul din baza, corelabil cu submission_id din API. */
.trimitere-slim .c-id { font-family:var(--font-mono); font-size:var(--fs-xs);
color:var(--muted); white-space:nowrap;
font-variant-numeric:tabular-nums; }
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-sm); font-weight:600; color:var(--ink);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
/* Pe desktop copiii .c-meta devin celule de grid (cod / operatie / data). */
.trimitere-slim .c-meta { display:contents; }
.trimitere-slim .c-cod { white-space:nowrap; }
.trimitere-slim .cod-rar-cod { font-family:var(--font-mono); font-weight:600;
font-size:var(--fs-xs); color:var(--accent); }
.trimitere-slim .cod-rar-cod.muted { color:var(--muted); font-weight:500; font-style:italic; }
/* Badge +N: trimiterea are mai multe coduri RAR; toate stau in title. */
.trimitere-slim .cod-plus { display:inline-block; font-family:var(--font-mono);
font-size:10px; font-weight:700; color:var(--muted); border:1px solid var(--line);
border-radius:99px; padding:0 5px; margin-left:4px; vertical-align:1px; cursor:default; }
.trimitere-slim .c-op { min-width:0; white-space:nowrap; overflow:hidden;
text-overflow:ellipsis; font-size:var(--fs-sm); color:var(--ink); }
.trimitere-slim .c-data { font-size:var(--fs-xs); color:var(--muted); white-space:nowrap;
font-variant-numeric:tabular-nums; }
.trimitere-slim .c-status, .trimitere-slim .c-env { justify-self:center; line-height:1;
display:inline-flex; align-items:center; }
/* Bulina de status: doar cerc colorat (currentColor din .s-*); numele starii
traieste in headerul coloanei + title/aria-label. */
.dot-stare { display:inline-block; width:11px; height:11px; border-radius:99px;
background:currentColor; }
/* Litera de mediu: P = fill atentie (declaratie reala, ireversibila — L.142);
T = contur discret (mediu de proba). */
.env-l { display:inline-flex; align-items:center; justify-content:center;
width:20px; height:20px; border-radius:99px; font-size:11px; font-weight:800;
cursor:default; }
.env-l-prod { background:color-mix(in srgb, var(--err) 80%, var(--card)); color:var(--card); }
.env-l-test { color:var(--muted); border:1px solid var(--line); }
.trimitere-slim .c-act { justify-self:end; }
/* Sub-linia de eroare: text brut, mono, un singur rand cu ellipsis (title = complet). */
.trimitere-slim .c-err { grid-column:3 / -1; min-width:0; font-family:var(--font-mono);
font-size:var(--fs-xs); color:var(--err); white-space:nowrap; overflow:hidden;
text-overflow:ellipsis; }
/* .camp-slim — varianta compacta camp formular: label --fs-sm muted deasupra, input --fs-md, fundal --card2.
Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
.camp-slim { margin-bottom:8px; }
@@ -779,25 +830,52 @@
.chip .chip-del:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
/* Varianta chip warn (ex. R-ODO necesita odometruInitial) */
.chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); }
/* .add-code — buton dashed pentru adaugare cod in chipbox */
.add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent;
border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line));
border-radius:5px; color:var(--accent); font:500 10px var(--font-ui); cursor:pointer; }
.add-code:hover, .add-code:focus-visible { border-style:solid; }
/* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
padding:8px 10px; border:1px solid var(--line); border-radius:6px;
background:var(--card2); margin-bottom:8px; }
/* Nume operatie emfatic (T-9 5.16): proeminent (bold) ca in mockup — e ancora
vizuala a randului de mapare op<->cod. */
.op-row-name { font-size:var(--fs-sm); font-weight:700; color:var(--ink); }
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); }
/* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */
/* Chip abia adaugat via /form-chips — confirmare vizibila a actiunii "+" */
.chip-nou { outline:2px solid color-mix(in srgb, var(--ok) 45%, transparent); outline-offset:1px; }
/* Eticheta mono "COD" pe chip-ul de cod */
.chip-cod-label { font-size:9px; font-weight:800; opacity:.65; letter-spacing:.03em; }
/* Chip warning operatie nemapata: dashed implicit; tinta activa = solid + inel —
singurul tratament "tare" din rand. */
.chip-op-warn { background:color-mix(in srgb, var(--warn) 16%, transparent); color:var(--warn);
border:1px dashed var(--warn); cursor:pointer; font-weight:600; }
.chip-op-warn.is-target { border-style:solid; outline:2px solid color-mix(in srgb, var(--warn) 40%, transparent);
outline-offset:1px; }
.chip-op-warn:focus-visible { outline:2px solid var(--warn); outline-offset:1px; }
/* Chip Nedeclarat: vizibil (line-through), NU mut/invizibil — cea mai riscanta
actiune legala (exclude de la declararea RAR) nu poate fi cea mai discreta. */
.chip-nedeclarat { background:var(--card2); color:var(--muted); border:1px dashed var(--line);
text-decoration:line-through; }
/* Sectiunea chips inerta pe durata unui request /form-chips in zbor:
click pe x/warning mid-flight nu se pierde, doar se ignora. */
.chips-editor.htmx-request { pointer-events:none; opacity:.7; }
/* Mobil: fara header, randul revine la 2 linii (placuta / cod·operatie·data),
cu actiune + litera mediu + bulina status in dreapta. Grid-areas peste
aceleasi celule; .c-meta redevine linie flex cu separatoare "·". */
@media (max-width:767px) {
.trimitere-slim { padding:12px 14px; }
.trimiteri-head { display:none; }
.trimitere-slim { padding:12px 14px; column-gap:8px;
grid-template-columns:22px auto minmax(0,1fr) auto auto auto;
grid-template-areas:
"check id veh act env status"
"check meta meta act env status"
"err err err err err err"; }
.trimitere-slim .c-check { grid-area:check; }
.trimitere-slim .c-id { grid-area:id; align-self:center; }
.trimitere-slim .slim-vin { grid-area:veh; font-size:var(--fs-md); }
.trimitere-slim .c-meta { grid-area:meta; display:flex; align-items:baseline;
gap:6px; min-width:0; margin-top:3px; }
.trimitere-slim .c-cod { flex:0 0 auto; }
.trimitere-slim .c-op { flex:1 1 auto; }
.trimitere-slim .c-data { flex:0 0 auto; }
.trimitere-slim .c-op::before, .trimitere-slim .c-data:not(:empty)::before {
content:"·"; color:var(--muted); margin-right:6px; }
.trimitere-slim .c-status { grid-area:status; }
.trimitere-slim .c-env { grid-area:env; }
.trimitere-slim .c-act { grid-area:act; }
.trimitere-slim .c-err { grid-area:err; }
}
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim US-002 === */
/* === Fix mobil Mapari (bug live 2026-06-29) ===
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim === */
/* === Fix mobil Mapari ===
Doua probleme raportate la 390px pe pagina Mapari:
(1) butoanele Salveaza/Sterge taiate: regula `.tabel-card td button {width:100%}`
(specificitate 0,1,2) batea `.act {width:44px}` (0,1,0) -> cele doua butoane act
@@ -823,10 +901,108 @@
.tabel-card td .act .act-tx { display:inline; }
.tabel-card td .act .act-ic { display:inline-block; }
}
/* === Meniu hamburger grupat === */
/* Iconite SVG stroke unic, currentColor — mostenesc culoarea intrarii. */
.ic { width:18px; height:18px; stroke:currentColor; fill:none; stroke-width:1.7;
stroke-linecap:round; stroke-linejoin:round; flex-shrink:0; }
.cont-menu a, .cont-menu form button { display:flex; align-items:center; gap:10px; }
.cont-menu a .mi, .cont-menu form button .mi { display:inline-flex; opacity:.6; }
.cont-menu a:hover .mi, .cont-menu form button:hover .mi { opacity:.9; }
.cont-menu-group { font-size:10px; text-transform:uppercase; letter-spacing:.08em;
color:var(--muted); font-weight:700; padding:8px 12px 4px; }
/* Badge-ul de mapari se ancoreaza la dreapta chiar cand intrarea e flex. */
.cont-menu a .tab-badge { margin-left:auto; }
/* === Pagina Setari cu sub-taburi === */
.setari-head { margin-bottom:4px; }
.setari-title { font-size:var(--fs-lg); margin:0 0 2px; letter-spacing:-.01em;
display:flex; align-items:center; gap:9px; }
.setari-title .ic { width:20px; height:20px; opacity:.7; }
.setari-sub { color:var(--muted); font-size:var(--fs-sm); margin:0 0 14px; }
.setari-subtabs { display:flex; gap:2px; border-bottom:1px solid var(--line); margin-bottom:20px;
overflow-x:auto; -webkit-overflow-scrolling:touch; scrollbar-width:none; }
.setari-subtabs::-webkit-scrollbar { display:none; }
.setari-subtab { appearance:none; background:transparent; border:none; border-bottom:2px solid transparent;
color:var(--muted); font:inherit; font-size:var(--fs-sm); font-weight:500; cursor:pointer;
padding:10px 2px; margin-right:22px; white-space:nowrap; display:inline-flex;
align-items:center; gap:8px; margin-bottom:-1px; transition:color .12s, border-color .12s; }
.setari-subtab:hover { color:var(--ink); }
/* Stare activa prin clasa (nu selector pe atribut): aria-selected ramane pe buton
pentru accesibilitate, dar stilul nu depinde de el. */
.setari-subtab.is-active { color:var(--ink); border-bottom-color:var(--accent); font-weight:600; }
.setari-subtab:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.st-dot { width:7px; height:7px; border-radius:99px; flex-shrink:0; }
.st-dot.ok { background:var(--ok); }
.st-dot.warn { background:var(--warn); }
.st-dot.info { background:var(--muted); }
.setari-pane[hidden] { display:none; }
/* Titlu de sectiune compact (uppercase mic) — ierarhie sub titlul paginii, nu concurenta cu el. */
.setari-zone-title { font-size:var(--fs-xs); font-weight:600; text-transform:uppercase;
letter-spacing:.07em; color:var(--muted); margin:0 0 10px; display:flex;
align-items:center; gap:8px; flex-wrap:wrap; }
.setari-zone-title .ic { opacity:.7; width:15px; height:15px; }
.setari-zone-title .setari-badge { text-transform:none; letter-spacing:0; }
.setari-zone-desc { color:var(--muted); font-size:var(--fs-sm); margin:0 0 12px; max-width:60ch; line-height:1.5; }
/* Formulare compacte pe 2 coloane in Setari. */
.setari-fgrid { display:grid; grid-template-columns:1fr 1fr; gap:10px 14px; max-width:560px; margin:0 0 12px; }
.setari-fgrid .fg-full { grid-column:1 / -1; }
.setari-fgrid label { display:block; font-size:var(--fs-xs); color:var(--muted); margin-bottom:3px; }
.setari-fgrid input { width:100%; box-sizing:border-box; font:inherit; font-size:14px;
background:var(--bg); color:var(--ink); border:1px solid var(--line);
border-radius:6px; padding:7px 10px; }
@media (max-width:560px) { .setari-fgrid { grid-template-columns:1fr; } }
.setari-btn-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.setari-micro { font-size:var(--fs-xs); color:var(--muted); }
/* Plan: tier + consum pe un rand, bara de progres sub ele. */
.plan-row { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin:0 0 8px; }
.plan-tier { font-size:var(--fs-md); font-weight:600; color:var(--ink); }
.plan-usage { font-size:var(--fs-sm); color:var(--muted); font-variant-numeric:tabular-nums; }
.plan-meter { height:6px; border-radius:99px; background:var(--line2); max-width:340px;
overflow:hidden; margin:0 0 8px; }
.plan-meter > span { display:block; height:100%; border-radius:99px; background:var(--accent); }
.setari-badge { font-size:11px; font-weight:700; padding:2px 9px; border-radius:99px;
text-transform:uppercase; letter-spacing:.03em; border:1px solid transparent; }
.setari-badge.sb-ok { color:var(--ok); background:color-mix(in srgb,var(--ok) 12%,transparent);
border-color:color-mix(in srgb,var(--ok) 40%,transparent); }
.setari-badge.sb-warn { color:var(--warn); background:color-mix(in srgb,var(--warn) 12%,transparent);
border-color:color-mix(in srgb,var(--warn) 40%,transparent); }
.setari-badge.sb-info { color:var(--muted); background:color-mix(in srgb,var(--muted) 12%,transparent);
border-color:color-mix(in srgb,var(--muted) 35%,transparent); }
.setari-subzone-sep { border:none; border-top:1px solid var(--line); margin:20px 0 18px; }
/* Cheie API afisata o singura data. */
.keybox { font-family:var(--font-mono); font-size:14px; background:var(--card2);
border:1px solid var(--line); border-radius:8px; padding:12px 14px; word-break:break-all;
max-width:480px; }
.btn-warn { background:transparent; color:var(--warn); border:1px solid var(--warn); border-radius:6px;
padding:8px 14px; font:inherit; font-weight:500; cursor:pointer; min-height:36px;
display:inline-flex; align-items:center; justify-content:center; gap:6px; }
.btn-warn:hover, .btn-warn:focus-visible { background:var(--warn); color:#fff; filter:none; }
/* Credentiale RAR: doua carduri de mediu paralele. */
.env-cards { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
.env-card { border:1px solid var(--line); border-radius:10px; padding:16px; background:var(--card2); }
.env-card.active-test { border-color:color-mix(in srgb,var(--ok) 45%,var(--line)); }
.env-card h4 { margin:0 0 12px; font-size:var(--fs-sm); display:flex; align-items:center;
justify-content:space-between; gap:8px; }
.env-card .env-sw { display:flex; align-items:center; gap:8px; font-size:var(--fs-sm);
color:var(--muted); margin-bottom:12px; }
.env-card .env-lbl { display:block; font-size:var(--fs-xs); color:var(--muted); margin-bottom:5px; }
.env-card input[type=email], .env-card input[type=password] {
width:100%; font:inherit; background:var(--bg); color:var(--ink); border:1px solid var(--line);
border-radius:6px; padding:8px 10px; margin-bottom:10px; font-size:var(--fs-sm); }
.env-card .env-confirm { display:flex; align-items:flex-start; gap:8px; font-size:var(--fs-xs);
color:var(--muted); line-height:1.4;
background:color-mix(in srgb,var(--err) 7%,transparent);
border:1px solid color-mix(in srgb,var(--err) 22%,transparent);
border-radius:7px; padding:9px 11px; margin-top:2px; }
.env-card .env-confirm input { margin-top:2px; flex-shrink:0; }
.default-env { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin:0 0 18px;
padding:14px; border:1px solid var(--line); border-radius:9px; background:var(--card2); }
.default-env select { font:inherit; background:var(--bg); color:var(--ink); border:1px solid var(--line);
border-radius:6px; padding:7px 10px; max-width:340px; }
@media (max-width:640px) { .env-cards { grid-template-columns:1fr; } }
</style>
</head>
<body>
{# US-010 (PRD 5.16): antet branduit ROA AUTOPASS.
{# Antet branduit ROA AUTOPASS.
Grila 3 coloane — stanga (logo) | centru (titlu+env+tier+account_name) | dreapta (RAR dot + tema + burger).
Antet MINIMAL pe /login: nu afiseaza RAR dot, meniu burger sau account_name (nelogat). #}
<header>
@@ -850,7 +1026,7 @@
</div>
{# Celula dreapta: dot RAR (numai cand logat) + selector tema + versiune + meniu burger #}
<div class="header-right">
{# US-003 (PRD 5.16): dot RAR in antet — OK = chip verde pulsant, BLOCAT = chip rosu.
{# Dot RAR in antet — OK = chip verde pulsant, BLOCAT = chip rosu.
Banda plina apare DOAR in _status.html cand BLOCAT (nu mai e mereu vizibila). #}
{% if is_authenticated|default(false) %}
{% if sanatate_ok|default(true) %}
@@ -858,7 +1034,7 @@
title="RAR online{% if last_login|default('') %} — Ultima autentificare: {{ last_login }}{% endif %}"
aria-label="RAR online">
<span class="rar-dot live" aria-hidden="true"></span>
<span class="rar-tx">RAR online</span>
<span class="rar-tx">RAR</span>
</div>
{% else %}
<div class="rar-chip rar-err" role="status"
@@ -868,8 +1044,12 @@
<span class="rar-tx">RAR blocat</span>
</div>
{% endif %}
{% include '_header_env_badge.html' %}
{# Comutatorul "Trimite automat la RAR" langa semnalul RAR.
In afara swap-ului periodic al #status-bar. #}
{% include '_auto_send_toggle.html' %}
{% endif %}
{# US-011 (PRD 5.16): selector tema = pill cu icon FIX (acelasi SVG ca landing) +
{# Selector tema = pill cu icon FIX (acelasi SVG ca landing) +
eticheta temei curente. Eticheta ascunsa pe <=560px via CSS. JS actualizeaza
doar #tema-label (iconita nu se mai schimba per tema — consecventa cu landing). #}
<button id="tema-toggle" class="tema-btn"
@@ -882,14 +1062,14 @@
<span class="muted" style="font-size:var(--fs-xs);">v{{ version }}</span>
{% if is_authenticated|default(false) %}
{# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout.
US-010: structura cu <hr> separatori + RAR status (prima intrare) + Plan tier.
Structura cu <hr> separatori + RAR status (prima intrare) + Plan tier.
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
<div class="cont-menu-wrap">
<button id="cont-menu-toggle" class="icon-btn"
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
{# Prima intrare: starea RAR (US-003) #}
{# Prima intrare: starea RAR #}
{% if sanatate_ok|default(true) %}
<div class="menu-rar-line ok" role="menuitem" aria-disabled="true">
<span style="width:8px;height:8px;border-radius:99px;background:currentColor;display:inline-block;"></span>
@@ -901,31 +1081,31 @@
RAR indisponibil
</div>
{% endif %}
{# Plan cont curent (US-006 PRD 5.17): linie detaliata cu trial/consum/warn.
Warn = culoare + text (accesibilitate, decizie #14). #}
{# Plan cont curent: linie detaliata cu trial/consum/warn.
Warn = culoare + text (accesibilitate). #}
<div class="menu-rar-line" role="menuitem" aria-disabled="true"
style="color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--muted){% endif %};
{% if plan_warn|default(false) %}font-weight:600;{% endif %}">
{{ plan_linie|default('Plan: ' + (tier_label|default('Gratuit'))) }}
</div>
<hr>
{# Navigare principala: Trimiteri + Mapari #}
<a role="menuitem" href="/">Trimiteri</a>
{# Grup LUCRU: fluxul zilnic — trimiteri, mapari, nomenclator. #}
<div class="cont-menu-group">Lucru</div>
<a role="menuitem" href="/"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/></svg></span>Trimiteri</a>
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
<a role="menuitem" href="/?tab=mapari"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14 14 10"/><path d="M11.5 6.5 13 5a4 4 0 0 1 6 6l-1.5 1.5"/><path d="M12.5 17.5 11 19a4 4 0 0 1-6-6l1.5-1.5"/></svg></span>Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
<a role="menuitem" href="/?tab=nomenclator"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4a1 1 0 0 1 1-1h13v18H6a1 1 0 0 0-1 1z"/><path d="M5 18h14"/></svg></span>Nomenclator</a>
<hr>
{# Nomenclator: coduri RAR — public, dar in meniu arata mai logic la logat #}
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
<hr>
{# Setari cont #}
<a role="menuitem" href="/?tab=cont">Cont</a>
<a role="menuitem" href="/?tab=integrare">Integrare</a>
<a role="menuitem" href="/?tab=jurnal">Jurnal</a>
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %}
{# Grup CONT: configurare + audit + admin. "Cont" redenumit "Setari" (URL ramane ?tab=cont). #}
<div class="cont-menu-group">Cont</div>
<a role="menuitem" href="/?tab=cont"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.6 4.6l2.1 2.1M17.3 17.3l2.1 2.1M4.6 19.4l2.1-2.1M17.3 6.7l2.1-2.1"/></svg></span>Setari</a>
<a role="menuitem" href="/?tab=integrare"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3v6M15 3v6"/><path d="M7 9h10v3a5 5 0 0 1-10 0z"/><path d="M12 17v4"/></svg></span>Integrare</a>
<a role="menuitem" href="/?tab=jurnal"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3h8l4 4v14H7z"/><path d="M15 3v4h4"/><path d="M10 13h6M10 17h6"/></svg></span>Jurnal</a>
{% if is_admin|default(false) %}<a role="menuitem" href="/admin"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><path d="M3.5 20a5.5 5.5 0 0 1 11 0"/><path d="M16 5.5a3 3 0 0 1 0 5"/><path d="M17.5 14.2A5.5 5.5 0 0 1 21 20"/></svg></span>Conturi clienti</a>{% endif %}
<hr>
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token|default('') }}">
<button role="menuitem" type="submit">Iesi din cont</button>
<button role="menuitem" type="submit"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-3"/><path d="M9 12h12"/><path d="m18 9 3 3-3 3"/></svg></span>Iesi din cont</button>
</form>
</div>
</div>
@@ -951,8 +1131,12 @@
<div id="detaliu-modal-body"></div>
</div>
</div>
{# Slot pentru modalul de confirmare tipata: auto-release OFF->ON injecteaza
aici modalul via OOB (#modal-confirma-slot). Bulk "Trimite toate" isi randeaza propriul
modal inline in _coada.html. #}
<div id="modal-confirma-slot"></div>
<script>
// Comutator tema ciclic (DRY E2 — PRD 5.15): config traieste intr-o singura structura
// Comutator tema ciclic: config traieste intr-o singura structura
// sursa-de-adevar THEMES din care se DERIVA CYCLE/VALID/LABELS/NEXT.
// Adaugarea unei teme noi = O singura intrare in THEMES.
// Ciclu: Light->Dark->Petrol->Grafit->Cobalt->Cupru->Hartie->Auto->(inapoi la Light).
@@ -990,7 +1174,7 @@
}
function _syncButton(stored) {
var s = VALID[stored] ? stored : 'auto';
// US-011: iconita e fixa (nu se schimba per tema); actualizam doar eticheta.
// Iconita e fixa (nu se schimba per tema); actualizam doar eticheta.
var label = document.getElementById('tema-label');
if (label) label.textContent = LABELS[s];
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
@@ -1102,6 +1286,56 @@
if (saveBtn) saveBtn.classList.add('dirty');
});
</script>
<script>
// Bara contextuala bulk (trimiteri): vizibila DOAR cu selectie.
// Delegare pe document (supravietuieste swap-urilor HTMX); dupa un swap al
// listei checkbox-urile se reseteaza, deci sync() ascunde bara automat.
(function() {
function syncBulkBar() {
var form = document.getElementById('bulk-trimiteri');
var bar = document.getElementById('bulk-bar');
if (!form || !bar) return;
var n = form.querySelectorAll('input[name="submission_id"]:checked').length;
bar.classList.toggle('activ', n > 0);
bar.querySelectorAll('.bulk-n').forEach(function(el) { el.textContent = n; });
bar.querySelectorAll('.bulk-n-txt').forEach(function(el) {
el.textContent = (n === 1 ? 'rand' : 'randuri');
});
}
document.addEventListener('change', function(e) {
if (e.target && e.target.name === 'submission_id') syncBulkBar();
});
document.addEventListener('click', function(e) {
if (!(e.target.closest && e.target.closest('#bulk-deselect'))) return;
document.querySelectorAll('#bulk-trimiteri input[name="submission_id"]:checked')
.forEach(function(cb) { cb.checked = false; });
syncBulkBar();
});
document.body.addEventListener('htmx:afterSwap', syncBulkBar);
})();
</script>
<script>
// Sub-taburi pagina Setari: comutare pur client-side, fara server.
// Delegare pe document -> supravietuieste swap-urilor HTMX ale #card-cont (dupa
// fiecare salvare serverul re-randeaza cu sub-tabul corect via `aria-selected`/`hidden`,
// iar acest handler preia doar navigarea manuala intre taburi).
document.addEventListener('click', function(e) {
var tab = e.target.closest('.setari-subtab');
if (!tab) return;
var card = tab.closest('#card-cont');
if (!card) return;
var name = tab.getAttribute('data-subtab');
card.querySelectorAll('.setari-subtab').forEach(function(t) {
var on = t === tab;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.classList.toggle('is-active', on);
});
card.querySelectorAll('.setari-pane').forEach(function(p) {
if (p.getAttribute('data-subpane') === name) { p.removeAttribute('hidden'); }
else { p.setAttribute('hidden', ''); }
});
});
</script>
<script>
// Toast global: feedback tranzitoriu vizibil + accesibil (aria-live).
// window.arataToast(text, stareCss) — stareCss (ex. "s-error"/"s-needs_review")
@@ -1267,7 +1501,7 @@
// Deschidere la click pe rand (htmx:beforeRequest): arata modalul cu placeholder
// inainte ca raspunsul fragmentului sa fie swap-uit in corp.
// Trateaza atat .trimitere-row (Trimiteri) cat si .btn-editeaza (preview import)
// → open() instaleaza inert pe <main>, focus-trap si readuce focusul la inchidere (US-006).
// → open() instaleaza inert pe <main>, focus-trap si readuce focusul la inchidere.
document.body.addEventListener('htmx:beforeRequest', function(evt) {
var elt = evt.detail && evt.detail.elt;
if (!elt || !elt.classList) return;
@@ -1380,7 +1614,7 @@
// Daca difera (schimbari externe — ex. worker a procesat trimiteri), reincarca automat
// pastrand filtrul curent. Fara nudge "Date noi" — auto-refresh e mai consistent.
// Decizie: nudge eliminat; distinctia propriu vs extern e imposibila pe client
// fara sesiune dedicata — auto-refresh acoper ambele cazuri (US-008).
// fara sesiune dedicata — auto-refresh acoper ambele cazuri.
var INTERVAL = 20000;
function versiuneCurenta() {
var e = document.getElementById('trimiteri-versiune');

View File

@@ -0,0 +1,69 @@
{% extends "landing_static.html" %}
{% block titlu %}Confidențialitate / GDPR{% endblock %}
{% block descriere %}Politica de confidențialitate și protecția datelor (GDPR) pentru ROA AUTOPASS.{% endblock %}
{% block continut %}
<h1>Politica de confidențialitate / GDPR</h1>
<p class="lp-sub">Ultima actualizare: 9 iulie 2026</p>
<h2>1. Cine prelucrează datele</h2>
<p>Operatorul datelor este <strong>ROMFAST</strong>, furnizorul serviciului ROA AUTOPASS.
Ne poți contacta oricând la <a href="mailto:contact@romfast.ro">contact@romfast.ro</a>.</p>
<h2>2. Ce date prelucrăm și de ce</h2>
<table>
<thead><tr><th>Date</th><th>Scop</th><th>Temei</th></tr></thead>
<tbody>
<tr>
<td>Date de cont: denumire firmă, CUI, email, parolă (criptată)</td>
<td>Crearea și administrarea contului, facturare</td>
<td>Executarea contractului</td>
</tr>
<tr>
<td>Date din prezentări: VIN, număr de înmatriculare, kilometraj, data prestației, operațiile efectuate</td>
<td>Transmiterea prestațiilor la RAR Auto-Pass în numele tău</td>
<td>Obligația legală a service-ului (Legea 142/2023, OMTI 210/2024) + contractul cu tine</td>
</tr>
<tr>
<td>Credențialele tale RAR Auto-Pass</td>
<td>Autentificarea la RAR pentru trimitere</td>
<td>Executarea contractului; stocate criptat</td>
</tr>
<tr>
<td>Jurnal tehnic (evenimente de trimitere, erori)</td>
<td>Funcționarea serviciului, suport, dovada transmiterii</td>
<td>Interes legitim</td>
</tr>
</tbody>
</table>
<p>Nu colectăm date suplimentare față de cele necesare trimiterii la RAR — nici pe conturile gratuite —
și nu folosim datele clienților tăi în scop de marketing.</p>
<h2>3. Cui le transmitem</h2>
<p>Exclusiv <strong>Registrului Auto Român (RAR)</strong>, prin API-ul oficial Auto-Pass, în scopul declarat.
Nu vindem și nu cedăm datele către alte părți. Datele sunt găzduite pe infrastructura noastră.</p>
<h2>4. Cât le păstrăm</h2>
<ul>
<li>Prezentările trimise și loturile de import se șterg <strong>automat după 90 de zile</strong>.</li>
<li>Poți șterge datele și mai devreme, direct din aplicație.</li>
<li>Datele de cont se păstrează cât timp contul este activ.</li>
<li>Credențialele RAR trimise punctual prin API se șterg imediat după prima autentificare reușită.</li>
</ul>
<h2>5. Cum le protejăm</h2>
<ul>
<li>Datele cu caracter personal din trimiteri și credențialele RAR sunt <strong>criptate</strong> în baza de date.</li>
<li>Parolele de cont se stochează doar ca hash; cheile API doar ca amprentă SHA-256.</li>
<li>Accesul la date este separat per cont: un cont nu poate vedea datele altui cont.</li>
</ul>
<h2>6. Drepturile tale</h2>
<p>Conform GDPR ai dreptul de acces, rectificare, ștergere, restricționare, portabilitate și opoziție,
precum și dreptul de a depune o plângere la ANSPDCP (<a href="https://www.dataprotection.ro" target="_blank" rel="noopener">dataprotection.ro</a>).
Pentru exercitarea drepturilor scrie-ne la <a href="mailto:contact@romfast.ro">contact@romfast.ro</a>
răspundem în cel mult 30 de zile.</p>
<h2>7. Cookies</h2>
<p>Folosim doar cookie-uri strict necesare (sesiunea de autentificare și protecția CSRF).
Nu folosim cookie-uri de tracking sau de marketing.</p>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "landing_static.html" %}
{% block titlu %}Documentație API{% endblock %}
{% block descriere %}Documentația API ROA AUTOPASS: autentificare, endpoint-uri, exemple de cerere și răspuns.{% endblock %}
{% block continut %}
<h1>Documentație API</h1>
<p class="lp-sub">Conectează softul tău de service direct la ROA AUTOPASS (plan Pro).</p>
<h2>Cum funcționează</h2>
<p>Softul tău trimite prezentările printr-un singur apel HTTP; noi validăm datele, mapăm operațiile pe coduri RAR,
oprim duplicatele și transmitem la RAR Auto-Pass. Autentificarea se face cu o <strong>cheie API</strong>
(antetul <code>X-API-Key</code>), generată din contul tău. Credențialele RAR le configurezi o singură dată în cont.</p>
<h2>Endpoint-uri principale</h2>
<table>
<thead><tr><th>Endpoint</th><th>Ce face</th></tr></thead>
<tbody>
<tr><td><code>POST /v1/prezentari</code></td><td>Trimite una sau mai multe prezentări spre declarare</td></tr>
<tr><td><code>POST /v1/prezentari/valideaza</code></td><td>Dry-run: validare fără trimitere</td></tr>
<tr><td><code>GET /v1/prezentari/{id}</code></td><td>Starea unei trimiteri</td></tr>
<tr><td><code>POST /v1/import</code></td><td>Import fișier xlsx/csv prin API</td></tr>
<tr><td><code>GET /v1/nomenclator</code></td><td>Nomenclatorul de coduri RAR (public)</td></tr>
</tbody>
</table>
<h2>Exemplu: cerere</h2>
<pre><code>curl -X POST "{{ base_url }}/v1/prezentari" \
-H "Content-Type: application/json" \
-H "X-API-Key: rfak_..." \
-d '{{ exemplu_json["cerere"] }}'</code></pre>
<p>Fiecare prestație are fie <code>cod_prestatie</code> (cod RAR din nomenclator), fie
<code>cod_op_service</code> + <code>denumire</code> — codurile tale interne le mapăm noi pe coduri RAR,
iar maparea rămâne salvată pentru trimiterile următoare.</p>
<h2>Exemplu: răspuns</h2>
<pre><code>{{ exemplu_json["raspuns"] }}</code></pre>
<p>Un rezultat per prezentare, în aceeași ordine ca cererea. <code>status</code> poate fi
<code>queued</code> (în coadă spre RAR) sau, dacă ceva blochează trimiterea,
<code>needs_mapping</code> / <code>needs_data</code> / <code>error</code> — cu explicația în <code>motiv</code>.</p>
<h2>Referință completă</h2>
<ul>
<li><a href="/docs" target="_blank" rel="noopener">Swagger UI — /docs</a></li>
<li><a href="/openapi.json" target="_blank" rel="noopener">Schema OpenAPI — /openapi.json</a></li>
</ul>
<p>După autentificare găsești în aplicație, la <strong>Integrare</strong>, exemple gata de copiat pentru
curl, Python, PHP, C#, Node și Visual FoxPro, plus testarea cheii API și a credențialelor RAR.</p>
<div class="lp-card">
<p style="margin:0;">Nu ai încă un cont? <a href="/#inregistrare">Creează unul gratuit</a> — primești 30 de zile
de Pro, cu acces API inclus. Întrebări de integrare: <a href="mailto:contact@romfast.ro">contact@romfast.ro</a>.</p>
</div>
{% endblock %}

View File

@@ -4,96 +4,26 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ROA AUTOPASS — declari prestațiile la RAR din câteva click-uri</title>
<meta name="description" content="Încarci fișierul tău cu operațiile service-ului, completezi o dată codurile RAR și le salvezi. ROMFAST trimite prestațiile la RAR AUTOPASS în locul tău, fără tastat manual. Conform Legii 142/2023.">
<style>
/* US-001/US-008 (PRD 5.16): IBM Plex eliminat complet — stive font sistem standard web.
Tokenurile --font-ui / --font-mono definite in :root (sursa unica de adevar). */
:root{--font-ui:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--font-mono:ui-monospace,"SF Mono","Cascadia Code","Segoe UI Mono","Roboto Mono",Menlo,Consolas,monospace;}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{font-family:var(--font-ui);-webkit-font-smoothing:antialiased;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);}
body[data-theme="grafit"]{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--hbg:rgba(15,18,24,.88);--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--mut:#5c6473}
body[data-theme="cobalt"]{--bg:#080d1c;--card:#111a33;--card2:#0b1226;--text:#e9ecfb;--sub:#8a93b8;--line:#1d2747;--line2:#161f3a;--accent:#4068FF;--hbg:rgba(8,13,28,.9);--okt:#2fd0a6;--infot:#8aa0ff;--errt:#f06a7a;--mut:#5a6390}
body[data-theme="cupru"]{--bg:#15110b;--card:#211a12;--card2:#15110b;--text:#efe6d6;--sub:#a89a85;--line:#36291c;--line2:#281e14;--accent:#D98A3D;--hbg:rgba(21,17,11,.9);--okt:#67b98c;--infot:#dfa45c;--errt:#e2685a;--mut:#6d5f4c}
body[data-theme="hartie"]{--bg:#f3efe6;--card:#fffdf7;--card2:#f3efe6;--text:#1e1a13;--sub:#6a6052;--line:#e2dccc;--line2:#ece6d9;--accent:#1F5FBF;--hbg:rgba(255,253,247,.92);--okt:#1c7d5d;--infot:#1F5FBF;--errt:#bd463c;--mut:#9a8f7d}
/* Teme aliniate cu aplicatia (base.html) — acelasi set de 8; 'auto' se rezolva la light/dark. */
body[data-theme="light"]{--bg:#f5f7fa;--card:#ffffff;--card2:#f5f7fa;--text:#1a1d24;--sub:#5c6473;--line:#e2e5ea;--line2:#eaedf2;--accent:#1F66C9;--hbg:rgba(245,247,250,.9);--okt:#15803d;--infot:#1F66C9;--errt:#dc2626;--mut:#8a92a0}
body[data-theme="dark"]{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--hbg:rgba(15,18,24,.88);--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--mut:#5c6473}
body[data-theme="petrol"]{--bg:#0e1416;--card:#161e20;--card2:#0e1416;--text:#e6e9ef;--sub:#8b93a7;--line:#232c2e;--line2:#1c2426;--accent:#0E7C7B;--hbg:rgba(14,20,22,.9);--okt:#2FBF8F;--infot:#3fb9b6;--errt:#E05D5D;--mut:#5c6473}
.page{width:100%;max-width:1280px;margin:0 auto;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);overflow:hidden;}
a{text-decoration:none;}
input[type=range]{-webkit-appearance:none;appearance:none;background:transparent;}
input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:99px;background:var(--line,#262b36);}
input[type=range]::-moz-range-track{height:6px;border-radius:99px;background:var(--line,#262b36);}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;margin-top:-6px;border-radius:99px;background:var(--accent,#2E74D6);cursor:pointer;border:none;}
input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:99px;background:var(--accent,#2E74D6);cursor:pointer;border:none;}
@media (max-width:900px){
.lp-nav{display:none!important;}
.lp-header{padding:0 18px!important;}
.lp-h1{font-size:32px!important;line-height:1.1!important;}
.page [style*="grid-template-columns"]{grid-template-columns:1fr!important;}
.page [style*="padding:80px 40px"]{padding:48px 20px!important;}
.page [style*="padding:0 40px 80px"]{padding:0 20px 48px!important;}
.page [style*="padding:56px 40px 80px"]{padding:36px 20px 48px!important;}
.page [style*="padding:44px"]{padding:28px!important;}
.page [style*="padding:56px 40px"]{padding:40px 22px!important;}
.page [style*="height:68px"]{height:60px!important;}
.page [style*="gap:56px"]{gap:32px!important;}
.page [style*="gap:48px"]{gap:28px!important;}
}
@media (max-width:560px){
.lp-h1{font-size:27px!important;}
.page [style*="padding:10px 40px"]{padding:10px 18px!important;}
.lp-header{padding:0 12px!important;}
#theme-label{display:none!important;}
.lp-hactions{gap:8px!important;}
.lp-hactions button{height:38px!important;padding:0 11px!important;font-size:13px!important;}
}
@media (max-width:430px){
.lp-hactions a.auth-login-link{display:none!important;}
}
</style>
<meta name="description" content="Încarci fișierul tău cu operațiile service-ului, completezi o dată codurile RAR și le salvezi. ROMFAST trimite prestațiile la RAR Auto-Pass în locul tău, fără tastat manual. Conform Legii 142/2023.">
{% include "_lp_style.html" %}
</head>
<body data-theme="grafit">
<script>try{var _t=localStorage.getItem('theme');var _V={light:1,dark:1,petrol:1,grafit:1,cobalt:1,cupru:1,hartie:1,auto:1};if(_t&&_V[_t]){var _r=_t==='auto'?(window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'):_t;document.body.setAttribute('data-theme',_r);}}catch(e){}</script>
<main class="page">
<!-- HEADER -->
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
<div style="display:flex;align-items:center;gap:14px;">
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
<div style="display:flex;flex-direction:column;line-height:1.05;">
<span style="font:700 17px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);">ROA AUTOPASS</span>
<span style="font:500 11px var(--font-ui);letter-spacing:.04em;color:var(--sub,#8b93a7);">Gateway RAR</span>
</div>
</div>
<div style="display:flex;align-items:center;gap:28px;">
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
<a href="#cum-functioneaza" style="color:inherit;text-decoration:none;">Cum funcționează</a><a href="#api" style="color:inherit;text-decoration:none;">API</a><a href="#pret" style="color:inherit;text-decoration:none;">Preț</a>
</div>
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
<button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px var(--font-ui);cursor:pointer;">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
<span id="theme-label">Grafit</span>
</button>
<a href="/login" class="auth-login-link" style="display:inline-flex;align-items:center;height:44px;padding:0 18px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;text-decoration:none;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Autentificare</a>
<button data-act="auth" data-tab="register" style="height:44px;padding:0 18px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont</button>
</div>
</div>
</div>
<!-- HEADER (comun cu paginile statice) -->
{% include "_lp_header.html" %}
<!-- HERO -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
<div>
<h1 class="lp-h1" style="font:700 50px/1.06 var(--font-ui);letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară prestațiile la RAR AUTOPASS, automat</h1>
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;max-width:480px;">Încarci fișierul tău așa cum îl ai, potrivești o dată operațiile cu codurile RAR, și trimitem prestațiile la RAR AUTOPASS în locul tău. Fără tastat câmp cu câmp.</p>
<h1 class="lp-h1" style="font:700 50px/1.06 var(--font-ui);letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară operațiile de service la RAR Auto-Pass, <span style="color:var(--accent,#2E74D6);">automat</span></h1>
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;max-width:480px;">Încarci fișierul tău așa cum îl ai, potrivești o dată operațiile cu codurile RAR, și trimitem prestațiile la RAR Auto-Pass în locul tău. Fără tastat câmp cu câmp.</p>
<div style="margin-bottom:32px;">
<p style="display:flex;align-items:center;gap:8px;font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin:0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg><span><span style="color:#1F9D5C;">Gratuit</span> până la 60 de trimiteri/lună</span></p>
</div>
<div style="display:flex;gap:12px;margin-bottom:22px;">
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
<button style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</button>
<a href="#pret" style="display:inline-flex;align-items:center;height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;text-decoration:none;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</a>
<a href="#cum-functioneaza" style="display:inline-flex;align-items:center;height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;text-decoration:none;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</a>
</div>
<div style="display:flex;align-items:center;gap:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);flex-wrap:wrap;">
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
@@ -106,7 +36,7 @@
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);">
<div>
<div style="font:700 14px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri RAR AUTOPASS</div>
<div style="font:700 14px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri RAR Auto-Pass</div>
<div style="font:400 12px var(--font-mono);color:var(--sub,#8b93a7);margin-top:2px;">Service Auto Vâlcea · 28 iun 2026</div>
</div>
<div style="display:flex;gap:8px;">
@@ -142,14 +72,14 @@
<!-- PROBLEM + CALCULATOR (combinat) -->
<div style="padding:80px 40px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
<div style="text-align:center;max-width:760px;margin:0 auto 40px;">
<h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 23 minute pe RAR AUTOPASS.<br><span style="color:var(--errt,#E05D5D);">Minutele acelea sunt bani.</span></h2>
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi. Mută cursorul la volumul service-ului tău și vezi cât te costă.</p>
<h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);"><span style="color:var(--errt,#E05D5D);">Pierzi 6 zile pe an</span><br>introducând manual în Auto-Pass ...</h2>
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">... VIN de 2 ori, număr de înmatriculare, kilometraj, dată, operații…<br>în loc să pleci mai devreme acasă.</p>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:stretch;">
<!-- STANGA: formularul RAR AUTOPASS -->
<!-- STANGA: formularul RAR Auto-Pass -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:24px;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">RAR AUTOPASS · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px var(--font-mono);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">RAR Auto-Pass · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px var(--font-mono);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
@@ -172,11 +102,13 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;">
<div>
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri/lună</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);" id="out-pres">100</span></div>
<input type="range" min="50" max="1500" step="10" value="100" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
<input type="range" min="0" max="5" step="1" value="1" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
<div style="display:flex;justify-content:space-between;margin-top:5px;font:400 11px var(--font-mono);color:var(--mut,#5c6473);"><span>50</span><span>100</span><span>150</span><span>200</span><span>300</span><span>500</span></div>
</div>
<div>
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Manoperă</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);"><span id="out-rate">60</span><span style="font:500 12px var(--font-ui);color:var(--sub,#8b93a7);"> lei/h</span></span></div>
<input type="range" min="30" max="200" step="5" value="60" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
<input type="range" min="0" max="5" step="1" value="2" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
<div style="display:flex;justify-content:space-between;margin-top:5px;font:400 11px var(--font-mono);color:var(--mut,#5c6473);"><span>30</span><span>50</span><span>60</span><span>80</span><span>100</span><span>150</span></div>
</div>
</div>
<div style="background:color-mix(in srgb,#E05D5D 9%,var(--card2,#0f1218));border:1px solid color-mix(in srgb,#E05D5D 28%,var(--line,#262b36));border-radius:10px;padding:22px 24px;">
@@ -208,13 +140,89 @@
</div>
</div>
<!-- SOLVE -->
<!-- SOLVE: fragment de wizard import (pasii reali din aplicatie) -->
<div id="cum-functioneaza" style="padding:80px 40px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
<div style="max-width:780px;margin:0 auto;text-align:center;">
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2>
<p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA AUTOPASS îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
<div style="max-width:720px;margin:0 auto;text-align:center;">
<h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Uite cât de simplu e</h2>
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;">Patru pași pe ecran, exact așa cum arată în aplicație: <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">încarci</span> fișierul, <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi</span> potrivirile propuse de noi, <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">verifici</span> lista și apeși <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Trimite</span>. Prima dată durează câteva minute — de la a doua, câteva secunde.</p>
</div>
<div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 var(--font-ui);color:var(--sub,#8b93a7);">
<div style="max-width:980px;margin:34px auto 0;background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -24px rgba(0,0,0,.45);overflow:hidden;">
<div style="display:flex;align-items:center;gap:7px;padding:10px 14px;border-bottom:1px solid var(--line,#262b36);background:var(--card2,#0f1218);">
<span style="width:10px;height:10px;border-radius:99px;background:var(--errt,#E05D5D);"></span><span style="width:10px;height:10px;border-radius:99px;background:#E0A93B;"></span><span style="width:10px;height:10px;border-radius:99px;background:var(--okt,#2FBF8F);"></span>
<span style="font:400 12px var(--font-mono);color:var(--sub,#8b93a7);margin-left:8px;">ROA AUTOPASS · Import fișier</span>
</div>
<div style="display:flex;flex-wrap:wrap;padding:16px 18px 0;">
<div style="flex:1;min-width:150px;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:2px solid var(--okt,#2FBF8F);font:500 12.5px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:20px;height:20px;border-radius:99px;border:1.5px solid currentColor;display:flex;align-items:center;justify-content:center;font:700 11px var(--font-ui);flex-shrink:0;">&#10003;</span>Încarcă fișier</div>
<div style="flex:1;min-width:150px;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:2px solid var(--okt,#2FBF8F);font:500 12.5px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:20px;height:20px;border-radius:99px;border:1.5px solid currentColor;display:flex;align-items:center;justify-content:center;font:700 11px var(--font-ui);flex-shrink:0;">&#10003;</span>Potrivește coloanele</div>
<div style="flex:1;min-width:150px;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:2px solid var(--accent,#2E74D6);font:500 12.5px var(--font-ui);color:var(--text,#e6e9ef);"><span style="width:20px;height:20px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;display:flex;align-items:center;justify-content:center;font:700 11px var(--font-ui);flex-shrink:0;">3</span>Verifică</div>
<div style="flex:1;min-width:150px;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:2px solid var(--line,#262b36);font:500 12.5px var(--font-ui);color:var(--mut,#5c6473);"><span style="width:20px;height:20px;border-radius:99px;border:1.5px solid var(--line,#262b36);display:flex;align-items:center;justify-content:center;font:700 11px var(--font-ui);flex-shrink:0;">4</span>Confirmă trimiterea</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1.15fr 1.35fr;gap:14px;padding:18px;">
<!-- Pas 1: incarcare -->
<div style="background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px;min-width:0;">
<div style="font:700 12px var(--font-ui);letter-spacing:.06em;text-transform:uppercase;color:var(--sub,#8b93a7);display:flex;align-items:center;gap:7px;"><span style="width:17px;height:17px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;display:inline-flex;align-items:center;justify-content:center;font:700 10px var(--font-ui);flex-shrink:0;">1</span>Încarcă fișier</div>
<div style="border:1.5px dashed color-mix(in srgb,var(--accent,#2E74D6) 45%,var(--line,#262b36));border-radius:8px;padding:16px 12px;text-align:center;font:400 12.5px/1.5 var(--font-ui);color:var(--sub,#8b93a7);">
Trage fișierul aici sau alege-l de pe calculator
<br><span style="display:inline-flex;align-items:center;gap:7px;margin-top:9px;padding:5px 11px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);font:500 12px var(--font-mono);color:var(--text,#e6e9ef);">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--okt,#2FBF8F)" stroke-width="1.8"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
comenzi_iunie.xlsx
</span>
</div>
<div style="font:400 11px/1.5 var(--font-ui);color:var(--okt,#2FBF8F);display:flex;gap:6px;align-items:flex-start;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>
Merge fișierul tău, așa cum îl scoți din programul de facturare — xlsx sau csv.
</div>
</div>
<!-- Pas 2: mapare coloane -->
<div style="background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px;min-width:0;">
<div style="font:700 12px var(--font-ui);letter-spacing:.06em;text-transform:uppercase;color:var(--sub,#8b93a7);display:flex;align-items:center;gap:7px;"><span style="width:17px;height:17px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;display:inline-flex;align-items:center;justify-content:center;font:700 10px var(--font-ui);flex-shrink:0;">2</span>Potrivește coloanele</div>
<div style="display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:6px;"><span style="padding:5px 8px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:400 11px var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Serie sasiu</span><span style="color:var(--mut,#5c6473);text-align:center;font:400 11px var(--font-ui);">&rarr;</span><span style="padding:5px 8px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 12%,var(--card,#181c24));border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 35%,var(--line,#262b36));color:var(--text,#e6e9ef);font:600 11px var(--font-ui);display:flex;align-items:center;justify-content:space-between;gap:6px;overflow:hidden;white-space:nowrap;">VIN <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.6;"><path d="M6 9l6 6 6-6"/></svg></span></div>
<div style="display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:6px;"><span style="padding:5px 8px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:400 11px var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Data iesire</span><span style="color:var(--mut,#5c6473);text-align:center;font:400 11px var(--font-ui);">&rarr;</span><span style="padding:5px 8px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 12%,var(--card,#181c24));border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 35%,var(--line,#262b36));color:var(--text,#e6e9ef);font:600 11px var(--font-ui);display:flex;align-items:center;justify-content:space-between;gap:6px;overflow:hidden;white-space:nowrap;">Data prestației <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.6;"><path d="M6 9l6 6 6-6"/></svg></span></div>
<div style="display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:6px;"><span style="padding:5px 8px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:400 11px var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Km bord</span><span style="color:var(--mut,#5c6473);text-align:center;font:400 11px var(--font-ui);">&rarr;</span><span style="padding:5px 8px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 12%,var(--card,#181c24));border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 35%,var(--line,#262b36));color:var(--text,#e6e9ef);font:600 11px var(--font-ui);display:flex;align-items:center;justify-content:space-between;gap:6px;overflow:hidden;white-space:nowrap;">Odometru <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.6;"><path d="M6 9l6 6 6-6"/></svg></span></div>
<div style="display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:6px;"><span style="padding:5px 8px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:400 11px var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Denumire lucrare</span><span style="color:var(--mut,#5c6473);text-align:center;font:400 11px var(--font-ui);">&rarr;</span><span style="padding:5px 8px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 12%,var(--card,#181c24));border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 35%,var(--line,#262b36));color:var(--text,#e6e9ef);font:600 11px var(--font-ui);display:flex;align-items:center;justify-content:space-between;gap:6px;overflow:hidden;white-space:nowrap;">Operații <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.6;"><path d="M6 9l6 6 6-6"/></svg></span></div>
<div style="font:400 11px/1.5 var(--font-ui);color:var(--okt,#2FBF8F);display:flex;gap:6px;align-items:flex-start;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>
Le potrivești o singură dată — la următorul fișier le ținem minte noi.
</div>
</div>
<!-- Pas 3: verificare -->
<div style="background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px;min-width:0;">
<div style="font:700 12px var(--font-ui);letter-spacing:.06em;text-transform:uppercase;color:var(--sub,#8b93a7);display:flex;align-items:center;gap:7px;"><span style="width:17px;height:17px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;display:inline-flex;align-items:center;justify-content:center;font:700 10px var(--font-ui);flex-shrink:0;">3</span>Verifică</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#2FBF8F 13%,transparent);color:var(--okt,#2FBF8F);white-space:nowrap;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>22 gata de trimis</span>
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#E0A93B 14%,transparent);color:#E0A93B;white-space:nowrap;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>1 verifică valori</span>
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#E05D5D 13%,transparent);color:var(--errt,#E05D5D);white-space:nowrap;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>1 cod RAR lipsă</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;border-radius:7px;background:var(--card,#181c24);border:1px solid var(--line2,#1f2530);min-width:0;">
<div style="min-width:0;"><div style="font:500 11px var(--font-mono);color:var(--text,#e6e9ef);">WBA8E9...K7F2</div><div style="font:400 10.5px var(--font-ui);color:var(--sub,#8b93a7);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Revizie periodică · 22.06.2026</div></div>
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#2FBF8F 13%,transparent);color:var(--okt,#2FBF8F);white-space:nowrap;flex-shrink:0;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>Gata de trimis</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;border-radius:7px;background:var(--card,#181c24);border:1px solid color-mix(in srgb,#E0A93B 40%,var(--line,#262b36));min-width:0;">
<div style="min-width:0;"><div style="font:500 11px var(--font-mono);color:var(--text,#e6e9ef);">WVWZZZ...3M1</div><div style="font:400 10.5px var(--font-ui);color:var(--sub,#8b93a7);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Schimb plăcuțe frână · 22.06.2026</div></div>
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#E0A93B 14%,transparent);color:#E0A93B;white-space:nowrap;flex-shrink:0;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>Verifică valori</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;border-radius:7px;background:var(--card,#181c24);border:1px solid color-mix(in srgb,#E05D5D 40%,var(--line,#262b36));min-width:0;">
<div style="min-width:0;"><div style="font:500 11px var(--font-mono);color:var(--text,#e6e9ef);">VF1RFB...A88</div><div style="font:400 10.5px var(--font-ui);color:var(--sub,#8b93a7);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">REP. ELECTROMOTOR · 21.06.2026</div></div>
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#E05D5D 13%,transparent);color:var(--errt,#E05D5D);white-space:nowrap;flex-shrink:0;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>Cod RAR lipsă</span>
</div>
<div style="display:flex;gap:6px;align-items:center;min-width:0;">
<span style="flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:6px;padding:5px 8px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);font:400 11px var(--font-ui);color:var(--text,#e6e9ef);white-space:nowrap;overflow:hidden;">63 — Instalație electrică <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.55;"><path d="M6 9l6 6 6-6"/></svg></span>
<span style="height:26px;padding:0 12px;border-radius:6px;background:var(--accent,#2E74D6);color:#fff;font:600 11px/26px var(--font-ui);flex-shrink:0;">Salvează</span>
</div>
<div style="font:400 10.5px/1.5 var(--font-ui);color:var(--accent,#2E74D6);display:flex;gap:6px;align-items:flex-start;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>
Sugestia e preselectată — o confirmi cu un click și rămâne salvată.
</div>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin:0 18px 18px;padding:13px 16px;border-radius:10px;background:color-mix(in srgb,#1F9D5C 10%,var(--card2,#0f1218));border:1px solid color-mix(in srgb,#1F9D5C 32%,var(--line,#262b36));flex-wrap:wrap;">
<div style="font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);"><strong style="color:var(--text,#e6e9ef);font-weight:700;">22 de prezentări</strong> gata de trimis · duplicatele sunt oprite automat, tu doar confirmi numărul</div>
<span style="height:38px;padding:0 18px;border-radius:6px;background:#1F9D5C;color:#fff;font:700 13px/38px var(--font-ui);">Trimite la RAR &rarr;</span>
</div>
</div>
<div style="text-align:center;max-width:880px;margin:30px auto 0;font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);">
<span style="text-decoration:line-through;text-decoration-color:var(--errt,#E05D5D);text-decoration-thickness:2px;">23 minute de tastat pentru fiecare comandă</span><span style="color:var(--text,#e6e9ef);font-weight:700;">&nbsp;&nbsp; câteva secunde pentru tot lotul.</span>
</div>
</div>
@@ -226,7 +234,7 @@
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu</div>
<h2 style="font:700 30px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Ai deja un soft de service? Conectează-l automat</h2>
<p style="font:400 15px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Softul tău se poate conecta și direct la API-ul RAR Auto-Pass. Cu ROMFAST primești în plus asistență la maparea automată a operațiilor tale (prin mai multe metode) și salvarea mapărilor pentru trimiterile viitoare — totul printr-un singur apel, cu cheie API per cont.</p>
<button style="height:44px;padding:0 20px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi documentația API <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button>
<a href="/documentatie-api" style="height:44px;padding:0 20px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:transform .18s ease, background .18s ease, border-color .18s ease;text-decoration:none;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi documentația API <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
</div>
<div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;">
<div style="display:flex;align-items:center;gap:7px;padding:11px 14px;border-bottom:1px solid #262b36;">
@@ -264,20 +272,18 @@
<h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
<p style="font:400 15px var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Alege planul potrivit volumului tău. Poți schimba sau anula oricând.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:stretch;">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:0 auto;align-items:stretch;max-width:920px;">
<!-- Gratuit -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Până la 60 de trimiteri/lună</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:14px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px;"><span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:7px;font:700 11.5px var(--font-ui);background:color-mix(in srgb,var(--accent,#2E74D6) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--line,#262b36));color:var(--accent,#2E74D6);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Import CSV/XLSX</span></div>
<div style="display:flex;align-items:flex-start;gap:7px;padding:7px 10px;border-radius:7px;background:color-mix(in srgb,#2FBF8F 13%,transparent);color:var(--okt,#2FBF8F);font:600 12px/1.45 var(--font-ui);margin-bottom:14px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg><span>Economisești ~2,5 ore / ~150 lei pe lună <span style="font-weight:400;opacity:.8;">· la 60 de trimiteri</span></span></div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Trimiteri nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Până la 60 de trimiteri/lună</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Îți sugerăm codul RAR pentru fiecare operație</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Alegerile se salvează — la următorul fișier nu mai mapezi nimic</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Protecție la duplicate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, în 48h</div>
</div>
<button data-act="auth" data-tab="register" data-plan="free" style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Creează cont gratuit</button>
@@ -286,16 +292,12 @@
<div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;display:flex;flex-direction:column;">
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Popular</div>
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Standard</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">49 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Trimiteri nelimitate</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:14px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">49 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px;"><span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:7px;font:700 11.5px var(--font-ui);background:color-mix(in srgb,var(--accent,#2E74D6) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--line,#262b36));color:var(--accent,#2E74D6);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Import CSV/XLSX</span><span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:7px;font:500 11.5px var(--font-ui);background:transparent;border:1px solid var(--line,#262b36);color:var(--mut,#5c6473);text-decoration:line-through;text-decoration-thickness:1px;">API</span></div>
<div style="display:flex;align-items:flex-start;gap:7px;padding:7px 10px;border-radius:7px;background:color-mix(in srgb,#2FBF8F 13%,transparent);color:var(--okt,#2FBF8F);font:600 12px/1.45 var(--font-ui);margin-bottom:14px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg><span>Economisești 4+ ore / 250+ lei pe lună <span style="font-weight:400;opacity:.8;">· la 100+ trimiteri</span></span></div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg><span><strong style="font-weight:700;">Tot ce este la Gratuit</strong> +</span></div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 24h</div>
</div>
<button style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="standard">Creează cont gratuit</button>
@@ -303,37 +305,17 @@
<!-- Pro -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Nelimitat + acces API</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:14px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px;"><span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:7px;font:700 11.5px var(--font-ui);background:color-mix(in srgb,var(--accent,#2E74D6) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--line,#262b36));color:var(--accent,#2E74D6);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Import CSV/XLSX</span><span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:7px;font:700 11.5px var(--font-ui);background:color-mix(in srgb,#2FBF8F 13%,transparent);border:1px solid color-mix(in srgb,#2FBF8F 40%,var(--line,#262b36));color:var(--okt,#2FBF8F);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/></svg>+ API</span></div>
<div style="display:flex;align-items:flex-start;gap:7px;padding:7px 10px;border-radius:7px;background:color-mix(in srgb,#2FBF8F 13%,transparent);color:var(--okt,#2FBF8F);font:600 12px/1.45 var(--font-ui);margin-bottom:14px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg><span>Economisești 4+ ore / 250+ lei pe lună <span style="font-weight:400;opacity:.8;">· la 100+ trimiteri</span></span></div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg><span><strong style="font-weight:700;">Tot ce este la Standard</strong> +</span></div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimite direct din softul de service (API)</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Confirmi mapările fie prin API, fie din browser</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 8h</div>
</div>
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="pro">Creează cont gratuit</button>
</div>
<!-- Premium -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></div>
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Integrare în softul tău</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic + onboarding dedicat</div>
</div>
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="premium">Creează cont gratuit</button>
</div>
</div>
</div>
@@ -366,7 +348,7 @@
<h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2>
<p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit. Imediat poți încărca primul fișier sau conecta softul de service.</p>
<div style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Pro gratuit 30 de zile, apoi automat pe Gratuit</div>
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Ai Pro gratuit primele 30 de zile, apoi pachetul ales</div>
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023 și OMTI 210/2024</div>
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Datele cu caracter personal criptate (GDPR)</div>
</div>
@@ -382,8 +364,8 @@
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="free" selected>Gratuit — 0 lei/lună</option><option value="standard">Standard — 49 lei/lună</option><option value="pro">Pro — 59 lei/lună</option><option value="premium">Premium — la cerere</option></select></label>
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="free" selected>Gratuit — 0 lei/lună</option><option value="standard">Standard — 49 lei/lună</option><option value="pro">Pro — 59 lei/lună</option></select></label>
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" /><span>Sunt de acord cu <a href="/termeni" target="_blank" rel="noopener" style="color:var(--accent,#2E74D6);">Termenii</a> și cu prelucrarea datelor conform <a href="/confidentialitate" target="_blank" rel="noopener" style="color:var(--accent,#2E74D6);">politicii de confidențialitate (GDPR)</a>.</span></label>
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button>
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div>
</form>
@@ -399,14 +381,8 @@
</div>
</div>
<!-- FOOTER -->
<div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;">
<div style="font:700 18px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div>
<div style="display:flex;gap:26px;font:400 14px var(--font-ui);color:var(--sub,#8b93a7);">
<span>Termeni</span><span>Confidențialitate / GDPR</span><span>Documentație API</span><span>Contact</span>
</div>
<div style="font:400 13px var(--font-ui);color:var(--mut,#5c6473);">© 2026 ROMFAST</div>
</div>
<!-- FOOTER (comun cu paginile statice) -->
{% include "_lp_footer.html" %}
</main>
<script>
(function(){
@@ -434,12 +410,13 @@
el.addEventListener('mouseleave',function(){keys.forEach(function(k){el.style.setProperty(k,saved[k]);});});
});
// Calculator: cost raportare manuala (2,5 min/prestatie).
// Calculator: cost raportare manuala (2,5 min/prestatie). Slidere cu trepte discrete.
var pres=document.getElementById('calc-pres'),rate=document.getElementById('calc-rate');
var PRES_STEPS=[50,100,150,200,300,500],RATE_STEPS=[30,50,60,80,100,150];
var nf=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:0});
var nf1=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:1});
function recalc(){
var p=+pres.value,r=+rate.value,minPer=2.5;
var p=PRES_STEPS[+pres.value],r=RATE_STEPS[+rate.value],minPer=2.5;
var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
document.getElementById('out-pres').textContent=p;
document.getElementById('out-rate').textContent=r;

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titlu %}ROA AUTOPASS{% endblock %} — ROA AUTOPASS</title>
<meta name="description" content="{% block descriere %}ROA AUTOPASS — gateway de declarare a prestatiilor la RAR Auto-Pass.{% endblock %}">
{% include "_lp_style.html" %}
<style>
/* Prose pentru paginile statice (termeni, GDPR, contact, documentatie API). */
.lp-prose{max-width:820px;margin:0 auto;padding:56px 40px 80px;}
.lp-prose h1{font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 8px;color:var(--text,#e6e9ef);}
.lp-prose .lp-sub{font:400 14px var(--font-ui);color:var(--mut,#5c6473);margin:0 0 36px;}
.lp-prose h2{font:700 20px/1.3 var(--font-ui);letter-spacing:-.01em;margin:36px 0 10px;color:var(--text,#e6e9ef);}
.lp-prose p,.lp-prose li{font:400 15px/1.7 var(--font-ui);color:var(--sub,#8b93a7);}
.lp-prose p{margin:0 0 12px;}
.lp-prose ul{margin:0 0 12px;padding-left:22px;}
.lp-prose li{margin-bottom:6px;}
.lp-prose strong{color:var(--text,#e6e9ef);font-weight:600;}
.lp-prose a{color:var(--accent,#2E74D6);}
.lp-prose code{font:400 13px var(--font-mono);color:var(--text,#e6e9ef);background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:4px;padding:1px 5px;}
.lp-prose pre{background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:8px;padding:16px;overflow-x:auto;margin:0 0 12px;}
.lp-prose pre code{background:none;border:none;padding:0;font:400 13px/1.6 var(--font-mono);}
.lp-prose table{width:100%;border-collapse:collapse;margin:0 0 12px;}
.lp-prose th,.lp-prose td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line,#262b36);font:400 14px/1.5 var(--font-ui);color:var(--sub,#8b93a7);}
.lp-prose th{color:var(--text,#e6e9ef);font-weight:600;}
.lp-prose .lp-card{background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:10px;padding:18px 20px;margin:0 0 12px;}
@media (max-width:700px){.lp-prose{padding:36px 20px 48px;}}
</style>
</head>
<body data-theme="grafit">
<script>try{var _t=localStorage.getItem('theme');var _V={light:1,dark:1,petrol:1,grafit:1,cobalt:1,cupru:1,hartie:1,auto:1};if(_t&&_V[_t]){var _r=_t==='auto'?(window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'):_t;document.body.setAttribute('data-theme',_r);}}catch(e){}</script>
<main class="page">
{% with lp_root = "/" %}{% include "_lp_header.html" %}{% endwith %}
<div class="lp-prose">
{% block continut %}{% endblock %}
</div>
{% include "_lp_footer.html" %}
</main>
<script>
(function(){
// Aceeasi rotatie de teme si cheie 'theme' ca landing/base.html.
var THEMES=[['light','Light'],['dark','Dark'],['petrol','Petrol'],['grafit','Grafit'],['cobalt','Cobalt'],['cupru','Cupru'],['hartie','Hârtie'],['auto','Auto']];
var VALID=THEMES.reduce(function(a,t){a[t[0]]=1;return a;},{});
var body=document.body;
function resolve(id){return id==='auto'?(window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'):id;}
function storedId(){try{var v=localStorage.getItem('theme');return (v&&VALID[v])?v:null;}catch(e){return null;}}
function curIndex(){var t=storedId()||body.getAttribute('data-theme')||'grafit';for(var i=0;i<THEMES.length;i++){if(THEMES[i][0]===t)return i;}return 3;}
function label(i){var l=document.getElementById('theme-label');if(l)l.textContent=THEMES[i][1];}
function applyTheme(i){i=((i%THEMES.length)+THEMES.length)%THEMES.length;var id=THEMES[i][0];body.setAttribute('data-theme',resolve(id));label(i);try{localStorage.setItem('theme',id);}catch(e){}}
(function(){var i=curIndex();body.setAttribute('data-theme',resolve(THEMES[i][0]));label(i);})();
function parseStyle(str){var o={};str.split(';').forEach(function(p){var idx=p.indexOf(':');if(idx>0)o[p.slice(0,idx).trim()]=p.slice(idx+1).trim();});return o;}
document.querySelectorAll('[style-hover]').forEach(function(el){
var hov=parseStyle(el.getAttribute('style-hover'));var keys=Object.keys(hov);var saved={};
el.addEventListener('mouseenter',function(){keys.forEach(function(k){saved[k]=el.style.getPropertyValue(k);el.style.setProperty(k,hov[k]);});});
el.addEventListener('mouseleave',function(){keys.forEach(function(k){el.style.setProperty(k,saved[k]);});});
});
// Pe paginile statice nu exista formularul #inregistrare: butonul de auth duce pe landing.
document.addEventListener('click',function(e){
var t=e.target.closest('[data-act]');if(!t)return;
var act=t.getAttribute('data-act');
if(act==='theme'){applyTheme(curIndex()+1);}
else if(act==='auth'){e.preventDefault();window.location.href='/#inregistrare';}
});
})();
</script>
</body>
</html>

View File

@@ -2,7 +2,7 @@
{% block title %}Autentificare — ROA AUTOPASS{% endblock %}
{% block content %}
{# /login — card de autentificare branduit, o singura coloana (simplificat din layout-ul
2-coloane US-010 PRD 5.16). Formular: CSRF, POST /login, link signup. #}
2-coloane). Formular: CSRF, POST /login, link signup. #}
<div class="login-2col" style="max-width:860px; margin:32px auto;">
{# Antet minimal deja randat in base.html (fara RAR dot, fara burger, fara account_name) #}
<div class="login-shell">

View File

@@ -44,8 +44,8 @@
{% endif %}
{# Format aliniat la formularul de inregistrare din landing (#inregistrare): aceleasi campuri,
etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro/premium),
normalizate server-side. #}
etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro),
normalizate server-side; 'premium' ramane valid in backend, doar alocat de admin. #}
<form method="post" action="/signup">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label style="display:block;margin-bottom:14px;">
@@ -75,7 +75,6 @@
<option value="free"{% if not plan or plan == 'free' %} selected{% endif %}>Gratuit — 0 lei/lună</option>
<option value="standard"{% if plan == 'standard' %} selected{% endif %}>Standard — 49 lei/lună</option>
<option value="pro"{% if plan == 'pro' %} selected{% endif %}>Pro — 59 lei/lună</option>
<option value="premium"{% if plan == 'premium' %} selected{% endif %}>Premium — la cerere</option>
</select>
</label>
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--muted);cursor:pointer;">

View File

@@ -0,0 +1,53 @@
{% extends "landing_static.html" %}
{% block titlu %}Termeni și condiții{% endblock %}
{% block descriere %}Termenii și condițiile de utilizare a serviciului ROA AUTOPASS.{% endblock %}
{% block continut %}
<h1>Termeni și condiții</h1>
<p class="lp-sub">Ultima actualizare: 9 iulie 2026</p>
<h2>1. Serviciul</h2>
<p><strong>ROA AUTOPASS</strong> este un serviciu operat de <strong>ROMFAST</strong> care preia prezentările
(prestațiile) din service-ul tău auto — prin import de fișier sau prin API — și le transmite în numele tău
către sistemul <strong>RAR Auto-Pass</strong>, conform Legii nr. 142/2023 și OMTI nr. 210/2024.</p>
<p>Serviciul nu înlocuiește obligațiile legale ale service-ului: tu rămâi responsabil de corectitudinea
datelor declarate (VIN, kilometraj, data prestației, operațiile efectuate).</p>
<h2>2. Contul</h2>
<ul>
<li>Contul se creează gratuit, pe baza datelor firmei (denumire, CUI, email).</li>
<li>Ești responsabil de confidențialitatea parolei și a cheilor API generate pe cont.</li>
<li>Credențialele tale RAR Auto-Pass sunt folosite exclusiv pentru transmiterea prestațiilor tale și sunt stocate criptat.</li>
</ul>
<h2>3. Planuri și prețuri</h2>
<ul>
<li><strong>Gratuit</strong> — 0 lei/lună, până la 60 de trimiteri/lună, import CSV/XLSX.</li>
<li><strong>Standard</strong> — 49 lei/lună (fără TVA), trimiteri nelimitate.</li>
<li><strong>Pro</strong> — 59 lei/lună (fără TVA), trimiteri nelimitate + acces API.</li>
</ul>
<p>Fiecare cont nou primește 30 de zile de Pro gratuit; la final trece automat pe planul Gratuit, fără plată.
Poți schimba sau anula planul oricând; prețurile pot fi actualizate cu notificare prealabilă.</p>
<h2>4. Ce garantăm și ce nu</h2>
<ul>
<li>Depunem eforturi rezonabile ca serviciul să fie disponibil continuu, dar nu garantăm funcționarea
neîntreruptă — inclusiv pentru că transmiterea depinde de disponibilitatea sistemului RAR Auto-Pass.</li>
<li>Fiecare trimitere are protecție la duplicate și stare vizibilă în dashboard (trimisă, în coadă, de corectat).</li>
<li>O prezentare înregistrată la RAR este definitivă în sistemul RAR; corecțiile ulterioare se fac prin suportul RAR.</li>
<li>Răspunderea ROMFAST este limitată la contravaloarea abonamentului plătit pe ultimele 3 luni.</li>
</ul>
<h2>5. Utilizare acceptabilă</h2>
<p>Este interzisă folosirea serviciului pentru declararea de date false, pentru conturi ale altor firme fără
acordul acestora sau în orice mod care afectează funcționarea platformei ori a sistemului RAR.
Conturile care încalcă aceste reguli pot fi suspendate.</p>
<h2>6. Datele tale</h2>
<p>Prelucrarea datelor cu caracter personal este descrisă în
<a href="/confidentialitate">Politica de confidențialitate / GDPR</a>. Pe scurt: reținem doar strictul necesar,
datele sunt criptate, iar trimiterile se șterg automat după 90 de zile.</p>
<h2>7. Contact</h2>
<p>Pentru orice întrebare legată de acești termeni: <a href="mailto:contact@romfast.ro">contact@romfast.ro</a>
sau pagina de <a href="https://romfast.ro/menu/contact.html" target="_blank" rel="noopener">Contact</a>.</p>
{% endblock %}

View File

@@ -16,7 +16,7 @@ Worker-ul face login per CONT cu acele creds, cache-uieste JWT (30h) in memorie
STERGE creds-urile efemere dupa primul login reusit. Token-ul in memorie acopera
restul trimiterilor; la restart token-ul se pierde si contul re-logheaza la urmatorul
submission care aduce creds proaspete (degradare acceptata). Fallback durabil: slotul
per-env al contului (accounts.rar_creds_{env}_enc, US-013; coloana legacy dropata).
per-env al contului (accounts.rar_creds_{env}_enc; coloana legacy dropata).
Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc.
Pornire: python -m app.worker
@@ -37,7 +37,7 @@ from ..config import Settings, get_settings, load_test_credentials
from ..crypto import decrypt_creds
from ..db import get_connection, init_db, read_heartbeat, write_heartbeat
from ..observ import log_event, set_source
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
from ..mapping import DEFAULT_ACCOUNT_ID, split_prestatii_excluse, upsert_nomenclator
from ..payload import build_rar_payload
from ..reconcile import match_finalizata
from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env
@@ -142,6 +142,34 @@ def purge_expired(conn) -> dict[str, int]:
}
def expire_held(conn, settings: Settings) -> int:
"""Expira randurile tinute manual (`queued AND held=1`) mai vechi de `held_retention_days`.
Inchide gaura GDPR/L.142: un `queued held=1` nu e nici `sent` nici
blocat -> altfel NU primeste `purge_after` -> PII criptat ar sta la nesfarsit.
Le trece la `error`/`TINUT_EXPIRAT` (terminal) si seteaza `purge_after` DIRECT la
momentul expirarii (NU lasa retentia de 30z a randurilor blocate sa se adauge; altfel
viata reala = 90 + 30 = 120 zile, nu 90). `purge_after` e setat cu o secunda IN TRECUT
(`datetime('now','-1 second')`): `purge_expired` foloseste comparatie STRICTA
(`purge_after < datetime('now')`), iar `datetime('now')` are rezolutie de o secunda —
cu `purge_after=now` cele doua ar fi egale in acelasi ciclu si randul NU s-ar purja
decat la ciclul urmator (~1h de PII in plus). Cu `-1 second` randul e purjabil IMEDIAT
in acelasi ciclu de purjare.
Intoarce numarul de randuri expirate.
"""
# Numar de zile validat (int) — construim string-ul '-N days' fara input extern.
days = int(settings.held_retention_days)
cutoff = f"-{days} days"
cur = conn.execute(
"UPDATE submissions SET status='error', rar_error='TINUT_EXPIRAT', "
"purge_after=datetime('now', '-1 second'), sending_since=NULL, updated_at=datetime('now') "
"WHERE status='queued' AND held=1 AND created_at < datetime('now', ?)",
(cutoff,),
)
return cur.rowcount
def requeue_with_backoff(conn, settings: Settings, submission_id: int, *, reason: str) -> None:
"""Re-pune randul in coada cu retry++ si next_attempt_at = now + backoff."""
row = conn.execute("SELECT retry_count FROM submissions WHERE id=?", (submission_id,)).fetchone()
@@ -171,6 +199,10 @@ def claim_one(conn) -> dict | None:
"SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc, s.rar_env "
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
"WHERE s.status='queued' "
# Randurile tinute manual (held=1) NU se revendica —
# asteapta eliberarea umana (manual/auto). Se aplica DOAR la claim din
# 'queued'; randurile deja 'sending' (orfani/reconciliere) sunt neafectate.
"AND s.held = 0 "
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
# Gate pe stare de cont: doar 'active' trimite. Derivam defensiv din `active`
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
@@ -226,7 +258,10 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d
sid = claimed["id"]
account_id = claimed.get("account_id")
content = claimed["content"]
payload = build_rar_payload(content)
# Prestatiile excluse de la declarare raman in content (persistat neatins) dar
# nu pleaca niciodata la RAR: ingustare la declarabile chiar la momentul trimiterii.
declarabile, _excluse = split_prestatii_excluse(content.get("prestatii"))
payload = build_rar_payload({**content, "prestatii": declarabile})
try:
data = rar.post_prezentare(token, payload)
mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id"))
@@ -432,7 +467,7 @@ def _creds_for(claimed: dict, settings: Settings) -> dict | None:
def _creds_from_account(conn, account_id: int, rar_env: str = "test") -> dict | None:
"""Creds RAR durabile per-cont din slotul per-env (US-013 — coloana legacy dropata).
"""Creds RAR durabile per-cont din slotul per-env (coloana legacy dropata).
Canal web: creds in accounts.rar_creds_{rar_env}_enc (per-env, singurul slot valid).
"""
@@ -450,7 +485,7 @@ def _keepalive_target(conn, settings: Settings) -> tuple[int | None, dict | None
"""Un cont cu creds durabile pentru login-ul de proba (sau creds <test> in dev).
Ancora M2: cauta in slotul per-env al mediului `settings.rar_env` (ancora globala).
US-013: coloana legacy accounts.rar_creds_enc a fost dropata — se foloseste EXCLUSIV
Coloana legacy accounts.rar_creds_enc a fost dropata — se foloseste EXCLUSIV
slotul per-env. Sare conturile ale caror creds NU se decripteaza sub cheia curenta
(in dev `start.sh both` genereaza o cheie efemera noua la fiecare pornire).
"""
@@ -533,6 +568,14 @@ def run() -> int:
# Purjare periodica (odata pe ora) — NU mai frecvent.
now_ts = time.time()
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
# Expira intai randurile tinute imbatranite, apoi purjeaza:
# expire_held seteaza purge_after=now-1s -> randul e purjabil IMEDIAT in
# acelasi ciclu (purge_expired foloseste comparatie stricta purge_after<now).
expired = expire_held(conn, settings)
if expired:
_wlog(conn, "held_expirat",
f"{expired} randuri tinute expirate (>{settings.held_retention_days}z) -> error/TINUT_EXPIRAT",
nivel="WARNING", context={"expirate": expired})
stats = purge_expired(conn)
if stats["submissions_purged"] or stats["batches_purged"] or stats["events_purged"]:
print(

View File

@@ -1,4 +1,4 @@
"""Liveness probe pentru worker (T6) — folosit de healthcheck-ul Docker.
"""Liveness probe pentru worker — folosit de healthcheck-ul Docker.
Worker-ul nu e server HTTP, deci `restart: always` prinde doar procesul MORT,
nu si worker-ul AGATAT (proces viu care nu mai bate heartbeat). Acest probe

View File

@@ -13,15 +13,35 @@ services:
- autopass-data:/data
environment:
AUTOPASS_DB_PATH: /data/autopass.db
# Override din environment (Dokploy) pentru staging; default = prod.
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-prod}
# Ancora globala, NU tinta trimiterilor: mediul RAR e per
# submission/cont (worker-ul trimite dupa submissions.rar_env). Variabila
# asta serveste doar ca fallback API pentru conturi fara medii disponibile,
# backfill la migrare (db.py) si afisaj dashboard (routes.py). Obligatorie
# ca sa nu difere tacit intre api si worker (vezi worker mai jos).
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:?seteaza AUTOPASS_RAR_ENV (test|prod) in .env (vezi .env.example)}
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
TZ: ${TZ:-Europe/Bucharest}
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false}
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:?seteaza AUTOPASS_REQUIRE_API_KEY (true in prod) in .env (vezi .env.example)}
# Embeddings (sugestie mapare, Stratul 2): prima cerere /mapari lazy-load-eaza
# modelul ~230MB. Doar API-ul il incarca (worker-ul nu). Default off.
AUTOPASS_EMBEDDINGS_ENABLED: ${AUTOPASS_EMBEDDINGS_ENABLED:-false}
# uvicorn 0.30.0 are proxy_headers=True implicit; lipsea doar increderea in
# sursa headerelor, altfel request.client.host = IP-ul Traefik pentru toti
# vizitatorii (rate-limit login/signup devine o galeata GLOBALA). Uvicorn
# citeste FORWARDED_ALLOW_IPS direct din mediu (fara flag CLI necesar).
# ATENTIE, doua invariante trebuie sa ramana adevarate impreuna cu "*":
# (1) serviciul api NU publica `ports:` pe host aici — accesibil DOAR prin Traefik;
# (2) Traefik NU e configurat cu forwardedHeaders.insecure=true.
# Daca (2) e incalcat, un atacator poate falsifica X-Forwarded-For si ocoli
# rate-limit-ul (galeata noua per cerere = brute-force nelimitat).
FORWARDED_ALLOW_IPS: "*"
# Fara secret persistent, fiecare redeploy delogheaza toti userii (main.py
# genereaza unul efemer la runtime). HTTPS_ONLY=true e corect pentru prod,
# dar inseamna ca un smoke-test simplu pe HTTP NU poate face login (browserul
# dropeaza cookie-ul Secure) — pe HTTP local seteaza explicit false.
AUTOPASS_SESSION_SECRET: ${AUTOPASS_SESSION_SECRET:?seteaza AUTOPASS_SESSION_SECRET in .env (vezi .env.example)}
AUTOPASS_SESSION_HTTPS_ONLY: ${AUTOPASS_SESSION_HTTPS_ONLY:-true}
restart: always
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8010/healthz').status==200 else 1)"]
@@ -36,11 +56,15 @@ services:
- autopass-data:/data
environment:
AUTOPASS_DB_PATH: /data/autopass.db
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-test}
# Vezi comentariul de la serviciul api: ancora globala, NU tinta trimiterilor
# (worker-ul trimite dupa submissions.rar_env, per cont). Aceeasi variabila
# obligatorie AICI, ca sa nu diverga tacit fata de api (era prod/test split-brain).
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:?seteaza AUTOPASS_RAR_ENV (test|prod) in .env (vezi .env.example)}
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
# Send activ by default (prod); pe staging seteaza AUTOPASS_WORKER_SEND_ENABLED=false
# in Dokploy ca worker-ul sa NU trimita declaratii reale la RAR (Legea 142/2023).
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:-true}
# Send catre RAR. Obligatoriu explicit — fara ea, un misconfig ar putea lasa
# worker-ul sa trimita declaratii reale neintentionat (Legea 142/2023).
# false = nu trimite (sigur pentru probe). true = end-to-end.
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:?seteaza AUTOPASS_WORKER_SEND_ENABLED (true|false) in .env (vezi .env.example)}
restart: always
depends_on:
- api
@@ -68,5 +92,33 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Backup SQLite criptat (T2/P0-4, evidenta legala L.142/2023) — vezi docs/backup.md.
# Serviciu OPTIONAL, profil "backup": nu porneste la `docker compose up` normal.
# Activare: `docker compose --profile backup up -d backup` (sau COMPOSE_PROFILES=backup
# in mediul Dokploy). Alternativa fara profil compose: cron pe host care ruleaza
# `docker compose exec api bash tools/backup_db.sh` — mai simpla daca ai deja acces SSH
# la host, dar in Dokploy hostul poate fi gestionat/efemer, de aceea preferam varianta
# de mai jos (calatoreste cu image-ul, nu depinde de un cron configurat manual pe VM).
# backup:
# build: .
# profiles: ["backup"]
# entrypoint: ["bash", "-c"]
# command:
# - |
# while true; do
# bash tools/backup_db.sh || echo "[backup] rulare esuata, reincerc la urmatorul ciclu"
# sleep 86400
# done
# volumes:
# - autopass-data:/data
# environment:
# AUTOPASS_DB_PATH: /data/autopass.db
# AUTOPASS_BACKUP_DIR: ${AUTOPASS_BACKUP_DIR:-/data/backups}
# AUTOPASS_BACKUP_KEEP: ${AUTOPASS_BACKUP_KEEP:-14}
# AUTOPASS_BACKUP_PASSPHRASE_FILE: ${AUTOPASS_BACKUP_PASSPHRASE_FILE:-}
# AUTOPASS_BACKUP_PASSPHRASE: ${AUTOPASS_BACKUP_PASSPHRASE:-}
# AUTOPASS_BACKUP_RCLONE_REMOTE: ${AUTOPASS_BACKUP_RCLONE_REMOTE:-}
# restart: always
volumes:
autopass-data:

File diff suppressed because one or more lines are too long

View File

@@ -444,6 +444,26 @@ randul se RE-ACTIVEAZA (re-clasificat + creds actualizate) si raspunsul poarta
care testeaza `deduped` nu se sparg). Pentru `sent`/`queued`/`sending`/`needs_*` ->
`deduped: true` (neschimbat).
**Forma raspunsului `POST /v1/prezentari` (2026-07-08).** Camp optional `raspuns` in
cerere: `"minim"` (implicit) sau `"complet"`. Rezultatele vin mereu in aceeasi ordine
cu lista `prezentari` din cerere.
- `minim` — 6 chei fixe per rezultat, usor de parsat de clienti simpli (VFP):
`index` (pozitia 0-based in cerere), `submission_id`, `vehicul` (nr. inmatriculare,
ecou normalizat), `status`, `motiv` (rezumat uman pe o linie; null pe queued curat),
`id_prezentare` (non-null doar pe dedup al unui rand deja trimis la RAR).
- `complet` — `SubmissionResult` intreg: in plus `erori`/`nemapate` (3 niveluri),
`deduped`, `reactivated`, `held`, `rar_env`, plus corelarea `index`/`vin`/
`nr_inmatriculare`. Formatul detaliat de dinainte, neschimbat.
**Sugestia principala pe coduri nemapate (2026-07-09).** Pe `needs_mapping`, `motiv`
include si propunerea de cod cand exista una: `Coduri fara mapare RAR: REP-MOT
(sugestie: OE-1 REPARATIE)`. In formatul `complet` (si in dry-run `/valideaza`),
fiecare element din `nemapate` poarta `sugestie: {cod_prestatie, nume_prestatie,
sursa} | null` (sursa: `gold_partajat` | `silver` | `embedding` | `fuzzy`).
SUGGESTION-ONLY: nu se aplica automat; confirmarea se face in tab-ul Mapari sau
prin `POST /v1/mapari`.
- `DELETE /v1/prezentari/{id}` — sterge o trimitere blocata a contului cheii API.
**200 + body JSON** `{ok, submission_id, status_anterior}` (NU 204 — clienti VFP string-parse).
Scope evaluat INAINTEA starii: cross-account / inexistent -> **404** (acelasi mesaj, B3);

99
docs/backup.md Normal file
View File

@@ -0,0 +1,99 @@
# Backup SQLite (T2/P0-4)
## Ce se salveaza si de ce
Baza SQLite (`AUTOPASS_DB_PATH`, prod `/data/autopass.db`) e sistemul legal de evidenta
al declaratiilor RAR AUTOPASS (Legea 142/2023). `FINALIZATA` e **terminal** la RAR — nu
exista anulare/corectie prin API. Pierderea volumului fara backup inseamna, la restaurare
dintr-un mediu gol, retrimiterea acelorasi randuri si duplicate pe care RAR le accepta
fara sa poata fi corectate. De aceea backup-ul e trigger dur: **configureaza-l inainte
de prima declaratie reala in prod**, nu dupa.
Backup-urile sunt criptate (gpg AES256) pentru ca snapshot-ul contine PII criptat Fernet
(nume, VIN, date client) plus metadate — un backup necriptat pe disc/remote ar fi un al
doilea loc unde datele astea pot scapa.
## Rulare manuala
```bash
# in container (api sau worker, acelasi image + volum):
docker compose exec api bash tools/backup_db.sh
# local (dev), cu parola de test:
AUTOPASS_DB_PATH=./data/autopass.db \
AUTOPASS_BACKUP_DIR=./data/backups \
AUTOPASS_BACKUP_PASSPHRASE_FILE=/cale/catre/parola \
bash tools/backup_db.sh
```
Scriptul foloseste `sqlite3.Connection.backup` (online, sigur cu WAL activ — NU `cp`),
comprima (gzip) si cripteaza (gpg simetric). Fara `AUTOPASS_BACKUP_PASSPHRASE_FILE` sau
`AUTOPASS_BACKUP_PASSPHRASE` seteaza, scriptul **refuza** sa scrie un backup necriptat si
iese cu cod diferit de 0. Pastreaza ultimele `AUTOPASS_BACKUP_KEEP` (implicit 14) fisiere
in `AUTOPASS_BACKUP_DIR` (implicit `/data/backups`, pe volumul persistent). Daca
`AUTOPASS_BACKUP_RCLONE_REMOTE` e setat si `rclone` e disponibil, urca automat
backup-ul nou catre remote (off-site); altfel sare peste, cu log explicit.
## Programare
Doua variante, alege una:
1. **Serviciu compose optional** (`docker-compose.yml`, serviciu `backup`, profil
`backup`, comentat implicit) — ruleaza scriptul intr-o bucla cu `sleep 86400` in
propriul container, pe acelasi volum `autopass-data`. Activare:
```bash
docker compose --profile backup up -d backup
```
sau, in Dokploy, seteaza `COMPOSE_PROFILES=backup` in mediul aplicatiei. Preferata
pentru un deploy Dokploy: calatoreste cu image-ul si nu depinde de acces SSH separat
la hostul care ruleaza containerele (host care in Dokploy poate fi gestionat/efemer).
2. **Cron pe host** — daca ai acces SSH direct la masina care ruleaza containerele:
```cron
0 3 * * * cd /cale/catre/autopass && docker compose exec -T api bash tools/backup_db.sh
```
In ambele cazuri, parola de criptare trebuie sa fie accesibila containerului (secret
montat, indicat prin `AUTOPASS_BACKUP_PASSPHRASE_FILE`) — nu doar in mediul shell al
cron-ului de pe host.
## Restaurare (pas cu pas)
1. **Opreste worker-ul** (`docker compose stop worker`) — altfel worker-ul poate scrie
in baza in timp ce o inlocuiesti, sau poate re-prelua randuri `sending` in timp ce
restaurarea e pe jumatate facuta.
2. Alege backup-ul de restaurat (fisier `autopass-YYYYmmdd-HHMMSS.db.gz.gpg` din
`AUTOPASS_BACKUP_DIR`).
3. Decripteaza + dezarhiveaza intr-un fisier temporar (NU suprascrie direct baza vie):
```bash
gpg --batch --decrypt --passphrase-file /cale/catre/parola \
--output /tmp/restore.db.gz autopass-20260706-030000.db.gz.gpg
gzip -d /tmp/restore.db.gz
```
4. Verifica integritatea inainte sa promovezi fisierul (vezi sectiunea urmatoare).
5. Muta fisierul verificat peste `AUTOPASS_DB_PATH` (backup-uieste intai baza curenta
daca mai exista, chiar corupta — pastreaz-o pentru investigare).
6. Porneste din nou worker-ul (`docker compose start worker`).
## Verificare (restore_check)
Un backup neverificat nu e backup. `tools/restore_check.sh` decripteaza + dezarhiveaza
cel mai recent backup (sau unul explicit, ca argument) intr-un fisier temporar si ruleaza
`PRAGMA integrity_check` + `SELECT count(*) FROM submissions`. Iese cu cod diferit de 0
daca decriptarea, integritatea sau interogarea esueaza.
```bash
AUTOPASS_BACKUP_PASSPHRASE_FILE=/cale/catre/parola tools/restore_check.sh
# sau explicit:
tools/restore_check.sh /data/backups/autopass-20260706-030000.db.gz.gpg
```
Ruleaza-l periodic (ex. dupa fiecare backup programat) — un backup care nu se poate
restaura si verifica nu ofera nicio garantie reala.
## Unde stau parolele
Parola de criptare (`AUTOPASS_BACKUP_PASSPHRASE_FILE`) e un secret separat de
`AUTOPASS_CREDS_KEY` (Fernet, creds RAR) — nu le refolosi una pe cealalta. In prod,
monteaz-o ca fisier secret (nu variabila de mediu inline in `.env` necriptat pe disc)
si restrictioneaza accesul la fisier. `AUTOPASS_BACKUP_PASSPHRASE` (variabila inline)
e doar pentru teste locale/CI, nu pentru prod.

36
docs/deploy-dokploy.md Normal file
View File

@@ -0,0 +1,36 @@
# Deploy pe Dokploy (autopass.romfast.ro)
Serviciul ruleaza in Dokploy (LXC 103, dokploy.romfast.ro), proiect AUTOPASS/production,
compose `autopass-roaautopass-jqqw1l`. Provider: Gitea → repo `rar-autopass` → branch `main`
`./docker-compose.yml`, autodeploy la push. Detalii de infrastructura in
`proxmox/lxc103-dokploy/docs/autopass.md` (repo-ul de infrastructura, nu acesta).
## Reguli la modificarea docker-compose.yml / Dockerfile
1. **Variabila noua obligatorie (`${VAR:?...}`)** → seteaz-o in Dokploy → service autopass →
Environment INAINTE de push. Altfel `docker compose` pica la interpolare, inainte de build,
cu `required variable VAR is missing a value`. Compose se opreste la PRIMA variabila lipsa,
deci mai multe variabile lipsa ies la iveala una pe runda de deploy.
Semnatura in Dokploy: deployment "Error" cu durata **0s** = interpolare env, nu build.
2. **Schimbarea uid-ului userului non-root din Dockerfile** → volumul de date pastreaza
ownership-ul vechi si SQLite devine readonly (`attempt to write a readonly database`),
api + worker intra in crash-loop, Traefik ramane fara backend sanatos → site 404.
Inainte de deploy, pe hostul Dokploy:
```bash
docker volume inspect autopass-roaautopass-jqqw1l_autopass-data --format '{{.Mountpoint}}'
docker run --rm <image-api> id app # afla uid/gid curent (acum 10001:10001)
chown -R 10001:10001 /var/lib/docker/volumes/autopass-roaautopass-jqqw1l_autopass-data/_data
cd /etc/dokploy/compose/autopass-roaautopass-jqqw1l/code
docker compose -p autopass-roaautopass-jqqw1l -f ./docker-compose.yml restart api worker
```
## Incident 2026-07-09 (referinta)
Deploy-urile picau la 0s dupa commit-ul care a facut obligatorii `AUTOPASS_RAR_ENV`,
`AUTOPASS_SESSION_SECRET` si `AUTOPASS_WORKER_SEND_ENABLED` (cauza 1, doua runde de fix).
Dupa setarea variabilelor (`AUTOPASS_RAR_ENV=prod`, `AUTOPASS_SESSION_SECRET=<hex32>`,
`AUTOPASS_WORKER_SEND_ENABLED=true`), site-ul a dat 404 din cauza volumului detinut de root
in fata userului `app` uid 10001 (cauza 2); rezolvat cu chown-ul de mai sus, fara pierdere
de date.

View File

@@ -0,0 +1,334 @@
# PRD: Cache persistent de vectori embeddings in SQLite
## 1. Introducere
Vectorii corpusului k-NN (17.181 exemple etichetate din `mapping_suggestions`) traiesc doar in RAM si se recalculeaza integral la fiecare pornire a API-ului (~1-2 minute de CPU), desi textele nu s-au schimbat. Warmup-ul in fundal (deja implementat) tine pagina Mapari rapida, dar lasa o fereastra de 1-2 minute dupa restart in care sugestiile embeddings lipsesc. Acest feature persista vectorii intr-o tabela SQLite si vectorizeaza incremental doar textele noi/modificate, reducand fereastra la doar incarcarea modelului (~10-20s).
## 2. Obiective
### Obiectiv Principal
- Elimina re-vectorizarea integrala a corpusului la fiecare restart: vectorii se calculeaza o singura data si se refolosesc.
### Obiective Secundare
- Fereastra fara sugestii embeddings dupa restart scade de la ~1-2 minute la ~10-20s (doar incarcarea modelului ONNX).
- Cresterea corpusului SILVER nu mai scumpeste restarturile (cost proportional doar cu randurile NOI).
- Cache reconstruibil oricand (`DELETE FROM embedding_cache` + restart), fara alte efecte.
### Metrici de Succes
- La corpus neschimbat: zero apeluri `embed()` pe corpus la startup; indexarea din cache < 5s.
- La N randuri noi in corpus: exact N texte vectorizate la urmatorul warmup.
- Sugestiile embeddings (sursa "similaritate") functioneaza identic inainte/dupa (acelasi cod sugerat pentru acelasi query).
## 3. User Stories
### US-001: Tabela embedding_cache in schema
**Ca** sistem
**Vreau** o tabela dedicata pentru vectorii corpusului
**Pentru ca** vectorii sa supravietuiasca restartului fara sa ating schema `mapping_suggestions`.
**Acceptance Criteria:**
- [ ] `app/schema.sql` contine tabela `embedding_cache` cu: `text_hash TEXT NOT NULL` (SHA-256 al textului normalizat), `model TEXT NOT NULL` (numele modelului fastembed), `vector BLOB NOT NULL` (float32 little-endian, 384 valori), `created_at TEXT NOT NULL DEFAULT (datetime('now'))`, `PRIMARY KEY (model, text_hash)`.
- [ ] `init_db()` creeaza tabela idempotent (CREATE TABLE IF NOT EXISTS), pe baza de date existenta si pe una noua.
- [ ] `python3 -m pytest -q` trece.
### US-002: Serializare + acces cache (functii pure)
**Ca** developer
**Vreau** functii de citire/scriere vectori in cache
**Pentru ca** logica de (de)serializare sa fie testabila izolat.
**Acceptance Criteria:**
- [ ] Functii in `app/embeddings.py` (sau modul nou `app/embedding_cache.py`): `vector_to_blob(list[float]) -> bytes` si `blob_to_vector(bytes) -> list[float]` (float32 LE, `array('f')` din stdlib, fara dependinta noua).
- [ ] `load_cached_vectors(conn, model, hashes) -> dict[hash, vector]` un singur SELECT (chunk-uit la limita de parametri SQLite), intoarce doar hash-urile gasite.
- [ ] `save_vectors(conn, model, items: list[(hash, vector)])` INSERT OR REPLACE batch, commit la final.
- [ ] Round-trip exact: `blob_to_vector(vector_to_blob(v)) == pytest.approx(v)` pentru vectori de 384 float.
- [ ] `python3 -m pytest -q` trece.
### US-003: ensure_embeddings_corpus foloseste cache-ul
**Ca** operator
**Vreau** ca warmup-ul sa vectorizeze doar textele lipsa din cache
**Pentru ca** restartul sa nu mai coste 1-2 minute de CPU.
**Acceptance Criteria:**
- [ ] `ensure_embeddings_corpus(conn, block=True)`: calculeaza hash per `denumire_normalizata`, citeste vectorii existenti din `embedding_cache` pentru modelul curent, apeleaza `embed()` DOAR pe textele fara vector in cache, salveaza vectorii noi in cache, apoi indexeaza corpusul complet (cache + noi) in `EmbeddingEngine`.
- [ ] `EmbeddingEngine.index_corpus` accepta vectori precalculati (parametru nou `vectors` sau metoda `index_corpus_precomputed`) fara sa apeleze backend-ul pentru ei; comportamentul existent (fara vectori -> embed tot) ramane pentru compatibilitate.
- [ ] Semantica semnaturii corpusului ramane neschimbata: acelasi corpus SILVER -> nu se reindexeaza; corpus schimbat -> reindexare (dar embed doar pe diferenta).
- [ ] Calea de request (block=False) ramane non-blocanta, neatinsa.
- [ ] Degradare gratioasa pastrata: orice eroare de cache (tabela lipsa, blob corupt) -> fallback pe embed complet, fara exceptie propagata.
- [ ] Blob corupt (lungime gresita) e ignorat si re-vectorizat, nu crapa indexarea.
- [ ] `python3 -m pytest -q` trece.
### US-004: Invalidare pe model + curatare orfane
**Ca** sistem
**Vreau** cache-ul legat de numele modelului si curatat de intrari moarte
**Pentru ca** schimbarea modelului sa nu serveasca vectori incompatibili, iar cache-ul sa nu creasca nelimitat.
**Acceptance Criteria:**
- [ ] Cheia de cache include `model` (= `FASTEMBED_MODEL`): la schimbarea constantei, vectorii vechi NU sunt folositi (se re-vectorizeaza sub noul model, intrarile vechi raman inerte).
- [ ] La finalul unei indexari reusite, intrarile `embedding_cache` ale modelului curent cu `text_hash` care nu mai exista in corpus sunt sterse (DELETE orfane).
- [ ] Test: schimbarea modelului (mock cu nume diferit) -> zero hit-uri din cache; stergerea unui rand din corpus -> intrarea orfana dispare dupa reindexare.
- [ ] `python3 -m pytest -q` trece.
### US-005: Teste de flux cold/warm start
**Ca** developer
**Vreau** teste care fixeaza comportamentul incremental
**Pentru ca** regresiile de performanta la startup sa fie prinse de suita.
**Acceptance Criteria:**
- [ ] Test cold start: cache gol + corpus de N randuri -> backend mock primeste exact N texte, cache-ul contine N intrari dupa.
- [ ] Test warm start: al doilea proces (engine nou, acelasi conn) -> backend mock primeste ZERO texte de corpus, `has_corpus()` True, `suggest_nearest` functioneaza cu vectorii din cache.
- [ ] Test incremental: se adauga 1 rand in `mapping_suggestions` -> backend mock primeste exact 1 text la reindexare.
- [ ] Testele folosesc backend mock (fara fastembed real), ruleaza in suita implicita (fara marker `live`).
- [ ] `python3 -m pytest -q` trece.
### US-006: Verificare end-to-end pe instanta reala
**Ca** operator
**Vreau** confirmarea pe serverul real ca restartul e ieftin
**Pentru ca** metrica de succes sa fie masurata, nu presupusa.
**Acceptance Criteria:**
- [ ] Log la finalul warmup-ului: numar vectori din cache / numar vectori calculati / durata totala (ex. `embeddings: warmup ok cache=17181 embed=0 in 12.3s`).
- [ ] Verify in browser: dupa `./start.sh stop && ./start.sh test both --send`, primul restart populeaza cache-ul; la al DOILEA restart, log-ul arata `embed=0` si sugestia "similaritate" apare in preview-ul regulilor text in < 30s de la pornire.
- [ ] Dimensiunea bazei creste cu ~26-30MB (17k x 384 float32) documentat in log/PRD, acceptat.
## 4. Cerinte Functionale
1. [REQ-001] Sistemul trebuie sa persiste vectorii corpusului SILVER in tabela `embedding_cache`, cheie `(model, text_hash)`.
2. [REQ-002] La warmup, sistemul trebuie sa vectorizeze DOAR textele fara intrare in cache pentru modelul curent.
3. [REQ-003] Cand corpusul SILVER se schimba (semnatura diferita), sistemul reindexeaza folosind cache-ul si vectorizeaza doar diferenta.
4. [REQ-004] Cand modelul se schimba, cache-ul vechi nu este folosit; vectorii se recalculeaza integral sub noua cheie de model.
5. [REQ-005] Orice eroare legata de cache degradeaza la comportamentul actual (embed complet), fara a bloca API-ul sau a arunca in request.
6. [REQ-006] Intrarile orfane (texte disparute din corpus) se curata la reindexare.
7. [REQ-007] Vectorii query (textul operatiei/pattern-ului cautat) NU se cacheaza se calculeaza la cerere (milisecunde).
## 5. Non-Goals (Ce NU facem)
- NU introducem vector store extern (sqlite-vec, Qdrant, FAISS) cosine in Python peste 17k vectori ramane suficient.
- NU cacheam embeddings pentru query-uri (texte noi cautate de utilizatori).
- NU modificam pipeline-ul de etichetare (`tools/mapare-llm/`) sau formatul seedului `app/data/operatii-etichetate.json`.
- NU vectorizam GOLD (`shared_mappings`) sau nomenclatorul sursa corpusului ramane exclusiv `mapping_suggestions`.
- NU schimbam modelul de embeddings, pragul `EMB_MIN_SIMILARITATE` sau precedenta GOLD > SILVER > embeddings.
- NU eliminam warmup-ul din fundal — ramane, doar devine ieftin.
## 6. Consideratii Tehnice
### Stack/Tehnologii
- SQLite existent (acelasi fisier `data/autopass.db`, WAL), stdlib `array`/`hashlib` — zero dependinte noi.
- fastembed/ONNX ramane sursa vectorilor la miss.
### Patterns de Urmat
- Degradare gratioasa ca in `ensure_embeddings_corpus`/`enrich_suggestions` (except -> pass, corpus gol e acceptabil).
- Schema idempotenta in `app/schema.sql` + `init_db()` (CREATE IF NOT EXISTS), ca restul tabelelor.
- Semnatura corpusului (`_corpus_signature_silver`) ramane mecanismul de decizie "reindexez sau nu".
- Backend injectabil in `EmbeddingEngine` pentru teste (mock, fara model real).
### Dependente
- Warmup-ul din fundal existent (`app/main.py::_warmup_embeddings`, `ensure_embeddings_corpus(block=True)`).
- `mapping_suggestions` populat la `init_db` din seedul comis (`app/operatii_seed.py`).
### Riscuri Tehnice
- Scriere concurenta API/worker pe SQLite: scrierile de cache se fac in tranzactii scurte, batch, doar din thread-ul de warmup al API-ului (worker-ul NU incarca modelul — invariant existent).
- Blob corupt/lungime gresita: tratat ca miss (re-embed), nu ca eroare.
- Crestere DB ~26-30MB: acceptata; curatarea orfanelor previne cresterea nelimitata.
- Hash-ul textului trebuie calculat pe `denumire_normalizata` EXACT cum intra in `index_corpus` — altfel miss permanent si cache inutil (test dedicat in US-005).
## 7. Consideratii UI/UX
Fara schimbari de UI. Efect indirect: sugestia "similaritate" (editor mapari + preview reguli text) devine disponibila la ~10-20s dupa restart in loc de 1-2 minute.
## 8. Success Metrics
- Apeluri `embed()` pe corpus la restart cu corpus neschimbat: 0 (log `embed=0`).
- Durata warmup la warm start: < 30s total (dominata de incarcarea modelului), fata de ~1-2 min acum.
- Zero regresii in suita: `python3 -m pytest -q` verde.
## 9. Open Questions
- [x] Vrem o comanda CLI de administrare (`python3 -m tools.embcache rebuild|clear|stats`)? DECIS (/autoplan): manual (`DELETE FROM embedding_cache`) suficient in v1; CLI deferat la TODOS.md.
- [x] Purjarea orfanelor sterge si intrarile modelelor VECHI? DECIS (/autoplan): DA, la aceeasi trecere simplu si sigur.
## 10. Amendamente /autoplan (2026-07-06)
Acceptate in scope (auto-decizii, vezi Decision Audit Trail):
1. **A1** Codul de cache traieste in modul NOU `app/embedding_cache.py` (functiile cu `conn`); `app/embeddings.py` ramane fara acces DB. `EmbeddingEngine.index_corpus` primeste parametru nou `vectors: list[list[float]] | None`, aliniat pozitional cu `items`; `None` = comportamentul actual (embed tot).
2. **A2** `save_vectors` scrie in tranzactii chunk-uite de **500 randuri/commit** AMENDEAZA AC-ul US-002 "commit la final". Motiv: un commit unic de ~26MB tine write-lock in timp ce worker-ul face `BEGIN IMMEDIATE` pe `submissions`.
3. **A3** Purjarea orfanelor: diff in Python (set hash-uri corpus vs set hash-uri cache), apoi `DELETE` pe chunk-uri de 500 pe PK. Include intrarile modelelor vechi (`model != curent`). Motiv: tranzactii scurte; (limita de parametri SQLite e 32766 pe 3.32+, nu 999 chunking-ul e pentru durata lock-ului).
4. **A4** Orice eroare a cailor de cache (SELECT/INSERT/blob corupt) se logheaza cu `log.warning` INAINTE de fallback-ul pe embed complet. Degradarea existenta (`except -> pass` pe indexare) ramane neschimbata scoping strict pe codul nou.
5. **A5** Metrica "acelasi cod sugerat" se verifica cu toleranta float32 (round-trip float64->float32 poate muta scoruri la a 7-a zecimala): test de echivalenta a RANKING-ULUI, nu egalitate stricta de scor.
6. **A6** Teste suplimentare la US-005: (a) chunking >500 randuri (scriere multi-tranzactie completa), (b) idempotenta la cache partial (populare intrerupta -> urmatorul warmup completeaza, fara duplicate, `INSERT OR REPLACE`).
7. **A7** Nota bounded acceptata: purjarea ruleaza doar la finalul unei indexari reusite; cu embeddings dezactivat sau warmup esuat permanent, cache-ul mort ramane pana la reset manual (`DELETE FROM embedding_cache`).
Adaugate de review-ul Eng (subagent independent, toate verificate in cod):
8. **A8** `index_corpus(items, signature, vectors)` VALIDEAZA `len(vectors) == len(items)` si absenta `None`-urilor; mismatch => `log.warning` + fallback pe embed complet. Test obligatoriu de ranking EXACT la warm start: >=2 query-uri distincte care aserteaza CODUL vecinului cel mai apropiat (nu doar non-empty) — singurul test care prinde o permutare de aliniere (misalignment = coduri gresite sugerate, silentios).
9. **A9** Secventa hash->load->embed->save->purge->index e protejata de un lock de modul (`threading.Lock`, pattern `_engine_lock` existent). CORECTEAZA riscul din sectiunea 6: "scrierile doar din thread-ul de warmup" e FALS — calea de request (`block=False`) trece de gate-ul `is_loaded()` (mapping.py:675) dupa incarcarea modelului si ar scrie/purja concurent cu warmup-ul (purjare falsa de randuri noi).
10. **A10** Conexiunile sunt autocommit (`isolation_level=None`, db.py:16): `save_vectors`/`purge_stale` emit EXPLICIT `BEGIN`/`COMMIT` per chunk — `conn.commit()` singur e no-op si ar produce silentios o tranzactie per rand (17k commit-uri), anuland exact motivul A2.
11. **A11** Numele modelului = PARAMETRU explicit pe tot lantul (inclusiv `ensure_embeddings_corpus`); nu se hardcodeaza `FASTEMBED_MODEL` in adancime — altfel testele cu mock scriu sub cheia modelului real si US-004 nu are unde injecta numele.
12. **A12** Vectorii din cache raman `array('f')` end-to-end (fara conversie la `list[float]`) — RAM ~8x mai mic; prag documentat: designul curent (cosine pur Python) e validat pana la ~50k randuri de corpus; peste, vezi TODOS (numpy).
13. **A13** Teste suplimentare obligatorii: (a) purjarea NU ruleaza cand indexarea a esuat (altfel un esec golește cache-ul permanent); (b) `len(vectors) != len(items)` => fallback gratios; (c) save partial esuat => indexarea continua din vectorii din RAM; (d) TOATE testele noi seteaza explicit `AUTOPASS_EMBEDDINGS_ENABLED=1` — gate-ul din mapping.py:671 face altfel testele vacuos-verzi.
14. **A14** Validare `len(vector) == 384` si la SCRIERE (nu doar la citire) — un backend defect nu umple DB-ul cu blob-uri arbitrare.
15. **A15** Orchestrarea hash->load->embed->save->purge se extrage intr-o functie in `embedding_cache.py` care primeste `embed_fn``ensure_embeddings_corpus` ramane citibila (~30 linii, nu ~80 cu 6 responsabilitati).
### Diagrama arhitectura
```
API startup (lifespan) request path (neatins)
| |
_warmup_embeddings (thread) enrich_suggestions
| |
ensure_embeddings_corpus(conn, block=True) suggest_nearest (query embed la cerere)
|
|-- SELECT mapping_suggestions ----------- corpus SILVER (sursa)
|-- _corpus_signature_silver ------------- decizia reindexarii (NESCHIMBATA)
|-- [NOU] embedding_cache.load_cached_vectors(conn, model, hashes)
| (SELECT chunk-uit; blob corupt => miss)
|-- embed() DOAR pe miss-uri (backend fastembed/mock)
|-- [NOU] embedding_cache.save_vectors(conn, model, items_noi)
| (INSERT OR REPLACE, commit la 500)
|-- [NOU] purge_stale(conn, model, hash-uri_corpus)
| (orfane + modele vechi, DELETE chunk-uit)
`-- EmbeddingEngine.index_corpus(items, signature, vectors=toate)
SQLite (WAL, busy_timeout 15s): embedding_cache (model, text_hash) PK
Worker: NU atinge embedding_cache (nu incarca modelul — invariant existent)
```
### Data flow cu shadow paths
```
corpus rows ─▶ filtrare denumire goala ─▶ hash sha256(text) ─▶ cache lookup ─▶ embed(miss) ─▶ save ─▶ index
│ │ │ │ │ │
[gol? => return] [toate goale? => [hash pe lista [tabela lipsa/ [backend [OperationalError
cache neatins] corpus gol, no-op] FILTRATA, nu pe DB locked => esueaza => la commit =>
rows brute!] warning + warning + warning + fallback;
embed complet] corpus gol] cache partial OK]
```
### Error & Rescue Registry
| Codepath | Ce poate esua | Exceptie | Rescued? | Actiune | User vede |
|---|---|---|---|---|---|
| load_cached_vectors | DB locked/busy | sqlite3.OperationalError | DA | log.warning + fallback embed complet | nimic (warmup mai lung) |
| load_cached_vectors | blob lungime gresita | (validare explicita) | DA | tratat ca miss, re-embed, log count | nimic |
| save_vectors | lock la commit chunk | sqlite3.OperationalError | DA | log.warning; cache partial ramane valid (idempotent) | nimic |
| purge_stale | lock la DELETE | sqlite3.OperationalError | DA | log.warning; orfanele raman pana la urmatoarea trecere | nimic |
| hash drift (normalizare schimbata) | miss permanent | — (nu e exceptie) | DA (observabil) | log warmup arata embed=N cand se astepta 0 (US-006) | nimic |
| ensure_embeddings_corpus | orice alta exceptie | Exception (catch-all EXISTENT) | DA | pass (degradare documentata REQ-005) | sugestii embeddings lipsesc |
### Failure Modes Registry
| Codepath | Failure mode | Rescued? | Test? | User vede? | Logged? |
|---|---|---|---|---|---|
| cold start | cache gol | DA (embed tot) | US-005 | fereastra 1-2 min (o data) | DA (US-006) |
| warm start | cache complet | — | US-005 | fereastra ~10-20s | DA |
| incremental | N texte noi | DA | US-005 | — | DA |
| blob corupt | re-embed silentios | DA | US-003 AC | nimic | DA (A4) |
| populare intrerupta | cache partial | DA (idempotent) | A6b | nimic | DA |
| model schimbat | zero hit, re-embed sub cheie noua | DA | US-004 | fereastra 1-2 min (o data) | DA |
| warmup esuat permanent | cache mort ne-purjat | partial (A7, manual) | — | nimic (bounded, ~30MB) | DA |
**Zero CRITICAL GAP** (niciun rand cu Rescued=N + Test=N + Silent).
### NOT in scope (deferate, cu motiv)
- CLI `tools/embcache` — manual suficient in v1 (Open Q1).
- Precompute vectori la build (artefact Docker) — ar face si primul start ieftin; separat.
- Graceful reload / zero-downtime — ar elimina complet fereastra post-restart; infra, nu app.
- Optimizare latenta per-query `suggest_nearest` (numpy) — de masurat intai; PRD-ul declara cosine Python suficient (non-goal).
- Cache query-uri (REQ-007), vector store extern, panou admin stats — non-goals/skip.
### What already exists (reuse)
- `_corpus_signature_silver` (mapping.py:635) — decizia reindexarii, pastrata identic.
- `ensure_embeddings_corpus` (mapping.py:649) — singurul punct de interceptie pe flux.
- `EmbeddingEngine(backend=...)` (embeddings.py:84) — mock-ul de test existent acopera US-005.
- Schema idempotenta `schema.sql` + `init_db()` — pattern-ul tabelei noi.
- `PRAGMA busy_timeout=15000` + WAL (db.py:18-20) — deja setate, sustin scrierile concurente.
### Dream state delta
Planul duce sistemul de la "restart plateste intreg corpusul" la "restart plateste doar delta"; cheia per-text face re-etichetarile (cod/is_nul) GRATUITE la restart — exact traiectoria 12 luni (corpus SILVER in crestere organica). Ramane in afara idealului: fereastra de incarcare a modelului (~10-20s) si costul per-query al cosine-ului pur Python (ambele in TODOS).
### Diagrama de acoperire teste (Eng S3)
```
CODE PATHS (toate NOI — teste planificate, nu existente) ACOPERIT DE
[+] app/embedding_cache.py
├── vector_to_blob/blob_to_vector round-trip US-002 AC (+A5 toleranta)
├── blob corupt (lungime gresita) => miss US-003 AC
├── len(vector)!=384 la SCRIERE => respins A14
├── save_vectors chunk 500 + BEGIN/COMMIT explicit A6a + A10
├── save partial esuat => indexare continua din RAM A13c
└── purge orfane + modele vechi; NU ruleaza la index esuat US-004 + A13a
[+] app/embeddings.py :: index_corpus(vectors=)
├── len(vectors)!=len(items) => fallback embed complet A8/A13b
└── ranking EXACT la warm start (>=2 query-uri, cod asertat) A8
[+] app/mapping.py :: ensure_embeddings_corpus
├── cold start: exact N texte la backend mock US-005
├── warm start: ZERO texte, has_corpus True, suggest ok US-005
├── incremental: +1 rand => exact 1 embed US-005
├── model schimbat => zero hit-uri cache US-004
├── hash pe lista FILTRATA (denumire goala exclusa) risc §6, test dedicat
└── concurenta warmup/request sub lock A9 (test cu 2 threaduri)
TOATE testele: AUTOPASS_EMBEDDINGS_ENABLED=1 explicit A13d (anti-vacuos)
COVERAGE PLANIFICAT: 15/15 cai identificate au test specificat | GAPS ramase: 0
E2E: verify pe instanta reala (US-006) — operational, in afara suitei
```
### Paralelizare worktree (Eng)
Implementare secventiala, fara oportunitate de paralelizare: lantul schema.sql → embedding_cache.py → embeddings.py (vectors=) → mapping.py (wiring) → teste e strict dependent, toate in acelasi modul de suggestii. Un singur lane.
## Implementation Tasks
Sintetizate din finding-urile review-ului. P1 blocheaza ship-ul.
- [ ] **T1 (P1, human: ~3h / CC: ~20min)** — embedding_cache — Modul nou: serializare array('f') end-to-end, load/save chunk 500 cu BEGIN/COMMIT explicit, validare dim la scriere/citire, purge diff-Python (orfane + modele vechi), log.warning pe erori — Surfaced by: US-001/002/004 + A2/A3/A4/A10/A12/A14 — Files: app/schema.sql, app/embedding_cache.py — Verify: pytest teste noi serializare/chunk/purge
- [ ] **T2 (P1, human: ~2h / CC: ~15min)** — embeddings — `index_corpus(vectors=)` cu validare aliniere + fallback; model ca parametru pe lant — Surfaced by: A8/A11 — Files: app/embeddings.py — Verify: test ranking exact + test mismatch
- [ ] **T3 (P1, human: ~2h / CC: ~15min)** — mapping — Wiring `ensure_embeddings_corpus`: orchestrare extrasa cu embed_fn (A15), lock de modul (A9), hash pe lista filtrata — Surfaced by: US-003 + A9/A15 — Files: app/mapping.py, app/embedding_cache.py — Verify: teste cold/warm/incremental + test concurenta
- [ ] **T4 (P1, human: ~2h / CC: ~15min)** — teste — Suita completa din diagrama (15 cai), toate cu AUTOPASS_EMBEDDINGS_ENABLED=1 — Surfaced by: US-005 + A6/A13 — Files: tests/ — Verify: python3 -m pytest -q
- [ ] **T5 (P2, human: ~30min / CC: ~5min)** — observabilitate — Log warmup `cache=N embed=M in Xs` — Surfaced by: US-006 — Files: app/mapping.py sau app/main.py — Verify: manual pe ./start.sh, doua restarturi
- [ ] **T6 (P2, human: ~15min / CC: ~2min)** — teste — Test echivalenta ranking cu toleranta float32 — Surfaced by: A5 — Files: tests/ — Verify: pytest
### Decision Audit Trail
<!-- AUTONOMOUS DECISION LOG -->
| # | Phase | Decision | Classification | Principle | Rationale | Rejected |
|---|-------|----------|----------------|-----------|-----------|----------|
| 1 | CEO 0C-bis | Design per-text (model, text_hash), nu blob monolit pe semnatura | Mechanical | P1 | Doar per-text da embed=0 la relabel (workflow activ: re-etichetare Haiku); semnatura include cod/is_nul, textul nu se schimba | blob monolit (re-embed tot la orice relabel) |
| 2 | CEO 0E | Modul nou app/embedding_cache.py; embeddings.py ramane fara DB | Mechanical | P5 | Separare existenta curata (engine pur / mapping face DB) | functii in embeddings.py |
| 3 | CEO 0D | Open Q2: purjare include modelele vechi | Mechanical | P2/P3 | ~5 linii, previne crestere moarta; propunerea PRD | lasare inerta |
| 4 | CEO 0D | CLI embcache deferat la TODOS | Mechanical | P3/P6 | PRD propune manual in v1 | build acum |
| 5 | CEO S2 | log.warning pe erorile cailor de cache (A4) | Mechanical | P1 (zero silent failures) | fallback-ul ramane, dar devine vizibil | pass mut |
| 6 | CEO S6/S5 | Metrica cu toleranta float32 + test echivalenta ranking (A5) | Mechanical | P1 | egalitate stricta ar esua legitim pe round-trip float32 | egalitate stricta |
| 7 | CEO S7/S1 | Commit chunk-uit 500 (A2) + purjare diff-Python chunk-uita (A3) | Mechanical | P5 | tranzactii scurte vs worker BEGIN IMMEDIATE | commit unic ~26MB |
| 8 | CEO S6 | Teste chunking + idempotenta cache partial (A6) | Mechanical | P1 | fixeaza comportamentul multi-tranzactie | doar testele US-005 |
| 9 | CEO 0D | Metrici Prometheus warmup | TASTE | — | log-ul US-006 acopera nevoia; counterul e nice-to-have | — (la gate) |
| 10 | CEO OV | Sidecar DB separat (voce externa) vs acelasi autopass.db | TASTE | — | user a confirmat P3 la D1; vocea externa ridica bloat backup + no-shrink fara VACUUM | — (la gate) |
| 11 | CEO 0A | Landscape check din cunostinte in-distributie (fara WebSearch) | Mechanical | P3 | pattern Layer 1 arhicunoscut (cache pe (model, hash(text))) | cautare web |
| 12 | CEO S4 | Dublu-index teoretic warmup/request: no action | Mechanical | P6 | SUPERSEDED de #14 — evaluarea era corecta doar pentru planul FARA cache; cu scrieri+DELETE, riscul devine real | lock nou |
| 13 | Eng S1 | A8: validare aliniere vectors/items + test ranking exact | Mechanical | P1 | misalignment = coduri gresite silentios; zip nu detecteaza nimic (embeddings.py:158) | liste paralele nevalidate |
| 14 | Eng S1 | A9: lock de modul pe secventa de cache (supersedes #12) | Mechanical | P1 | calea de request scrie si ea dupa is_loaded() (mapping.py:675); purjare concurenta = stergere falsa de randuri noi | write-uri doar pe block=True (re-embed repetat pe request path) |
| 15 | Eng S2 | A10: BEGIN/COMMIT explicit per chunk (autocommit db.py:16) | Mechanical | P5 | conn.commit() e no-op in autocommit; chunking-ul A2 nu s-ar intampla de fapt | commit() naiv |
| 16 | Eng S2 | A11: model ca parametru explicit pe tot lantul | Mechanical | P5 | testabilitate US-004; mock-urile nu trebuie sa scrie sub cheia modelului real | FASTEMBED_MODEL hardcodat |
| 17 | Eng S4 | A12: array('f') end-to-end + prag documentat ~50k | Mechanical | P1 | RAM ~8x mai mic gratis; blob_to_vector trece oricum prin array('f') | list[float] boxed (~2GB la 170k) |
| 18 | Eng S3 | A13: 4 teste suplimentare (purge-dupa-esec, mismatch, save partial, env flag explicit) | Mechanical | P1 | teste vacuos-verzi cu embeddings dezactivat = mai rau decat lipsa lor | doar US-005+A6 |
| 19 | Eng S3 | A14: validare dimensiune vector la scriere | Mechanical | P1 | o linie; previne umplerea DB cu blob-uri arbitrare | validare doar la citire |
| 20 | Eng S2 | A15: orchestrare extrasa cu embed_fn injectat | Mechanical | P5 | ensure_embeddings_corpus ramane citibila | functie de 80 linii cu 6 responsabilitati |
## GSTACK REVIEW REPORT
| Review | Trigger | Why | Runs | Status | Findings |
|--------|---------|-----|------|--------|----------|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | issues_open (via /autoplan) | 6 propuneri, 2 acceptate, 2 deferate, 2 taste la gate |
| Codex Review | `/codex review` | Independent 2nd opinion | 1 | issues_found (subagent-only) | Codex indisponibil (usage limit); voce externa = subagent Claude, 6 finding-uri CEO + 8 Eng |
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | clean (via /autoplan) | 8 issues, 0 critical gaps — toate absorbite ca A8-A15 |
| Design Review | `/plan-design-review` | UI/UX gaps | 0 | SKIPPED | fara scope UI (grep 0 potriviri) |
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | SKIPPED | fara suprafata developer-facing noua |
**CROSS-MODEL:** indisponibil — Codex pe usage limit pana la 18 iul; toate vocile externe au rulat ca subagenti Claude independenti (context proaspat, fara istoricul review-ului).
**VERDICT:** CEO + ENG CLEARED — APPROVED la gate-ul /autoplan (2026-07-06): taste #9 = SKIP metrici Prometheus (log-ul US-006 suficient); taste #10 = cache ramane in autopass.db (sidecar respins; caveat VACUUM documentat). Ready to implement.
NO UNRESOLVED DECISIONS

View File

@@ -0,0 +1,209 @@
# PRD 5.22 — UI compact Trimiteri + bara contextuala "Aplica cod" + exemplu Reguli automate + fix buton "+" editor import
**Stare**: inchis
> Mockup aprobat de utilizator (artifact `6efc43e0`, v2, 2026-07-07). Decizii inchise:
> tabel desktop pe un rand cu header; status = bulina + tooltip; mediu = litera T/P;
> multi-cod = primul cod + badge `+N` cu tooltip; sub-linie de problema = textul brut
> al erorii, mono, trunchiat, doar pe erori; bara "Aplica cod" contextuala la selectie;
> Reguli automate = exemplu concret in loc de paragraf descriptiv; fix no-op silentios
> la butonul "+" din editorul de import.
## 1. Obiectiv
Patru imbunatatiri UI/UX aprobate pe mockup: (a) lista Trimiteri devine tabel compact
pe un singur rand pe desktop, cu header de coloane si semnalizare compacta
status/mediu; (b) actiunea bulk "Aplica cod" devine bara contextuala vizibila doar la
selectie; (c) sectiunea "Reguli automate (text)" din Mapari isi inlocuieste descrierea
cu un exemplu concret; (d) butonul "+" din editorul de prestatii (import) nu mai
esueaza silentios — feedback vizibil la succes si la refuz.
## 2. Non-Goals (anti scope-creep)
- NU se schimba logica backend a `bulk-fix` (`POST /trimiteri/bulk-fix`) — doar prezentarea.
- NU se schimba masina de stari `submissions`, filtrele, paginarea sau modalul de detaliu.
- NU se adauga functionalitate noua la regulile text (`/mapari/reguli-text`) — doar microcopy.
- NU se redeseneaza restul editorului de rand din import (doar zona chips "+").
- NU se ating API v1, worker-ul sau schema DB.
## 3. Stories atomice
### US-001: Fix buton "+" chips prestatii — feedback la refuz si la succes
**Ca** operator care editeaza un rand de import **vreau** ca apasarea pe "+" sa aiba
mereu un efect vizibil **pentru ca** azi codul invalid sau duplicat produce un raspuns
identic, fara niciun mesaj ("nu se intampla nimic").
- **Depinde de**: —
- **Fisiere**: `app/web/routes.py` (`post_form_chips`), `app/web/templates/_chips_prestatii.html`, `tests/test_web_corectie_prestatii.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_corectie_prestatii.py``test_add_cod_duplicat_afiseaza_mesaj`, `test_add_cod_invalid_afiseaza_mesaj`, `test_add_cod_reusit_marcheaza_chip_nou`
- **Acceptance criteria**:
- [ ] Ramurile `add`, `add_extra`, `add_flat` din `post_form_chips`: cod care nu trece
verificarea in `nomenclator_rar` → mesaj de eroare in fragmentul re-randat
("Cod necunoscut in nomenclator"), nu raspuns identic.
- [ ] Cod deja prezent in lista (dedup) → mesaj "X este deja in lista — nu a fost
adaugat inca o data", pe TOATE cele 3 ramuri.
- [ ] Adaugare reusita → chip-ul nou primeste clasa de evidentiere (`chip-nou`,
outline verde) + mesaj de confirmare "X adaugat" (aria-live pentru SR).
- [ ] Mesajele traiesc in fragmentul `_chips_prestatii.html` (swap `outerHTML` pe
`#chips-section`) si dispar la urmatoarea actiune reusita.
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: browser pe import → Editeaza rand → alege cod deja adaugat → "+"
→ mesaj rosu vizibil; alege cod nou → "+" → chip verde evidentiat + mesaj.
### US-002: Reguli automate — exemplu concret in loc de descriere
**Ca** utilizator al paginii Mapari **vreau** sa vad un exemplu de regula in loc de un
paragraf explicativ **pentru ca** exemplul arata direct formatul si scuteste cititul.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_mapari_text_rules.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_mapari_text_rules.py``test_sectiune_reguli_afiseaza_exemplu_nu_descriere`
- **Acceptance criteria**:
- [ ] Textul descriptiv din capul sectiunii "Reguli automate (text)" (inclusiv
empty-state) e inlocuit cu un singur exemplu formatat:
`Daca operatia contine "verificare" → Cod RAR = OE-2 Intretinere si revizie`,
cu pattern-ul si codul evidentiate vizual (chip warn / chip accent, ca in mockup).
- [ ] Nicio schimbare functionala la tabel, adaugare, preview sau stergere reguli.
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: browser pe Mapari → sectiunea Reguli automate afiseaza exemplul.
### US-003: View-model rand trimitere — coduri multiple + eroare bruta
**Ca** dashboard **vreau** ca randul de trimitere sa expuna lista completa de coduri
RAR si textul brut al erorii **pentru ca** UI-ul compact afiseaza `primul cod +N`
(tooltip cu toate) si sub-linia de eroare cu mesajul RAR, nu eticheta explicativa.
- **Depinde de**: —
- **Fisiere**: `app/payload_view.py`, `app/web/routes.py` (`_submission_row_view`), `tests/test_payload_view.py`, `tests/test_web_submissions.py` (~4 fisiere)
- **Test intai (RED)**: `tests/test_payload_view.py``test_coduri_rar_lista_ordonata_dedup`, `test_coduri_rar_gol_fara_prestatii`; `tests/test_web_submissions.py``test_rand_expune_eroare_bruta_doar_pe_error`
- **Acceptance criteria**:
- [ ] `prezentare_din_payload` intoarce camp nou `coduri_rar`: lista ordonata,
dedup, a codurilor RAR (`cod_prestatie`) din TOATE prestatiile (lista goala
cand nu exista); campurile existente raman neschimbate (compatibilitate).
- [ ] `_submission_row_view` expune `eroare_bruta`: mesajul RAR brut cand exista
(`rar_error` → mesajul serverului, ex. `ORA-12899...`), altfel `motiv` scurt;
sir gol pe stari fara problema. `needs_mapping` NU produce `eroare_bruta`
(informatia "nemapat" traieste in coloana Cod RAR).
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: — (acoperit de US-004).
### US-004: Trimiteri desktop — tabel cu header, un rand per trimitere
**Ca** operator pe desktop **vreau** trimiterile intr-un tabel compact cu header de
coloane **pentru ca** este loc pe orizontala si scanez mai repede o lista pe un rand.
- **Depinde de**: US-003
- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/templates/base.html` (CSS), `tests/test_web_submissions_layout.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_submissions_layout.py``test_desktop_header_coloane`, `test_status_bulina_cu_tooltip`, `test_mediu_litera_t_p`, `test_multicod_badge_plus_n`, `test_sublinie_eroare_bruta_doar_pe_error`
- **Acceptance criteria**:
- [ ] Pe desktop (>= breakpoint existent), lista devine tabel/grid cu header:
(checkbox) · Vehicul · Cod RAR · Operatie · Data · Status · Mediu · (actiuni).
Un rand per trimitere, fara wrap; operatia primeste ellipsis.
- [ ] Status = DOAR bulina colorata (culorile claselor `s-*` existente), nume stare
complet in `title` (tooltip); coloana are headerul "Status".
- [ ] Mediu = litera in coloana proprie cu header "Mediu": `P` alb pe fundal
`--err` (productie), `T` contur discret (testare); `title` cu numele complet.
- [ ] Mai multe coduri: primul cod + badge `+N` cu `title` care listeaza toate
codurile din `coduri_rar`; un singur cod → fara badge; niciun cod → `nemapat`.
- [ ] Sub-linia de problema apare DOAR cand `eroare_bruta` e ne-goala: text mono,
`--fs-xs`, culoare `--err`, un singur rand cu `text-overflow:ellipsis`,
`title` cu textul complet.
- [ ] Comportamente pastrate: click pe rand → modal detaliu; checkbox doar pe
`gestionabil`; buton "Trimite" doar pe `tinut`; paginare/filtre neatinse.
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: browser HTMX pe http://localhost:8010/ (desktop 1280px) —
randuri pe o singura linie, header vizibil, tooltip-uri corecte.
### US-005: Trimiteri mobil — bulina status + litera mediu
**Ca** operator pe mobil **vreau** aceleasi semnale compacte (bulina + T/P) pe lista
de 2 linii **pentru ca** pill-urile cu text ocupa spatiu inutil pe ecran mic.
- **Depinde de**: US-004
- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/templates/base.html` (media query), `tests/test_web_submissions_layout.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_submissions_layout.py``test_mobil_pastreaza_doua_linii_cu_bulina_si_litera`
- **Acceptance criteria**:
- [ ] Sub breakpoint, randul revine la 2 linii (placuta / cod·operatie·data), iar in
dreapta stau litera de mediu + bulina de status (+ butonul "Trimite" pe tinute).
- [ ] Fara header de tabel pe mobil; fara scroll orizontal la 390px.
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: browser la 390px — lista lizibila, fara overflow orizontal.
### US-006: Bara contextuala "Aplica cod" — vizibila doar la selectie
**Ca** operator **vreau** ca actiunile bulk sa apara doar cand am selectat randuri,
cu text explicit **pentru ca** butonul permanent "Aplica cod" fara context e confuz.
- **Depinde de**: US-004
- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/templates/base.html` (CSS + JS numarare selectie), `tests/test_web_bulk_fix.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_bulk_fix.py``test_bara_bulk_ascunsa_implicit`, `test_bara_bulk_texte_explicite`
- **Acceptance criteria**:
- [ ] Inputul de cod + "Aplica cod" + "Sterge selectate" dispar din capul listei;
apar intr-o bara contextuala (stil accent, ca in mockup) randata ascunsa si
afisata prin JS cand >= 1 checkbox e bifat; dispare la deselectare.
- [ ] Bara contine: contor "N selectate", input cod RAR, buton "Aplica acest cod la
N randuri", separator, buton "Sterge N randuri", actiune "Deselecteaza";
N se actualizeaza la fiecare bifare (JS, fara roundtrip).
- [ ] Sub bara: hint pe un rand — "Aplica codul RAR doar operatiilor fara cod din
randurile selectate, apoi le repune in coada."
- [ ] Endpoint-urile, CSRF, `hx-confirm` pe stergere si `hx-disinherit` raman
functional identice; `bulk_message` se afiseaza ca acum.
- [ ] Starea barei supravietuieste re-randarii fragmentului (re-evaluare la
`htmx:afterSwap` — dupa swap selectia se pierde, bara se ascunde).
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: browser — capul listei curat; bifez 2 randuri blocate → bara
apare cu "2 selectate"; aplic cod → lista se reincarca, bara dispare, sumar vizibil.
## 4. Riscuri
- **Tabel vs. lista pe acelasi fragment**: `_submissions.html` e folosit si de
paginare/filtre/OOB — orice schimbare de structura trebuie sa pastreze id-urile
(`#submissions-wrap`, `#bulk-trimiteri`, OOB-urile). Mitigare: pastram form-ul si
id-urile existente, schimbam doar interiorul listei; testele de layout existente
(`test_web_submissions_layout.py`) ruleaza inainte si dupa.
- **`rar_error` brut poate contine detalii tehnice lungi**: afisare pe un rand cu
ellipsis + `title`; nu se logheaza/expune nimic nou (textul exista deja in modal).
- **JS-ul de numarare selectie** ruleaza peste randuri re-randate de HTMX: legare prin
delegare pe container, nu pe noduri (altfel handler-ii mor la swap).
- **Doua semnale rotunde alaturate pe mobil** (bulina status + litera mediu): litera
are forma/continut diferit de bulina — validat in mockup; daca in practica se
confunda, fallback aprobat: mediu doar pe randurile de productie.
## 5. Intrebari deschise
— (toate inchise prin mockup v2 aprobat)
## 6. Valuri de executie (graful de dependente)
```
Val 1: [US-001] [US-002] [US-003] ← fara dependente, fisiere disjuncte → paralel
Val 2: [US-004] ← deblocat de US-003
Val 3: [US-005] [US-006] ← deblocate de US-004; ating aceleasi fisiere → secvential intre ele
```
---
## Raport VERIFY
**Verdict: PASS** — 2026-07-07, E2E in browser (Playwright, uvicorn pe DB temporar, 7 trimiteri
seedate: sent/queued/held/needs_mapping/error, test+prod, multi-cod).
- Desktop 1280px: header Vehicul/Cod RAR/Operatie/Data/Status/Mediu; un rand per trimitere;
bulina colorata cu title; `P` fill rosu / `T` contur; `OE-1 +2` cu tooltip; sub-linie
`ORA-12899...` mono trunchiata doar pe rândul error; `nemapat` italic fara sub-linie.
- Bara bulk: ascunsa implicit; la 2 checkbox-uri → "2 selectate · Aplica acest cod la 2 randuri |
Sterge 2 randuri | Deselecteaza" + hint; aplicat OE-2 → ambele randuri → `queued`, bara dispare.
- Mapari: exemplul "Daca operatia contine «verificare» → Cod RAR = OE-2 Intretinere si revizie"
inlocuieste descrierea.
- Chips "+": adaugare OE-1 → chip evidentiat (chip-nou) + "OE-1 adaugat."; duplicat → "OE-1 este
deja in lista — nu a fost adaugat inca o data."; select gol → "Selecteaza un cod RAR din lista".
- Mobil 390px: fara header, 2 linii, T/P + bulina in dreapta, fara scroll orizontal.
- Suita completa: **1622 passed, 1 skipped**.
**Cauza-radacina REALA a bug-ului "+" (gasita LA VERIFY, nu in analiza statica)**: form-urile
parinte din modalele de editare au `hx-disabled-elt="find button"`; atributul se MOSTENESTE pe
butoanele chips, iar `find button` rezolvat relativ la butonul "+" (fara butoane descendente) →
`null` → TypeError intern htmx 1.9.12 INAINTE de request → nicio cerere, nicio eroare vizibila.
Fix: `hx-disabled-elt="this"` explicit pe toate butoanele hx-post din `_chips_prestatii.html`
(care si dezactiveaza butonul cat cererea e in zbor). No-op-urile silentioase server-side
(cod invalid pe add/add_flat, duplicat) erau defecte reale suplimentare, reparate separat.
Observatii non-blocante: (1) `bulk_message` ("N reusite") dispare imediat — HX-Trigger
`trimiteriChanged` reincarca lista peste raspunsul cu mesaj (comportament pre-existent);
(2) pe mobil, operatia e trunchiata agresiv pe randurile cu buton "Trimite" (spatiul e
consumat de actiune + mediu + status) — acceptat, informatia completa e in modal.

View File

@@ -0,0 +1,514 @@
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260707-194818.md -->
# PRD 5.23 — Editor prestatii unificat in modalul "Editare rand" (un select, un rand de chips)
**Stare**: inchis (2026-07-08; executie echipa agenti Sonnet TDD, VERIFY context curat PASS,
code-review CLOSE = 1 blocker de siguranta prins + reparat. Vezi Anexa /autoplan, GSTACK REVIEW
REPORT, Raport VERIFY; mockup final aprobat: select cu placeholder-tinta)
> Proces: `docs/ROADMAP.md` §5. Contract RAR: `docs/api-rar-contract.md`.
> Context vizual: screenshot `2026-07-07-003.jpg` (modal "Editare rand 4", badge "Cod RAR lipsa").
## 1. Obiectiv
Modalul "Editare rand" (import preview + corectie Trimiteri) are azi TREI controale pentru aceeasi
sarcina — asocierea codurilor RAR la prestatii: (1) rand per operatie cu select "— alege cod RAR —"
+ buton "Adauga", (2) container separat de chips pentru coduri libere (ex. `OE-1 ×`), (3) select
"+ Adauga alta operatie / cod RAR" cu buton "+". Utilizatorul nu stie care control face ce, iar
chips-urile apar in containere diferite, pe randuri diferite.
Livrabila: **un singur control** — un rand unic de chips (toate codurile, legate de operatie sau
libere) + **un singur select** care adauga chip instant la selectie (fara butoane "Adauga"/"+").
Selectul primeste sugestii fuzzy/k-NN pentru operatia nemapata si optiunea "Nu se declara la RAR".
## 2. Non-Goals (anti scope-creep)
- NU se schimba panoul de mapare (`_mapari.html`, `_preview_import.html`). EXCEPTIE confirmata in
review (eng, 2026-07-07): `resolve_prestatii` primeste O SINGURA treapta noua de precedenta —
`exclus=1` explicit pe item (din override/corectie) supravietuieste re-rezolvarii (azi
`it.pop("exclus", None)` la `mapping.py:298` il sterge, ceea ce face US-004 imposibil altfel).
Restul precedentei ramane neatins.
- NU se schimba contractul API `POST /v1/prezentari`. `override_json["prestatii"]` castiga campul
`exclus` pe item; itemii exclusi RAMAN in `payload_json` (adnotati), nu mai sunt eliminati la
salvare — `split_prestatii_excluse` ii scoate doar din payload-ul trimis la RAR.
- NU se schimba validarea codului fata de nomenclator la salvare (invariant ORA-12899) — ramane.
- NU se adauga cautare/autocomplete text in select (ramane `<select>` nativ; imbunatatire separata).
- NU se atinge butonul "salveaza ca regula" de la Trimiteri — ramane, doar mutat pe chip-ul nou.
## 3. Comportamentul controlului unificat (spec)
```
Prestatii — coduri RAR
┌───────────────────────────────────────────────────────────────┐
│ [⚠ OP-ITP · fara cod] [OE-1 ×] [— alege cod RAR — ▾] │
└───────────────────────────────────────────────────────────────┘
```
- **Un singur container** `.chips` cu toate elementele: chips-uri cod (× sterge), chips-uri warning
pentru operatii nemapate, si selectul unic la capat. Wrap natural doar la lipsa de spatiu —
niciodata containere/chenare separate pe zone.
- **Chip cod legat de operatie**: eticheta `COD`, titlu/tooltip cu denumirea operatiei sursa
(ex. `OE-8 — Inlocuire anvelope`). `×` scoate codul; daca operatia ramane fara cod, reapare
chip-ul warning al operatiei.
- **Chip cod liber** (fara operatie sursa): identic vizual, tooltip "cod adaugat manual".
- **Chip warning** (`⚠ OP-ITP · fara cod`): reprezinta operatia din import nemapata. Click pe el
il face **tinta activa** (evidentiat); implicit tinta = prima operatie nemapata.
- **Selectul unic**, `hx-trigger="change"``POST /form-chips` cu `chips_action=add`:
- daca exista tinta activa (operatie nemapata) → codul se leaga de acea operatie
(chip warning → chip cod); tinta trece automat la urmatoarea operatie nemapata;
- **placeholder-ul selectului numeste tinta** (decizie gate D2, 2026-07-07): cu tinta activa →
`Cod pentru OP-X — alege`; fara tinta → `— adauga cod RAR —`. Legatura cod↔operatie e
explicita la momentul alegerii, fara text suplimentar sub control;
- fara operatii nemapate → codul se adauga ca chip liber;
- dupa adaugare selectul revine la placeholder; chip-ul nou primeste evidentierea `.chip-nou`.
- **Continutul selectului**, cand tinta e o operatie nemapata:
- `optgroup "Sugestii"` sus: sugestia principala + fuzzy/k-NN top-3 pentru denumirea operatiei
(aceleasi surse ca in `_preview_import.html:89-121`);
- optiunea `"Nu se declara la RAR (exclude operatia)"` = sentinel `__NEDECLARAT__`
(`EXCLUDE_SENTINEL`, `app/mapping.py:44`) → operatia primeste chip distinct `Nedeclarat`
(stil mut, ×-ul readuce chip-ul warning), item anotat `exclus` in starea chips;
- restul nomenclatorului, ca azi.
Fara tinta (doar cod liber): fara optgroup Sugestii si fara `__NEDECLARAT__`.
- **Dedup**: acelasi `(operatie, cod)` nu se adauga de doua ori (comportament existent pastrat).
- **Fara text explicativ permanent** sub control (decizie utilizator la aprobarea mockup-ului,
2026-07-07): starea tintei se comunica DOAR vizual (stilul chip-ului warning evidentiat);
mesajele `chips_ok`/`chips_error` raman exclusiv ca feedback tranzitoriu la actiuni.
- Mockup aprobat: artifact "Mockup — Editor prestatii unificat (PRD 5.23)".
- **Modul plat** (corectii fara operatii din import) foloseste ACELASI control: zero chips warning,
selectul adauga doar coduri libere. Logica `has_r_odo`/`data-has-r-odo` (reveal odometru initial
pe R-ODO/I-ODO) si stilul `chip-warn` pe aceste coduri se pastreaza neschimbate.
### 3.1 Specificatie vizuala si de stari (din review-ul de design, 2026-07-07)
**Scara de dominanta vizuala — UN SINGUR tratament puternic simultan:**
| Element | Fundal / chenar / text | Nota |
|---|---|---|
| Chip tinta activa | fundal warn-soft, chenar SOLID 1px `--warn` + inel 2px warn-transparent | singurul element "tare" din rand |
| Chip warning (ne-tinta) | fundal warn-soft, chenar DASHED 1px `--warn` | click → devine tinta |
| Chip cod | fundal `--card2`-albastru (`--accent` 15%), text `--accent`, chenar subtil | eticheta `COD` mono |
| Chip Nedeclarat | fundal gri mut, text taiat (line-through), chenar dashed gri | NU discret pana la invizibil |
| `.chip-nou` | animatie de emfaza >=1.5s (fade), tranzitorie | nu concureaza cu tinta |
| Chip R-ODO/I-ODO | `chip-warn` existent, neschimbat | reveal odometru pastrat |
**Invariante de interactiune:**
- Ordinea chips e STABILA: operatiile in ordinea din import, codurile libere la coada; o rezolvare
nu re-ordoneaza randul (warning → cod in acelasi slot).
- Cat exista operatii nemapate, EXISTA tinta (implicit prima nemapata); click pe tinta activa =
no-op (nu se poate "deselecta" — adaugarile libere sunt posibile doar fara warnings).
- Click pe chip warning = comutare de tinta CLIENT-SIDE (toggle clasa + hidden `chips_target_index`),
fara round-trip; indexul tintei pleaca la server la urmatorul add.
- Selectul are id stabil (`chips-picker`); dupa swap `outerHTML`, focusul revine pe select.
- Optiunile selectului: `COD — denumire nomenclator`; in optgroup "Sugestii" acelasi format
(optgroup-ul e singura marca de sugestie). Eticheta chip warning: max ~18ch + ellipsis,
text complet in `title`.
**Tabel de stari (ce VEDE utilizatorul):**
| Stare | Ce se vede |
|---|---|
| Loading (add in zbor) | selectul disabled (`hx-disabled-elt="this"`) + stil `.htmx-request` pe sectiune; dedup face inofensiv un dublu-fire |
| Eroare add (400/500/retea) | mesaj `chips_error` SUB control (pattern 5.22, persista pana la urmatoarea actiune); selectul re-activat; tinta neschimbata; `hx-on::response-error` acopera esecul de retea |
| Gol (mod plat, zero chips) | doar selectul cu placeholder "— adauga cod RAR —"; container cu min-height (modalul nu salta la primul chip) |
| Succes partial | chip-ul nou cu emfaza >=1.5s; tinta avanseaza (vezi decizia de la gate) |
| Complet (zero nemapate) | mesaj tranzitoriu "Toate operatiile au cod" (`chips_ok`); optgroup Sugestii si `__NEDECLARAT__` dispar din select |
| Nedeclarat | mesajul numeste operatia: "OP-X nu se va declara la RAR" |
| Nomenclator gol | empty state existent (pastrat) |
## 4. Stories atomice
### US-001: Stare chips extinsa cu `exclus` + actiuni add/remove unificate in backend
**Ca** dezvoltator **vreau** o singura pereche de actiuni `add`/`remove` pe starea chips (cu tinta
optionala si suport sentinel `__NEDECLARAT__`) **pentru ca** azi exista 4 actiuni suprapuse
(`add`, `add_flat`, `add_extra`, `remove_flat`) care dubleaza logica.
- **Depinde de**: —
- **Fisiere**: `app/web/routes.py` (`post_form_chips` :2402-2521, `_chips_state_from_form` :3862-3918), `tests/test_web_chips.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_chips.py``test_add_cu_tinta_leaga_de_operatie`,
`test_add_fara_tinta_adauga_cod_liber`, `test_add_nedeclarat_marcheaza_exclus`,
`test_remove_cod_readuce_operatia_nemapata`, `test_dedup_operatie_cod`
- **Acceptance criteria**:
- [x] Starea chips serializata intr-UN SINGUR hidden `chips_state` = JSON versionat
(`{"v":1,"items":[{"cod":"","op":"","den":"","exclus":0},...]}`) — decizie gate D4:
elimina structural clasa de bug-uri de aliniere pe index (2 regresii in 2 release-uri).
`_chips_state_from_form` parseaza JSON-ul; JSON invalid → eroare vizibila, nu stare goala.
- [x] Compat tranzitorie la deploy: daca `chips_state` lipseste dar exista listele vechi
(`cod_prestatie`/`chip_op_service`/`chip_denumire`), parserul cade inapoi pe ele cu
`exclus=0` (form-uri deschise peste deploy); fallback-ul se sterge in release-ul urmator.
- [x] `chips_state` prezent mereu (lista goala = `{"v":1,"items":[]}`): golirea intentionata a
chips se persista la salvare (inlocuieste santinela `chips_present`).
- [x] `chips_action=add` + `chips_target_index=i` leaga codul de operatia i; fara target →
chip liber; cod `__NEDECLARAT__` + target → item cu `exclus=1` si fara `cod_prestatie`.
- [x] `chips_action=remove` (index unic) acopera si fostul `remove_flat`; actiunile
`add_flat`/`add_extra`/`chips_add_cod_{i}`/`chips_add_cod_flat` sunt eliminate.
- [x] Tinta implicita dupa un add reusit = urmatoarea operatie nemapata (sau nici una).
- [x] Validare tinta SEMANTICA, nu doar in-range: `chips_target_index` trebuie sa arate spre o
operatie nemapata si ne-exclusa; altfel `chips_error` (acopera POST fabricat si index
stale dupa un remove).
- [x] `chip_exclus` parsat strict ("1"→1, orice altceva→0); `__NEDECLARAT__` fara tinta valida →
`chips_error` explicit.
- [x] Testele existente pe actiunile vechi (`tests/test_web_corectie_prestatii.py`,
`tests/test_web_form_editare_slim.py`) sunt ACTUALIZATE in acelasi val (suita nu ramane
rosie intre valuri).
- [x] Emfaza chip nou pe INDEX (`chips_added_index`), nu pe valoarea codului (doua operatii pot
avea legitim acelasi cod).
- [x] `python3 -m pytest -q` trece.
- **Verificare E2E**: acoperit de US-005.
### US-002: Template unificat — un container, un select, fara butoane Adauga/+
**Ca** operator **vreau** un singur rand de chips si un singur select care adauga instant
**pentru ca** azi nu stiu care din cele 3 controale trebuie folosit.
- **Depinde de**: US-001
- **Fisiere**: `app/web/templates/_chips_prestatii.html` (rescriere), `app/web/templates/base.html`
(CSS `.chips`/`.chip*` :815-843, eliminare `.op-row`), `tests/test_web_chips.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_chips.py``test_render_un_singur_container_chips`,
`test_render_fara_op_row_si_fara_butoane_adauga`, `test_chip_warning_pentru_operatie_nemapata`
- **Acceptance criteria**:
- [x] Un singur container `.chips` contine: chips cod (cu tooltip operatia sursa), chips warning
operatii nemapate, chip `Nedeclarat` pentru itemi exclusi, selectul unic la capat.
- [x] Selectul (nume camp: `chips_pick`, id stabil `chips-picker`) are `hx-trigger="change"`,
`hx-post="/form-chips"`, `hx-target="#chips-section"`, `hx-swap="outerHTML"`,
`hx-include="closest form"` (transporta CSRF + starea hidden — omiterea = 403) si
`hx-disabled-elt="this"` (capcana htmx din 5.22 — NU mosteni de la form).
- [x] Selectul revine la placeholder dupa ORICE raspuns (succes SI eroare); valoarea ramasa in
`chips_pick` NU e aplicata la Salveaza pe niciuna din cai (regresia c6e29aa nu reapare
sub alt nume — codul se adauga DOAR prin actiunea add).
- [x] Pe durata unui request /form-chips, INTREAGA sectiune e inerta (`pointer-events:none` pe
`.htmx-request` sau echivalent) — click pe × / chip warning mid-flight nu pierde actiuni.
- [x] Click pe chip warning comuta tinta CLIENT-SIDE (clasa + hidden `chips_target_index`),
fara round-trip; click pe tinta activa = no-op.
- [x] Focus restaurat pe `#chips-picker` dupa swap.
- [x] Zero aparitii `op-row`, `Adauga`, `add_flat`, `chips_add_cod_` in template.
- [x] `chips_submission_id` e emis ca hidden in sectiune si ecouat de `/form-chips`, astfel
incat "salveaza ca regula" ramane disponibil si DUPA re-randari (azi dispare la prima
interactiune — bug perpetuat altfel).
- **Verificare E2E**: acoperit de US-005.
### US-003: Sugestii fuzzy/k-NN + optiunea "Nu se declara la RAR" in selectul unic
**Ca** operator **vreau** sugestiile de mapare direct in selectul din modal **pentru ca** azi ele
exista doar in panoul de mapare, iar in modal caut manual prin tot nomenclatorul.
- **Depinde de**: US-001, US-002
- **Fisiere**: `app/web/routes.py` (`post_form_chips` — construire sugestii pentru operatia tinta,
refolosind sursele din preview), `app/web/templates/_chips_prestatii.html`, `tests/test_web_chips.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_chips.py``test_select_are_optgroup_sugestii_pt_tinta`,
`test_select_are_nedeclarat_doar_cu_tinta`, `test_select_fara_tinta_fara_sugestii`
- **Acceptance criteria**:
- [x] Cu tinta activa: `optgroup "Sugestii"` cu sugestia principala + top-3 fuzzy/k-NN pentru
`denumire`-a operatiei; sub el optiunea `__NEDECLARAT__`; apoi nomenclatorul complet.
- [x] Fara tinta: selectul listeaza doar nomenclatorul (fara Sugestii, fara `__NEDECLARAT__`).
- [x] Sugestiile folosesc aceleasi functii ca `_preview_import.html` (fara drum nou de cod).
- [x] Degradare gratioasa: motor de sugestii rece/dezactivat → select DOAR cu nomenclatorul
(fara optgroup), fara eroare si fara blocare pe warmup (lock non-blocking, PRD 5.21).
- [x] `python3 -m pytest -q` trece.
- **Verificare E2E**: acoperit de US-005.
### US-004: Persistarea `exclus` prin salvare (import + Trimiteri) — cu round-trip complet
**Ca** operator **vreau** ca excluderea aleasa in modal sa se pastreze la salvare, sa produca
starea corecta si sa fie REVERSIBILA la redeschidere **pentru ca** altfel optiunea din select ar fi
cosmetica sau, mai rau, operatia ar fi declarata contra intentiei mele (RAR e ireversibil).
> Corectie din review (eng, CONFIRMATA in cod): pipeline-ul actual sterge `exclus` per-item
> (`resolve_prestatii` face `it.pop("exclus", None)`, `mapping.py:298`) si elimina itemii exclusi
> din `payload_json` la corectie (`routes.py:1744`). Fara fix-urile de mai jos, un item exclus din
> modal ar reveni `needs_mapping` sau — daca operatia are mapare pe cont — ar fi DECLARAT la RAR.
- **Depinde de**: US-001
- **Fisiere**: `app/mapping.py` (`resolve_prestatii` — treapta noua: item-exclus explicit
supravietuieste), `app/web/routes.py` (`web_editeaza_rand`, `post_corectie_trimitere` :1619,
`_prestatii_chips_from_payload` :1336 + toti constructorii de context chips),
`app/api/v1/import_router.py` (`apply_row_override` :319-366), `tests/test_web_editare_rand.py` (~4 fisiere)
- **Test intai (RED)**: `tests/test_web_editare_rand.py``test_salvare_pastreaza_exclus_in_override`,
`test_exclus_supravietuieste_resolve_chiar_cu_mapare_pe_cont`,
`test_redeschidere_modal_arata_chip_nedeclarat_si_x_readuce_warning`,
`test_rand_toate_operatiile_excluse_devine_excluded`, `test_salvare_trimitere_cu_exclus`
- **Acceptance criteria**:
- [x] `resolve_prestatii` pastreaza `exclus=1` venit explicit pe item (precedenta: item-exclus >
cod explicit > regula exclus cont > mapare > reguli text); restul treptelor neschimbate.
- [x] Itemii exclusi RAMAN in `payload_json`/`override_json` adnotati cu `exclus=1`;
`split_prestatii_excluse` ii scoate DOAR din payload-ul trimis la RAR si din cheia idempotenta.
- [x] Toti constructorii de chips (`_prestatii_chips_from_payload`, preview, re-render eroare)
propaga `exclus` → chip `Nedeclarat` reapare la redeschidere; `×` il readuce la warning.
- [x] Rand cu toate operatiile excluse → stare preview `excluded` ("Nedeclarat"); la Trimiteri →
`needs_data` cu motiv explicit (invariantele existente).
- [x] Validarea cod-vs-nomenclator la salvare ramane; sentinelul `__NEDECLARAT__` e RESPINS
explicit daca apare ca `cod_prestatie` in starea hidden (nu doar in select), pe ambele cai.
- [x] `python3 -m pytest -q` trece.
- **Verificare E2E**: acoperit de US-005.
### US-005: Verificare E2E browser pe ambele fluxuri
**Ca** lead **vreau** dovada in browser ca editarea e acum un singur control coerent
**pentru ca** bug-ul raportat e de UX vizual, nu doar de logica.
- **Depinde de**: US-002, US-003, US-004
- **Fisiere**: — (doar verificare; eventuale fix-uri in fisierele de mai sus)
- **Test intai (RED)**: —
- **Acceptance criteria**:
- [x] Import preview: rand cu operatie nemapata + cod liber → modalul arata UN rand de chips
(warning + cod + select), fara chenare/zone separate; screenshot atasat la raport.
- [x] Selectarea unui cod din Sugestii transforma chip-ul warning in chip cod, instant, fara "+".
- [x] Alegerea "Nu se declara la RAR" produce chip `Nedeclarat`; salvare → rand "Nedeclarat".
- [x] Trimiteri (`/trimitere/{id}` corectie): acelasi control, "salveaza ca regula" functional.
- [x] Salvarea persista chips-urile (regresie c6e29aa) si nu reapar controale duble la re-deschidere.
- **Verificare E2E**: browser HTMX pe http://localhost:8010/ (import xlsx test + o trimitere
`needs_mapping`), conform pasilor de mai sus.
## 5. Riscuri
- **`hx-disabled-elt` mostenit de la form** a mai rupt butoanele chips o data (5.22) — selectul unic
primeste explicit `hx-disabled-elt="this"`; test de template pentru atribut.
- **`change` pe select nativ** se declanseaza si la navigarea cu tastatura pe unele browsere vechi;
acceptat (comportamentul modern e la confirmare), iar chip-ul gresit se sterge cu un click.
- **Compatibilitate stare veche**: submission-uri/override-uri existente fara `chip_exclus` trebuie
citite ca `exclus=0` (default la deserializare) — altfel salvarea de pe randuri vechi crapa.
- **Doua endpoint-uri de save** (import + Trimiteri) cu acelasi contract de campuri — orice
schimbare de nume de camp se face simultan in ambele, plus `_chips_state_from_form` unic partajat.
## 6. Intrebari deschise
> Rezolvate cu utilizatorul la aprobare (2026-07-07):
- `×` pe un chip `Nedeclarat`**readuce chip-ul warning** al operatiei (operatia NU se sterge).
- Selectul unic ramane `<select>` nativ cu tot nomenclatorul — **acceptat**; autocomplete ramane
non-goal.
## 7. Valuri de executie (graful de dependente)
```
Val 1: [US-001] ← fara dependente
Val 2: [US-002] [US-004] ← deblocate de US-001, fisiere distincte → paralel
Val 3: [US-003] ← deblocat de US-002
Val 4: [US-005] ← verificare finala E2E
```
---
## Anexa /autoplan — Review CEO (Faza 1, 2026-07-07, mod SELECTIVE EXPANSION, voci: subagent-only)
### NOT in scope (decizii din review)
- Autocomplete/cautare in picker — non-goal confirmat de utilizator.
- Schimbarea panoului de mapare / precedentei `resolve_prestatii` — non-goal PRD.
- Badge-uri sursa sugestie (GOLD/SILVER) in select — respins (P5): optgroup "Sugestii" e suficient.
- "Bulk: aplica maparea la N randuri identice" in preview — deferat TODOS (in afara blast radius).
- Metrici de baza (deschideri modal / % randuri mapate manual) — deferat TODOS.
### What already exists (refolosit, nu reconstruit)
- Stare chips in hidden inputs + `/form-chips` stateless (`_chips_prestatii.html`, `routes.py:2402`).
- Validare cod vs nomenclator la add si la save (c6e29aa); mesaje `chips_error`/`chips_ok` (5.22).
- Sugestii: `suggest_codes` + `sugestie_principala` (`mapping.py:117,868`) — aceleasi surse ca preview.
- `EXCLUDE_SENTINEL`/`split_prestatii_excluse` (0bc6187); `apply_row_override` (c6e29aa).
- Empty state nomenclator lipsa; `has_r_odo`/reveal odometru; buton "salveaza ca regula" (Trimiteri).
### Dream state delta
Planul muta editorul de la 3 controale/4 actiuni la 1 control/2 actiuni si aduce sugestiile in
modal — pas direct spre idealul 12 luni (o singura componenta de mapare peste tot). Ramase pe
traiectorie: autocomplete (daca nomenclatorul creste), componenta partajata cu panoul de mapare.
### Arhitectura (Sectiunea 1)
```
[modal Editare rand]──include──▶[_form_editare]──include──▶[_chips_prestatii (REWRITE)]
│ │ hx-post change/×
▼ ▼
POST editeaza (import) ─┐ POST /form-chips (add|remove)
POST corecteaza (Trim.) ─┴─▶ _chips_state_from_form ──▶ apply_row_override(prestatii+exclus)
│ (commit)
suggest_codes/mapping.py ◀──────┘ resolve + split_prestatii_excluse
```
Cuplaj NOU: /form-chips → motorul de sugestii (pana acum doar preview-ul) — justificat (reuse),
cu conditia caii NE-blocante pe request (lock embeddings non-blocking, PRD 5.21). Fara migrare DB
(`exclus` traieste in override_json). Rollback: git revert simplu. Fereastra de deploy: un modal
deschis peste deploy trimite campuri vechi (`chips_add_cod_{i}`) pe care handlerul nou le ignora
— utilizatorul redeschide editarea; acceptat (sesiune de editare scurta).
### Registru erori & rescue (Sectiunea 2)
```
CODEPATH | CE POATE ESUA | TRATARE | USER VEDE
--------------------------|------------------------------------|--------------------------------|-----------
/form-chips add | cod gol / necunoscut | chips_error (exista) | mesaj rosu
/form-chips add | index tinta iesit din forma | chips_error (exista) | mesaj rosu
/form-chips add NED | __NEDECLARAT__ fara tinta | GAP -> chips_error nou | mesaj rosu
/form-chips render | motor sugestii rece/dezactivat | GAP -> degradare la nomenclator| select fara optgroup
/form-chips | nomenclator gol | empty state (exista) | avertisment
save (ambele) | cod necunoscut in stare | re-render cu eroare (exista) | modal + eroare
save (ambele) | stare veche fara chip_exclus | default exclus=0 (PRD risc) | nimic (compat)
save (ambele) | CSRF invalid/expirat | 403 (existent) | eroare form
enqueue | toate operatiile excluse | excluded / needs_data (exista) | stare explicita
```
GAP-uri de inchis in implementare: NED-fara-tinta si degradarea sugestiilor (ambele = criterii noi).
### Securitate (Sectiunea 3)
Fara endpoint nou. Input nou `chip_exclus`: parsare stricta ("1" -> 1, orice altceva -> 0).
Sentinelul `__NEDECLARAT__` nu ajunge NICIODATA in payload RAR (split inainte de enqueue —
invariant existent; test explicit). XSS: `denumire` in options/title — autoescape Jinja (exista).
Sugestii cross-account: politica 5.14 (suggestion-only) neschimbata. CSRF pe /form-chips: exista.
Nicio amenintare High nemitigata.
### Edge cases date/interactiune (Sectiunea 4)
- Dublu `change` in zbor: `hx-disabled-elt="this"` pe select (criteriu US-002).
- Focus pierdut dupa swap `outerHTML`: focusul revine pe select dupa add (criteriu nou US-002).
- Navigare cu tastatura prin select declanseaza change pe unele browsere: acceptat (PRD risc),
chip-ul gresit se scoate cu un click.
- Iesire din modal mid-edit: stare pierduta (comportament existent, form-local) — neschimbat.
- Dedup (operatie,cod) si cod liber duplicat: exista, se pastreaza.
- Mod plat + `has_r_odo`: pastrate explicit (spec actualizat).
### Calitate (Sectiunea 5)
4 actiuni -> 2 reduce ramificarea din `post_form_chips` (~120 linii azi). `_chips_state_from_form`
ramane UNICUL parser, partajat de ambele endpoint-uri de save (risc PRD; test comun).
Alternativa "un singur camp hidden JSON" in loc de 4 liste paralele — decizie de gust (vezi gate).
### Teste (Sectiunea 6) — completari la RED-urile din PRD
- `test_add_nedeclarat_fara_tinta_da_eroare` (US-001)
- `test_sugestii_indisponibile_degradare_fara_optgroup` (US-003)
- `test_mod_plat_fara_operatii_select_doar_coduri_libere` (US-002)
- `test_has_r_odo_pastrat_dupa_actiuni` (US-002)
- `test_sentinel_nedeclarat_nu_ajunge_in_payload` (US-004)
- Template: `test_select_are_hx_disabled_elt_this` (capcana 5.22)
### Performanta (Sectiunea 7)
Sugestiile se calculeaza la FIECARE re-render /form-chips pentru operatia tinta — bounded
(matvec numpy 5.21, corpus 17k, <100ms tipic); calea request NU asteapta warmup (lock non-blocking).
18 optiuni in select trivial. Fara probleme.
### Observabilitate (Sectiunea 8)
Starea randurilor e deja vizibila in dashboard (needs_mapping/excluded). Gap acceptat si deferat
TODOS: metrica "deschideri modal / sugestie acceptata" (leaga de decizia de produs, nu de 5.23).
### Deploy (Sectiunea 9)
Fara migrare, fara feature flag (UI inlocuit atomic in acelasi template). Worker neatins.
Post-deploy: smoke = deschide un rand needs_mapping, adauga un cod, salveaza.
### Traiectorie (Sectiunea 10)
Reversibilitate 4/5 (revert curat; starile cu exclus persistate raman valide split exista
independent). Datorie REDUSA (actiuni duplicate eliminate). Comentariul de header din
`_chips_prestatii.html` se rescrie (diagrama noua a starii parte din US-002).
### Design/UX la nivel CEO (Sectiunea 11)
Ierarhie: chips (starea) inaintea selectului (actiunea) corect. Stari acoperite: gol (hint),
nomenclator lipsa, eroare, succes, exclus. Mobil: rand cu wrap; latimea selectului pe 390px =
criteriu in faza Design. Accesibilitate: aria-labels existente se pastreaza; focus dupa swap.
Riscul de mis-binding (tinta implicita + fara text permanent) ridicat de vocea externa,
decizie la gate. Detaliu complet in Faza 2 (plan-design-review).
### Decision Audit Trail (/autoplan)
| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins |
|---|------|---------|-------------|-----------|-------------|---------|
| 1 | CEO | Abordare A (rescriere template + unificare actiuni) | Mecanica | P1,P5 | B pastreaza confuzia; C incalca non-goal aprobat | B, C |
| 2 | CEO | A11y tastatura + focus dupa swap criterii US-002 | Mecanica | P1 | in blast radius, 2 fisiere | |
| 3 | CEO | Badge sursa sugestie in select respins | Mecanica | P5 | optgroup "Sugestii" spune deja asta | badge-uri |
| 4 | CEO | Bulk-apply mapare pe randuri identice TODOS | Mecanica | P2,P3 | in afara blast radius (panou preview = non-goal) | |
| 5 | CEO | Metrici deschideri-modal/sugestii TODOS | Mecanica | P3 | produs, nu 5.23 | |
| 6 | CEO | GAP-uri erori (NED-fara-tinta, degradare sugestii) criterii noi | Mecanica | P1 | zero silent failures | |
| 7 | CEO | Auto-advance tinta + fara eticheta pe select | TASTE (gate) | | voce externa: risc mis-binding pe declaratii ireversibile | |
| 8 | CEO | Paleta click 18 coduri vs select nativ | TASTE (gate) | | premisa "tot nomenclatorul" e falsa (18 coduri fixe) | |
| 9 | CEO | Stare chips: 4 liste hidden vs un camp JSON | TASTE (gate) | | a 3-a regresie in zona; JSON = sursa unica | |
| 10 | CEO | Test cu operator real in US-005 | Mecanica | P1 | verifica direct riscul de mis-binding; cost ~0 | |
| 11 | Design | Scara de dominanta vizuala (tabel §3.1) | Mecanica | P1,P5 | 3 tratamente concurente fara ierarhie = confuzia initiala reambalata | |
| 12 | Design | Stari loading/eroare/gol/complet specificate (tabel §3.1) | Mecanica | P1 | eroarea de retea lasa selectul disabled permanent altfel | |
| 13 | Design | Tinta invarianta (exista cat sunt nemapate; click pe tinta = no-op) | Mecanica | P5 | interzice starea "cod liber cu warnings inca pe rand" tacuta | |
| 14 | Design | Comutare tinta CLIENT-SIDE (fara round-trip) | Mecanica | P3 | round-trip pe click de highlight = lent + race cu add-ul in zbor | server-side |
| 15 | Design | Ordine chips stabila + focus pe select dupa swap | Mecanica | P1 | reflow-ul + focusul pierdut rup fluxul de tastatura pe 10 randuri | |
| 16 | Design | Optiuni `COD — denumire`; chip warning ellipsis 18ch | Mecanica | P5 | 18 coduri criptice fara denumire = memorare fortata | |
| 17 | Design | Nedeclarat vizibil (line-through, nu mut) + mesaj cu numele operatiei | Mecanica | P1 | cea mai riscanta actiune legala nu poate fi cea mai discreta | stil mut |
| 18 | Design | Mesaje chips_ok/err raman pattern 5.22 (persista pana la actiune) | Mecanica | P4 | timer de auto-dismiss = JS nou fara valoare | timer 4s |
| 19 | Design | Auto-advance tinta: pastrat cu emfaza vs eliminat | TASTE (gate) | | CEO-voice: elimina (mis-binding); Design-voice: pastreaza (momentum) + emfaza | |
| 20 | Design | Touch targets × pe chips sub 44px (UI compact 11px existent) | Mecanica | P4 | consecvent cu design-ul compact existent; nota in TODOS a11y | 44px |
| 21 | Eng | `exclus` supravietuieste `resolve_prestatii` (treapta noua, non-goal amendat) | Mecanica | P1 | P1 confirmat: `it.pop("exclus")` mapping.py:298 face US-004 imposibil; alternativa (regula pe cont) schimba semantica aprobata | regula per-cont; taierea US-004 |
| 22 | Eng | Itemii exclusi raman in payload_json + `exclus` in toti constructorii de chips | Mecanica | P1 | P1 confirmat: routes.py:1744 ii elimina × pe Nedeclarat imposibil dupa salvare | |
| 23 | Eng | `chips_submission_id` hidden + ecou in /form-chips ("salveaza ca regula" persistent) | Mecanica | P1,P3 | butonul dispare azi la prima re-randare; fluxul nou trece TOT prin /form-chips | |
| 24 | Eng | `chips_pick` reset dupa orice raspuns; valoarea NEaplicata la Salveaza | Mecanica | P5 | clasa de bug c6e29aa; sentinel NED pozitional pe calea corectie | aplicare implicita |
| 25 | Eng | Validare semantica tinta + chip_exclus strict + sectiune inerta in request | Mecanica | P1 | POST fabricat / index stale / race mid-flight leaga codul de alt item | |
| 26 | Eng | `chips_present=1` golirea listei se persista (nu no-op) | Mecanica | P1 | `_chips_state_from_form` intoarce None pe form fara chips golire imposibila | no-op actual |
| 27 | Eng | Actualizarea testelor existente pe actiunile vechi in acelasi val | Mecanica | P6 | altfel suita e rosie intre Val 1 si Val 2 | |
| 28 | Eng | Emfaza chip nou pe index, nu pe cod | Mecanica | P5 | doua operatii cu acelasi cod = dubla evidentiere falsa | |
| 29 | Eng | Fara memoizare sugestii per re-render | Mecanica | P3 | rapidfuzz pe 18 randuri + k-NN gardat = bounded; memoizarea = complexitate fara castig | memoizare |
| D2 | Gate | Auto-advance pastrat + placeholder-ul selectului numeste tinta | USER | | leaga explicit codul de operatie la momentul alegerii, fara text sub control | doar emfaza; fara auto-advance |
| D3 | Gate | Select nativ pastrat (mockup v1); paleta v2 vazuta si RESPINSA de utilizator | USER | | directia utilizatorului; paleta ramane alternativa daca selectul se dovedeste lent (US-005 cu operator) | paleta 18 coduri |
| D4 | Gate | Stare chips = UN hidden `chips_state` JSON versionat + fallback tranzitoriu liste vechi | USER | | elimina clasa de regresii de aliniere pe index | 4 liste paralele |
| D5 | Gate | Plan revizuit APROBAT de utilizator | USER | | | |
### Anexa Eng (Faza 3) — diagrama de acoperire teste
```
CODE PATHS USER FLOWS
[+] post_form_chips (rescris, 2 actiuni) [+] Corectie rand cu N operatii nemapate
├── add cu tinta valida [RED planificat] ├── sugestie→chip instant [RED + E2E US-005]
├── add fara tinta (liber) [RED planificat] ├── comutare tinta client [E2E US-005]
├── add NED cu/fara tinta [RED planificat] ├── × pe cod → warning [RED planificat]
├── tinta semantica invalida [RED planificat] ├── NED → Nedeclarat → × [RED planificat]
├── remove index/liber [RED planificat] ├── eroare retea la add [→E2E] [GAP inchis §3.1]
└── dedup + cod necunoscut [exista, se muta] └── golire totala + salvare [RED chips_present]
[+] resolve_prestatii treapta item-exclus [RED planificat — REGRESIUNE potentiala: IRON RULE,
test obligatoriu ca exclus NU e sters si ca restul precedentei ramane identic]
[+] _chips_state_from_form (4 liste + compat vechi + liste scurte) [RED planificat]
[+] save x2 (import/corectie): exclus round-trip, NED respins, chips_pick ignorat [RED planificat]
[+] template: hx-atribute, focus, sectiune inerta, mod plat, has_r_odo [RED planificat]
COVERAGE planificata: 100% din ramurile noi au RED numit in stories. Fara eval-uri LLM (nu se
ating prompturi). Regresiune-cheie: precedenta resolve_prestatii → test dedicat blocant.
```
ENG DUAL VOICES CONSENS (subagent-only):
```
Dimensiune Claude Codex Consens
1. Arhitectura solida? DA* N/A OK (directia 4→2 actiuni corecta; * dupa fix P1-uri)
2. Acoperire teste suficienta? NU→DA N/A FLAGGED→INCHIS (6 teste noi adaugate in stories)
3. Riscuri de performanta? NU N/A OK (18 coduri, k-NN gardat, bounded)
4. Securitate acoperita? PARTIAL→DA N/A INCHIS (validare semantica tinta, chip_exclus strict, NED respins la save)
5. Drumuri de eroare tratate? PARTIAL→DA N/A INCHIS (§3.1 + criterii noi)
6. Risc de deploy gestionabil? DA N/A OK (fara migrare; fereastra modal-peste-deploy acceptata)
```
---
## Raport VERIFY
Verificator independent (subagent context curat, doar PRD + §5.6), 2026-07-08. **VERDICT: PASS**
pe toate criteriile testabile; regresia de aur RAR live = NEPROBAT (contul QA fara creds RAR).
- **Suita**: `python3 -m pytest -q` verde. La VERIFY: 1672 passed, 1 skipped. Dupa fix-urile
de code-review CLOSE: **1680 passed, 1 skipped** (+45 teste noi fata de baseline 1635).
- **US-001..US-004** PASS cu dovezi de cod + teste (chips_state JSON unic, 2 actiuni add/remove,
exclus supravietuieste resolve_prestatii cu test de regresie blocant pe precedenta, sentinel
respins pe ambele cai).
- **US-005 E2E browser** (import preview + Trimiteri corectie, Playwright): PASS pe (a) un singur
rand de chips cu tinta evidentiata + placeholder-tinta + optgroup Sugestii + optiune "Nu se
declara la RAR" la deschiderea initiala; (b) sugestiechip cod instant fara "+"; (c) Nedeclarat
salvare rand "Nedeclarat", redeschidere arata chipul, × readuce warning; (d) acelasi control
la Trimiteri, "salveaza ca regula" persistent; (e) fara controale duble, zero erori JS de flux.
- **Gap prins in executie** (dupa US-003, reparat inainte de VERIFY): tinta implicita + sugestiile
nu se calculau la randarea initiala a modalului helper partajat `_chips_target_si_sugestii`
in toate caile de randare.
### Code-review CLOSE (high, 8 unghiuri + verify) — 1 BLOCKER de siguranta + 2 findings, toate reparate
- **BLOCKER (CONFIRMED, reparat)**: itemii exclusi (`exclus=1`, cod null) ramaneau in `payload_json`
pe calea de corectie, iar worker-ul ii trimitea la RAR ca `{codPrestatie: null}` risc
ORA-12899 / FINALIZATA partial ireversibil. Nimic nu facea `split_prestatii_excluse` la MOMENTUL
trimiterii. Fix la chokepoint unic (worker, inainte de `build_rar_payload`) acopera toate
canalele (API/import/corectie) + filtru defensiv in `build_rar_payload` (sare itemii exclusi /
fara cod) + teste care dovedesc ca itemul exclus NU apare in payload-ul RAR.
- **Consistenta round-trip (reparat)**: `commit_import`/`web_confirma_import` pastreaza acum
prestatiile complete in `payload_json` (cheia idempotentei = doar declarabile, identica cu
preview); bonus: bug latent de idempotenta previewcommit in `_web_compute_preview` reparat.
- **Conventii CLAUDE.md (reparat)**: referinte PRD/US-xxx/decizii scoase din comentariile noi.
- **Robustete tinta (reparat)**: un item fara `cod_op_service` nu mai poate deveni tinta
invizibila; sugestiile intorc gol pe denumire goala.
## GSTACK REVIEW REPORT
| Review | Trigger | Why | Runs | Status | Findings |
|--------|---------|-----|------|--------|----------|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | CLEAR (PLAN via /autoplan) | 5 proposals, 2 accepted, 3 deferred; 8 constatari voce externa |
| Codex Review | `/codex review` | Independent 2nd opinion | 0 | UNAVAILABLE (usage limit pana 2026-07-18) | voci = subagent-only |
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (PLAN via /autoplan) | 8 issues (4×P1 confirmate in cod), 0 critical gaps ramase |
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (FULL via /autoplan) | score: 6/10 9/10, 10 decizii in §3.1 |
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | SKIPPED | fara scope developer-facing |
- **CROSS-MODEL:** indisponibil (Codex la limita de utilizare); toate vocile externe = subagenti
Claude independenti (context curat, fara review-ul anterior).
- **VERDICT:** CEO + ENG + DESIGN CLEARED gata de implementare. Decizii gate aplicate:
D2 placeholder-tinta, D3 select (paleta respinsa), D4 stare JSON, D5 aprobat.
NO UNRESOLVED DECISIONS

View File

@@ -0,0 +1,60 @@
# Runbook — Rollback sigur al feature-ului "Auto send / coada tinuta" (PRD 5.19)
Procedura operationala pentru cazul in care trebuie sa dai **revert pe cod** care
atinge `claim_one` / logica `held` din PRD 5.19.
## De ce e periculos revertul (Riscul R4)
Feature-ul 5.19 tine randuri manual: un rand `queued AND held=1` NU pleaca la RAR
pentru ca `claim_one` (worker) filtreaza cu `AND held=0`.
Daca dai revert pe codul worker-ului **DUPA** ce baza contine deja randuri cu
`held=1`, worker-ul revenit **pierde filtrul** `AND held=0` -> ar prelua si trimite
la RAR TOATE randurile tinute. **RAR FINALIZATA e IREVERSIBILA** (fara anulare prin
API) — declari real, definitiv, randuri care fusesera puse deoparte intentionat.
## Procedura (copy-paste, in ordine)
### 1. INAINTE de orice revert — carantineaza randurile tinute
Pe masina gateway, cu acelasi mediu ca aplicatia (acelasi `AUTOPASS_DB_PATH`):
```bash
# Verifica intai cate randuri sunt tinute (nu scrie nimic):
python3 -m tools.carantina_held --dry-run
# Apoi carantineaza efectiv:
python3 -m tools.carantina_held
```
Optional, opreste worker-ul in timp ce faci operatia, ca sa nu concureze:
```bash
./start.sh stop
```
### 2. Ce face carantina
`tools/carantina_held.py` ruleaza, atomic:
```sql
UPDATE submissions
SET status='error', rar_error='ROLLBACK_QUARANTINE', updated_at=datetime('now')
WHERE held=1 AND status='queued';
```
Adica scoate randurile tinute din coada (`queued` -> `error`) cu mesajul
`ROLLBACK_QUARANTINE`. Un worker fara filtrul `held` **nu mai poate prelua** un rand
`error` -> randurile NU pleaca la RAR pe timpul revertului. `sending` / `sent` NU se
ating (FINALIZATA e terminal la RAR). Tool-ul afiseaza cate randuri a carantinat.
### 3. DUPA revert (sau dupa ce revii pe codul 5.19) — reactiveaza randurile
Randurile carantinate sunt acum `error` / `ROLLBACK_QUARANTINE`. Ele NU sunt trimise
si NU se re-incearca automat. Cand vrei sa le declari la RAR, **repune-le manual din
dashboard** (butonul de repunere pe rand -> revine `queued`) si trimite-le controlat
(per rand sau "Trimite toate"), dupa ce te-ai asigurat ca filtrul `held` este din nou
in cod (revenire pe 5.19) sau ca trimiterea lor e intentionata.
> Nu exista un "un-quarantine" automat: reactivarea e deliberat manuala, ca sa nu
> repui accidental in coada exact randurile pe care le protejai.

View File

@@ -25,3 +25,5 @@ dbfread==2.0.7
# Model multilingv (~230MB pe disc, ONNX quantizat), fastembed fara torch, lazy-load la runtime.
# Degradare gratioasa daca lipseste la runtime (is_available()=False, suggest_nearest=[]).
fastembed>=0.8.0
# Folosit direct la cautarea NN (matvec pe corpus); oricum tras de fastembed/onnxruntime.
numpy>=1.26

View File

@@ -22,12 +22,75 @@ os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
# lazy-load-eze modelul de ~230MB la fiecare test care atinge editorul de mapari
# (suita rapida, fara download in CI). Testele de embeddings il pornesc punctual.
os.environ.setdefault("AUTOPASS_EMBEDDINGS_ENABLED", "false")
# Seed-ul de operatii etichetate (SILVER, PRD 5.18) e ON in app, dar OFF in teste:
# multe teste presupun mapping_suggestions GOL la init_db. Testele US-004/005/006 il
# pornesc punctual (object.__setattr__ pe settings sau apel direct la seeder).
# Seed-ul de operatii etichetate e ON in app, dar OFF in teste: multe teste presupun
# mapping_suggestions GOL la init_db. Testele care au nevoie de el il pornesc punctual
# (object.__setattr__ pe settings sau apel direct la seeder).
os.environ.setdefault("AUTOPASS_SEED_OPERATII_ENABLED", "false")
# Referinta la init_db-ul REAL (nemodificat), capturata O SINGURA DATA la prima rulare a
# fixturii de mai jos. Wrapper-ul o refoloseste ca sa nu se auto-imbrace recursiv.
_REAL_INIT_DB = None
@pytest.fixture(autouse=True)
def _auto_send_on_default_account(request, monkeypatch):
"""Contul default id=1 (seed schema.sql, INSERT OR IGNORE) porneste pe
`auto_send_enabled=0` (Auto OFF) — corect in productie (nimic nu pleaca fara confirmare).
Dar suita exercita lantul `POST -> claim_one -> sent` pe contul id=1
(test_import_e2e, test_creds_delivery, test_live_rar): cu Auto OFF, ingestia ar marca
`held=1` si worker-ul (`claim_one ... AND held=0`) nu ar mai lua randul
-> testele ar stagna tacit. E un fix de STARE DB, nu env var (coloana e per-rand in
`accounts`), deci il aplicam wraparind `init_db` (contul id=1 NU e creat de `create_account`
-> un patch pe factory NU l-ar acoperi).
Wrapam `init_db` astfel incat orice DB de test creat sa aiba id=1 pe Auto ON (held=0 la
ingestie), pastrand suita verde ca inainte de 5.19. Conturile create explicit de teste
(`create_account`) raman pe default OFF -> testele care VOR Auto OFF il primesc corect.
Testele care verifica DEFAULT-ul brut (id=1 == 0 imediat dupa init_db) se exclud cu
markerul `no_auto_send_seed`.
"""
global _REAL_INIT_DB
import sys
import app.db as _db
if _REAL_INIT_DB is None:
_REAL_INIT_DB = _db.init_db
if request.node.get_closest_marker("no_auto_send_seed"):
yield
return
def _init_db_auto_on():
_REAL_INIT_DB()
conn = _db.get_connection()
try:
conn.execute("UPDATE accounts SET auto_send_enabled=1 WHERE id=1")
conn.commit()
finally:
conn.close()
# app.db.init_db: acopera fixturile care fac `from app.db import init_db` in corpul lor
# (importul ruleaza DUPA acest patch -> primeste wrapper-ul) + apelurile `app.db.init_db()`.
monkeypatch.setattr(_db, "init_db", _init_db_auto_on)
# app.main.init_db: lifespan-ul TestClient cheama numele importat la nivel de modul
# (`from .db import init_db`); daca app.main e deja importat, patch-uim si referinta lui.
# Daca NU e inca importat, primul `from app.main import app` din test il va lega la
# wrapper-ul de mai sus automat (citeste app.db.init_db la momentul importului).
_main = sys.modules.get("app.main")
if _main is not None:
monkeypatch.setattr(_main, "init_db", _init_db_auto_on, raising=False)
yield
# Anti-leak: daca app.main a fost importat IN timpul testului (legand wrapper-ul la
# import, nu prin monkeypatch), readu-l la init_db-ul real ca sa nu ramana wrapat global.
_main = sys.modules.get("app.main")
if _main is not None and getattr(_main, "init_db", None) is _init_db_auto_on:
_main.init_db = _REAL_INIT_DB
@pytest.fixture(autouse=True)
def _reset_embeddings_singleton():
"""Reseteaza singleton-ul global de embeddings intre teste (izolare de ordine).
@@ -51,7 +114,7 @@ def _reset_embeddings_singleton():
def make_test_cui(seed: str = "") -> str:
"""Factory centralizat (D#14, PRD 5.12 US-001): genereaza un CUI de test unic din seed.
"""Factory centralizat: genereaza un CUI de test unic din seed.
Folosit de fixture-urile de test care creeaza conturi via /signup sau create_account
si au nevoie de un CUI unic per test (altfel unicitatea CUI-ului bloca al doilea signup
@@ -69,3 +132,8 @@ def pytest_configure(config):
config.addinivalue_line(
"markers", "live: test live pe RAR test (necesita AUTOPASS_LIVE_RAR=1 + creds reale)"
)
config.addinivalue_line(
"markers",
"no_auto_send_seed: dezactiveaza fixtura autouse care pune id=1 pe Auto ON "
"(teste care verifica default-ul brut auto_send_enabled=0 al contului id=1)",
)

View File

@@ -1,8 +1,8 @@
"""Teste US-003 (PRD 3.6): Acasa unificata — Trimiteri ca sectiune permanenta.
"""Teste Acasa unificata — Trimiteri ca sectiune permanenta.
Tab-ul "Trimiteri" (coada) eliminat din tab-bar; Trimiterile devin o sectiune sub
upload pe Acasa, cu heading "Trimiterile tale". ?tab=coada si /_fragments/coada
servesc continutul Acasa (nu 404, nu fragment orfan). Poll aliniat la 15s (M5).
servesc continutul Acasa (nu 404, nu fragment orfan).
"""
from __future__ import annotations
@@ -50,14 +50,14 @@ def _seed_submission(status: str = "sent", n: int = 1) -> None:
def test_tab_bar_fara_trimiteri(client):
"""US-009: tab-bar eliminat; 'Coada' nu exista; Mapari/Cont/Nomenclator raman in meniu."""
"""Tab-bar eliminat; 'Coada' nu exista; Mapari/Cont/Nomenclator raman in meniu."""
r = client.get("/")
assert r.status_code == 200
html = r.text
# "Coada" nu trebuie sa existe nici ca tab, nici ca link in meniu
assert 'id="tab-coada"' not in html
assert 'href="/?tab=coada"' not in html
# US-009: tab-bar eliminat; Mapari/Cont/Nomenclator sunt in meniul hamburger
# Mapari/Cont/Nomenclator sunt in meniul hamburger
for label in ("Mapari", "Cont", "Nomenclator"):
assert f">{label}" in html or f"{label}<" in html, f"lipseste intrarea {label} in meniu"
@@ -70,8 +70,7 @@ def test_acasa_contine_sectiunea_trimiteri(client):
html = r.text
assert 'id="filtre-trimiteri"' in html
assert "/_fragments/submissions" in html
# PRD 5.9 US-003: detaliul s-a mutat in modalul global (#modal-detaliu); vechiul
# panou inert #trimitere-detaliu a fost eliminat.
# Detaliul e in modalul global (#modal-detaliu); panoul inert vechi eliminat.
assert 'id="modal-detaliu"' in html
assert 'id="trimitere-detaliu"' not in html
@@ -108,7 +107,7 @@ def test_acasa_fara_linkuri_ajutor(client):
def test_acasa_fara_wayfinding_ajutor(client):
"""US-001 (5.5): randul 'Ajutor' (wayfinding Mapari/Coduri RAR) eliminat din Acasa —
"""Randul 'Ajutor' (wayfinding Mapari/Coduri RAR) eliminat din Acasa —
navigarea traieste in tab-bar si in meniul de cont."""
r = client.get("/?tab=acasa")
html = r.text
@@ -132,7 +131,7 @@ def test_trimiteri_fara_poll_periodic_pe_tabel(client):
"""Tabelul de trimiteri NU se reimprospateaza periodic: #submissions-wrap se
incarca la load / actiunile utilizatorului (trimiteriChanged) / reincarcaTrimiteri.
Reimprospatarea automata la date noi externe se face prin pollerul de versiune
care cheama reincarcaTrimiteri() — fara nudge manual (US-008)."""
care cheama reincarcaTrimiteri()."""
_seed_submission("sent")
r = client.get("/?tab=acasa")
html = r.text
@@ -142,6 +141,6 @@ def test_trimiteri_fara_poll_periodic_pe_tabel(client):
assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}"
assert "reincarcaTrimiteri" in wrap
assert "trimiteriChanged" in wrap
# Pollerul de versiune exista (auto-refresh la date noi externe). Nudge eliminat (US-008).
# Pollerul de versiune exista (auto-refresh la date noi externe); fara nudge manual.
assert "/_fragments/trimiteri-versiune" in html
assert 'id="nudge-trimiteri"' not in html

View File

@@ -1,4 +1,4 @@
"""Teste US-004 (PRD 5.5): model de stare a contului `accounts.status` + helperi.
"""Teste model de stare a contului `accounts.status` + helperi.
Acopera: derivarea status din active la migrare, invariantul active=1 <=> status='active',
helperii set_status/delete_account, protectia contului de sistem id=1, excluderea 'deleted'
@@ -94,7 +94,7 @@ def test_delete_purjeaza_pii_si_elibereaza_cui(conn):
"""Stergerea soft purjeaza creds RAR + revoca cheile API + elibereaza CUI (re-inregistrabil)."""
from app.accounts import create_account, delete_account, list_accounts
acct_id = create_account(conn, "Service GDPR", cui="RO12345", active=True)
# US-013: creds in sloturi per-env (rar_creds_enc legacy dropata)
# creds in sloturi per-env
conn.execute(
"UPDATE accounts SET rar_creds_test_enc='secret_enc_test', rar_creds_prod_enc='secret_enc_prod' WHERE id=?",
(acct_id,),
@@ -138,7 +138,6 @@ def test_migrare_deriva_status_din_active(conn):
from app.db import _migrate
# Reconstruim accounts fara `status` (rebuild de tabela — singura cale in SQLite vechi).
# US-013: rar_creds_enc nu mai exista in accounts (dropata); nu e in legacy DDL.
conn.executescript(
"""
PRAGMA foreign_keys=OFF;

View File

@@ -1,4 +1,4 @@
"""Teste US-001 (PRD 3.1): coloana accounts.active + helper-e cont in app/accounts.py."""
"""Teste coloana accounts.active + helper-e cont in app/accounts.py."""
from __future__ import annotations
@@ -119,7 +119,7 @@ def test_list_accounts_ordonat_fara_creds(conn):
# ---------------------------------------------------------------------------
# US-001 (PRD 5.12): accounts.email + validari companie/email/CUI
# accounts.email + validari companie/email/CUI
# ---------------------------------------------------------------------------
def test_create_account_fara_email_ridica(conn):
@@ -176,7 +176,7 @@ def test_account_is_complete_false_pe_legacy_incomplet(conn):
# ---------------------------------------------------------------------------
# 5.17 US-001/US-008: schema tier + trial_until + set_tier + CLI set-tier
# schema tier + trial_until + set_tier + CLI set-tier
# ---------------------------------------------------------------------------
def test_migrare_tier_trial_defensiva(conn):

View File

@@ -1,4 +1,4 @@
"""Teste US-008 (PRD 5.5): rute admin pentru ciclul de viata al conturilor —
"""Rute admin pentru ciclul de viata al conturilor —
block/archive/delete + bulk pe lista account_id, require_admin + CSRF + PRG, dev id=1 protejat.
"""

View File

@@ -1,7 +1,4 @@
"""Teste US-011 (PRD 3.3b): panou admin web /admin — conturi in asteptare + activare.
TDD strict: testele se scriu INAINTE de implementare; la inceput pica (RED),
dupa implementare trec (GREEN).
"""Teste panou admin web /admin — conturi in asteptare + activare.
Fisiere testate: app/web/admin_routes.py, app/web/templates/admin.html.
"""
@@ -175,7 +172,7 @@ def test_non_admin_403(client):
Creeaza intai un cont admin (bootstrap: primul user devine admin),
apoi un al doilea cont (non-admin) si verifica ca al doilea primeste 403.
"""
# Primul signup devine automat admin (bootstrap US-010)
# Primul signup devine automat admin (bootstrap)
_signup(client, "Admin Bootstrap SA", "bootstrap@test.ro")
# Al doilea user NU e admin
@@ -286,7 +283,7 @@ def test_set_tier_din_admin_incheie_trial(client):
"""POST /admin/set-tier -> tier actualizat, trial_until=NULL (trial incheiat), 303.
Contul nou are trial Pro 30z; alocarea manuala trebuie sa-l incheie ca alegerea
sa aiba efect imediat (decizie user 2026-06-29)."""
sa aiba efect imediat."""
target_id = _signup(client, "Firma Upgrade SRL", "upgrade@test.ro")
tier0, trial0 = _get_tier_trial(target_id)
assert tier0 == "free", "cont nou trebuie sa porneasca pe free"

View File

@@ -1,4 +1,4 @@
"""Teste US-010 (PRD 3.3b): rol admin + bootstrap + guard require_admin.
"""Teste rol admin + bootstrap + guard require_admin.
Fisiere testate: app/users.py (helper-e admin), app/web/session.py (require_admin),
tools/account.py (subcomanda set-admin).

View File

@@ -1,4 +1,4 @@
"""Teste API /v1/prezentari — rutare validare (T3) + idempotenta."""
"""Teste API /v1/prezentari — rutare validare + idempotenta."""
from __future__ import annotations
@@ -31,7 +31,7 @@ def _body(**over):
"prestatii": [{"cod_prestatie": "OE-1"}],
}
prez.update(over)
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"}
def test_prezentare_valida_queued(client):
@@ -63,7 +63,7 @@ def test_idempotenta_dedup(client):
# --------------------------------------------------------------------------- #
# PRD 5.7 — raspuns onest: erori/nemapate/motiv pe randuri blocate #
# Raspuns onest: erori/nemapate/motiv pe randuri blocate #
# --------------------------------------------------------------------------- #
def test_needs_data_intoarce_erori(client):
@@ -117,6 +117,66 @@ def test_raspuns_nu_reflecta_parola(client):
assert "password" not in r.text
def test_rezultate_poarta_index_si_ecou_vehicul(client):
"""Fiecare rezultat e corelabil cu cererea: index 0-based + ecou vin/nr,
inclusiv pe randuri blocate si pe dedup."""
body = _body()
prez2 = dict(body["prezentari"][0])
prez2.update({"vin": "WAUZZZ8K9BA123456", "nr_inmatriculare": "cj45xyz",
"data_prestatie": "2099-01-01"})
body["prezentari"].append(prez2)
r = client.post("/v1/prezentari", json=body)
res = r.json()["results"]
assert [x["index"] for x in res] == [0, 1]
assert res[0]["vin"] == "WVWZZZ1KZAW000123"
assert res[1]["nr_inmatriculare"] == "CJ45XYZ" # ecou normalizat (upper)
assert res[1]["status"] == "needs_data"
# Dedup: acelasi continut -> rezultatul pastreaza corelarea.
r2 = client.post("/v1/prezentari", json=body)
res2 = r2.json()["results"]
assert res2[0]["deduped"] is True
assert res2[0]["index"] == 0
assert res2[0]["vin"] == "WVWZZZ1KZAW000123"
def test_nemapat_primeste_sugestie(client):
"""Cod nemapat cu denumire apropiata de nomenclator -> sugestie fuzzy in
nemapate[].sugestie (complet) si in motiv (ambele forme)."""
prestatii = [{"cod_op_service": "REV-PER", "denumire": "Revizie periodica"}]
r = client.post("/v1/prezentari", json=_body(prestatii=prestatii))
res = r.json()["results"][0]
assert res["status"] == "needs_mapping"
sug = res["nemapate"][0]["sugestie"]
assert sug["cod_prestatie"] == "OE-3" # seed: REVIZIE PERIODICA
assert sug["sursa"] == "fuzzy"
assert "sugestie: OE-3" in res["motiv"]
# Forma minima: sugestia ramane vizibila in motiv.
body = _body(prestatii=prestatii, vin="WAUZZZ8K9BA123456")
del body["raspuns"]
r2 = client.post("/v1/prezentari", json=body)
assert "sugestie: OE-3" in r2.json()["results"][0]["motiv"]
def test_raspuns_minim_implicit(client):
"""Fara `raspuns` in cerere -> forma compacta cu chei fixe, fara campurile detaliate."""
body = _body(prestatii=[{"cod_op_service": "REP-MOT", "denumire": "Reparatie motor"}])
del body["raspuns"]
r = client.post("/v1/prezentari", json=body)
res = r.json()["results"][0]
assert set(res) == {"index", "submission_id", "vehicul", "status", "motiv", "id_prezentare"}
assert res["index"] == 0
assert res["vehicul"] == "B999TST"
assert res["status"] == "needs_mapping"
assert "REP-MOT" in res["motiv"]
# 'minim' explicit = acelasi lucru cu default-ul.
body["raspuns"] = "minim"
r2 = client.post("/v1/prezentari", json=body)
assert set(r2.json()["results"][0]) == set(res)
def test_json_malformat_422(client):
# Lipseste vin -> validare de shape Pydantic -> 422 (NU needs_data).
bad = {"rar_credentials": {"email": "x", "password": "y"},

View File

@@ -1,4 +1,4 @@
"""Teste US-010 (PRD 5.6): API DELETE + re-pune in coada, scoped + oracol scope/stare."""
"""Teste API DELETE + re-pune in coada, scoped + oracol scope/stare."""
from __future__ import annotations

View File

@@ -0,0 +1,156 @@
"""Teste API GET /v1/prezentari — filtrare pe perioada (data_de/data_pana) + obs expus.
data_prestatie traieste in payload_json (nu e coloana SQL), deci filtrul se aplica
in Python dupa derivarea prezentarii (vezi app/api/v1/router.py:list_prezentari),
acelasi pattern ca /_fragments/submissions (dashboard web).
"""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _body(**over):
prez = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
prez.update(over)
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"}
def _trimite(client, data_prestatie: str, **over) -> int:
r = client.post("/v1/prezentari", json=_body(data_prestatie=data_prestatie, **over))
assert r.status_code == 200, r.text
return r.json()["results"][0]["submission_id"]
def test_filtrare_ambii_parametri(client):
id_inainte = _trimite(client, "2026-06-01")
id_in_interval = _trimite(client, "2026-06-15")
id_dupa = _trimite(client, "2026-07-01")
r = client.get("/v1/prezentari", params={"data_de": "2026-06-10", "data_pana": "2026-06-20"})
assert r.status_code == 200
ids = [s["id"] for s in r.json()["submissions"]]
assert ids == [id_in_interval]
assert id_inainte not in ids
assert id_dupa not in ids
def test_filtrare_doar_data_de(client):
id_vechi = _trimite(client, "2026-06-01")
id_nou = _trimite(client, "2026-06-20")
r = client.get("/v1/prezentari", params={"data_de": "2026-06-10"})
assert r.status_code == 200
ids = [s["id"] for s in r.json()["submissions"]]
assert id_nou in ids
assert id_vechi not in ids
def test_filtrare_doar_data_pana(client):
id_vechi = _trimite(client, "2026-06-01")
id_nou = _trimite(client, "2026-06-20")
r = client.get("/v1/prezentari", params={"data_pana": "2026-06-10"})
assert r.status_code == 200
ids = [s["id"] for s in r.json()["submissions"]]
assert id_vechi in ids
assert id_nou not in ids
def test_fara_parametri_comportament_neschimbat(client):
"""Fara data_de/data_pana, comportamentul ramane identic (SQL LIMIT, fara filtru Python)."""
id1 = _trimite(client, "2026-06-01")
id2 = _trimite(client, "2026-07-01")
r = client.get("/v1/prezentari")
assert r.status_code == 200
ids = [s["id"] for s in r.json()["submissions"]]
assert id1 in ids
assert id2 in ids
@pytest.mark.parametrize("param", ["data_de", "data_pana"])
def test_format_invalid_422(client, param):
r = client.get("/v1/prezentari", params={param: "15-06-2026"})
assert r.status_code == 422
def test_format_invalid_data_nesexistenta_422(client):
r = client.get("/v1/prezentari", params={"data_de": "2026-02-30"})
assert r.status_code == 422
def test_interactiune_cu_status(client):
"""Filtrul de perioada se combina cu status (AND): doar randuri care satisfac AMBELE."""
id_queued_in_interval = _trimite(client, "2026-06-15")
# Al doilea rand, status diferit (needs_data via VIN invalid) dar tot in interval.
r = client.post("/v1/prezentari", json=_body(
data_prestatie="2026-06-16", vin="WVWZZZ1OZIQ45678",
))
assert r.status_code == 200
id_needs_data = r.json()["results"][0]["submission_id"]
lista = client.get("/v1/prezentari", params={
"status": "queued", "data_de": "2026-06-01", "data_pana": "2026-06-30",
}).json()["submissions"]
ids = [s["id"] for s in lista]
assert id_queued_in_interval in ids
assert id_needs_data not in ids
def test_interactiune_cu_limit(client):
"""LIMIT se aplica DUPA filtrarea pe perioada, nu inainte (altfel ar trunchia gresit)."""
ids_in_interval = [
_trimite(client, "2026-06-10", vin=f"WVWZZZ1KZAW{i:06d}") for i in range(5)
]
# Randuri in afara intervalului, mai recente (ORDER BY id DESC) — daca LIMIT s-ar
# aplica INAINTE de filtrare, ar "manca" toate randurile din interval.
for i in range(5):
_trimite(client, "2026-08-01", vin=f"WVWZZZ1KZBW{i:06d}")
r = client.get("/v1/prezentari", params={
"data_de": "2026-06-01", "data_pana": "2026-06-30", "limit": 3,
})
assert r.status_code == 200
submissions = r.json()["submissions"]
assert len(submissions) == 3
ids = [s["id"] for s in submissions]
assert all(i in ids_in_interval for i in ids)
def test_obs_prezent_in_raspuns(client):
sid = _trimite(client, "2026-06-15", obs="observatie test")
lista = client.get("/v1/prezentari").json()["submissions"]
rand = next(s for s in lista if s["id"] == sid)
assert rand["prezentare"]["obs"] == "observatie test"
def test_obs_gol_cand_lipseste(client):
sid = _trimite(client, "2026-06-15")
lista = client.get("/v1/prezentari").json()["submissions"]
rand = next(s for s in lista if s["id"] == sid)
assert rand["prezentare"]["obs"] == ""

View File

@@ -1,4 +1,4 @@
"""Teste US-005 — camp rar_env pe POST /v1/prezentari si /valideaza.
"""Teste camp rar_env pe POST /v1/prezentari si /valideaza.
Acopera: default din cont, tinta explicita, respingere tinta indisponibila,
echo GET, valoare invalida (422 Pydantic), echo dry-run valideaza.
@@ -38,7 +38,7 @@ def _body(rar_env=None, **over):
"prestatii": [{"cod_prestatie": "OE-1"}],
}
prez.update(over)
body = {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
body = {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"}
if rar_env is not None:
body["rar_env"] = rar_env
return body

View File

@@ -1,4 +1,4 @@
"""US-011 (PRD 5.15): account-scope pe GET-urile de listare API (securitate).
"""Teste account-scope pe GET-urile de listare API (securitate).
Verifica:
- GET /v1/prezentari: un cont nu vede submissions ale altui cont
@@ -6,9 +6,8 @@ Verifica:
- Unauthenticated access cu require_api_key=True -> 401
- Filtre si paginare nu sparg scope-ul
Legatura cu implementare: resolve_account_id + account_scope_clause (mecanisme
existente, reutilizate). Testele LOCK DOWN comportamentul deja implementat
si verifica alinierea cu AUTOPASS_REQUIRE_API_KEY (dev vs prod).
Foloseste resolve_account_id + account_scope_clause; verifica alinierea
cu AUTOPASS_REQUIRE_API_KEY (dev vs prod).
"""
from __future__ import annotations
@@ -227,7 +226,7 @@ def test_get_listare_filtru_status_nu_sparge_scope(client):
# ============================================================================
# PRD 5.17 — enforcement planuri: gate API (T4) + volum (T3) + kill-switch (T5)
# Enforcement planuri: gate API + volum + kill-switch
# ============================================================================
_PREZ_PLAN = {
@@ -279,7 +278,7 @@ def _insert_n_submissions(account_id: int, n: int) -> None:
def test_free_fara_api_403(client):
"""Cont free (non-default, cu cheie API) → 403 PLAN_FARA_API pe POST /v1/prezentari.
T4 PRD 5.17: gate API refuza conturi cu api_access=False in PLANS.
Gate-ul API refuza conturi cu api_access=False in PLANS.
"""
acct_id, key = _create_account_with_key("FreePlanGate")
_set_tier_acct(acct_id, "free", trial_until=None)
@@ -349,8 +348,8 @@ def test_trial_pro_api_ok(client):
def test_dry_run_valideaza_ramane_permis(client):
"""POST /v1/prezentari/valideaza (dry-run) e permis pe orice plan, inclusiv free.
Decizie design (PRD 5.17): valideaza nu face enqueue si nu consuma cota,
deci NU e protejat de gate-ul API — integratorii pot testa fara upgrade.
Valideaza nu face enqueue si nu consuma cota, deci NU e protejat de
gate-ul API — integratorii pot testa fara upgrade.
"""
acct_id, key = _create_account_with_key("FreeValideazaTest")
_set_tier_acct(acct_id, "free", trial_until=None)
@@ -374,8 +373,8 @@ def test_dry_run_valideaza_ramane_permis(client):
def test_free_peste_60_respins_api(client):
"""Dev account (id=1) la 60/60 pe free → a 61-a prezentare respinsa 422 PLAN_LIMITA_LUNARA.
Dev account (id=1) in dev mode (require_api_key=False) are bypass la gate-ul API (T4),
dar NU la verificarea de volum (T3). La 60/60, urmatoarea cerere trebuie respinsa 422.
Dev account (id=1) in dev mode (require_api_key=False) are bypass la gate-ul API,
dar NU la verificarea de volum. La 60/60, urmatoarea cerere trebuie respinsa 422.
"""
_set_tier_acct(1, "free", trial_until=None)
_insert_n_submissions(1, 60)
@@ -392,7 +391,7 @@ def test_free_peste_60_respins_api(client):
def test_eroare_3_niveluri_plan_limita(client):
"""Eroarea PLAN_LIMITA_LUNARA contine toate 3 nivelurile: cod, problema, cauza, fix.
Pattern standard (PRD 5.17): eroare structurata pe 3 niveluri — nu 500, nu catch-all.
Pattern standard de eroare structurata pe 3 niveluri — nu 500, nu catch-all.
"""
_set_tier_acct(1, "free", trial_until=None)
_insert_n_submissions(1, 60)
@@ -480,7 +479,7 @@ def test_retry_idempotent_nu_consuma_cota(client):
assert usage_1 == 1, f"Dupa prima trimitere: asteptat usage=1, primit {usage_1}"
# Retry (acelasi payload -> acelasi idempotency_key -> dedup, fara INSERT nou)
r2 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]})
r2 = client.post("/v1/prezentari", json={"prezentari": [prez_unic], "raspuns": "complet"})
assert r2.status_code == 200, f"Retry trebuie sa treaca (dedup): {r2.text}"
assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped"
@@ -512,7 +511,7 @@ def test_dev_id1_neblocat(client):
def test_flag_enforce_plans_false_sare_enforcement(client, monkeypatch):
"""Kill-switch AUTOPASS_ENFORCE_PLANS=false sare toate gate-urile de plan.
T5 PRD 5.17: flag de operare pentru debugging sau rollback rapid fara revert de cod.
Flag de operare pentru debugging sau rollback rapid fara revert de cod.
Chiar si cu free la 60/60, nu trebuie 422 cand flag-ul e oprit.
"""
from app.config import get_settings

View File

@@ -1,4 +1,4 @@
"""Teste US-004 (PRD 5.6): audit cerere API per cont in jurnal."""
"""Teste audit cerere API per cont in jurnal."""
from __future__ import annotations

View File

@@ -0,0 +1,150 @@
"""Schema comutator Auto per cont + flag `held` pe submission.
Acopera: migrarea aditiva idempotenta (coloane + index partial pe DB vechi), helperii
`get_auto_send`/`set_auto_send`/`held_for_account` din app/accounts.py si default-ul 0.
"""
from __future__ import annotations
import os
import sqlite3
import tempfile
import pytest
@pytest.fixture()
def fresh_conn(monkeypatch):
"""DB nou cu schema curenta (init_db)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _old_db(path: str) -> sqlite3.Connection:
"""DB in forma PRE-5.19: accounts fara auto_send_enabled, submissions fara held."""
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute(
"CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, "
"cui TEXT)"
)
conn.execute(
"CREATE TABLE submissions (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"idempotency_key TEXT NOT NULL UNIQUE, account_id INTEGER, status TEXT, "
"payload_json TEXT NOT NULL)"
)
return conn
def _migrate_old(path: str, monkeypatch) -> sqlite3.Connection:
from app.config import get_settings
get_settings.cache_clear()
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
from app.db import _migrate
_migrate(conn)
return conn
def test_migrare_adauga_coloane_si_index(tmp_path, monkeypatch):
"""Pe DB vechi (fara coloane) migrarea adauga held, auto_send_enabled si indexul partial."""
path = str(tmp_path / "old.db")
_old_db(path).close()
conn = _migrate_old(path, monkeypatch)
sub = {r["name"] for r in conn.execute("PRAGMA table_info(submissions)").fetchall()}
assert "held" in sub
acc = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "auto_send_enabled" in acc
idx = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'"
).fetchall()}
assert "idx_submissions_held" in idx
conn.close()
def test_migrare_idempotenta_a_doua_rulare(tmp_path, monkeypatch):
"""A doua rulare _migrate pe DB deja migrat nu arunca si nu dubleaza nimic."""
path = str(tmp_path / "old.db")
_old_db(path).close()
conn = _migrate_old(path, monkeypatch)
from app.db import _migrate
_migrate(conn) # nu arunca
acc = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "auto_send_enabled" in acc
conn.close()
def test_migrare_idempotenta_pe_db_fresh(fresh_conn):
"""_migrate re-rulat pe schema curenta (init_db) nu strica nimic."""
from app.db import _migrate
_migrate(fresh_conn)
sub = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(submissions)").fetchall()}
assert "held" in sub
def test_held_for_account_auto_on(fresh_conn):
from app.accounts import create_account, held_for_account, set_auto_send
aid = create_account(fresh_conn, "Auto ON")
set_auto_send(fresh_conn, aid, True)
assert held_for_account(fresh_conn, aid) == 0
def test_held_for_account_auto_off(fresh_conn):
from app.accounts import create_account, held_for_account
aid = create_account(fresh_conn, "Auto OFF")
assert held_for_account(fresh_conn, aid) == 1
def test_held_for_account_cont_inexistent(fresh_conn):
from app.accounts import held_for_account
assert held_for_account(fresh_conn, 99999) == 1
def test_get_set_auto_send_round_trip(fresh_conn):
from app.accounts import create_account, get_auto_send, set_auto_send
aid = create_account(fresh_conn, "Round trip")
assert get_auto_send(fresh_conn, aid) is False
set_auto_send(fresh_conn, aid, True)
assert get_auto_send(fresh_conn, aid) is True
# Idempotent: acelasi set nu arunca si pastreaza valoarea.
set_auto_send(fresh_conn, aid, True)
assert get_auto_send(fresh_conn, aid) is True
set_auto_send(fresh_conn, aid, False)
assert get_auto_send(fresh_conn, aid) is False
def test_get_auto_send_cont_inexistent(fresh_conn):
from app.accounts import get_auto_send
assert get_auto_send(fresh_conn, 99999) is False
@pytest.mark.no_auto_send_seed # verifica default-ul BRUT id=1==0; nu wrapa init_db (conftest)
def test_default_auto_send_zero(fresh_conn):
"""Cont nou (create_account) si contul id=1 pornesc pe auto_send_enabled=0 (Auto OFF)."""
from app.accounts import create_account
aid = create_account(fresh_conn, "Nou")
row = fresh_conn.execute(
"SELECT auto_send_enabled FROM accounts WHERE id=?", (aid,)
).fetchone()
assert row["auto_send_enabled"] == 0
row1 = fresh_conn.execute(
"SELECT auto_send_enabled FROM accounts WHERE id=1"
).fetchone()
assert row1["auto_send_enabled"] == 0
def test_config_held_zile():
from app.config import Settings
s = Settings()
assert s.held_warn_days == 7
assert s.held_retention_days == 90

View File

@@ -1,6 +1,5 @@
"""Teste US-007 (PRD 3.6) actualizate dupa US-002 (PRD 5.11).
"""Teste comutator auto_send: macro autosend_toggle neutralizat (intoarce string gol).
US-002: macro autosend_toggle neutralizat (intoarce string gol).
Checkbox-ul name=auto_send a fost scos din UI. Coloanele DB raman.
Testele de UI verifica ABSENTA toggle-ului; testele de backend (stocare DB) raman.
"""
@@ -97,24 +96,24 @@ def _macro_html(checked: bool = True, form_id: str = "") -> str:
return str(mod.autosend_toggle(form_id=form_id, checked=checked))
# --- markup: macro neutralizat dupa US-002 ---
# --- markup: macro neutralizat ---
def test_comutator_coada_prezent():
"""US-002: macro autosend_toggle neutralizat -> output gol (fara checkbox)."""
"""macro autosend_toggle neutralizat -> output gol (fara checkbox)."""
html = _macro_html()
assert 'name="auto_send"' not in html, "US-002: checkbox auto_send scos din UI"
assert 'name="auto_send"' not in html, "checkbox auto_send scos din UI"
assert html.strip() == "", f"macro neutralizat trebuie sa intoarca string gol, got: {html!r}"
def test_eticheta_scoped_pe_operatie():
"""US-002: macro neutralizat -> nicio eticheta scoped."""
"""macro neutralizat -> nicio eticheta scoped."""
html = _macro_html()
assert "aceasta operatie" not in html
assert html.strip() == ""
def test_default_pune_automat():
"""US-002: macro neutralizat intoarce gol indiferent de parametrul checked."""
"""macro neutralizat intoarce gol indiferent de parametrul checked."""
html_default = _macro_html(checked=True)
assert html_default.strip() == ""
html_off = _macro_html(checked=False)
@@ -154,7 +153,7 @@ def test_tine_pentru_verificare_mapeaza_auto_send_false(client):
# --- prezent in AMBELE locuri (mapari tab + panou preview) ---
def test_comutator_in_tab_mapari(client):
"""US-002: tabul Mapari nu mai contine checkbox auto_send (macro neutralizat)."""
"""Tabul Mapari nu mai contine checkbox auto_send (macro neutralizat)."""
from app.db import get_connection
import json
acct = _create_account_user("tm@test.com")
@@ -172,12 +171,12 @@ def test_comutator_in_tab_mapari(client):
_login(client, "tm@test.com")
resp = client.get("/?tab=mapari")
assert resp.status_code == 200
assert 'name="auto_send"' not in resp.text, "US-002: checkbox auto_send scos din UI"
assert "In coada" not in resp.text, "US-002: coloana 'In coada' scoasa"
assert 'name="auto_send"' not in resp.text, "checkbox auto_send scos din UI"
assert "In coada" not in resp.text, "coloana 'In coada' scoasa"
def test_comutator_in_panou_preview(client):
"""US-002: panoul de mapare din preview nu mai contine checkbox auto_send."""
"""Panoul de mapare din preview nu mai contine checkbox auto_send."""
_create_account_user("pp@test.com")
_seed_nomenclator("R-FRANE")
_login(client, "pp@test.com")
@@ -203,5 +202,5 @@ def test_comutator_in_panou_preview(client):
})
assert r.status_code == 200
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
assert 'name="auto_send"' not in r.text, "US-002: checkbox auto_send scos din preview"
assert "In coada automat" not in r.text, "US-002: eticheta 'In coada automat' scoasa"
assert 'name="auto_send"' not in r.text, "checkbox auto_send scos din preview"
assert "In coada automat" not in r.text, "eticheta 'In coada automat' scoasa"

Some files were not shown because too many files have changed in this diff Show More