Compare commits

...

15 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
70 changed files with 5740 additions and 877 deletions

View File

@@ -40,6 +40,7 @@ AUTOPASS_LIVE_RAR=1 python3 -m pytest tests/test_live_rar.py -q # LIVE opt-in:
python3 -m tools.apikey create --account 2 | list | rotate | revoke python3 -m tools.apikey create --account 2 | list | rotate | revoke
docker compose up --build # deploy: api + worker + autoheal, acelasi image + volum SQLite 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 ## Arhitectura
@@ -63,6 +64,7 @@ Flux: validare (`validation.py`) → mapare operatie→cod (`mapping.py`) → en
- **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`. - **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 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. - **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`. - **`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`). - **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`). - **422 fara echo de credentiale**: handler-ul din `main.py` pastreaza type/loc/msg dar DROP-a `input`/`ctx` (altfel reflecta `rar_credentials.password`).

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`, Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, `Odometru final`,
`Odometru initial`, `Operatie`, `Observatii`. Fiecare cont poate avea mai multe formate memorate. `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) ## API v1 (curl)
Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`. Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`.

View File

@@ -2,6 +2,44 @@
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare. 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) ## Din /autoplan PRD 5.21 (2026-07-06)
- [ ] **CLI administrare cache embeddings (`python3 -m tools.embcache stats|clear|rebuild`)** — v1 foloseste - [ ] **CLI administrare cache embeddings (`python3 -m tools.embcache stats|clear|rebuild`)** — v1 foloseste

View File

@@ -49,11 +49,13 @@ from ...mapping import (
_emite_text_rule_hits, _emite_text_rule_hits,
account_or_default, account_or_default,
has_no_auto_send, has_no_auto_send,
load_excluded_ops,
load_mapping_meta, load_mapping_meta,
load_nomenclator_codes, load_nomenclator_codes,
load_text_rules, load_text_rules,
normalize_for_match, normalize_for_match,
resolve_prestatii, resolve_prestatii,
split_prestatii_excluse,
) )
from ...validation import validate_prezentare from ...validation import validate_prezentare
from ...rar_env import MediuIndisponibil, rar_env_efectiv_cont, rezolva_rar_env from ...rar_env import MediuIndisponibil, rar_env_efectiv_cont, rezolva_rar_env
@@ -135,11 +137,12 @@ def _resolve_row_for_preview(
valid_codes: set[str] | None = None, valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None, text_rules: list[dict] | None = None,
reviewed: bool = False, reviewed: bool = False,
excluded_ops: set[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Rezolva un rand din import pentru preview: aplica mapare coloane + validare. """Rezolva un rand din import pentru preview: aplica mapare coloane + validare.
Intoarce un dict cu: 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) resolved: valorile finale rezolvate (VIN, data, km, prestatii)
errors: lista erori validare errors: lista erori validare
flags: motive needs_review flags: motive needs_review
@@ -218,7 +221,7 @@ def _resolve_row_for_preview(
# Rezolvare prestatii # Rezolvare prestatii
prestatii = mapped.get("prestatii") or [] 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 mapped["prestatii"] = resolved
# Determinare stare # Determinare stare
@@ -230,8 +233,22 @@ def _resolve_row_for_preview(
"flags": all_flags, "flags": all_flags,
} }
# Validare continut # Operatii excluse de la declarare: ies DOAR din payload-ul trimis la RAR (si din
errors = validate_prezentare(mapped) # 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: if all_flags and not reviewed:
# needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala. # needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala.
@@ -266,8 +283,9 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any], rar
return build_key(account_id, canon, rar_env) return build_key(account_id, canon, rar_env)
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza # Campuri de continut editabile in preview. Prestatiile (chips operatie<->cod RAR)
# aici (raman in panoul de mapare). obs = text liber, se trateaza ca non-canonic # 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. # (doar .strip(), fara canonicalize_row) — urmeaza ramura `else` din _merge_override.
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs") EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs")
@@ -318,9 +336,13 @@ def apply_row_override(
account_id: int | None, account_id: int | None,
row_index: int, row_index: int,
fields: dict[str, str | None], fields: dict[str, str | None],
prestatii: list[dict[str, str]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Persista override-ul canonic pentru un rand de preview (mutatie PURA de stocare). """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 NU recalculeaza statusul si NU atinge `submissions` — preview-ul rederiva statusul
prin `_resolve_row_for_preview` (un singur clasificator, fara drift). prin `_resolve_row_for_preview` (un singur clasificator, fara drift).
@@ -358,6 +380,8 @@ def apply_row_override(
current = dec current = dec
new_override = _merge_override(current, fields) 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 enc = encrypt_creds(new_override) if new_override else None
# 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 # trebuie sa reconfirme dupa editare. NU conditionam pe reviewed curent: orice override
@@ -763,9 +787,10 @@ def preview_import(
# Incarca maparea de operatii o singura data # Incarca maparea de operatii o singura data
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} 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 valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct) 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 # 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). # la preview (trebuie sa coincida cu ce va folosi commit-ul fara rar_env explicit).
@@ -821,13 +846,22 @@ def preview_import(
override=overrides[i] or None, override=overrides[i] or None,
valid_codes=valid_codes, valid_codes=valid_codes,
text_rules=text_rules, text_rules=text_rules,
excluded_ops=excluded_ops,
) )
# Calculeaza cheia de idempotenta pentru randurile ok/needs_review # Calculeaza cheia de idempotenta pentru randurile ok/needs_review
key = None key = None
if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"): if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"):
try: 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) keys_for_lookup.append(key)
if key not in key_to_index: if key not in key_to_index:
key_to_index[key] = [] key_to_index[key] = []
@@ -1111,9 +1145,10 @@ def commit_import(
# Incarca maparea de operatii # Incarca maparea de operatii
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} 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 valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct) text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
# Construieste payload-urile submissions # Construieste payload-urile submissions
enqueued: list[dict] = [] enqueued: list[dict] = []
@@ -1167,26 +1202,30 @@ def commit_import(
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val) obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["obs"] = obs_denumire mapped["obs"] = obs_denumire
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview) # Override editat in preview — aplicat INAINTE de resolve_prestatii,
prestatii = mapped.get("prestatii") or [] # ca in _resolve_row_for_preview: prestatiile din override (chips
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules) # editate in modal) trec prin ACELASI resolve, altfel raman raw si
mapped["prestatii"] = resolved # hash-ul/telemetria ar folosi prestatiile pre-editare.
# 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 = ok_row.get("override") or {} override = ok_row.get("override") or {}
if override: if override:
mapped.update(override) mapped.update(override)
# Re-canonicalizeaza pentru a obtine cheia IDENTICA cu cea din preview
# (_build_idempotency_key = canonicalize_row + build_key peste mapped). # Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
canon = canonicalize_row(mapped) 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({ mapped.update({
"vin": canon["vin"], "vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"], "nr_inmatriculare": canon["nr_inmatriculare"],
@@ -1196,13 +1235,13 @@ def commit_import(
# Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env) # Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env)
key = build_key(account_id, canon, 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({ rows_for_hash.append(json.dumps({
"row_index": row_index, "row_index": row_index,
"vin": mapped.get("vin"), "vin": mapped.get("vin"),
"data_prestatie": mapped.get("data_prestatie"), "data_prestatie": mapped.get("data_prestatie"),
"odometru_final": mapped.get("odometru_final"), "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)) }, sort_keys=True, ensure_ascii=False))
payload_json = json.dumps(mapped, ensure_ascii=False) payload_json = json.dumps(mapped, ensure_ascii=False)

View File

@@ -21,6 +21,7 @@ from ...auth import _extract_key, account_for_key, resolve_account_id
from ...config import get_settings from ...config import get_settings
from ...db import get_connection from ...db import get_connection
from ...mapping import account_or_default from ...mapping import account_or_default
from ...rar_env import rar_env_efectiv
router = APIRouter(prefix="/v1", tags=["integrare"]) router = APIRouter(prefix="/v1", tags=["integrare"])
@@ -35,7 +36,8 @@ def ping(
Intoarce: Intoarce:
account_id — contul rezolvat din cheie (sau 1 in dev fara cheie) 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 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_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 are_creds_test — True daca contul are creds RAR pentru mediul Testare
@@ -62,7 +64,8 @@ def ping(
conn = get_connection() conn = get_connection()
try: try:
row = conn.execute( 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() ).fetchone()
finally: finally:
conn.close() conn.close()
@@ -71,9 +74,15 @@ def ping(
are_creds_prod = bool(row and row["rar_creds_prod_enc"]) are_creds_prod = bool(row and row["rar_creds_prod_enc"])
are_creds_rar = are_creds_test or are_creds_prod 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({ return JSONResponse({
"account_id": aid, "account_id": aid,
"mediu": settings.rar_env, "mediu": mediu_efectiv,
"autentificat_cu_cheie": autentificat_cu_cheie, "autentificat_cu_cheie": autentificat_cu_cheie,
"are_creds_rar": are_creds_rar, "are_creds_rar": are_creds_rar,
"are_creds_test": are_creds_test, "are_creds_test": are_creds_test,

View File

@@ -31,23 +31,27 @@ from ...mapping import (
account_or_default, account_or_default,
account_scope_clause, account_scope_clause,
classify_prezentare, classify_prezentare,
load_excluded_ops,
load_mapping_meta, load_mapping_meta,
load_nomenclator,
load_nomenclator_codes, load_nomenclator_codes,
load_text_rules, load_text_rules,
pending_unmapped, pending_unmapped,
reresolve_account, reresolve_account,
save_mapping, save_mapping,
sugestie_pentru_op,
) )
from ...models import ( from ...models import (
PrezentareRequest, PrezentareRequest,
PrezentariResponse, PrezentariResponse,
SubmissionResult, SubmissionResult,
SubmissionResultMinim,
ValidarePrezentariRequest, ValidarePrezentariRequest,
ValidareResponse, ValidareResponse,
ValidareResult, ValidareResult,
) )
from ...observ import log_event 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 ( from ...submissions_admin import (
SubmissionNotFound, SubmissionNotFound,
SubmissionStateConflict, SubmissionStateConflict,
@@ -69,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 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. """classify_prezentare + aplicarea modului on_unmapped_error.
Cand exista coduri nemapate si error_mode=True, marcheaza outcome-ul ca respingere 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. (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 cl["blocked_error"] = bool(cl["unmapped"]) and error_mode
return cl return cl
@@ -88,6 +92,16 @@ 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). # Mesaj uman pentru un rand tinut manual (queued AND held=1).
# Reutilizeaza vocabularul existent AUTO_SEND_OPRIT (errors.py) — nu inventam al treilea # 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. # vocabular "auto_send". Onestitate: raspunsul NU trebuie sa arate un "queued" curat fals.
@@ -116,8 +130,16 @@ def _motiv_clasificare(cl: dict) -> str | None:
(e.get("problema") or e.get("message") or "") for e in cl["errors"] (e.get("problema") or e.get("message") or "") for e in cl["errors"]
).strip("; ") or "Date incomplete (respinse de RAR)." ).strip("; ") or "Date incomplete (respinse de RAR)."
if cl["unmapped"]: if cl["unmapped"]:
coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"]) parti = []
return f"Coduri fara mapare RAR: {coduri}" 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 return None
@@ -184,8 +206,11 @@ def create_prezentari(
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat. # 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 gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
valid_codes = load_nomenclator_codes(conn) or None 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). # Reguli text incarcate o data per cerere (seam partajat cu dry-run).
text_rules = load_text_rules(conn, acct) 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) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
# Rezolva mediul RAR tinta (cerut > default cont > ancora globala). # Rezolva mediul RAR tinta (cerut > default cont > ancora globala).
@@ -285,7 +310,8 @@ def create_prezentari(
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam # retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'. # creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
if existing["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"]: if cl["blocked_error"]:
# on_unmapped_error=True: nu reactivam; randul ramane 'error'. # on_unmapped_error=True: nu reactivam; randul ramane 'error'.
results.append(_rezultat_respins(existing["id"], cl, rar_env=env)) results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
@@ -337,7 +363,8 @@ def create_prezentari(
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea # Helper pur partajat cu dry-run: reproduce EXACT clasificarea
# (canonicalize + mapare op->cod + validare; fara gate auto_send). # (canonicalize + mapare op->cod + validare; fara gate auto_send).
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"]: if cl["blocked_error"]:
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat). # on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
results.append(_rezultat_respins(None, cl, rar_env=env)) results.append(_rezultat_respins(None, cl, rar_env=env))
@@ -354,6 +381,14 @@ def create_prezentari(
# pe queued tinut manual (held=1) expune held=true + motiv. # pe queued tinut manual (held=1) expune held=true + motiv.
results.append(_rezultat_enqueue(sub_id, cl, held=held_val, rar_env=env)) 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), # Audit cerere API per cont. Doar metadate (count + distributie status),
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL). # NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
dist: dict[str, int] = {} dist: dict[str, int] = {}
@@ -374,7 +409,21 @@ def create_prezentari(
) )
finally: finally:
conn.close() 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) @router.post("/prezentari/valideaza", response_model=ValidareResponse)
@@ -395,8 +444,10 @@ def valideaza_prezentari(
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None 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. # Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
text_rules = load_text_rules(conn, acct) 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) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
# Rezolva env identic ca trimiterea reala si ecou-ieste in raspuns. # Rezolva env identic ca trimiterea reala si ecou-ieste in raspuns.
@@ -429,7 +480,8 @@ def valideaza_prezentari(
for i, prez in enumerate(req.prezentari): for i, prez in enumerate(req.prezentari):
content = prez.model_dump() 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"]: if res["blocked_error"]:
res = {**res, "status": "error"} res = {**res, "status": "error"}
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT # Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
@@ -451,12 +503,35 @@ def valideaza_prezentari(
return ValidareResponse(results=results) 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") @router.get("/prezentari")
def list_prezentari( def list_prezentari(
status: str | None = None, status: str | None = None,
data_de: str | None = None,
data_pana: str | None = None,
limit: int = 100, limit: int = 100,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(resolve_account_id),
) -> dict: ) -> dict:
data_de = _valida_data_filtru("data_de", data_de)
data_pana = _valida_data_filtru("data_pana", data_pana)
conn = get_connection() conn = get_connection()
try: try:
scope_sql, scope_params = account_scope_clause(account_id) scope_sql, scope_params = account_scope_clause(account_id)
@@ -467,24 +542,49 @@ def list_prezentari(
"id, status, id_prezentare, rar_status_code, retry_count, " "id, status, id_prezentare, rar_status_code, retry_count, "
"created_at, updated_at, payload_json, rar_env, held" "created_at, updated_at, payload_json, rar_env, held"
) )
where = [scope_sql]
params: list = list(scope_params)
if status: 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( rows = conn.execute(
f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? " f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC",
f"ORDER BY id DESC LIMIT ?", params,
scope_params + [status, limit],
).fetchall() ).fetchall()
else: else:
rows = conn.execute( rows = conn.execute(
f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?", f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC LIMIT ?",
scope_params + [limit], params + [limit],
).fetchall() ).fetchall()
out = [] out = []
for r in rows: for r in rows:
d = dict(r) d = dict(r)
# Campuri afisabile derivate din payload (acelasi helper ca dashboardul web); # Campuri afisabile derivate din payload (acelasi helper ca dashboardul web);
# payload_json brut nu se intoarce in raspuns. # 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) out.append(d)
if filtru_perioada and len(out) >= limit:
break
return {"submissions": out} return {"submissions": out}
finally: finally:
conn.close() conn.close()

View File

@@ -178,6 +178,26 @@ def _migrate(conn: sqlite3.Connection) -> None:
conn.execute( conn.execute(
"ALTER TABLE import_rows ADD COLUMN reviewed INTEGER NOT NULL DEFAULT 0" "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) # Index batch_id pe submissions (poate lipsi pe DB veche)
existing_idx = {r["name"] for r in conn.execute( existing_idx = {r["name"] for r in conn.execute(
@@ -201,6 +221,45 @@ def _migrate(conn: sqlite3.Connection) -> None:
) )
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: def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
"""Coloane medii RAR per cont + backfill din ancora globala. """Coloane medii RAR per cont + backfill din ancora globala.

View File

@@ -226,7 +226,9 @@ class EmbeddingEngine:
{ {
"cod": self._corpus_items[i]["cod"], "cod": self._corpus_items[i]["cod"],
"is_nul": bool(self._corpus_items[i].get("is_nul", False)), "is_nul": bool(self._corpus_items[i].get("is_nul", False)),
"similaritate": float(sims[i]), # 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 for i in idx
] ]

View File

@@ -19,9 +19,25 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re
from typing import Any 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: def _op_identity(p: Any) -> str:
"""Cod RAR (normalizat) daca exista, altfel codul intern ROAAUTO.""" """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)) 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). - prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
""" """
vin = (raw.get("vin") or "").strip().upper() 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 # Odometru: strip ".0" Excel float coercion
odo_raw = raw.get("odometru_final") odo_raw = raw.get("odometru_final")

View File

@@ -39,6 +39,10 @@ DEFAULT_ACCOUNT_ID = 1
# Sub acest scor (0..100) nu preselectam nicio sugestie — userul alege manual. # Sub acest scor (0..100) nu preselectam nicio sugestie — userul alege manual.
SUGGEST_MIN_SCORE = 60 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 # # Pur: normalizare + fuzzy + rezolvare #
@@ -243,11 +247,15 @@ def resolve_prestatii(
mapping: dict[str, str], mapping: dict[str, str],
valid_codes: set[str] | None = None, valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None, text_rules: list[dict] | None = None,
excluded_ops: set[str] | None = None,
) -> tuple[list[dict], list[dict]]: ) -> tuple[list[dict], list[dict]]:
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste. """Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
Reguli (hibrid): Reguli (hibrid):
- item cu `cod_prestatie` valid (in nomenclator) -> pastrat ca atare. - 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, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
- item fara cod, nemapat exact, dar al carui text da match pe o regula text - item fara cod, nemapat exact, dar al carui text da match pe o regula text
(substring) -> umplem cod_prestatie din prima regula care potriveste. (substring) -> umplem cod_prestatie din prima regula care potriveste.
@@ -258,9 +266,16 @@ def resolve_prestatii(
COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
la RAR (terminal) -> nu-l trimitem niciodata raw. la RAR (terminal) -> nu-l trimitem niciodata raw.
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service` Precedenta (stricta): item-exclus EXPLICIT (`exclus=1` venit pe item, ex. din
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista editorul de chips) > `cod_prestatie` direct valid > exclus de la declarare
cod valid SI op nu e in `mapping`. (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 `valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e
None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece
@@ -285,10 +300,21 @@ def resolve_prestatii(
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit. # un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
it.pop("cod_sursa", None) it.pop("cod_sursa", None)
it.pop("regula_fara_autosend", None) it.pop("regula_fara_autosend", None)
exclus_explicit = bool(it.pop("exclus", None))
cod = (it.get("cod_prestatie") or "").strip().upper() cod = (it.get("cod_prestatie") or "").strip().upper()
op = (it.get("cod_op_service") or "").strip() op = (it.get("cod_op_service") or "").strip()
cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes) 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 it["cod_prestatie"] = cod
else: else:
# cod lipsa SAU necunoscut in nomenclator -> ruta de mapare. # cod lipsa SAU necunoscut in nomenclator -> ruta de mapare.
@@ -299,7 +325,11 @@ def resolve_prestatii(
it["cod_op_service"] = op it["cod_op_service"] = op
if not it.get("denumire"): if not it.get("denumire"):
it["denumire"] = cod 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] it["cod_prestatie"] = mapping[op]
elif op: elif op:
# Mapare exacta absenta -> incearca regulile text (substring). # Mapare exacta absenta -> incearca regulile text (substring).
@@ -322,6 +352,17 @@ def resolve_prestatii(
return resolved, unmapped 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) # # Persistenta (conn) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -404,10 +445,11 @@ def load_nomenclator_codes(conn) -> set[str]:
def load_mapping(conn, account_id: int | None) -> dict[str, 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) acct = account_or_default(account_id)
rows = conn.execute( 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,), (acct,),
).fetchall() ).fetchall()
return {r["cod_op_service"]: r["cod_prestatie"] for r in rows} return {r["cod_op_service"]: r["cod_prestatie"] for r in rows}
@@ -417,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. """{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
Varianta extinsa care include si flagul auto_send per operatie. 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) acct = account_or_default(account_id)
rows = conn.execute( 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,), (acct,),
).fetchall() ).fetchall()
return { return {
@@ -429,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( def classify_prezentare(
content: dict, content: dict,
mapping: dict[str, str], mapping: dict[str, str],
mapping_meta: dict[str, dict], mapping_meta: dict[str, dict],
valid_codes: set[str] | None = None, valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None, text_rules: list[dict] | None = None,
excluded_ops: set[str] | None = None,
) -> dict: ) -> dict:
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte. """Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
@@ -454,7 +510,7 @@ def classify_prezentare(
"odometru_final": canon["odometru_final"], "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 c["prestatii"] = resolved
if unmapped: if unmapped:
@@ -466,6 +522,19 @@ def classify_prezentare(
) )
errors: list[dict] = [] errors: list[dict] = []
else: 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) errors = validate_prezentare(c)
if errors: if errors:
status = "needs_data" status = "needs_data"
@@ -522,6 +591,7 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
).fetchall() ).fetchall()
agg: dict[tuple[int, str], dict[str, Any]] = {} agg: dict[tuple[int, str], dict[str, Any]] = {}
excluded_by_acct: dict[int, set[str]] = {}
for r in rows: for r in rows:
acct = r["account_id"] if r["account_id"] is not None else DEFAULT_ACCOUNT_ID acct = r["account_id"] if r["account_id"] is not None else DEFAULT_ACCOUNT_ID
try: try:
@@ -536,6 +606,11 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
op = (item.get("cod_op_service") or "").strip() op = (item.get("cod_op_service") or "").strip()
if not op: if not op:
continue 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) key = (acct, op)
entry = agg.setdefault( entry = agg.setdefault(
key, key,
@@ -563,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: 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) acct = account_or_default(account_id)
op = (cod_op_service or "").strip() op = (cod_op_service or "").strip()
cod = (cod_prestatie or "").strip().upper() cod = (cod_prestatie or "").strip().upper()
if not op or not cod: if not op or not cod:
raise ValueError("cod_op_service si cod_prestatie sunt obligatorii") raise ValueError("cod_op_service si cod_prestatie sunt obligatorii")
conn.execute( conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) " "INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send, exclus) "
"VALUES (?, ?, ?, ?) " "VALUES (?, ?, ?, ?, 0) "
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET " "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), (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]: def load_text_rules(conn, account_id: int | None) -> list[dict]:
"""Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC. """Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC.
@@ -883,6 +981,40 @@ def enrich_suggestions(
return {"sugestie_principala": sugestie_principala, "surse": surse} 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: 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. """Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
@@ -929,6 +1061,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
valid_codes = load_nomenclator_codes(conn) or None valid_codes = load_nomenclator_codes(conn) or None
# Incarca regulile text O DATA, inainte de bucla pe randuri. # Incarca regulile text O DATA, inainte de bucla pe randuri.
text_rules = load_text_rules(conn, acct) text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
if batch_id is not None: if batch_id is not None:
# Scope la batch-ul specificat (import commit explicit). # Scope la batch-ul specificat (import commit explicit).
@@ -954,7 +1087,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
content = json.loads(r["payload_json"]) content = json.loads(r["payload_json"])
except (ValueError, TypeError): except (ValueError, TypeError):
continue 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 content["prestatii"] = resolved
payload_json = json.dumps(content, ensure_ascii=False) payload_json = json.dumps(content, ensure_ascii=False)
@@ -969,6 +1102,28 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
stats["still_blocked"] += 1 stats["still_blocked"] += 1
continue continue
# 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. # Ramura auto_send eliminata din reresolve.
# Un cod rezolvat -> queued direct (review_manual ramane 0). # Un cod rezolvat -> queued direct (review_manual ramane 0).

View File

@@ -11,6 +11,8 @@ from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from app.idempotency import normalize_nr_inmatriculare
class RarCredentials(BaseModel): class RarCredentials(BaseModel):
"""Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza.""" """Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza."""
@@ -69,11 +71,16 @@ class PrezentareIn(BaseModel):
obs: str | None = None obs: str | None = None
b64_image: str | None = None b64_image: str | None = None
@field_validator("vin", "nr_inmatriculare") @field_validator("vin")
@classmethod @classmethod
def _norm_upper(cls, v: str) -> str: def _norm_upper(cls, v: str) -> str:
return v.strip().upper() 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") @field_validator("data_prestatie", "odometru_final")
@classmethod @classmethod
def _norm_strip(cls, v: str) -> str: def _norm_strip(cls, v: str) -> str:
@@ -97,11 +104,37 @@ class PrezentareRequest(BaseModel):
on_unmapped_error: bool | None = None on_unmapped_error: bool | None = None
# Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT). # Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT).
rar_env: Literal["test", "prod"] | None = None 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): class SubmissionResult(BaseModel):
# submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True). # submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True).
submission_id: int | None = None 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 status: str
id_prezentare: int | None = None id_prezentare: int | None = None
deduped: bool = False # True daca idempotency a intors un submission existent deduped: bool = False # True daca idempotency a intors un submission existent
@@ -127,7 +160,9 @@ class SubmissionResult(BaseModel):
class PrezentariResponse(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): class ValidarePrezentariRequest(BaseModel):

View File

@@ -33,6 +33,16 @@ def _cod(p: object) -> str | None:
return str(cod).strip().upper() if cod else 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]: def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
"""Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare.""" """Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare."""
payload: dict[str, Any] = { payload: dict[str, Any] = {
@@ -46,6 +56,7 @@ def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
"prestatii": [ "prestatii": [
{"codPrestatie": _cod(p), "idPrezentare": None} {"codPrestatie": _cod(p), "idPrezentare": None}
for p in (prezentare.get("prestatii") or []) for p in (prezentare.get("prestatii") or [])
if _declarabil(p)
], ],
"sistemReparat": prezentare.get("sistem_reparat") or "null", "sistemReparat": prezentare.get("sistem_reparat") or "null",
"status": "FINALIZATA", "status": "FINALIZATA",

View File

@@ -13,11 +13,34 @@ la em-dash. Citeste cheile tolerant (canalele API si import pot diferi usor:
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime
from typing import Any from typing import Any
EMPTY = "" 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: def _clean_str(value: Any) -> str:
"""str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe).""" """str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe)."""
if value is None: if value is None:
@@ -59,6 +82,19 @@ def _vin_scurt(vin: str) -> str:
return "" + vin[-6:] 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]: def _prima_prestatie(prestatii: Any) -> dict[str, Any]:
"""Primul item de prestatie ca dict, defensiv (lista goala/non-dict -> {}).""" """Primul item de prestatie ca dict, defensiv (lista goala/non-dict -> {})."""
if isinstance(prestatii, list): if isinstance(prestatii, list):
@@ -83,10 +119,11 @@ def _payload_dict(payload: str | dict | None) -> dict[str, Any]:
return {} 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. """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. vehicul_nr, vin, vin_scurt, operatie, data_prestatie, odometru, cod.
`operatie` = denumire prestatiei daca exista, altfel codul; `cod` = codul RAR `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")) denumire = _clean_str(item.get("denumire"))
operatie = denumire or cod operatie = denumire or cod
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0" # cod_rar: primul cod_prestatie din TOATE prestatiile (NU doar primul item si NU
cod_rar = _clean_cod_rar(item.get("cod_prestatie")) # 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), # Operatia de service originala (codul intern + denumire venita prin API/import),
# distincta de operatia RAR mapata (cod_rar). # distincta de operatia RAR mapata (cod_rar).
@@ -138,6 +178,9 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
"odometru": odo or EMPTY, "odometru": odo or EMPTY,
"cod": cod or EMPTY, "cod": cod or EMPTY,
"cod_rar": cod_rar 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 # Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
"op_service_cod": op_service_cod, "op_service_cod": op_service_cod,
"op_service_denumire": op_service_denumire, "op_service_denumire": op_service_denumire,

View File

@@ -82,8 +82,9 @@ CREATE TABLE IF NOT EXISTS operations_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
cod_op_service TEXT NOT NULL, 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, 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')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (account_id, cod_op_service) UNIQUE (account_id, cod_op_service)
); );
@@ -175,7 +176,8 @@ CREATE TABLE IF NOT EXISTS import_rows (
resolved_status TEXT NOT NULL DEFAULT 'pending' resolved_status TEXT NOT NULL DEFAULT 'pending'
CHECK (resolved_status IN ( CHECK (resolved_status IN (
'pending','ok','needs_mapping','needs_data', 'pending','ok','needs_mapping','needs_data',
'needs_review','already_sent','duplicate_in_file' 'needs_review','already_sent','duplicate_in_file',
'excluded'
)), )),
error TEXT error TEXT
); );

View File

@@ -25,6 +25,7 @@ from .mapping import (
account_or_default, account_or_default,
account_scope_clause, account_scope_clause,
classify_prezentare, classify_prezentare,
load_excluded_ops,
load_mapping_meta, load_mapping_meta,
load_nomenclator_codes, load_nomenclator_codes,
) )
@@ -102,7 +103,8 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict:
mapping_meta = load_mapping_meta(conn, account_id) mapping_meta = load_mapping_meta(conn, account_id)
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()} mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None 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 # Re-snapshot `held` din comutatorul contului la re-punere in coada
# (paritate cu create_prezentari/reresolve_account). Fara asta un rand repus pastra # (paritate cu create_prezentari/reresolve_account). Fara asta un rand repus pastra

View File

@@ -18,6 +18,7 @@ from datetime import date
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from app.errors import eroare as _eroare 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: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
@@ -73,7 +74,7 @@ def validate_prezentare(content: dict) -> list[dict]:
)) ))
# --- nrInmatriculare --- # --- nrInmatriculare ---
nrinm = _norm(content.get("nr_inmatriculare")) nrinm = normalize_nr_inmatriculare(content.get("nr_inmatriculare"))
if not NRINM_RE.match(nrinm): if not NRINM_RE.match(nrinm):
errors.append(_eroare( errors.append(_eroare(
"NR_INMATRICULARE_FORMAT", "NR_INMATRICULARE_FORMAT",

View File

@@ -69,6 +69,50 @@ def _payload_json_compact(account_id: int) -> str:
return json.dumps(_payload_prezentari_dict(account_id), separators=(",", ":"), ensure_ascii=False) 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: def _snippet_curl_prezentari(base_url: str, account_id: int) -> str:
payload = _payload_json_str(account_id) payload = _payload_json_str(account_id)
return f"""curl -X POST "{base_url}/v1/prezentari" \\ return f"""curl -X POST "{base_url}/v1/prezentari" \\

View File

@@ -359,6 +359,31 @@ def parse_erori(rar_error: object) -> list[dict]:
return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}] 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) # Etichete stari preview import (vocabular DIFERIT de starile de submission)
# #
@@ -375,6 +400,7 @@ STARI_PREVIEW: dict[str, tuple[str, str]] = {
"needs_data": ("Date incomplete", "s-needs_data"), "needs_data": ("Date incomplete", "s-needs_data"),
"already_sent": ("Deja trimis", "s-already_sent"), "already_sent": ("Deja trimis", "s-already_sent"),
"duplicate_in_file": ("Duplicat in fisier", "s-duplicate_in_file"), "duplicate_in_file": ("Duplicat in fisier", "s-duplicate_in_file"),
"excluded": ("Nedeclarat", "s-excluded"),
} }
@@ -397,6 +423,8 @@ def nota_umana_preview(status: str, errors: list, flags: list) -> str:
""" """
if status in ("already_sent", "duplicate_in_file"): if status in ("already_sent", "duplicate_in_file"):
return "" 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, # needs_mapping: codul RAR lipseste — prioritizeaza 'unmapped' inaintea flags,
# altfel un rand cu si un flag (ex. VIN numeric) ar afisa textul flag-ului # altfel un rand cu si un flag (ex. VIN numeric) ar afisa textul flag-ului
# si ascunde motivul real (cod lipsa). # si ascunde motivul real (cod lipsa).

View File

@@ -27,11 +27,12 @@ from .. import __version__
from .. import errors as _errors from .. import errors as _errors
from ..auth import rotate_api_key from ..auth import rotate_api_key
from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS
from ..payload_view import prezentare_din_payload from ..payload_view import iso_date_prefix as _iso_date_prefix, prezentare_din_payload
from ..web.csrf import get_csrf_token, verify_csrf from ..web.csrf import get_csrf_token, verify_csrf
from .labels import ( from .labels import (
ETICHETA_ULTIMA_AUTENTIFICARE_RAR, ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
STARI_PREVIEW, STARI_PREVIEW,
eroare_bruta,
eticheta_env, eticheta_env,
eticheta_rar, eticheta_rar,
eticheta_scurta, eticheta_scurta,
@@ -71,12 +72,14 @@ from ..submissions_admin import (
from ..mapping import ( from ..mapping import (
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
_emite_text_rule_hits, _emite_text_rule_hits,
EXCLUDE_SENTINEL,
account_or_default, account_or_default,
account_scope_clause, account_scope_clause,
delete_text_rule, delete_text_rule,
enrich_suggestions, enrich_suggestions,
ensure_embeddings_corpus, ensure_embeddings_corpus,
has_no_auto_send, has_no_auto_send,
load_excluded_ops,
load_mapping_meta, load_mapping_meta,
load_nomenclator, load_nomenclator,
load_nomenclator_codes, load_nomenclator_codes,
@@ -85,8 +88,10 @@ from ..mapping import (
pending_unmapped, pending_unmapped,
reresolve_account, reresolve_account,
resolve_prestatii, resolve_prestatii,
save_exclusion,
save_mapping, save_mapping,
save_text_rule, save_text_rule,
split_prestatii_excluse,
suggest_codes, suggest_codes,
text_rules_overlap, text_rules_overlap,
) )
@@ -115,6 +120,7 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
# Expune parse_erori si eticheta_env in toate template-urile # Expune parse_erori si eticheta_env in toate template-urile
templates.env.globals["parse_erori"] = parse_erori templates.env.globals["parse_erori"] = parse_erori
templates.env.globals["eticheta_env"] = eticheta_env templates.env.globals["eticheta_env"] = eticheta_env
templates.env.globals["EXCLUDE_SENTINEL"] = EXCLUDE_SENTINEL
def _mediu_instanta() -> str: def _mediu_instanta() -> str:
@@ -510,7 +516,7 @@ def _render_integrare(request: Request, conn, account_id: int) -> str:
configurate pe cont), preia base_url real si genereaza snippet-uri multi-limbaj. configurate pe cont), preia base_url real si genereaza snippet-uri multi-limbaj.
""" """
from ..mapping import account_or_default from ..mapping import account_or_default
from .integrare_examples import exemple as _exemple from .integrare_examples import exemple as _exemple, exemplu_json as _exemplu_json
acct = account_or_default(account_id) acct = account_or_default(account_id)
row_creds = conn.execute( row_creds = conn.execute(
@@ -532,6 +538,7 @@ def _render_integrare(request: Request, conn, account_id: int) -> str:
"account_id": acct, "account_id": acct,
"base_url": base_url, "base_url": base_url,
"exemple": ex, "exemple": ex,
"exemplu_json": _exemplu_json(),
"are_cheie": are_cheie, "are_cheie": are_cheie,
"are_creds": are_creds, "are_creds": are_creds,
"csrf_token": csrf_token, "csrf_token": csrf_token,
@@ -830,6 +837,32 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None, s
conn.close() conn.close()
# Pagini statice publice cu antet/footer comune cu landing-ul.
_PAGINI_STATICE = {
"termeni": "termeni.html",
"confidentialitate": "confidentialitate.html",
}
@router.get("/termeni", response_class=HTMLResponse)
@router.get("/confidentialitate", response_class=HTMLResponse)
def pagina_statica(request: Request) -> HTMLResponse:
nume = request.url.path.strip("/")
return templates.TemplateResponse(_PAGINI_STATICE[nume], {"request": request})
@router.get("/documentatie-api", response_class=HTMLResponse)
def pagina_documentatie_api(request: Request) -> HTMLResponse:
"""Documentatie API publica (varianta scurta a tab-ului Integrare, fara login)."""
from .integrare_examples import exemplu_json
return templates.TemplateResponse("documentatie_api.html", {
"request": request,
"base_url": str(request.base_url).rstrip("/"),
"exemplu_json": exemplu_json(),
})
@router.get("/_fragments/acasa", response_class=HTMLResponse) @router.get("/_fragments/acasa", response_class=HTMLResponse)
def fragment_acasa(request: Request) -> HTMLResponse: def fragment_acasa(request: Request) -> HTMLResponse:
"""Fragment HTMX pentru tab-ul Acasa.""" """Fragment HTMX pentru tab-ul Acasa."""
@@ -1067,25 +1100,6 @@ def fragment_trimiteri_versiune(request: Request) -> JSONResponse:
conn.close() conn.close()
def _iso_date_prefix(value: object) -> 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.
"""
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
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana # Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
# scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "". # scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "".
_STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping") _STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping")
@@ -1131,6 +1145,8 @@ def _submission_row_view(r) -> dict:
"motiv": motiv, "motiv": motiv,
# eticheta umana scurta a problemei sub pill (text, nu cod brut). # eticheta umana scurta a problemei sub pill (text, nu cod brut).
"eticheta_problema": _eticheta_problema(r["status"], motiv), "eticheta_problema": _eticheta_problema(r["status"], motiv),
# textul brut compact al erorii (mesaj RAR/validare) — sub-linia randului.
"eroare_bruta": eroare_bruta(r["status"], r["rar_error"]),
# randurile blocate (error/needs_data/needs_mapping) sunt selectabile pentru # randurile blocate (error/needs_data/needs_mapping) sunt selectabile pentru
# stergere bulk; sent/sending/queued raman read-only (fara checkbox). # stergere bulk; sent/sending/queued raman read-only (fara checkbox).
"gestionabil": r["status"] in _GESTIONABILE_WEB, "gestionabil": r["status"] in _GESTIONABILE_WEB,
@@ -1329,8 +1345,10 @@ def _payload_form_values(payload_json) -> dict:
def _prestatii_chips_from_payload(payload_json) -> list[dict]: def _prestatii_chips_from_payload(payload_json) -> list[dict]:
"""Extrage lista de chips prestatii din payload_json pentru _form_editare.html. """Extrage lista de chips prestatii din payload_json pentru _form_editare.html.
Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire}. Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire, exclus}.
Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''. Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''.
`exclus` propaga adnotarea din payload (chip Nedeclarat la redeschidere);
default 0 pentru payload-uri vechi fara camp.
""" """
try: try:
data = json.loads(payload_json) if payload_json else {} data = json.loads(payload_json) if payload_json else {}
@@ -1346,6 +1364,7 @@ def _prestatii_chips_from_payload(payload_json) -> list[dict]:
"cod_prestatie": (item.get("cod_prestatie") or "").strip().upper(), "cod_prestatie": (item.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (item.get("cod_op_service") or "").strip(), "cod_op_service": (item.get("cod_op_service") or "").strip(),
"denumire": (item.get("denumire") or "").strip(), "denumire": (item.get("denumire") or "").strip(),
"exclus": 1 if item.get("exclus") else 0,
}) })
return chips return chips
@@ -1494,6 +1513,15 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
ctx["prestatii_chips"] = prestatii_chips ctx["prestatii_chips"] = prestatii_chips
ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips) ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips)
ctx["form_chips_url"] = "/form-chips" ctx["form_chips_url"] = "/form-chips"
# Tinta implicita + sugestii la randarea INITIALA (inainte de orice /form-chips) —
# helper partajat cu post_form_chips: cat exista operatii nemapate, EXISTA tinta.
# Fara conn nu se pot calcula sugestiile (enrich_suggestions).
ctx["chips_target_index"] = None
ctx["sugestii_tinta"] = []
if conn is not None:
ctx["chips_target_index"], ctx["sugestii_tinta"] = _chips_target_si_sugestii(
conn, prestatii_chips, nomenclator_rar
)
# submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html. # submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html.
# Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste # Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste
# → butonul nu apare (corect: /form-chips nu are scop de submission). # → butonul nu apare (corect: /form-chips nu are scop de submission).
@@ -1649,6 +1677,41 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
if isinstance(obs_val, str): if isinstance(obs_val, str):
content["obs"] = obs_val.strip() content["obs"] = obs_val.strip()
# Stare chips: editorul unificat trimite UN SINGUR hidden `chips_state`
# (JSON versionat, cu `exclus` per item — vezi _chips_state_from_form). Cand e
# prezent, INLOCUIESTE integral prestatiile, cu round-trip complet pe exclus.
# Cod necunoscut in nomenclator -> respins (invariant ORA-12899);
# sentinelul __NEDECLARAT__ nu e niciodata in nomenclator, deci pica in aceeasi
# verificare daca ajunge (fabricat) direct ca cod_prestatie in starea hidden.
_chips_state_raw = form.get("chips_state")
_are_chips_state = _chips_state_raw is not None and str(_chips_state_raw).strip() != ""
if _are_chips_state:
_chips_din_stare = _chips_from_state_json(str(_chips_state_raw))
if _chips_din_stare is None:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id, error=True,
message="Stare chips invalida — reincarca formularul si reintroduce codurile.",
),
)
valid_codes_now = load_nomenclator_codes(conn) or set()
necunoscute = sorted(
{c["cod_prestatie"] for c in _chips_din_stare if c["cod_prestatie"]} - valid_codes_now
)
if necunoscute:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id, error=True,
message=f"Cod RAR necunoscut in nomenclator: {', '.join(necunoscute)}. "
"Alege un cod valid din lista.",
),
)
content["prestatii"] = _chips_din_stare
else:
# Compat: formular vechi (peste deploy) cu listele paralele
# cod_prestatie/chip_op_service/chip_denumire + pickere ne-aprobate.
# Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii. # Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii.
# form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din # form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din
# prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite # prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite
@@ -1724,14 +1787,25 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()} mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, account_id) text_rules = load_text_rules(conn, account_id)
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules) excluded_ops = load_excluded_ops(conn, account_id)
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
# Persistam TOATE itemii (inclusiv cei exclusi, adnotati exclus=1) — round-trip
# complet in editor (chip Nedeclarat reapare la redeschidere, x il readuce la
# warning). Excluderea din payload-ul RAR/cheia idempotentei se face DOAR local,
# mai jos, prin canon; content["prestatii"] NU se ingusteaza niciodata.
content["prestatii"] = resolved content["prestatii"] = resolved
# telemetrie pentru itemii rezolvati prin regula text (calea corectie web). # telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
_emite_text_rule_hits(conn, account_id, row["id"], resolved) _emite_text_rule_hits(conn, account_id, row["id"], resolved)
# Prestatiile excluse de la declarare NU intra in payload-ul trimis la RAR nici
# in cheia de idempotenta (ca in reresolve_account) — dar RAMAN in payload_json
# persistat mai sus. Toate excluse -> needs_data cu motiv explicit mai jos.
declarabile, excluse = split_prestatii_excluse(resolved)
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie. # Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
canon = canonicalize_row(content) # Foloseste DOAR partea declarabila pentru cheie (exclusii nu schimba hash-ul).
canon = canonicalize_row({**content, "prestatii": declarabile})
content.update({ content.update({
"vin": canon["vin"], "vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"], "nr_inmatriculare": canon["nr_inmatriculare"],
@@ -1752,6 +1826,24 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
message="Lipseste inca un cod RAR — alege-l mai jos sau in tab-ul Mapari."), message="Lipseste inca un cod RAR — alege-l mai jos sau in tab-ul Mapari."),
) )
if excluse and not declarabile:
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), row["id"]),
)
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row2, conn=conn, account_id=account_id, error=True,
message="Toate operatiile randului sunt excluse de la declarare — nu se trimite la RAR."),
)
errors = validate_prezentare(content) errors = validate_prezentare(content)
if errors: if errors:
# Inca invalid: persista valorile introduse, ramane needs_data, arata motivul pe camp. # Inca invalid: persista valorile introduse, ramane needs_data, arata motivul pe camp.
@@ -2360,6 +2452,71 @@ async def post_bulk_fix(request: Request) -> HTMLResponse:
conn.close() conn.close()
def _sugestii_select_pentru_tinta(conn, denumire: str | None, nomenclator: list[dict]) -> list[dict]:
"""Sugestii pentru optgroup "Sugestii" din selectul unic.
Refoloseste EXACT sursele din `_preview_import.html:89-121` (fara drum nou de cod):
`sugestie_principala` (GOLD/SILVER/embedding, `enrich_suggestions`) prima, apoi
top-3 fuzzy (`suggest_codes`), dedupliate, pastrand doar coduri care exista in
nomenclator. Fara memoizare — se recalculeaza la fiecare re-render.
Degradare gratioasa: motor de sugestii rece/dezactivat/exceptie -> lista goala
(selectul cade pe nomenclatorul complet, fara optgroup). `ensure_embeddings_corpus`
ruleaza cu `block=False` implicit — calea de request nu asteapta warmup-ul.
Fara denumire (tinta neidentificata) fuzzy match-ul ar intoarce primele coduri
din nomenclator ca sugestii irelevante — golim explicit.
"""
if not (denumire or "").strip():
return []
try:
ensure_embeddings_corpus(conn, nomenclator)
by_cod = {n["cod_prestatie"]: n.get("nume_prestatie") for n in (nomenclator or [])}
out: list[dict] = []
seen: set[str] = set()
enriched = enrich_suggestions(conn, denumire)
principala = enriched.get("sugestie_principala") if enriched else None
if principala and principala.get("cod_prestatie") in by_cod:
cod = principala["cod_prestatie"]
out.append({"cod_prestatie": cod, "nume_prestatie": by_cod.get(cod)})
seen.add(cod)
for s in suggest_codes(denumire, nomenclator, limit=3):
cod = s.get("cod_prestatie")
if cod and cod not in seen and cod in by_cod:
out.append({"cod_prestatie": cod, "nume_prestatie": by_cod.get(cod)})
seen.add(cod)
return out
except Exception:
return [] # degradare gratioasa: selectul ramane cu nomenclatorul complet
def _chips_target_si_sugestii(
conn, chips: list[dict], nomenclator: list[dict],
) -> tuple[int | None, list[dict]]:
"""Tinta implicita (prima operatie nemapata ne-exclusa, identificabila) + sugestii.
Sursa unica pentru `post_form_chips` SI randarea INITIALA a modalului (inainte
de orice /form-chips): cat exista operatii nemapate, EXISTA tinta. Fara acest
helper partajat, cele doua cai ar putea diverge.
Necesita `cod_op_service` truthy: template-ul randeaza chip-ul warning (tinta
vizibila) doar pentru operatii identificabile; un item raw fara cod_op_service
n-are ce sa arate ca tinta si ar ramane invizibil.
"""
target_index = next(
(i for i, c in enumerate(chips)
if not c.get("cod_prestatie") and not c.get("exclus") and c.get("cod_op_service")),
None,
)
sugestii: list[dict] = []
if target_index is not None:
_den_tinta = chips[target_index].get("denumire") or chips[target_index].get("cod_op_service")
sugestii = _sugestii_select_pentru_tinta(conn, _den_tinta, nomenclator)
return target_index, sugestii
# =========================================================================== # # =========================================================================== #
# Endpoint /form-chips — re-randare chips prestatii. Preia starea curenta din # # Endpoint /form-chips — re-randare chips prestatii. Preia starea curenta din #
# form + actiunea (add/remove) si re-randeaza _chips_prestatii.html. Fara # # form + actiunea (add/remove) si re-randeaza _chips_prestatii.html. Fara #
@@ -2371,107 +2528,120 @@ async def post_bulk_fix(request: Request) -> HTMLResponse:
async def post_form_chips(request: Request) -> HTMLResponse: async def post_form_chips(request: Request) -> HTMLResponse:
"""Re-randeaza sectiunea chips prestatii (HTMX server-driven). """Re-randeaza sectiunea chips prestatii (HTMX server-driven).
Primeste starea curenta a chip-urilor (3 liste paralele: cod_prestatie, Stare unica: hidden `chips_state` (JSON versionat, vezi _chips_state_from_form).
chip_op_service, chip_denumire) + actiunea (add/remove) si returneaza DOUA actiuni: add (leaga codul de operatia tinta prin chips_target_index, sau
_chips_prestatii.html actualizat. Fara scriere in DB (stateless mid-edit). adauga cod liber fara tinta; sentinelul __NEDECLARAT__ + tinta marcheaza
Auth: sesiune activa; CSRF verificat. exclus=1 pe item) si remove (chips_remove_index, index unic — acopera si fostul
remove_flat). Fara scriere in DB (stateless mid-edit). Auth: sesiune activa;
CSRF verificat.
""" """
account_id = require_login(request) account_id = require_login(request)
form = await request.form() form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or "")) verify_csrf(request, str(form.get("csrf_token") or ""))
# Reconstruct current chips state from parallel hidden inputs (emise de _chips_prestatii.html). chips, chips_error = _chips_state_from_form(form)
# Toate cele 3 liste sunt aceeasi lungime (emise index-by-index in template). if chips is None:
cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")] chips = []
op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")]
den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")]
# Aliniaza listele la lungimea maxima (defensive)
n = max(len(cod_list), len(op_list), len(den_list)) if (cod_list or op_list or den_list) else 0
chips: list[dict] = []
for i in range(n):
chips.append({
"cod_prestatie": cod_list[i] if i < len(cod_list) else "",
"cod_op_service": op_list[i] if i < len(op_list) else "",
"denumire": den_list[i] if i < len(den_list) else "",
})
action = str(form.get("chips_action") or "").strip() action = str(form.get("chips_action") or "").strip()
chips_extra_error = False # T-C1/T-E4 (5.16): semnal pentru add_extra esuat # Orice adaugare are un efect vizibil: mesaj de refuz (chips_error) sau
# confirmare + chip evidentiat (chips_ok / chips_added_index). Fara no-op silentios.
chips_ok = ""
chips_added_index: int | None = None
conn = get_connection() conn = get_connection()
def _cod_valid(cod: str) -> bool:
return conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
).fetchone() is not None
def _target_valid(idx: int) -> bool:
return 0 <= idx < len(chips) and not chips[idx].get("cod_prestatie") and not chips[idx].get("exclus")
try: try:
if action == "add": if not chips_error and action == "add":
# Adauga cod la operatia specificata prin chips_add_op_index raw_target = str(form.get("chips_target_index") or "").strip()
target_idx: int | None = None
if raw_target != "":
try: try:
op_idx = int(str(form.get("chips_add_op_index") or 0)) target_idx = int(raw_target)
except (ValueError, TypeError): except (ValueError, TypeError):
op_idx = 0 target_idx = -1 # index nenumeric -> forteaza "tinta invalida" mai jos
add_cod = str(form.get(f"chips_add_cod_{op_idx}") or "").strip().upper()
if add_cod and 0 <= op_idx < len(chips):
exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (add_cod,)
).fetchone()
if exists:
chips[op_idx]["cod_prestatie"] = add_cod
elif action == "add_flat": # Mai multe <select name="chips_pick"> pot coexista pe pagina (un picker
# Adauga cod nou in lista plata (fara op_service) # per operatie nemapata + unul flat); doar cel interactionat are valoare.
add_cod_flat = str(form.get("chips_add_cod_flat") or "").strip().upper() pick = next(
if add_cod_flat: (v.strip().upper() for v in form.getlist("chips_pick") if isinstance(v, str) and v.strip()),
exists = conn.execute( "",
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (add_cod_flat,) )
).fetchone()
if exists:
chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""})
elif action == "add_extra": if not pick:
# Adauga cod RAR liber (extra, fara op_service) in modul operatii. chips_error = "Selecteaza un cod RAR din lista inainte de a adauga."
# Refoloseste `chips_add_cod_flat` (acelasi select; dedup per-item pastrat). elif pick == EXCLUDE_SENTINEL:
# Select gol sau cod invalid → chips_extra_error = True (semnal vizibil). # "Nu se declara la RAR": cere OBLIGATORIU o tinta valida (operatie
add_cod_extra = str(form.get("chips_add_cod_flat") or "").strip().upper() # nemapata, ne-exclusa) — sentinelul nu are sens pe cod liber.
if add_cod_extra: if target_idx is None or not _target_valid(target_idx):
exists = conn.execute( chips_error = "\"Nu se declara la RAR\" cere o operatie tinta valida."
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (add_cod_extra,)
).fetchone()
if exists:
# Dedup per-item (E4): nu adauga un chip extra identic deja existent
existing_pairs = {
(c.get("cod_op_service", ""), c.get("cod_prestatie", ""))
for c in chips
}
if ("", add_cod_extra) not in existing_pairs:
chips.append({"cod_prestatie": add_cod_extra, "cod_op_service": "", "denumire": ""})
else: else:
chips_extra_error = True # cod necunoscut in nomenclator chips[target_idx]["exclus"] = 1
chips[target_idx]["cod_prestatie"] = ""
op_nume = chips[target_idx].get("cod_op_service") or "Operatia"
chips_ok = f"{op_nume} nu se va declara la RAR."
chips_added_index = target_idx
elif not _cod_valid(pick):
chips_error = f"Cod necunoscut in nomenclator: {pick}."
elif target_idx is not None and not _target_valid(target_idx):
chips_error = "Operatia tinta nu mai exista sau e deja rezolvata — reincarca editarea."
elif target_idx is not None:
# Leaga codul de operatia tinta (E4 binding).
op_tinta = chips[target_idx].get("cod_op_service")
dedup = any(c.get("cod_op_service") == op_tinta and c.get("cod_prestatie") == pick for c in chips)
if dedup:
chips_error = f"{pick} este deja in lista pentru aceasta operatie."
else: else:
chips_extra_error = True # select gol chips[target_idx]["cod_prestatie"] = pick
chips[target_idx]["exclus"] = 0
chips_ok = f"{pick} adaugat."
chips_added_index = target_idx
else:
# Fara tinta -> chip liber (fara operatie sursa).
dedup = any(not c.get("cod_op_service") and c.get("cod_prestatie") == pick for c in chips)
if dedup:
chips_error = f"{pick} este deja in lista — nu a fost adaugat inca o data."
else:
chips.append({"cod_prestatie": pick, "cod_op_service": "", "denumire": "", "exclus": 0})
chips_ok = f"{pick} adaugat."
chips_added_index = len(chips) - 1
elif action == "remove": elif not chips_error and action == "remove":
# Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata) # Index unic (acopera si fostul remove_flat): chip legat de operatie ->
# codul se sterge (operatia redevine nemapata/warning, inclusiv Nedeclarat
# -> warning); chip liber -> iese complet din lista.
try: try:
remove_idx = int(str(form.get("chips_remove_index") or 0)) remove_idx = int(str(form.get("chips_remove_index") or ""))
except (ValueError, TypeError): except (ValueError, TypeError):
remove_idx = 0 remove_idx = -1
if 0 <= remove_idx < len(chips): if 0 <= remove_idx < len(chips):
chips[remove_idx]["cod_prestatie"] = "" item = chips[remove_idx]
if item.get("cod_op_service"):
item["cod_prestatie"] = ""
item["exclus"] = 0
else:
chips.pop(remove_idx)
elif action == "remove_flat":
# Sterge un chip plat dupa cod (in mod fara op_service)
remove_cod = str(form.get("chips_remove_cod") or "").strip().upper()
chips = [
c for c in chips
if not (not c.get("cod_op_service") and c.get("cod_prestatie") == remove_cod)
]
# Compute has_r_odo dupa actiune
has_r_odo = _has_r_odo_chips(chips) has_r_odo = _has_r_odo_chips(chips)
# Incarca nomenclatorul pentru picker
nomenclator_rar = load_nomenclator(conn) nomenclator_rar = load_nomenclator(conn)
# Tinta implicita dupa actiune (prima operatie nemapata) + sugestii pentru ea
# (optgroup "Sugestii" din select) — helper partajat cu randarea initiala.
chips_target_index, sugestii_tinta = _chips_target_si_sugestii(conn, chips, nomenclator_rar)
finally: finally:
conn.close() conn.close()
# Ecou (nu re-derivat): permite butonului "salveaza ca regula" sa ramana
# disponibil dupa re-randari succesive via /form-chips.
chips_submission_id = str(form.get("chips_submission_id") or "").strip()
return templates.TemplateResponse("_chips_prestatii.html", { return templates.TemplateResponse("_chips_prestatii.html", {
"request": request, "request": request,
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
@@ -2480,7 +2650,12 @@ async def post_form_chips(request: Request) -> HTMLResponse:
"has_r_odo": has_r_odo, "has_r_odo": has_r_odo,
"form_chips_url": "/form-chips", "form_chips_url": "/form-chips",
"chips_section_id": "chips-section", "chips_section_id": "chips-section",
"chips_extra_error": chips_extra_error, "chips_error": chips_error,
"chips_ok": chips_ok,
"chips_added_index": chips_added_index,
"chips_target_index": chips_target_index,
"chips_submission_id": chips_submission_id,
"sugestii_tinta": sugestii_tinta,
}) })
@@ -2563,7 +2738,7 @@ def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
prestatiei jonctionat din nomenclator. Scoped pe cont (NOT NULL → simplu).""" prestatiei jonctionat din nomenclator. Scoped pe cont (NOT NULL → simplu)."""
acct = account_or_default(account_id) acct = account_or_default(account_id)
rows = conn.execute( rows = conn.execute(
"SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie " "SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, o.exclus, n.nume_prestatie "
"FROM operations_mapping o " "FROM operations_mapping o "
"LEFT JOIN nomenclator_rar n ON n.cod_prestatie = o.cod_prestatie " "LEFT JOIN nomenclator_rar n ON n.cod_prestatie = o.cod_prestatie "
"WHERE o.account_id=? ORDER BY o.cod_op_service", "WHERE o.account_id=? ORDER BY o.cod_op_service",
@@ -2575,6 +2750,7 @@ def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
"cod_op_service": r["cod_op_service"], "cod_op_service": r["cod_op_service"],
"cod_prestatie": r["cod_prestatie"], "cod_prestatie": r["cod_prestatie"],
"auto_send": bool(r["auto_send"]), "auto_send": bool(r["auto_send"]),
"exclus": bool(r["exclus"]),
"nume_prestatie": r["nume_prestatie"], "nume_prestatie": r["nume_prestatie"],
} }
for r in rows for r in rows
@@ -2661,6 +2837,17 @@ def post_mapare(
conn = get_connection() conn = get_connection()
try: try:
cod = cod_prestatie.strip().upper() cod = cod_prestatie.strip().upper()
if cod == EXCLUDE_SENTINEL:
# Regula "nu se declara la RAR": exclude + re-rezolva blocatele
# (randurile cu toate operatiile excluse trec pe needs_data cu motiv).
save_exclusion(conn, account_id, cod_op_service)
stats = reresolve_account(conn, account_id)
msg = (
f"{cod_op_service.strip()} exclus de la declarare. "
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} oprite/date lipsa, "
f"{stats['still_blocked']} inca nemapate."
)
return _render_mapari(request, conn, account_id, message=msg)
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone() exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
if not exists: if not exists:
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}") return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
@@ -2707,6 +2894,16 @@ def post_editeaza_mapare_salvata(
conn = get_connection() conn = get_connection()
try: try:
cod = cod_prestatie.strip().upper() cod = cod_prestatie.strip().upper()
if cod == EXCLUDE_SENTINEL:
# Trecerea unei mapari salvate pe "nu se declara" — exclude + re-rezolva.
save_exclusion(conn, account_id, cod_op_service)
stats = reresolve_account(conn, account_id)
msg = (
f"{cod_op_service.strip()} exclus de la declarare. "
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} oprite/date lipsa, "
f"{stats['still_blocked']} inca nemapate."
)
return _render_mapari(request, conn, account_id, message=msg)
exists = conn.execute( exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
).fetchone() ).fetchone()
@@ -3123,6 +3320,7 @@ def _web_compute_preview(
# needs_mapping si exclus din commit. Incarcate o data. # needs_mapping si exclus din commit. Incarcate o data.
valid_codes = load_nomenclator_codes(conn) or None valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct) text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
# Detectie coercion flags din valorile stocate (VIN numeric) # Detectie coercion flags din valorile stocate (VIN numeric)
coercion_flags_map: dict[int, list[str]] = {} coercion_flags_map: dict[int, list[str]] = {}
@@ -3169,12 +3367,18 @@ def _web_compute_preview(
valid_codes=valid_codes, valid_codes=valid_codes,
text_rules=text_rules, text_rules=text_rules,
reviewed=reviewed_flags[i], reviewed=reviewed_flags[i],
excluded_ops=excluded_ops,
) )
key: str | None = None key: str | None = None
if info["resolved_status"] in ("ok", "needs_review", "needs_data"): if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
try: try:
key = _build_idempotency_key(account_id, info["resolved"], preview_env) # Cheia foloseste DOAR partea declarabila (itemii exclusi nu intra in
# hash) — identica cu cea calculata la commit, altfel un rand cu
# prestatii excluse arata alt duplicat decat cel real.
decl, _exc = split_prestatii_excluse(info["resolved"].get("prestatii"))
key_resolved = {**info["resolved"], "prestatii": decl} if _exc else info["resolved"]
key = _build_idempotency_key(account_id, key_resolved, preview_env)
keys_for_lookup.append(key) keys_for_lookup.append(key)
key_to_indices.setdefault(key, []).append(i) key_to_indices.setdefault(key, []).append(i)
except Exception: except Exception:
@@ -3735,12 +3939,16 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) ->
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(), "cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (p.get("cod_op_service") or "").strip(), "cod_op_service": (p.get("cod_op_service") or "").strip(),
"denumire": (p.get("denumire") or "").strip(), "denumire": (p.get("denumire") or "").strip(),
"exclus": 1 if p.get("exclus") else 0,
} }
for p in (res.get("prestatii") or []) for p in (res.get("prestatii") or [])
if isinstance(p, dict) if isinstance(p, dict)
] ]
_preview_has_r_odo = _has_r_odo_chips(_preview_chips) _preview_has_r_odo = _has_r_odo_chips(_preview_chips)
_preview_nomenclator = load_nomenclator(conn) _preview_nomenclator = load_nomenclator(conn)
_preview_target_idx, _preview_sugestii = _chips_target_si_sugestii(
conn, _preview_chips, _preview_nomenclator
)
return templates.TemplateResponse("_editare_preview_modal.html", { return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request, "request": request,
"import_id": import_id, "import_id": import_id,
@@ -3766,6 +3974,8 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) ->
"obs_val": (res.get("obs") or "").strip(), "obs_val": (res.get("obs") or "").strip(),
"nomenclator_rar": _preview_nomenclator, "nomenclator_rar": _preview_nomenclator,
"form_chips_url": "/form-chips", "form_chips_url": "/form-chips",
"chips_target_index": _preview_target_idx,
"sugestii_tinta": _preview_sugestii,
}) })
finally: finally:
conn.close() conn.close()
@@ -3798,6 +4008,131 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe
conn.close() conn.close()
CHIPS_STATE_VERSION = 1
def _parse_chip_exclus(raw) -> int:
"""Parsare stricta a flag-ului exclus: DOAR '1' -> 1, orice altceva -> 0."""
return 1 if str(raw).strip() == "1" else 0
def _chips_state_to_json(chips: list[dict]) -> str:
"""Serializeaza chips (lista interna) in JSON versionat pentru hidden `chips_state`."""
return json.dumps({
"v": CHIPS_STATE_VERSION,
"items": [
{
"cod": c.get("cod_prestatie") or "",
"op": c.get("cod_op_service") or "",
"den": c.get("denumire") or "",
"exclus": 1 if c.get("exclus") else 0,
}
for c in chips
],
}, ensure_ascii=False)
# Expune serializarea chips_state in template (randare initiala, fara trecere prin
# post_form_chips) -- sursa unica de adevar pentru hidden `chips_state`.
templates.env.globals["chips_state_json"] = _chips_state_to_json
def _chips_from_state_json(raw: str) -> list[dict] | None:
"""Decodeaza hidden `chips_state` (JSON versionat) in lista interna de chips.
Intoarce None cand JSON-ul e invalid sau structura nu se potriveste
(fara cheie 'items' lista) — apelantul trateaza asta ca eroare vizibila,
NU ca stare goala (altfel o golire accidentala trece neobservata).
"""
try:
parsed = json.loads(raw)
except (ValueError, TypeError):
return None
if not isinstance(parsed, dict) or not isinstance(parsed.get("items"), list):
return None
chips: list[dict] = []
for it in parsed["items"]:
if not isinstance(it, dict):
return None
chips.append({
"cod_prestatie": str(it.get("cod") or "").strip().upper(),
"cod_op_service": str(it.get("op") or "").strip(),
"denumire": str(it.get("den") or "").strip(),
"exclus": _parse_chip_exclus(it.get("exclus")),
})
return chips
def _chips_from_legacy_lists(form) -> list[dict]:
"""Compat tranzitorie: reconstruieste chips din listele paralele vechi.
cod_prestatie / chip_op_service / chip_denumire (+ chip_exclus optional,
parsat strict). Codurile alese in pickere dar ne-aprobate prin '+' (E.g.
chips_add_cod_{i}, chips_add_cod_flat) se aplica implicit — comportament
pastrat identic cu forma veche (form-uri deschise peste deploy). Dedup pe
perechea (op, cod). Se sterge in release-ul urmator.
"""
flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")]
op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")]
den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")]
excl_list = [str(e) for e in form.getlist("chip_exclus")]
n = max(len(cod_list), len(op_list), len(den_list))
chips: list[dict] = []
for i in range(n):
cod = cod_list[i] if i < len(cod_list) else ""
if not cod:
# picker per-operatie cu cod ales dar ne-aprobat prin '+ Adauga'
cod = str(form.get(f"chips_add_cod_{i}") or "").strip().upper()
chips.append({
"cod_prestatie": cod,
"cod_op_service": op_list[i] if i < len(op_list) else "",
"denumire": den_list[i] if i < len(den_list) else "",
"exclus": _parse_chip_exclus(excl_list[i]) if i < len(excl_list) else 0,
})
pairs = {(c["cod_op_service"], c["cod_prestatie"]) for c in chips}
if flat_picker and ("", flat_picker) not in pairs:
chips.append({"cod_prestatie": flat_picker, "cod_op_service": "", "denumire": "", "exclus": 0})
seen: set = set()
out: list[dict] = []
for c in chips:
if not (c["cod_prestatie"] or c["cod_op_service"] or c["denumire"]):
continue
pair = (c["cod_op_service"], c["cod_prestatie"])
if pair in seen:
continue
seen.add(pair)
out.append(c)
return out
def _chips_state_from_form(form) -> tuple[list[dict] | None, str]:
"""Reconstruieste starea chips prestatii din formular.
Sursa unica: hidden `chips_state` (JSON versionat {"v":1,"items":[...]});
compat tranzitorie pe listele paralele vechi (cod_prestatie/chip_op_service/
chip_denumire) cand `chips_state` lipseste (form deschis peste deploy).
Intoarce (chips, error):
- error non-gol -> JSON invalid; apelantul arata chips_error si NU aplica
nicio schimbare (NU stare goala silentioasa).
- chips=None + error gol -> formularul nu are deloc sectiune de chips;
prestatiile raman neatinse.
- chips=[] + error gol -> golire intentionata (chips_state prezent, items:[]);
se persista la salvare.
"""
raw_state = form.get("chips_state")
if raw_state is not None and str(raw_state).strip() != "":
chips = _chips_from_state_json(str(raw_state))
if chips is None:
return None, "Stare chips invalida — reincarca formularul si reintroduce codurile."
return chips, ""
flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
if "cod_prestatie" not in form and "chip_op_service" not in form and not flat_picker:
return None, ""
return _chips_from_legacy_lists(form), ""
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse) @router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse: async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal. """Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal.
@@ -3817,13 +4152,99 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
camp: (str(form.get(camp)) if form.get(camp) is not None else None) camp: (str(form.get(camp)) if form.get(camp) is not None else None)
for camp in EDIT_FIELDS for camp in EDIT_FIELDS
} }
# Chips prestatii din modal: None = form fara sectiune de chips (neatins).
chips_state, chips_parse_error = _chips_state_from_form(form)
conn = get_connection() conn = get_connection()
try: try:
if chips_parse_error:
# JSON chips_state corupt -> eroare vizibila, NU stare goala silentioasa;
# override NEATINS (nicio prestatie nu se schimba).
_nom_err = load_nomenclator(conn)
_cur_result, _cur_row = _preview_one_row(conn, import_id, account_id, row_index)
_cur_chips = (
(_cur_row.get("resolved") or {}).get("prestatii")
if _cur_row and not isinstance(_cur_result, str) else None
) or []
_cur_chips = [
{
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (p.get("cod_op_service") or "").strip(),
"denumire": (p.get("denumire") or "").strip(),
"exclus": 1 if p.get("exclus") else 0,
}
for p in _cur_chips if isinstance(p, dict)
]
_cur_target_idx, _cur_sugestii = _chips_target_si_sugestii(conn, _cur_chips, _nom_err)
return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request,
"import_id": import_id,
"row_index": row_index,
"csrf_token": get_csrf_token(request),
"vin": str(form.get("vin") or ""),
"stare_css": "",
"stare_eticheta": "",
"form_nr": str(form.get("nr_inmatriculare") or ""),
"form_vin": str(form.get("vin") or ""),
"form_data": str(form.get("data_prestatie") or ""),
"form_odo_final": str(form.get("odometru_final") or ""),
"form_odo_initial": str(form.get("odometru_initial") or ""),
"err_map": {},
"fix_map": {},
"vin_context": str(form.get("vin") or ""),
"btn_label": "Salveaza",
"message": chips_parse_error,
"chips_error": chips_parse_error,
"prestatii_chips": _cur_chips,
"has_r_odo": _has_r_odo_chips(_cur_chips),
"obs_val": str(form.get("obs") or "").strip(),
"nomenclator_rar": _nom_err,
"form_chips_url": "/form-chips",
"chips_target_index": _cur_target_idx,
"sugestii_tinta": _cur_sugestii,
})
# Invariant ORA-12899: orice cod trimis din form se valideaza fata de
# nomenclator INAINTE de persistare (RAR accepta NUMAI coduri valide).
if chips_state is not None:
valid_codes = load_nomenclator_codes(conn) or set()
necunoscute = sorted(
{c["cod_prestatie"] for c in chips_state if c["cod_prestatie"]} - valid_codes
)
if necunoscute:
_nom = load_nomenclator(conn)
_nec_target_idx, _nec_sugestii = _chips_target_si_sugestii(conn, chips_state, _nom)
return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request,
"import_id": import_id,
"row_index": row_index,
"csrf_token": get_csrf_token(request),
"vin": str(form.get("vin") or ""),
"stare_css": "",
"stare_eticheta": "",
"form_nr": str(form.get("nr_inmatriculare") or ""),
"form_vin": str(form.get("vin") or ""),
"form_data": str(form.get("data_prestatie") or ""),
"form_odo_final": str(form.get("odometru_final") or ""),
"form_odo_initial": str(form.get("odometru_initial") or ""),
"err_map": {},
"fix_map": {},
"vin_context": str(form.get("vin") or ""),
"btn_label": "Salveaza",
"message": f"Cod RAR necunoscut in nomenclator: {', '.join(necunoscute)}. "
"Alege un cod valid din lista.",
"prestatii_chips": chips_state,
"has_r_odo": _has_r_odo_chips(chips_state),
"obs_val": str(form.get("obs") or "").strip(),
"nomenclator_rar": _nom,
"form_chips_url": "/form-chips",
"chips_target_index": _nec_target_idx,
"sugestii_tinta": _nec_sugestii,
})
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error # Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
# pastreaza formularul modal cu valorile la 4xx/5xx). # pastreaza formularul modal cu valorile la 4xx/5xx).
apply_row_override( apply_row_override(
conn, import_id=import_id, account_id=account_id, conn, import_id=import_id, account_id=account_id,
row_index=row_index, fields=fields, row_index=row_index, fields=fields, prestatii=chips_state,
) )
result, row = _preview_one_row(conn, import_id, account_id, row_index) result, row = _preview_one_row(conn, import_id, account_id, row_index)
if row is None or isinstance(result, str): if row is None or isinstance(result, str):
@@ -3849,11 +4270,13 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(), "cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (p.get("cod_op_service") or "").strip(), "cod_op_service": (p.get("cod_op_service") or "").strip(),
"denumire": (p.get("denumire") or "").strip(), "denumire": (p.get("denumire") or "").strip(),
"exclus": 1 if p.get("exclus") else 0,
} }
for p in (res.get("prestatii") or []) for p in (res.get("prestatii") or [])
if isinstance(p, dict) if isinstance(p, dict)
] ]
_err_nomenclator = load_nomenclator(conn) _err_nomenclator = load_nomenclator(conn)
_err_target_idx, _err_sugestii = _chips_target_si_sugestii(conn, _err_chips, _err_nomenclator)
return templates.TemplateResponse("_editare_preview_modal.html", { return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request, "request": request,
"import_id": import_id, "import_id": import_id,
@@ -3878,6 +4301,8 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
"obs_val": str(form.get("obs") or res.get("obs") or "").strip(), "obs_val": str(form.get("obs") or res.get("obs") or "").strip(),
"nomenclator_rar": _err_nomenclator, "nomenclator_rar": _err_nomenclator,
"form_chips_url": "/form-chips", "form_chips_url": "/form-chips",
"chips_target_index": _err_target_idx,
"sugestii_tinta": _err_sugestii,
}) })
# Succes: reincarca preview-ul complet + toast + inchide modal (vezi helper). # Succes: reincarca preview-ul complet + toast + inchide modal (vezi helper).
@@ -4072,6 +4497,7 @@ async def web_mapare_operatii(
codes_list = form.getlist("cod_prestatie") codes_list = form.getlist("cod_prestatie")
salvate: list[str] = [] salvate: list[str] = []
excluse_ops: list[str] = []
sarite_invalide: list[str] = [] sarite_invalide: list[str] = []
for cod_op_service, cod_prestatie in zip(ops_list, codes_list): for cod_op_service, cod_prestatie in zip(ops_list, codes_list):
@@ -4082,6 +4508,12 @@ async def web_mapare_operatii(
if not cod_op_service or not cod_prestatie: if not cod_op_service or not cod_prestatie:
continue continue
# Regula "nu se declara la RAR": exclude operatia de la declarare.
if cod_prestatie == EXCLUDE_SENTINEL:
save_exclusion(conn, account_id, cod_op_service)
excluse_ops.append(cod_op_service)
continue
# Validare per-item (D#12): cod invalid -> skip + sumar, nu all-or-nothing # Validare per-item (D#12): cod invalid -> skip + sumar, nu all-or-nothing
exists = conn.execute( exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,) "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
@@ -4097,10 +4529,12 @@ async def web_mapare_operatii(
parts: list[str] = [] parts: list[str] = []
if salvate: if salvate:
parts.append(f"Salvate: {', '.join(salvate)}.") parts.append(f"Salvate: {', '.join(salvate)}.")
if excluse_ops:
parts.append(f"Excluse de la declarare: {', '.join(excluse_ops)}.")
if sarite_invalide: if sarite_invalide:
parts.append(f"Coduri necunoscute ignorate: {', '.join(sarite_invalide)}.") parts.append(f"Coduri necunoscute ignorate: {', '.join(sarite_invalide)}.")
message = " ".join(parts) if parts else None message = " ".join(parts) if parts else None
error = bool(sarite_invalide) and not salvate error = bool(sarite_invalide) and not salvate and not excluse_ops
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str): if isinstance(result, str):
@@ -4314,9 +4748,10 @@ async def web_confirma_import(
# Mapare operatii # Mapare operatii
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping_ops = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping_ops = {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 valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct) text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
# Rezolva mediul RAR tinta al lotului: form > default cont > ancora globala. # Rezolva mediul RAR tinta al lotului: form > default cont > ancora globala.
try: try:
@@ -4369,24 +4804,28 @@ async def web_confirma_import(
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val) 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}] mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
# Rezolva prestatii # Override editat in preview — aplicat INAINTE de resolve_prestatii,
prestatii = mapped.get("prestatii") or [] # ca in _resolve_row_for_preview: prestatiile din override (chips
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules) # editate in modal) trec prin ACELASI resolve.
mapped["prestatii"] = resolved_p
# Canonicalizare
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 = item.get("override") or {} override = item.get("override") or {}
if override: if override:
mapped.update(override) mapped.update(override)
canon = canonicalize_row(mapped)
# Rezolva prestatii
prestatii = mapped.get("prestatii") or []
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules, excluded_ops)
# Prestatiile excluse NU pleaca la RAR si nu intra in cheia de
# idempotenta, dar RAMAN in payload_json persistat (round-trip in
# editor) — ingustarea la declarabile se face doar local, pentru
# canon/cheie, si la worker, la momentul trimiterii.
declarabile_p, _excluse_p = split_prestatii_excluse(resolved_p)
# rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip.
if not declarabile_p:
continue
mapped["prestatii"] = resolved_p
# Canonicalizare (cheia identica cu preview: canonicalize_row + build_key)
canon = canonicalize_row({**mapped, "prestatii": declarabile_p})
mapped.update({ mapped.update({
"vin": canon["vin"], "vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"], "nr_inmatriculare": canon["nr_inmatriculare"],
@@ -4402,7 +4841,7 @@ async def web_confirma_import(
"odometru_final": mapped.get("odometru_final"), "odometru_final": mapped.get("odometru_final"),
"prestatii": [ "prestatii": [
str(p.get("cod_prestatie") or p.get("cod_op_service") or "") str(p.get("cod_prestatie") or p.get("cod_op_service") or "")
for p in resolved_p for p in declarabile_p
], ],
}, sort_keys=True, ensure_ascii=False)) }, sort_keys=True, ensure_ascii=False))

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. UN singur container `.chips`: chips cod (legate de operatie sau libere), chips
Inclusa si din _form_editare.html pentru randarea initiala. 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). Diagrama starii per item (chips_state.items[i]):
Fiecare operatie are un picker propriu cand e nemapata (E4 binding op<->cod). cod="" & exclus=0 -> chip warning (warn OP-X . fara cod), click = tinta
Reveal odometru initial semnalat prin data-has-r-odo="true" si chip-warn pe R-ODO/I-ODO. 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): Context vars (toate cu defaults):
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire} prestatii_chips — list of {cod_prestatie, cod_op_service, denumire, exclus}
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru picker nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru select
has_r_odo — True daca orice chip e R-ODO sau I-ODO (server-computed) 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' form_chips_url — URL pentru HTMX; default '/form-chips'
chips_section_id — ID div (default 'chips-section') chips_section_id — ID div (default 'chips-section')
csrf_token — CSRF (trecut prin hx-include din form parinte) csrf_token — CSRF (trecut prin hx-include din form parinte)
@@ -19,68 +33,92 @@
{% set _chips_url = form_chips_url or '/form-chips' %} {% set _chips_url = form_chips_url or '/form-chips' %}
{% set _sec_id = chips_section_id or 'chips-section' %} {% set _sec_id = chips_section_id or 'chips-section' %}
{% set _chips = prestatii_chips or [] %} {% set _chips = prestatii_chips or [] %}
{% set _has_ops = _chips | selectattr('cod_op_service') | list | length > 0 %} {% set _sub_id = chips_submission_id if (chips_submission_id is defined and chips_submission_id) else '' %}
{# chips_submission_id e setat din _detaliu_ctx cand chips sunt randate in modalul de detaliu. {% set _target_idx = chips_target_index if (chips_target_index is defined and chips_target_index is not none) else none %}
Lipseste cand _chips_prestatii.html e rerandat via /form-chips (stateless, fara submission). #} {% set _target_op = (_chips[_target_idx].cod_op_service or '') if (_target_idx is not none and _target_idx < _chips | length) else '' %}
{% set _sub_id = chips_submission_id if chips_submission_id is defined else none %}
<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"> aria-live="polite" aria-label="Prestatii cod RAR">
{# ===== Input-uri hidden pentru starea curenta a chip-urilor ===== {# ===== Stare: UN SINGUR hidden JSON versionat + tinta + submission id ===== #}
TOATE itemele emit 3 hidden inputs (cod poate fi "" pentru unmapped). <input type="hidden" name="chips_state" value='{{ chips_state_json(_chips) }}'>
Paralele index-by-index: cod_prestatie[i], chip_op_service[i], chip_denumire[i]. <input type="hidden" name="chips_target_index" id="chips-target-index"
Filtrate la submit de post_corectie_trimitere (coduri goale = neschimbate). #} value="{{ _target_idx if _target_idx is not none else '' }}">
{% for chip in _chips %} <input type="hidden" name="chips_submission_id" value="{{ _sub_id }}">
<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 %}
<div class="camp-slim" style="margin-bottom:8px;"> <div class="camp-slim" style="margin-bottom:8px;">
<label>Prestatii — cod RAR pe fiecare operatie</label> <label>Prestatii — cod RAR</label>
{% if _has_ops %} <div class="chips" role="group" aria-label="Prestatii cod RAR">
{# ===== Mod operatii: UN picker PE operatie (E4 binding) ===== #}
{% for chip in _chips %} {% for chip in _chips %}
{% if chip.cod_op_service %} {% set _idx = loop.index0 %}
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %} {% set _is_target = _target_idx is not none and _idx == _target_idx %}
{% set _nemapat = not chip.cod_prestatie %}
<div class="op-row {% if _nemapat %}op-row-warn{% endif %}" style="margin-bottom:6px;"> {% if chip.cod_op_service and not chip.cod_prestatie and not chip.exclus %}
<span class="op-row-name"> {# ===== Chip warning: operatie nemapata — click comuta tinta client-side ===== #}
{{ chip.cod_op_service }} {% set _label = chip.cod_op_service %}
{% if chip.denumire and chip.denumire != chip.cod_op_service %} {% set _label_scurt = (_label[:18] ~ '…') if _label | length > 18 else _label %}
<span class="muted" style="font-weight:400;font-size:11px;"> — {{ chip.denumire }}</span> {% set _title = chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '') %}
{% endif %} <button type="button"
{% if _nemapat %} class="chip chip-op-warn {% if _is_target %}is-target{% endif %}"
<span style="color:var(--warn);font-size:10px;font-weight:400;"> · lipsa cod</span> data-chip-idx="{{ _idx }}"
{% endif %} data-op-nume="{{ chip.cod_op_service }}"
</span> title="{{ _title }}"
<span style="display:flex;align-items:center;gap:8px;"> onclick="window.chipsSetTarget && window.chipsSetTarget(this, {{ _idx }})"
{% if chip.cod_prestatie %} aria-pressed="{{ 'true' if _is_target else 'false' }}"
{# ===== Operatie mapata: chip cu × ===== #} aria-label="Operatia {{ chip.cod_op_service }} nu are cod RAR{% if _is_target %} (tinta curenta){% endif %}">
<span class="chip {% if _is_warn %}chip-warn{% endif %}" &#9888; {{ _label_scurt }} &middot; fara cod
aria-label="Prestatie {{ chip.cod_prestatie }} adaugata pentru {{ chip.cod_op_service }}"> </button>
{{ chip.cod_prestatie }}
{% 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" <button type="button" class="chip-del"
hx-post="{{ _chips_url }}" hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form" hx-include="closest form"
hx-target="#{{ _sec_id }}" hx-target="#{{ _sec_id }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-vals='{"chips_action":"remove","chips_remove_index":{{ loop.index0 }}}' hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}'
aria-label="Sterge codul {{ chip.cod_prestatie }} pentru {{ chip.cod_op_service }}"> aria-label="Anuleaza Nedeclarat pentru {{ chip.cod_op_service or 'cod liber' }} (readuce avertismentul)">
&times;
</button>
</span>
{% 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; &times;
</button> </button>
</span> </span>
{# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut {# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
(in modalul de detaliu, nu la re-randarea stateless via /form-chips). (modalul de detaliu, nu la re-randarea stateless via /form-chips fara ecou). #}
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">
{% if _sub_id and chip.cod_op_service and chip.cod_prestatie %} {% 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" <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-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form" hx-include="closest form"
hx-target="#detaliu-modal-body" hx-target="#detaliu-modal-body"
hx-swap="innerHTML" hx-swap="innerHTML"
@@ -88,147 +126,94 @@
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}"> aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
salveaza ca regula salveaza ca regula
</button> </button>
{% endif %}
</span> </span>
{% else %} {% endif %}
{# ===== Operatie nemapata: picker galben cu "alege cod RAR" ===== #} {% endif %}
<select name="chips_add_cod_{{ loop.index0 }}" {% endfor %}
id="picker-op-{{ loop.index0 }}"
aria-label="Alege cod RAR pentru {{ chip.cod_op_service }}" {# ===== Selectul unic: adauga instant la schimbare, placeholder numeste tinta ===== #}
style="min-width:160px;font-size:11px;height:26px;"> {% if nomenclator_rar %}
<option value="">— alege cod RAR —</option> <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 []) %} {% for n in (nomenclator_rar or []) %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option> <option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %} {% endfor %}
</select> </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 %}
{# ===== 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).
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 %} {% else %}
{# T-D1/T-E5 (5.16): empty state in mod operatii 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;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 #}
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;"> <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> </div>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{# Hint discret fara chips (debut) #} {# Rezultatul ultimei adaugari: refuz (rol=alert) sau confirmare.
{% if not _chips %} Clasa chips-extra-error pastrata pe eroare (selector stabil pentru teste/QA). #}
<div style="font-size:10px;color:var(--muted);padding:4px 0;"> {% if chips_error is defined and chips_error %}
Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare. <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> </div>
{% endif %} {% endif %}
</div> </div>
</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

@@ -216,6 +216,41 @@
</div> </div>
</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) #} {# Card: De ce nu ajunge la RAR? (onestitate canal API) #}
{# Un POST /v1/prezentari poate intoarce 200 dar randul sa NU plece la RAR. #} {# Un POST /v1/prezentari poate intoarce 200 dar randul sa NU plece la RAR. #}
<div class="card" style="margin-bottom:16px;"> <div class="card" style="margin-bottom:16px;">

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

@@ -82,6 +82,7 @@
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required <select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
aria-label="Cod RAR pentru {{ e.cod_op_service }}"> aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option> <option value="">— alege cod RAR —</option>
<option value="__NEDECLARAT__">Nu se declara la RAR (exclude operatia)</option>
{% for n in nomenclator %} {% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}> <option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }} {{ n.cod_prestatie }} — {{ n.nume_prestatie }}
@@ -129,7 +130,7 @@
<tbody> <tbody>
{% for m in saved_mappings %} {% for m in saved_mappings %}
{# data-dt-row = haystack de cautare (randul contine un <select> cu tot nomenclatorul). #} {# 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"> <td data-eticheta="Operatie">
<form id="map-salv-{{ loop.index }}" hx-post="/mapari/salvate" hx-target="#mapari-section" hx-swap="outerHTML"> <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 '' }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
@@ -142,14 +143,16 @@
</form> </form>
<div><strong>{{ m.cod_op_service }}</strong></div> <div><strong>{{ m.cod_op_service }}</strong></div>
<div class="muted map-acum" style="font-size:12px;"> <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> </div>
</td> </td>
<td data-eticheta="Cod RAR"> <td data-eticheta="Cod RAR">
<select name="cod_prestatie" form="map-salv-{{ loop.index }}" required <select name="cod_prestatie" form="map-salv-{{ loop.index }}" required
aria-label="Cod RAR pentru {{ m.cod_op_service }}"> 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 %} {% 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 }} {{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option> </option>
{% endfor %} {% endfor %}
@@ -178,18 +181,19 @@
<!-- ============================================================ --> <!-- ============================================================ -->
<div class="card"> <div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2> <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;"> {# Un exemplu concret in loc de descriere — arata direct formatul unei reguli. #}
O regula leaga orice operatie al carei text <strong>contine</strong> (nu egal, ci substring) <div class="regula-exemplu"
un cuvant de un cod RAR. Util pentru operatii fara cod intern: ex. orice operatie care style="margin:0 0 12px; font-size:13.5px; max-width:680px; padding:10px 12px;
<em>contine</em> „verificare" primeste codul ales. Match insensibil la majuscule/diacritice. border:1px dashed var(--line); border-radius:8px; background:var(--card2);">
</p> Daca operatia contine
<span style="font-family:var(--font-mono); font-size:12.5px; padding:1px 6px; border-radius:4px;
{% if not text_rules %} background:color-mix(in srgb, var(--warn) 18%, transparent); color:var(--warn);">verificare</span>
<div class="empty" style="margin-bottom:12px;"> <span class="muted" aria-hidden="true">&rarr;</span>
Inca nu ai reguli. Ex: operatia contine «verificare» &rarr; OE-2. Cod RAR =
Mapeaza automat operatii similare fara cod intern. Adauga prima regula mai jos. <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> </div>
{% endif %}
<div class="tablewrap tabel-card"> <div class="tablewrap tabel-card">
<table> <table>

View File

@@ -40,6 +40,7 @@
('needs_data', 'Date incomplete'), ('needs_data', 'Date incomplete'),
('already_sent', 'Deja trimis'), ('already_sent', 'Deja trimis'),
('duplicate_in_file','Duplicat in fisier'), ('duplicate_in_file','Duplicat in fisier'),
('excluded', 'Nedeclarat'),
] %} ] %}
<div id="preview-rezumat" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> <div id="preview-rezumat" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
{% for status_key, label in status_labels %} {% for status_key, label in status_labels %}
@@ -126,6 +127,7 @@
<div class="mapcol"> <div class="mapcol">
<select name="cod_prestatie" aria-label="Cod RAR pentru {{ e.cod_op_service }}"> <select name="cod_prestatie" aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option> <option value="">— alege cod RAR —</option>
<option value="__NEDECLARAT__">Nu se declara la RAR (exclude operatia)</option>
{% for n in nomenclator %} {% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}> <option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }} {{ n.cod_prestatie }} — {{ n.nume_prestatie }}

View File

@@ -60,45 +60,71 @@
hx-disinherit="hx-confirm" hx-disinherit="hx-confirm"
style="margin:0;"> style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <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;"> {# Bara contextuala bulk (5.22): randata inactiva (ascunsa prin CSS), afisata de
{# Bulk-fix: input cod + buton aplica #} 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" <input type="text" name="cod_prestatie" id="bulk-fix-cod"
placeholder="Cod RAR (ex: OE-1)" placeholder="Cod RAR (ex: OE-1)"
autocomplete="off" autocapitalize="characters" autocomplete="off" autocapitalize="characters"
style="width:120px; font-size:12px; padding:3px 7px; style="width:120px; font-size:12px; padding:3px 7px;
border:1px solid var(--line); border-radius:5px; 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"> aria-label="Cod RAR de aplicat la randurile selectate">
<button type="button" <button type="button"
hx-post="/trimiteri/bulk-fix" hx-post="/trimiteri/bulk-fix"
hx-target="#submissions-wrap" hx-target="#submissions-wrap"
hx-swap="innerHTML" hx-swap="innerHTML"
style="background:var(--card); color:var(--accent); border-color:var(--accent); style="background:var(--card); color:var(--accent); border:1px solid var(--accent);
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;" font-size:12.5px; padding:4px 10px; border-radius:5px; cursor:pointer;"
aria-label="Aplica codul RAR la randurile blocate selectate"> 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> </button>
{# Separator vizual #}
<span style="color:var(--muted); font-size:11px; padding:0 2px;" aria-hidden="true">|</span> <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" <button type="submit" id="bulk-sterge-btn"
style="background:var(--card); color:var(--err); border-color:var(--err); style="background:var(--card); color:var(--err); border:1px solid var(--err);
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;"> font-size:12.5px; padding:4px 10px; border-radius:5px; cursor:pointer;">
Sterge selectate 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> </button>
</div> </div>
<div class="bulk-hint">
Aplica codul RAR doar operatiilor fara cod din randurile selectate, apoi le repune in coada.
</div>
{# Lista slim trimiteri. {# Tabel compact trimiteri (grid CSS, PRD 5.22).
Inlocuieste tabelul cu randuri compacte: VIN mono + operatie·ora + pill. Desktop: un rand per trimitere sub un header de coloane; status = bulina cu
Nr. inmatriculare, data prestatie si nr. prezentare RAR raman accesibile tooltip, mediu = litera T/P, multi-cod = primul cod + badge +N.
pe linia meta discreta (linia 3) si in modalul de detaliu. #} 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" <ul class="lista-trimiteri-slim" role="list"
aria-label="Lista trimiteri"> 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 %} {% for r in rows %}
{# Randul slim: stanga = VIN mono scurt (L1) + operatie·ora muted (L2) + meta (L3); {# Rand clickabil (role=button, Enter/Space in JS din base.html) — deschide
dreapta = pill de stare. Click deschide modalul global (#detaliu-modal-body). modalul global de detaliu (#detaliu-modal-body). #}
Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
<li id="trimitere-row-{{ r.id }}" <li id="trimitere-row-{{ r.id }}"
class="trimitere-slim" class="trimitere-slim"
data-detaliu-id="{{ r.id }}" data-detaliu-id="{{ r.id }}"
@@ -110,24 +136,21 @@
style="cursor:pointer;" style="cursor:pointer;"
title="Click pentru detaliul complet"> title="Click pentru detaliul complet">
{# Zona checkbox — nu declanseaza modalul (stopPropagation). {# Checkbox — nu declanseaza modalul (stopPropagation). Vizibil DOAR pe
Vizibila DOAR pe randurile gestionabile (error/needs_data/needs_mapping). randurile gestionabile; latimea fixa previne reflow. #}
Latimea fixa previne reflow la prezenta/absenta checkbox-ului. #} <div class="c-check" onclick="event.stopPropagation();">
<div style="flex:0 0 22px; display:flex; align-items:center;" onclick="event.stopPropagation();">
{% if r.gestionabil %} {% if r.gestionabil %}
<input type="checkbox" name="submission_id" value="{{ r.id }}" <input type="checkbox" name="submission_id" value="{{ r.id }}"
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere"> aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
{% endif %} {% endif %}
</div> </div>
{# Bloc text principal — stanga, ocupa spatiul ramas. Rand de 2 linii (spec 5.16): {# Numarul trimiterii (id din baza) — aceeasi valoare ca `submission_id`
L1 = placuta (identificator primar); L2 = cod RAR · operatie · data prestatie. #} din raspunsul API si ca titlul modalului de detaliu/editare. #}
<div style="flex:1 1 auto; min-width:0;"> <span class="c-id">#{{ r.id }}</span>
{# Linia 1: nr. inmatriculare (placuta) — identificatorul primar pe care il {# Vehicul: placuta (identificator primar); fallback VIN scurt / 'fara numar'
scaneaza operatorul. .slim-vin reumplut (acelasi nume de clasa, churn minim). (niciodata em-dash izolat). #}
Fallback cand placuta lipseste ('—'): VIN scurt, apoi mesaj neutru
(nu randa em-dash izolat ca identificator). #}
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %} {% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
<div class="slim-vin">{{ r.prez.vehicul_nr }}</div> <div class="slim-vin">{{ r.prez.vehicul_nr }}</div>
{% elif r.prez.vin_scurt and r.prez.vin_scurt != '—' %} {% elif r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
@@ -136,40 +159,42 @@
<div class="slim-vin muted">fara numar</div> <div class="slim-vin muted">fara numar</div>
{% endif %} {% endif %}
{# Linia 2: cod RAR (sau 'nemapat') · operatie (ink, ellipsis) · data prestatie. {# Pe desktop .c-meta e display:contents (copiii devin celule de grid);
Separatorul "·" e injectat prin CSS intre celule. Operatia primeste ellipsis pe mobil e linia 2 (flex) cu separatoare "·" din CSS. #}
ca randul sa NU treaca pe a 3-a linie nici la 390px. <div class="c-meta">
VIN integral, #id_prezentare si secundele traiesc in modalul de detaliu. #} {% set _cods = r.prez.coduri_rar or [] %}
<div class="slim-meta slim-rand2"> <span class="c-cod">
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %} {% if _cods %}
<span class="cod-rar-cod">{{ r.prez.cod_rar }}</span> <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 %} {% else %}
<span class="cod-rar-cod cod-rar-sub muted">nemapat</span> <span class="cod-rar-cod cod-rar-sub muted">nemapat</span>
{% endif %} {% endif %}
<span class="slim-op">{{ r.prez.operatie }}</span> </span>
{% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %} <span class="c-op">{{ r.prez.operatie }}</span>
<span class="slim-data muted">{{ r.prez.data_prestatie }}</span> <span class="c-data">{% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %}{{ r.prez.data_prestatie }}{% endif %}</span>
{% endif %}
</div> </div>
{# Micro-linie umana a problemei — text mic s-error, DOAR pe stari de problema {# Status: doar bulina colorata; numele complet in title + aria-label. #}
(loud-on-exception D6). Randul normal/finalizat ramane strict 2 linii. <span class="c-status">
Token tipografic --fs-xs (>=12px, scala 5.16). #} <span class="dot-stare {{ r.stare_css }}" title="{{ r.stare_text }}"
{% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %} role="img" aria-label="{{ r.stare_text }}"></span>
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div> </span>
{% endif %}
</div>
{# Zona dreapta: pill stare + badge mediu RAR + buton Trimite #} {# Mediu: litera compacta — P fill atentie (productie, risc L.142),
<div style="flex:0 0 auto; display:flex; flex-direction:column; align-items:flex-end; gap:3px; white-space:nowrap;"> T contur discret (testare). Numele complet in title. #}
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span> <span class="c-env">
{% if r.rar_env %} {% if r.rar_env %}
{% set _eb = eticheta_env(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 %} {% endif %}
{# Buton "Trimite" DOAR pe randurile tinute (queued AND held=1). </span>
Afordanta dedicata cu stopPropagation (randul e role=button) — NU declanseaza modalul
de detaliu. Confirmare 1-linie prin hx-confirm (per rand); ireversibilitate in microcopy. #} {# Actiuni: buton "Trimite" DOAR pe randurile tinute (queued AND held=1). #}
<div class="c-act">
{% if r.tinut %} {% if r.tinut %}
<form hx-post="/trimitere/{{ r.id }}/trimite-acum" <form hx-post="/trimitere/{{ r.id }}/trimite-acum"
hx-target="#submissions-wrap" hx-swap="innerHTML" hx-target="#submissions-wrap" hx-swap="innerHTML"
@@ -180,7 +205,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-trimite-rand" <button type="submit" class="btn-trimite-rand"
onclick="event.stopPropagation();" onclick="event.stopPropagation();"
style="margin-top:2px; padding:2px 10px; font-size:var(--fs-xs); style="padding:2px 10px; font-size:var(--fs-xs);
border:1px solid var(--accent); border-radius:99px; cursor:pointer; border:1px solid var(--accent); border-radius:99px; cursor:pointer;
background:var(--accent); color:#fff; font-weight:600;"> background:var(--accent); color:#fff; font-weight:600;">
Trimite Trimite
@@ -188,6 +213,12 @@
</form> </form>
{% endif %} {% endif %}
</div> </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> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -2,9 +2,9 @@
{% import '_macros.html' as ui %} {% import '_macros.html' as ui %}
{# Detaliu editabil in-place. Fragmentul se swap-uieste in corpul modalului global {# 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. (#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` Operatie + coduri RAR rezolvate apar IMPREUNA, read-only, folosind `prez.coduri_rar`
(fallback „nemapat"), fara eticheta separata „Cod RAR". #} (toate codurile, fallback „nemapat"), fara eticheta separata „Cod RAR". #}
{% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %} {% 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;"> <div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
{# === Header — #id + pill stare + badge mediu RAR + motiv === #} {# === Header — #id + pill stare + badge mediu RAR + motiv === #}

View File

@@ -141,7 +141,7 @@
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);} .s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
.s-ok{color:var(--ok);} .s-ok{color:var(--ok);}
.s-needs_review{color:var(--warn);} .s-needs_review{color:var(--warn);}
.s-already_sent,.s-duplicate_in_file{color:var(--muted);} .s-already_sent,.s-duplicate_in_file,.s-excluded{color:var(--muted);}
/* Badge mediu RAR — semantica risc L.142. /* Badge mediu RAR — semantica risc L.142.
Productie: fill atentie (ton --err), text alb — declaratie reala, ireversibila. Productie: fill atentie (ton --err), text alb — declaratie reala, ireversibila.
Testare: outline discret, ton --muted — mediu de proba, low-stakes. Testare: outline discret, ton --muted — mediu de proba, low-stakes.
@@ -741,35 +741,72 @@
.compact-nr { font-size:var(--fs-xl); font-weight:700; line-height:1; } .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; } .compact-lbl { font-size:10px; color:var(--muted); margin-top:3px; white-space:nowrap; }
} }
/* .lista-trimiteri-slim + .trimitere-slim — lista compacta cu separator --line2. /* Bara contextuala bulk (5.22): ascunsa fara selectie; .activ (setat de JS
Randul e clickabil (rol button), tinta min-height:44px pe mobil. */ 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; } .lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
.trimitere-slim { display:flex; align-items:center; justify-content:space-between; gap:12px; .trimiteri-head, .trimitere-slim {
padding:14px 16px; border-bottom:1px solid var(--line2); min-height:44px; cursor:pointer; } 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:last-child { border-bottom:none; }
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); } .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; } .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); } .trimitere-slim .c-check { display:flex; align-items:center; }
.slim-meta { font-size:var(--fs-sm); color:var(--muted); margin-top:3px; } /* Numarul trimiterii: id-ul din baza, corelabil cu submission_id din API. */
/* Linia 2 a randului slim (5.16): cod RAR · operatie (ellipsis) · data, pe UN rand. .trimitere-slim .c-id { font-family:var(--font-mono); font-size:var(--fs-xs);
Ellipsis-ul pe operatie garanteaza 2 linii MAX si la 390px. */ color:var(--muted); white-space:nowrap;
.slim-rand2 { display:flex; align-items:baseline; gap:6px; min-width:0; } font-variant-numeric:tabular-nums; }
.slim-rand2 .cod-rar-cod { flex:0 0 auto; font-family:var(--font-mono); font-weight:600; .slim-vin { font-family:var(--font-mono); font-size:var(--fs-sm); font-weight:600; color:var(--ink);
color:var(--accent); } white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
.slim-rand2 .cod-rar-cod.muted { color:var(--muted); font-weight:500; } /* Pe desktop copiii .c-meta devin celule de grid (cod / operatie / data). */
.slim-rand2 .slim-op { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; .trimitere-slim .c-meta { display:contents; }
text-overflow:ellipsis; color:var(--ink); } .trimitere-slim .c-cod { white-space:nowrap; }
.slim-rand2 .slim-data { flex:0 0 auto; } .trimitere-slim .cod-rar-cod { font-family:var(--font-mono); font-weight:600;
.slim-rand2 .slim-op::before, .slim-rand2 .slim-data::before { font-size:var(--fs-xs); color:var(--accent); }
content:"·"; color:var(--muted); margin-right:6px; } .trimitere-slim .cod-rar-cod.muted { color:var(--muted); font-weight:500; font-style:italic; }
.lista-trimiteri-slim .eticheta-problema { font-size:var(--fs-xs); line-height:1.3; margin-top:2px; } /* Badge +N: trimiterea are mai multe coduri RAR; toate stau in title. */
/* Pill slim (5.16): fill-tint + dot 7px + text colorat per stare (currentColor din .s-*). .trimitere-slim .cod-plus { display:inline-block; font-family:var(--font-mono);
Pastrat pe FIECARE rand inclusiv Finalizat (linistit dar prezent). */ font-size:10px; font-weight:700; color:var(--muted); border:1px solid var(--line);
.lista-trimiteri-slim .pill { display:inline-flex; align-items:center; gap:5px; font-weight:600; border-radius:99px; padding:0 5px; margin-left:4px; vertical-align:1px; cursor:default; }
background:color-mix(in srgb, currentColor 14%, transparent); .trimitere-slim .c-op { min-width:0; white-space:nowrap; overflow:hidden;
border-color:color-mix(in srgb, currentColor 35%, transparent); } text-overflow:ellipsis; font-size:var(--fs-sm); color:var(--ink); }
.lista-trimiteri-slim .pill::before { content:""; width:7px; height:7px; border-radius:99px; .trimitere-slim .c-data { font-size:var(--fs-xs); color:var(--muted); white-space:nowrap;
background:currentColor; flex-shrink:0; } 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. /* .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. */ Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
.camp-slim { margin-bottom:8px; } .camp-slim { margin-bottom:8px; }
@@ -793,22 +830,49 @@
.chip .chip-del:focus-visible { outline:2px solid var(--accent); outline-offset:1px; } .chip .chip-del:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
/* Varianta chip warn (ex. R-ODO necesita odometruInitial) */ /* Varianta chip warn (ex. R-ODO necesita odometruInitial) */
.chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); } .chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); }
/* .add-code — buton dashed pentru adaugare cod in chipbox */ /* Chip abia adaugat via /form-chips — confirmare vizibila a actiunii "+" */
.add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent; .chip-nou { outline:2px solid color-mix(in srgb, var(--ok) 45%, transparent); outline-offset:1px; }
border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line)); /* Eticheta mono "COD" pe chip-ul de cod */
border-radius:5px; color:var(--accent); font:500 10px var(--font-ui); cursor:pointer; } .chip-cod-label { font-size:9px; font-weight:800; opacity:.65; letter-spacing:.03em; }
.add-code:hover, .add-code:focus-visible { border-style:solid; } /* Chip warning operatie nemapata: dashed implicit; tinta activa = solid + inel —
/* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */ singurul tratament "tare" din rand. */
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px; .chip-op-warn { background:color-mix(in srgb, var(--warn) 16%, transparent); color:var(--warn);
padding:8px 10px; border:1px solid var(--line); border-radius:6px; border:1px dashed var(--warn); cursor:pointer; font-weight:600; }
background:var(--card2); margin-bottom:8px; } .chip-op-warn.is-target { border-style:solid; outline:2px solid color-mix(in srgb, var(--warn) 40%, transparent);
/* Nume operatie emfatic (T-9 5.16): proeminent (bold) ca in mockup — e ancora outline-offset:1px; }
vizuala a randului de mapare op<->cod. */ .chip-op-warn:focus-visible { outline:2px solid var(--warn); outline-offset:1px; }
.op-row-name { font-size:var(--fs-sm); font-weight:700; color:var(--ink); } /* Chip Nedeclarat: vizibil (line-through), NU mut/invizibil — cea mai riscanta
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); } actiune legala (exclude de la declararea RAR) nu poate fi cea mai discreta. */
/* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */ .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) { @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 === */ /* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim === */
/* === Fix mobil Mapari === /* === Fix mobil Mapari ===
@@ -1222,6 +1286,34 @@
if (saveBtn) saveBtn.classList.add('dirty'); if (saveBtn) saveBtn.classList.add('dirty');
}); });
</script> </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> <script>
// Sub-taburi pagina Setari: comutare pur client-side, fara server. // Sub-taburi pagina Setari: comutare pur client-side, fara server.
// Delegare pe document -> supravietuieste swap-urilor HTMX ale #card-cont (dupa // Delegare pe document -> supravietuieste swap-urilor HTMX ale #card-cont (dupa

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 charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>ROA AUTOPASS — declari prestațiile la RAR din câteva click-uri</title> <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."> <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.">
<style> {% include "_lp_style.html" %}
/* 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>
</head> </head>
<body data-theme="grafit"> <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> <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"> <main class="page">
<!-- HEADER --> <!-- HEADER (comun cu paginile statice) -->
<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;"> {% include "_lp_header.html" %}
<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>
<!-- HERO --> <!-- HERO -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
<div> <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> <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 AUTOPASS în locul tău. Fără tastat câmp cu câmp.</p> <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;"> <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> <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>
<div style="display:flex;gap:12px;margin-bottom:22px;"> <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> <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>
<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="#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>
<div style="display:flex;align-items:center;gap:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);flex-wrap:wrap;"> <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> <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="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 style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);">
<div> <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 style="font:400 12px var(--font-mono);color:var(--sub,#8b93a7);margin-top:2px;">Service Auto Vâlcea · 28 iun 2026</div>
</div> </div>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
@@ -142,14 +72,14 @@
<!-- PROBLEM + CALCULATOR (combinat) --> <!-- PROBLEM + CALCULATOR (combinat) -->
<div style="padding:80px 40px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));"> <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;"> <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> <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, 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> <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>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:stretch;"> <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="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:flex;flex-direction:column;gap:10px;">
<div style="display:grid;grid-template-columns:1fr 1fr;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> <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 style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;">
<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);">Trimiteri/lună</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);" id="out-pres">100</span></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> <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> <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> </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;"> <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>
</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 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;"> <div style="max-width:720px;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> <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 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> <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>
<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> <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>
</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> <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> <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> <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>
<div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;"> <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;"> <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> <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> <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>
<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 --> <!-- 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="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="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="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="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;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;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>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>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>Îț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>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>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(--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(--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(--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, în 48h</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> </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> <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="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="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="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="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="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Trimiteri nelimitate</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;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><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>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>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 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> </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> <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 --> <!-- 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="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="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="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="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Nelimitat + acces API</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;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><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>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>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>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>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>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>Suport email, maxim 8h</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> </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> <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> </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>
</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> <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> <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;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>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 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> </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);">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);">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: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: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;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</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> <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> <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> </form>
@@ -399,14 +381,8 @@
</div> </div>
</div> </div>
<!-- FOOTER --> <!-- FOOTER (comun cu paginile statice) -->
<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;"> {% include "_lp_footer.html" %}
<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>
</main> </main>
<script> <script>
(function(){ (function(){
@@ -434,12 +410,13 @@
el.addEventListener('mouseleave',function(){keys.forEach(function(k){el.style.setProperty(k,saved[k]);});}); 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=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 nf=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:0});
var nf1=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:1}); var nf1=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:1});
function recalc(){ 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; var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
document.getElementById('out-pres').textContent=p; document.getElementById('out-pres').textContent=p;
document.getElementById('out-rate').textContent=r; 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

@@ -44,8 +44,8 @@
{% endif %} {% endif %}
{# Format aliniat la formularul de inregistrare din landing (#inregistrare): aceleasi campuri, {# Format aliniat la formularul de inregistrare din landing (#inregistrare): aceleasi campuri,
etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro/premium), etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro),
normalizate server-side. #} normalizate server-side; 'premium' ramane valid in backend, doar alocat de admin. #}
<form method="post" action="/signup"> <form method="post" action="/signup">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label style="display:block;margin-bottom:14px;"> <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="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="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="pro"{% if plan == 'pro' %} selected{% endif %}>Pro — 59 lei/lună</option>
<option value="premium"{% if plan == 'premium' %} selected{% endif %}>Premium — la cerere</option>
</select> </select>
</label> </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;"> <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

@@ -37,7 +37,7 @@ from ..config import Settings, get_settings, load_test_credentials
from ..crypto import decrypt_creds from ..crypto import decrypt_creds
from ..db import get_connection, init_db, read_heartbeat, write_heartbeat from ..db import get_connection, init_db, read_heartbeat, write_heartbeat
from ..observ import log_event, set_source 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 ..payload import build_rar_payload
from ..reconcile import match_finalizata from ..reconcile import match_finalizata
from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env
@@ -258,7 +258,10 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d
sid = claimed["id"] sid = claimed["id"]
account_id = claimed.get("account_id") account_id = claimed.get("account_id")
content = claimed["content"] 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: try:
data = rar.post_prezentare(token, payload) data = rar.post_prezentare(token, payload)
mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id")) mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id"))

View File

@@ -48,7 +48,11 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
**Ultima actualizare**: 2026-07-06**5.21 CACHE PERSISTENT EMBEDDINGS IN SQLITE — LIVRAT** (PRD: [prd-5.21](prd/prd-5.21-embedding-cache-sqlite.md); executie echipa de agenti Sonnet: cache-core T1+T2, wiring T3+T5, tests T4+T6, reviewer adversarial). Vectorii corpusului k-NN (17.181 exemple SILVER) persista in tabela noua `embedding_cache` (PK `(model, text_hash)`, blob float32 LE 384, `array('f')` end-to-end); la warmup se vectorizeaza DOAR miss-urile (hash sha256 pe `denumire_normalizata` din lista FILTRATA, identica cu ce intra in `index_corpus`), restarturile cu corpus neschimbat = `embed=0`. Modul nou `app/embedding_cache.py` (fara dependinte noi): `sync_corpus_vectors(conn, model, texts, embed_fn)` hash->load->embed(miss)->save; scrieri/stergeri in chunk-uri de 500 cu `BEGIN`/`COMMIT` EXPLICIT (conexiunile sunt autocommit — `conn.commit()` e no-op); validare dimensiune la scriere SI citire (blob corupt = miss re-vectorizat); purjare orfane + modele vechi DOAR dupa indexare reusita; toate erorile SQLite -> `log.warning` + fallback embed complet (degradare gratioasa, nimic propagat). `EmbeddingEngine.index_corpus(vectors=)` valideaza alinierea `len(vectors)==len(items)` + absenta None (mismatch -> fallback embed complet). Secventa protejata de `_embeddings_lock` (mapping.py); pe calea de request (`block=False`) acquire NE-blocant — daca warmup-ul tine lock-ul, return imediat (fix MAJOR din review: altfel primul request in fereastra de cold start ingheta ~1-2 min). Log observabilitate: `embeddings: warmup ok cache=N embed=M in Xs` (eticheta `corpus reindexat` pe calea de request). Semnatura corpusului (`_corpus_signature_silver`) = mecanism de decizie NESCHIMBAT; worker-ul nu atinge cache-ul. Review adversarial: 1 MAJOR + 2 MINOR + 1 NIT, toate reparate. Suita completa: **1596 passed, 1 skipped** (+31 teste noi: 15/15 cai din diagrama de acoperire, toate cu `AUTOPASS_EMBEDDINGS_ENABLED=1` explicit, backend mock). Crestere DB ~26-30MB acceptata; deferate in TODOS.md: CLI embcache, precompute la build, graceful reload, numpy la >50k randuri. **De verificat pe instanta reala (US-006)**: doua restarturi `./start.sh`, al doilea cu `embed=0` si sugestia "similaritate" in <30s. **Ultima actualizare**: 2026-07-08**5.23 EDITOR PRESTATII UNIFICAT IN MODAL — LIVRAT** (PRD: [prd-5.23](prd/prd-5.23-editor-prestatii-unificat.md); executie echipa agenti Sonnet TDD in valuri, lead orchestreaza). Cele 3 controale suprapuse pentru asocierea codurilor RAR (rand-per-operatie + container chips libere + select "+ Adauga") inlocuite cu **UN singur rand de chips + UN singur select** care adauga instant la `change` (fara butoane "Adauga"/"+"). Starea chips = UN hidden `chips_state` JSON versionat (`{"v":1,"items":[{cod,op,den,exclus}]}`, `_chips_state_from_form` unic + fallback tranzitoriu pe listele vechi), `post_form_chips` redus la 2 actiuni (`add`/`remove`, validare semantica tinta). Selectul primeste optgroup "Sugestii" (fuzzy/k-NN pentru operatia tinta, aceleasi surse ca preview-ul, degradare gratioasa non-blocanta pe motor rece) + optiune "Nu se declara la RAR" (`__NEDECLARAT__` → chip `Nedeclarat` line-through, × readuce warning). Tinta implicita (prima operatie nemapata) evidentiata + placeholder-ul o numeste ("Cod pentru OP-X — alege"), comutare tinta client-side fara round-trip; helper partajat `_chips_target_si_sugestii` pe TOATE caile de randare (initiala + /form-chips + erori). `exclus` persistat prin salvare (round-trip complet): treapta noua de precedenta in `resolve_prestatii` (item-exclus explicit domina, `it.pop` inlocuit, test de regresie blocant pe precedenta), itemii exclusi RAMAN in `payload_json` adnotati. **CLOSE `/code-review high` (8 unghiuri) — 1 BLOCKER de siguranta prins + reparat**: itemii exclusi (cod null) ajungeau la RAR via worker (`build_rar_payload` fara filtru) → risc ORA-12899/FINALIZATA partial ireversibil; fix la chokepoint unic (worker `split_prestatii_excluse` inainte de `build_rar_payload`, acopera toate canalele) + filtru defensiv + teste care dovedesc; plus consistenta round-trip la import (`web_confirma_import`/`commit_import` pastreaza full, cheie=declarabile) + bug latent idempotenta preview↔commit reparat + curatare comentarii (conventie CLAUDE.md). VERIFY context curat PASS (E2E browser Playwright ambele fluxuri: import preview + Trimiteri corectie; regresia de aur RAR live NEPROBAT — cont QA fara creds). Suita completa: **1680 passed, 1 skipped** (+45 teste noi fata de 1635). Deferate in TODOS: cale legacy `/trimitere/{id}/mapeaza` (formular per-operatie separat), autocomplete in select, bulk-apply mapare pe randuri identice. Backend worker/masina stari NEATINS (exceptie: split la trimitere); fara migrare schema (`exclus` in `payload_json`).
> 2026-07-07 — **5.22 UI COMPACT TRIMITERI + FIX BUTON "+" EDITOR — LIVRAT** (PRD: [prd-5.22](prd/prd-5.22-ui-compact-trimiteri-reguli-fix-chips.md); mockup aprobat de utilizator inainte de executie). Trimiteri pe desktop = tabel grid cu header (Vehicul/Cod RAR/Operatie/Data/Status/Mediu), un rand per trimitere: status = bulina colorata cu tooltip (`.dot-stare`), mediu = litera `P` (fill --err, risc L.142) / `T` (contur), multi-cod = primul cod + badge `+N` cu toate codurile in title (`payload_view.coduri_rar` nou), sub-linie de eroare = textul BRUT (mesaj RAR, ex. ORA-12899) mono trunchiat doar pe error/needs_data (`labels.eroare_bruta` nou; needs_mapping exclus — "nemapat" e in coloana Cod RAR). Mobil (<=767px): fara header, rand pe 2 linii prin grid-areas, T/P + bulina in dreapta. Actiunile bulk ("Aplica cod"/"Sterge") mutate intr-o **bara contextuala** vizibila doar la selectie, cu contor si texte explicite ("Aplica acest cod la N randuri") + hint; JS delegat + sync la `htmx:afterSwap`. Mapari: descrierea sectiunii "Reguli automate (text)" inlocuita cu un exemplu concret formatat. **Fix bug "+" editor prestatii** — cauza-radacina gasita LA VERIFY E2E (nu in analiza statica): `hx-disabled-elt="find button"` de pe form-urile parinte se mosteneste pe butoanele chips → `find button` relativ la "+" = null → TypeError intern htmx 1.9.12 INAINTE de request (nicio cerere, nicio eroare) → fix `hx-disabled-elt="this"` explicit pe toate butoanele hx-post din `_chips_prestatii.html`; separat, no-op-urile silentioase server-side din `post_form_chips` (cod invalid pe add/add_flat, duplicat pe toate ramurile) au acum mesaje explicite (`chips_error`) si confirmare la succes (chip `.chip-nou` evidentiat + "X adaugat."). Verify E2E in browser pe desktop 1280 + mobil 390 (screenshots), bulk-fix aplicat live. Suita completa: **1622 passed, 1 skipped** (+26 teste noi). Observatii deferate: `bulk_message` e suprascris imediat de reload-ul `trimiteriChanged` (pre-existent); operatia trunchiata agresiv pe mobil pe randurile cu buton "Trimite".
> 2026-07-06 — **5.21 CACHE PERSISTENT EMBEDDINGS IN SQLITE — LIVRAT** (PRD: [prd-5.21](prd/prd-5.21-embedding-cache-sqlite.md); executie echipa de agenti Sonnet: cache-core T1+T2, wiring T3+T5, tests T4+T6, reviewer adversarial). Vectorii corpusului k-NN (17.181 exemple SILVER) persista in tabela noua `embedding_cache` (PK `(model, text_hash)`, blob float32 LE 384, `array('f')` end-to-end); la warmup se vectorizeaza DOAR miss-urile (hash sha256 pe `denumire_normalizata` din lista FILTRATA, identica cu ce intra in `index_corpus`), restarturile cu corpus neschimbat = `embed=0`. Modul nou `app/embedding_cache.py` (fara dependinte noi): `sync_corpus_vectors(conn, model, texts, embed_fn)` hash->load->embed(miss)->save; scrieri/stergeri in chunk-uri de 500 cu `BEGIN`/`COMMIT` EXPLICIT (conexiunile sunt autocommit — `conn.commit()` e no-op); validare dimensiune la scriere SI citire (blob corupt = miss re-vectorizat); purjare orfane + modele vechi DOAR dupa indexare reusita; toate erorile SQLite -> `log.warning` + fallback embed complet (degradare gratioasa, nimic propagat). `EmbeddingEngine.index_corpus(vectors=)` valideaza alinierea `len(vectors)==len(items)` + absenta None (mismatch -> fallback embed complet). Secventa protejata de `_embeddings_lock` (mapping.py); pe calea de request (`block=False`) acquire NE-blocant — daca warmup-ul tine lock-ul, return imediat (fix MAJOR din review: altfel primul request in fereastra de cold start ingheta ~1-2 min). Log observabilitate: `embeddings: warmup ok cache=N embed=M in Xs` (eticheta `corpus reindexat` pe calea de request). Semnatura corpusului (`_corpus_signature_silver`) = mecanism de decizie NESCHIMBAT; worker-ul nu atinge cache-ul. Review adversarial: 1 MAJOR + 2 MINOR + 1 NIT, toate reparate. Suita completa: **1596 passed, 1 skipped** (+31 teste noi: 15/15 cai din diagrama de acoperire, toate cu `AUTOPASS_EMBEDDINGS_ENABLED=1` explicit, backend mock). Crestere DB ~26-30MB acceptata; deferate in TODOS.md: CLI embcache, precompute la build, graceful reload, numpy la >50k randuri. **De verificat pe instanta reala (US-006)**: doua restarturi `./start.sh`, al doilea cu `embed=0` si sugestia "similaritate" in <30s.
> 2026-07-06 — **HARDENING 80/20 LIVRAT + PUSH** (`main` la `63b6cbc`; plan /autoplan `main-hardening-8020-plan-20260703.md`, audit securitate 2026-07-03, inainte de expunerea publica autopass.romfast.ro). **P0**: compose fail-safe — `:?` obligatoriu pe `AUTOPASS_REQUIRE_API_KEY`/`AUTOPASS_WORKER_SEND_ENABLED`/`AUTOPASS_RAR_ENV` (split-brain api-prod/worker-test eliminat; post-5.20 variabila e doar ancora de fallback, nu tinta trimiterilor), `FORWARDED_ALLOW_IPS="*"` ca rate-limit-ul sa vada IP-ul real dupa Traefik (invariante in comentariu: api fara `ports:` pe host, Traefik fara `forwardedHeaders.insecure`), `AUTOPASS_SESSION_SECRET` obligatoriu + `SESSION_HTTPS_ONLY` default true; invarianta E1 fail-fast la boot (`validate_prod_invariants`): prod fara cheie API sau session secret -> RuntimeError cu mesaj actionabil. **P0-4 backup criptat SQLite**: `tools/backup_db.sh` (snapshot online stdlib + gpg AES256 + retentie + rclone optional), `tools/restore_check.sh` (integrity_check pe restore), `docs/backup.md`; trigger dur: configurat INAINTE de prima declaratie reala prod. **P1**: headers de securitate in-app (`SecurityHeadersMiddleware`, HSTS doar pe https) + body-cap global 10MB (`BodyCapMiddleware` ASGI pur, 413 inainte de parserul multipart/JSON; verificarea per-endpoint ramane strat 2) + imagine Docker non-root (uid 10001, `HOME=/home/app` pt fastembed, chown /data INAINTE de VOLUME, loguri pe `/data/logs`, port 8010 aliniat EXPOSE/CMD; migrare one-off volum existent: `docker compose run --rm --user root api chown -R app:app /data`). **P2**: print-uri cu email in stdout eliminate (signup + notify degradat din `app/email.py` -> `log_event` fara PII) + fix crestere monotona chei `ratelimit._hits`. Executie multi-agent (4 agenti implementare pe fisiere disjuncte + review dedicat: 0 constatari blocante, 3 nit-uri acceptate). Suita completa: 1557 passed, 1 skipped (3 rulari independente). **Post-deploy HOUR-1 (Dokploy)**: seteaza env-urile obligatorii INAINTE de redeploy (`:?` pica pornirea), creeaza cheia API + tier pro/trial pe contul propriu (altfel 403 PLAN_FARA_API), ruleaza migrarea chown, seteaza passphrase-ul de backup. > 2026-07-06 — **HARDENING 80/20 LIVRAT + PUSH** (`main` la `63b6cbc`; plan /autoplan `main-hardening-8020-plan-20260703.md`, audit securitate 2026-07-03, inainte de expunerea publica autopass.romfast.ro). **P0**: compose fail-safe — `:?` obligatoriu pe `AUTOPASS_REQUIRE_API_KEY`/`AUTOPASS_WORKER_SEND_ENABLED`/`AUTOPASS_RAR_ENV` (split-brain api-prod/worker-test eliminat; post-5.20 variabila e doar ancora de fallback, nu tinta trimiterilor), `FORWARDED_ALLOW_IPS="*"` ca rate-limit-ul sa vada IP-ul real dupa Traefik (invariante in comentariu: api fara `ports:` pe host, Traefik fara `forwardedHeaders.insecure`), `AUTOPASS_SESSION_SECRET` obligatoriu + `SESSION_HTTPS_ONLY` default true; invarianta E1 fail-fast la boot (`validate_prod_invariants`): prod fara cheie API sau session secret -> RuntimeError cu mesaj actionabil. **P0-4 backup criptat SQLite**: `tools/backup_db.sh` (snapshot online stdlib + gpg AES256 + retentie + rclone optional), `tools/restore_check.sh` (integrity_check pe restore), `docs/backup.md`; trigger dur: configurat INAINTE de prima declaratie reala prod. **P1**: headers de securitate in-app (`SecurityHeadersMiddleware`, HSTS doar pe https) + body-cap global 10MB (`BodyCapMiddleware` ASGI pur, 413 inainte de parserul multipart/JSON; verificarea per-endpoint ramane strat 2) + imagine Docker non-root (uid 10001, `HOME=/home/app` pt fastembed, chown /data INAINTE de VOLUME, loguri pe `/data/logs`, port 8010 aliniat EXPOSE/CMD; migrare one-off volum existent: `docker compose run --rm --user root api chown -R app:app /data`). **P2**: print-uri cu email in stdout eliminate (signup + notify degradat din `app/email.py` -> `log_event` fara PII) + fix crestere monotona chei `ratelimit._hits`. Executie multi-agent (4 agenti implementare pe fisiere disjuncte + review dedicat: 0 constatari blocante, 3 nit-uri acceptate). Suita completa: 1557 passed, 1 skipped (3 rulari independente). **Post-deploy HOUR-1 (Dokploy)**: seteaza env-urile obligatorii INAINTE de redeploy (`:?` pica pornirea), creeaza cheia API + tier pro/trial pe contul propriu (altfel 403 PLAN_FARA_API), ruleaza migrarea chown, seteaza passphrase-ul de backup.

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_*` -> care testeaza `deduped` nu se sparg). Pentru `sent`/`queued`/`sending`/`needs_*` ->
`deduped: true` (neschimbat). `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. - `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). **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); Scope evaluat INAINTEA starii: cross-account / inexistent -> **404** (acelasi mesaj, B3);

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,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

@@ -31,7 +31,7 @@ def _body(**over):
"prestatii": [{"cod_prestatie": "OE-1"}], "prestatii": [{"cod_prestatie": "OE-1"}],
} }
prez.update(over) 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): def test_prezentare_valida_queued(client):
@@ -117,6 +117,66 @@ def test_raspuns_nu_reflecta_parola(client):
assert "password" not in r.text 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): def test_json_malformat_422(client):
# Lipseste vin -> validare de shape Pydantic -> 422 (NU needs_data). # Lipseste vin -> validare de shape Pydantic -> 422 (NU needs_data).
bad = {"rar_credentials": {"email": "x", "password": "y"}, bad = {"rar_credentials": {"email": "x", "password": "y"},

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

@@ -38,7 +38,7 @@ def _body(rar_env=None, **over):
"prestatii": [{"cod_prestatie": "OE-1"}], "prestatii": [{"cod_prestatie": "OE-1"}],
} }
prez.update(over) 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: if rar_env is not None:
body["rar_env"] = rar_env body["rar_env"] = rar_env
return body return body

View File

@@ -479,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}" assert usage_1 == 1, f"Dupa prima trimitere: asteptat usage=1, primit {usage_1}"
# Retry (acelasi payload -> acelasi idempotency_key -> dedup, fara INSERT nou) # 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.status_code == 200, f"Retry trebuie sa treaca (dedup): {r2.text}"
assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped" assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped"

View File

@@ -161,7 +161,7 @@ def _body(**over):
"prestatii": [{"cod_prestatie": "OE-1"}], "prestatii": [{"cod_prestatie": "OE-1"}],
} }
prez.update(over) 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_api_dedup_dupa_t9(client): def test_api_dedup_dupa_t9(client):

View File

@@ -0,0 +1,78 @@
"""Robustete selectie tinta chips: doar operatii nemapate IDENTIFICABILE (cu
cod_op_service) pot deveni tinta. Un item fara cod_op_service (chip liber, fara
nicio operatie asociata) nu are cum sa fie afisat ca tinta in template (chip-ul
warning se randeaza doar cand cod_op_service e truthy) — daca ar fi ales tinta,
ar deveni invizibil: userul nu vede niciun highlight, dar selectul tot arata
sugestii/NEDECLARAT pentru un "cui" pe care nu-l poate vedea.
"""
from __future__ import annotations
import os
import tempfile
import pytest
@pytest.fixture()
def conn(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.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _seed_cod(conn, cod: str, nume: str) -> None:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, nume),
)
conn.commit()
def test_item_fara_cod_op_service_nu_devine_tinta(conn):
"""Chip liber (fara cod_op_service) nemapat nu poate fi tinta — e invizibil in template."""
from app.web.routes import _chips_target_si_sugestii
_seed_cod(conn, "OE-1", "Schimb ulei")
from app.mapping import load_nomenclator
nomenclator = load_nomenclator(conn)
chips = [{"cod_prestatie": "", "cod_op_service": "", "denumire": "", "exclus": 0}]
target_index, sugestii = _chips_target_si_sugestii(conn, chips, nomenclator)
assert target_index is None
assert sugestii == []
def test_operatie_identificabila_ramane_tinta(conn):
"""Regresie: o operatie nemapata cu cod_op_service ramane tinta normal."""
from app.web.routes import _chips_target_si_sugestii
_seed_cod(conn, "OE-1", "Schimb ulei motor")
from app.mapping import load_nomenclator
nomenclator = load_nomenclator(conn)
chips = [{"cod_prestatie": "", "cod_op_service": "Op-A", "denumire": "Schimb ulei motor", "exclus": 0}]
target_index, sugestii = _chips_target_si_sugestii(conn, chips, nomenclator)
assert target_index == 0
assert any(s.get("cod_prestatie") == "OE-1" for s in sugestii)
def test_sugestii_pentru_tinta_fara_denumire_intoarce_gol():
"""Denumire (si cod_op_service) goala pe tinta -> lista goala, fara sugestii irelevante."""
from app.web.routes import _sugestii_select_pentru_tinta
class _FakeConn:
pass
nomenclator = [{"cod_prestatie": "OE-1", "nume_prestatie": "Schimb ulei"}]
out = _sugestii_select_pentru_tinta(_FakeConn(), "", nomenclator)
assert out == []
out_none = _sugestii_select_pentru_tinta(_FakeConn(), None, nomenclator)
assert out_none == []

View File

@@ -37,7 +37,7 @@ def _body(password="corecta", **over):
"prestatii": [{"cod_prestatie": "OE-1"}], "prestatii": [{"cod_prestatie": "OE-1"}],
} }
prez.update(over) prez.update(over)
return {"rar_credentials": {"email": "x@y.ro", "password": password}, "prezentari": [prez]} return {"rar_credentials": {"email": "x@y.ro", "password": password}, "prezentari": [prez], "raspuns": "complet"}
def _force_status(sid, status): def _force_status(sid, status):

View File

@@ -0,0 +1,370 @@
"""Teste regula "exclude de la declarare" (operations_mapping.exclus).
O operatie exclusa nu se declara la RAR: randurile de import cu toate operatiile
excluse devin 'excluded' (Nedeclarat) si nu se comit; operatiile excluse dispar
din panoul de mapare; submission-urile API blocate trec pe needs_data cu motiv.
Un cod ales explicit pe rand bate regula de excludere.
"""
from __future__ import annotations
import io
import json
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
from tests.test_web_preview_edit import ( # helpers reutilizate
_csv_bytes,
_get_csrf,
_upload_and_preview,
)
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "excl.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
_ROWS_ITP = [
{
"VIN": "WVWZZZ3CZ9E123456",
"Nr": "TM789BC",
"Data": "2026-05-15",
"KM": "82500",
"Operatie": "OP-ITP",
},
{
"VIN": "WVWZZZ1KZAW000123",
"Nr": "B001TST",
"Data": "2026-06-10",
"KM": "123456",
"Operatie": "OP-1",
},
]
def _seed(account_id: int = 1) -> None:
"""Nomenclator + mapare OP-1 -> R-FRANE (OP-ITP ramane nemapat)."""
from app.db import get_connection
conn = get_connection()
try:
for cod, nume in (("R-FRANE", "Reparatie frane"), ("OE-2", "Verificare")):
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, nume),
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, 'OP-1', 'R-FRANE', 1)",
(account_id,),
)
conn.commit()
finally:
conn.close()
def _row_status(iid: int, row_index: int) -> str:
from app.db import get_connection
from app.web.routes import _preview_one_row
conn = get_connection()
try:
result, row = _preview_one_row(conn, iid, 1, row_index)
assert row is not None and not isinstance(result, str)
return row["resolved_status"]
finally:
conn.close()
def test_panoul_de_mapare_ofera_optiunea_nedeclarat(client):
_seed()
iid = _upload_and_preview(client, rows=_ROWS_ITP)
r = client.get(f"/_import/{iid}/preview")
assert r.status_code == 200
assert '__NEDECLARAT__' in r.text, "Selectul de mapare trebuie sa ofere 'Nu se declara la RAR'"
def test_exclude_din_preview_marcheaza_randul_nedeclarat(client):
_seed()
iid = _upload_and_preview(client, rows=_ROWS_ITP)
assert _row_status(iid, 0) == "needs_mapping"
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/mapare-operatii", data={
"cod_op_service": "OP-ITP",
"cod_prestatie": "__NEDECLARAT__",
"csrf_token": csrf,
})
assert r.status_code == 200, r.text
assert "Excluse de la declarare: OP-ITP" in r.text
assert "Nedeclarat" in r.text
assert _row_status(iid, 0) == "excluded"
assert _row_status(iid, 1) == "ok"
# Operatia exclusa nu mai apare in panoul de mapat
assert "Operatii de mapat" not in r.text or "OP-ITP" not in r.text.split("Operatii de mapat")[1][:2000]
# Regula persistata
from app.db import get_connection
conn = get_connection()
try:
rule = conn.execute(
"SELECT cod_prestatie, exclus FROM operations_mapping WHERE account_id=1 AND cod_op_service='OP-ITP'"
).fetchone()
finally:
conn.close()
assert rule is not None and rule["exclus"] == 1 and rule["cod_prestatie"] == ""
def test_commit_sare_randurile_excluse(client):
_seed()
iid = _upload_and_preview(client, rows=_ROWS_ITP)
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/mapare-operatii", data={
"cod_op_service": "OP-ITP",
"cod_prestatie": "__NEDECLARAT__",
"csrf_token": csrf,
})
assert r.status_code == 200
# Doar randul OP-1 e "gata de trimis" -> n_confirmat=1
csrf = _get_csrf(client)
rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
assert rc.status_code == 200, rc.text
from app.db import get_connection
conn = get_connection()
try:
subs = conn.execute("SELECT payload_json FROM submissions WHERE batch_id=?", (iid,)).fetchall()
finally:
conn.close()
assert len(subs) == 1, "Doar randul declarabil se enqueue-uieste"
payload = json.loads(subs[0]["payload_json"])
assert payload["prestatii"][0]["cod_prestatie"] == "R-FRANE"
def test_rand_mixt_exclude_doar_operatia_exclusa(client):
"""Rand cu 2 operatii (una exclusa, una mapata) -> ok; payload fara cea exclusa."""
_seed()
rows = [{
"VIN": "WVWZZZ1KZAW000789",
"Nr": "B003TST",
"Data": "2026-06-12",
"KM": "90000",
"Operatie": "OP-ITP",
}]
iid = _upload_and_preview(client, rows=rows)
csrf = _get_csrf(client)
client.post(f"/_import/{iid}/mapare-operatii", data={
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
})
assert _row_status(iid, 0) == "excluded"
# Editorul de rand: adauga explicit un cod suplimentar -> randul redevine declarabil
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"cod_prestatie": ["", "OE-2"],
"chip_op_service": ["OP-ITP", ""],
"chip_denumire": ["", ""],
})
assert r.status_code == 200, r.text
assert _row_status(iid, 0) == "ok"
csrf = _get_csrf(client)
rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
assert rc.status_code == 200, rc.text
from app.db import get_connection
conn = get_connection()
try:
sub = conn.execute("SELECT payload_json FROM submissions WHERE batch_id=?", (iid,)).fetchone()
finally:
conn.close()
payload = json.loads(sub["payload_json"])
# Round-trip: itemul exclus RAMANE in payload_json (persistat), adnotat exclus,
# dar fara cod_prestatie — nu pleaca niciodata la RAR (vezi build_rar_payload).
by_op = {p.get("cod_op_service"): p for p in payload["prestatii"] if p.get("cod_op_service")}
assert by_op["OP-ITP"]["exclus"] is True
assert not by_op["OP-ITP"].get("cod_prestatie")
coduri_declarabile = [p.get("cod_prestatie") for p in payload["prestatii"] if not p.get("exclus")]
assert coduri_declarabile == ["OE-2"], f"Operatia exclusa nu trebuie sa plece la RAR: {coduri_declarabile}"
def test_reupload_rand_cu_exclus_deja_comis_e_already_sent(client):
"""Cheia de idempotenta la commit (doar declarabile) e IDENTICA cu preview.
Re-incarcarea aceluiasi rand (dupa ce a fost deja comis cu un item exclus
adnotat in payload) trebuie recunoscuta drept duplicat — nu un rand nou.
"""
_seed()
rows = [{
"VIN": "WVWZZZ1KZAW000789",
"Nr": "B003TST",
"Data": "2026-06-12",
"KM": "90000",
"Operatie": "OP-ITP",
}]
iid = _upload_and_preview(client, rows=rows)
csrf = _get_csrf(client)
client.post(f"/_import/{iid}/mapare-operatii", data={
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
})
client.post(f"/_import/{iid}/rand/0/editeaza", data={
"cod_prestatie": ["", "OE-2"],
"chip_op_service": ["OP-ITP", ""],
"chip_denumire": ["", ""],
})
csrf = _get_csrf(client)
rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
assert rc.status_code == 200, rc.text
# Re-incarca acelasi rand + acelasi cod ales pe editorul de rand. Maparea de
# coloane e deja memorata din primul upload -> raspunsul sare direct la preview
# (fara link mapare-coloane); extragem import_id din hx-get pe #import-section.
csv_data = _csv_bytes(rows)
csrf = _get_csrf(client)
r2 = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r2.status_code == 200, r2.text
m2 = re.search(r"/_import/(\d+)/preview", r2.text)
assert m2, f"import_id negasit in raspunsul de reupload: {r2.text[:300]}"
iid2 = int(m2.group(1))
csrf = _get_csrf(client)
client.post(f"/_import/{iid2}/mapare-operatii", data={
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
})
client.post(f"/_import/{iid2}/rand/0/editeaza", data={
"cod_prestatie": ["", "OE-2"],
"chip_op_service": ["OP-ITP", ""],
"chip_denumire": ["", ""],
})
# already_sent se calculeaza doar la refresh-ul complet de preview (lookup pe
# tot batch-ul), nu la editarea unui singur rand — reincarcam preview-ul.
rp = client.get(f"/_import/{iid2}/preview")
assert rp.status_code == 200
assert "Deja trimis" in rp.text, \
"Randul re-incarcat trebuie recunoscut deja trimis (cheia de idempotenta neschimbata)"
def test_cod_explicit_pe_operatie_bate_regula_de_excludere(client):
"""Chips cu cod explicit PE operatia exclusa -> codul ales de user castiga."""
_seed()
rows = [_ROWS_ITP[0]]
iid = _upload_and_preview(client, rows=rows)
csrf = _get_csrf(client)
client.post(f"/_import/{iid}/mapare-operatii", data={
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
})
assert _row_status(iid, 0) == "excluded"
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"cod_prestatie": "OE-2",
"chip_op_service": "OP-ITP",
"chip_denumire": "Inspectie",
})
assert r.status_code == 200, r.text
assert _row_status(iid, 0) == "ok"
def test_mapari_tab_exclude_si_rerezolva_submissions_api(client):
"""Excluderea din tab-ul Mapari trece submission-urile blocate (canal API) pe needs_data."""
_seed()
# Submission blocat pe OP-XYZ (canal API, batch_id NULL)
r = client.post("/v1/prezentari", json={
"rar_credentials": {"email": "x@y.ro", "password": "s"},
"prezentari": [{
"vin": "WVWZZZ1KZAW000555",
"nr_inmatriculare": "B055TST",
"data_prestatie": "2026-06-20",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": "OP-XYZ", "denumire": "Operatie interna"}],
}],
})
assert r.status_code == 200, r.text
sid = r.json()["results"][0]["submission_id"]
assert r.json()["results"][0]["status"] == "needs_mapping"
# Operatia apare la mapat
frag = client.get("/_fragments/mapari")
assert "OP-XYZ" in frag.text
csrf = _get_csrf(client)
resp = client.post("/mapari", data={
"cod_op_service": "OP-XYZ",
"cod_prestatie": "__NEDECLARAT__",
"csrf_token": csrf,
})
assert resp.status_code == 200, resp.text
assert "exclus de la declarare" in resp.text
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT status, rar_error FROM submissions WHERE id=?", (sid,)).fetchone()
# Operatia exclusa nu mai apare in pending
from app.mapping import pending_unmapped
pend = pending_unmapped(conn, 1)
finally:
conn.close()
assert row["status"] == "needs_data"
assert "excluse de la declarare" in (row["rar_error"] or "")
assert all(e["cod_op_service"] != "OP-XYZ" for e in pend)
def test_maparea_unei_operatii_excluse_reactiveaza_declararea(client):
"""save_mapping peste o regula de excludere reseteaza exclus=0."""
_seed()
from app.db import get_connection
from app.mapping import save_exclusion, load_excluded_ops, load_mapping
conn = get_connection()
try:
save_exclusion(conn, 1, "OP-ITP")
assert "OP-ITP" in load_excluded_ops(conn, 1)
from app.mapping import save_mapping
save_mapping(conn, 1, "OP-ITP", "OE-2", auto_send=False)
assert "OP-ITP" not in load_excluded_ops(conn, 1)
assert load_mapping(conn, 1)["OP-ITP"] == "OE-2"
finally:
conn.close()
def test_ingestie_api_cu_operatie_exclusa_nu_blocheaza(client):
"""POST /v1/prezentari cu operatie deja exclusa -> needs_data cu motiv, nu needs_mapping."""
_seed()
from app.db import get_connection
from app.mapping import save_exclusion
conn = get_connection()
try:
save_exclusion(conn, 1, "OP-ITP")
conn.commit()
finally:
conn.close()
r = client.post("/v1/prezentari", json={
"rar_credentials": {"email": "x@y.ro", "password": "s"},
"prezentari": [{
"vin": "WVWZZZ1KZAW000777",
"nr_inmatriculare": "B077TST",
"data_prestatie": "2026-06-21",
"odometru_final": "60000",
"prestatii": [{"cod_op_service": "OP-ITP", "denumire": "ITP"}],
}],
})
assert r.status_code == 200, r.text
rez = r.json()["results"][0]
assert rez["status"] == "needs_data", rez

View File

@@ -75,7 +75,7 @@ def _body(**over) -> dict:
"prestatii": [{"cod_prestatie": "OE-1"}], "prestatii": [{"cod_prestatie": "OE-1"}],
} }
prez.update(over) prez.update(over)
return {"prezentari": [prez]} return {"prezentari": [prez], "raspuns": "complet"}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from app.idempotency import build_key, canonicalize_row from app.idempotency import build_key, canonicalize_row, normalize_nr_inmatriculare
def _canon(): def _canon():
@@ -26,3 +26,35 @@ def test_key_stabil_pe_env():
assert build_key(1, canon, "prod") == build_key(1, canon, "prod") assert build_key(1, canon, "prod") == build_key(1, canon, "prod")
# None si 1 colapseaza la aceeasi cheie (account_or_default), pe acelasi env # None si 1 colapseaza la aceeasi cheie (account_or_default), pe acelasi env
assert build_key(None, canon, "test") == build_key(1, canon, "test") assert build_key(None, canon, "test") == build_key(1, canon, "test")
def test_normalize_nr_inmatriculare_elimina_spatii_cratime_puncte():
assert normalize_nr_inmatriculare("B 99 XYZ") == "B99XYZ"
assert normalize_nr_inmatriculare("B-99-XYZ") == "B99XYZ"
assert normalize_nr_inmatriculare("B.99.XYZ") == "B99XYZ"
assert normalize_nr_inmatriculare(" b99xyz ") == "B99XYZ"
def test_canonicalize_row_normalizeaza_nr_inmatriculare():
raw = {"nr_inmatriculare": "B 99 XYZ"}
assert canonicalize_row(raw)["nr_inmatriculare"] == "B99XYZ"
def test_build_key_acelasi_pentru_formate_echivalente_nrinm():
""""B 99 XYZ" si "B99XYZ" trebuie sa produca aceeasi cheie de dedup."""
raw_cu_spatii = {
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B 99 XYZ",
"data_prestatie": "2026-01-10", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
raw_fara_spatii = {
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B99XYZ",
"data_prestatie": "2026-01-10", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
canon1 = canonicalize_row(raw_cu_spatii)
canon1["prestatii"] = raw_cu_spatii["prestatii"]
canon2 = canonicalize_row(raw_fara_spatii)
canon2["prestatii"] = raw_fara_spatii["prestatii"]
assert build_key(1, canon1, "test") == build_key(1, canon2, "test")

View File

@@ -46,3 +46,39 @@ def test_eticheta_lunga_ramane_pentru_subtext():
assert isinstance(css_class, str) and css_class, ( assert isinstance(css_class, str) and css_class, (
f"eticheta_stare({status!r}) trebuie sa aiba css_class non-vida la pozitia 3" f"eticheta_stare({status!r}) trebuie sa aiba css_class non-vida la pozitia 3"
) )
def test_eroare_bruta_prefera_cauza_tehnica():
import json
from app.web.labels import eroare_bruta
rar_error = json.dumps({
"cod": "RAR_EROARE_SERVER",
"problema": "RAR a esuat la inregistrarea prezentarii",
"cauza": "ORA-12899: value too large for column PRESTATII.COD",
"fix": "",
"field": None,
"message": "ORA-12899: value too large for column PRESTATII.COD",
})
assert eroare_bruta("error", rar_error) == (
"ORA-12899: value too large for column PRESTATII.COD"
)
def test_eroare_bruta_goala_pe_needs_mapping_si_stari_ok():
from app.web.labels import eroare_bruta
assert eroare_bruta("needs_mapping", '{"unmapped": [{"cod_op_service": "X"}]}') == ""
assert eroare_bruta("sent", "orice") == ""
assert eroare_bruta("queued", None) == ""
def test_eroare_bruta_validare_needs_data():
import json
from app.web.labels import eroare_bruta
rar_error = json.dumps([{"field": "vin", "message": "VIN invalid (17 caractere)"}])
assert eroare_bruta("needs_data", rar_error) == "VIN invalid (17 caractere)"
def test_eroare_bruta_nu_arunca_pe_json_corupt():
from app.web.labels import eroare_bruta
assert eroare_bruta("error", "{corupt") == "{corupt"
assert eroare_bruta("error", None) == ""

View File

@@ -205,7 +205,7 @@ def _body(prestatii, **over):
"prestatii": prestatii, "prestatii": prestatii,
} }
prez.update(over) 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_nomenclator_seed_la_boot(client): def test_nomenclator_seed_la_boot(client):

View File

@@ -100,3 +100,35 @@ def test_normalizare_vin_nrinm_in_payload():
def test_sistem_reparat_default_null(): def test_sistem_reparat_default_null():
assert build_rar_payload(_internal())["sistemReparat"] == "null" assert build_rar_payload(_internal())["sistemReparat"] == "null"
def test_item_exclus_nu_apare_in_payload():
"""Un item adnotat exclus=True nu produce niciodata {codPrestatie: null} la RAR."""
internal = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [
{"cod_prestatie": "OE-1"},
{"cod_prestatie": None, "cod_op_service": "200", "denumire": "Spalare", "exclus": True},
],
}
p = build_rar_payload(internal)
assert p["prestatii"] == [{"codPrestatie": "OE-1", "idPrezentare": None}]
def test_item_fara_cod_prestatie_nu_apare_in_payload():
"""Defense-in-depth: orice item fara cod (chiar neadnotat exclus) e sarit, niciodata null."""
internal = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [
{"cod_prestatie": "OE-1"},
{"cod_prestatie": None, "cod_op_service": "200"},
],
}
p = build_rar_payload(internal)
assert p["prestatii"] == [{"codPrestatie": "OE-1", "idPrezentare": None}]

View File

@@ -123,6 +123,28 @@ def test_cod_rar_gol_cand_nemapat():
assert d3["cod_rar"] == EMPTY assert d3["cod_rar"] == EMPTY
def test_cod_rar_din_toate_prestatiile_cand_prima_e_exclusa():
"""Prima prestatie exclusa (fara cod) nu ascunde codurile de pe itemii urmatori.
Rand importat editat: operatia originala ramane item 0 cu exclus=True si
cod_prestatie null, codurile adaugate sunt itemii 1..N.
"""
d = prezentare_din_payload({
"prestatii": [
{"cod_prestatie": None, "cod_op_service": "OP-ITP",
"denumire": "Inspectie Tehnica Periodica", "exclus": True},
{"cod_prestatie": "OE-2"},
{"cod_prestatie": "OE-4"},
{"cod_prestatie": "OE-8"},
]
})
assert d["cod_rar"] == "OE-2"
assert d["coduri_rar"] == ["OE-2", "OE-4", "OE-8"]
# operatia si op. de service raman derivate din primul item (descrierea originala)
assert d["operatie"] == "Inspectie Tehnica Periodica"
assert d["op_service_cod"] == "OP-ITP"
def test_operatie_ramane_denumire_sau_op(): def test_operatie_ramane_denumire_sau_op():
"""operatie si cod raman neschimbate (compatibilitate cu apelantii existenti).""" """operatie si cod raman neschimbate (compatibilitate cu apelantii existenti)."""
# cu ambele coduri -> operatie=denumire, cod=cod_prestatie (neschimbat) # cu ambele coduri -> operatie=denumire, cod=cod_prestatie (neschimbat)
@@ -212,3 +234,23 @@ def test_fara_operatie_service_cand_lipseste():
d4 = prezentare_din_payload("nu-e-json") d4 = prezentare_din_payload("nu-e-json")
assert d4["op_service_cod"] == "" assert d4["op_service_cod"] == ""
assert d4["op_service_denumire"] == "" assert d4["op_service_denumire"] == ""
def test_coduri_rar_lista_ordonata_dedup():
from app.payload_view import prezentare_din_payload
p = prezentare_din_payload({
"vin": "WVWZZZ1JZXW000111",
"prestatii": [
{"cod_prestatie": "OE-1", "cod_op_service": "A"},
{"cod_prestatie": "oe-2", "cod_op_service": "B"},
{"cod_prestatie": "OE-1", "cod_op_service": "C"}, # duplicat
{"cod_op_service": "D"}, # nemapat, fara cod
],
})
assert p["coduri_rar"] == ["OE-1", "OE-2"]
def test_coduri_rar_gol_fara_prestatii():
from app.payload_view import prezentare_din_payload
assert prezentare_din_payload({"vin": "X"})["coduri_rar"] == []
assert prezentare_din_payload(None)["coduri_rar"] == []

View File

@@ -99,7 +99,7 @@ def _body(**over):
"prestatii": [{"cod_prestatie": "OE-1"}], "prestatii": [{"cod_prestatie": "OE-1"}],
} }
prez.update(over) prez.update(over)
return {"rar_credentials": {"email": "x@y.ro", "password": "HUNTER2SECRET"}, "prezentari": [prez]} return {"rar_credentials": {"email": "x@y.ro", "password": "HUNTER2SECRET"}, "prezentari": [prez], "raspuns": "complet"}
def test_422_does_not_echo_password(env): def test_422_does_not_echo_password(env):

View File

@@ -63,10 +63,28 @@ def test_nrinmatriculare_prea_lung():
def test_nrinmatriculare_caracter_special(): def test_nrinmatriculare_caracter_special():
errors = validate_prezentare(_base(nr_inmatriculare="B-99")) errors = validate_prezentare(_base(nr_inmatriculare="B_99"))
assert "nr_inmatriculare" in _fields(errors) assert "nr_inmatriculare" in _fields(errors)
def test_nrinmatriculare_normalizeaza_spatii():
c = _base(nr_inmatriculare="B 99 XYZ")
assert c["nr_inmatriculare"] == "B99XYZ"
assert validate_prezentare(c) == []
def test_nrinmatriculare_normalizeaza_cratime():
c = _base(nr_inmatriculare="B-99-XYZ")
assert c["nr_inmatriculare"] == "B99XYZ"
assert validate_prezentare(c) == []
def test_nrinmatriculare_normalizeaza_puncte():
c = _base(nr_inmatriculare="B.99.XYZ")
assert c["nr_inmatriculare"] == "B99XYZ"
assert validate_prezentare(c) == []
def test_data_prea_veche(): def test_data_prea_veche():
errors = validate_prezentare(_base(data_prestatie="2024-11-30")) errors = validate_prezentare(_base(data_prestatie="2024-11-30"))
assert "data_prestatie" in _fields(errors) assert "data_prestatie" in _fields(errors)
@@ -135,7 +153,7 @@ def test_b64image_valid_ok():
def test_erori_multiple_cumulate(): def test_erori_multiple_cumulate():
errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X-Y", data_prestatie="2024-01-01")) errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X_Y", data_prestatie="2024-01-01"))
assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors) assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors)
@@ -215,7 +233,7 @@ def test_back_compat_field_message():
"""Fiecare eroare are inca field + message (forma veche).""" """Fiecare eroare are inca field + message (forma veche)."""
errs = validate_prezentare(_base( errs = validate_prezentare(_base(
vin="BAD", vin="BAD",
nr_inmatriculare="X-Y", nr_inmatriculare="X_Y",
data_prestatie="2024-01-01", data_prestatie="2024-01-01",
odometru_final="abc", odometru_final="abc",
prestatii=[], prestatii=[],
@@ -232,7 +250,7 @@ def test_toate_codurile_in_catalog():
cazuri = [ cazuri = [
_base(vin="WVWZZZ1OZAW000123"), # VIN_FORMAT _base(vin="WVWZZZ1OZAW000123"), # VIN_FORMAT
_base(nr_inmatriculare="X-Y"), # NR_INMATRICULARE_FORMAT _base(nr_inmatriculare="X_Y"), # NR_INMATRICULARE_FORMAT
_base(data_prestatie="15-06-2026"), # DATA_FORMAT _base(data_prestatie="15-06-2026"), # DATA_FORMAT
_base(data_prestatie="2024-11-30"), # DATA_PREA_VECHE _base(data_prestatie="2024-11-30"), # DATA_PREA_VECHE
_base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR _base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR

View File

@@ -246,3 +246,34 @@ def test_bulk_scoped_cont(client):
# Randul lui B NEATINS # Randul lui B NEATINS
assert _get_status(sid_b) == "needs_mapping", \ assert _get_status(sid_b) == "needs_mapping", \
"Randul contului B a fost modificat de contul A — INCALCARE SCOPE!" "Randul contului B a fost modificat de contul A — INCALCARE SCOPE!"
def test_bara_bulk_ascunsa_implicit(client):
"""Controalele bulk stau intr-o bara contextuala ascunsa implicit (fara selectie)."""
acct = _create_account_user("bara.hidden@test.com")
_insert_submission(acct)
_login(client, "bara.hidden@test.com")
html = client.get("/_fragments/submissions").text
assert 'id="bulk-bar"' in html, "Bara contextuala bulk lipseste"
m = re.search(r'id="bulk-bar"[^>]*class="([^"]*)"', html) or \
re.search(r'class="([^"]*)"[^>]*id="bulk-bar"', html)
assert m and "bulk-bar" in m.group(1) and "activ" not in m.group(1), (
f"Bara bulk trebuie randata inactiva (ascunsa prin CSS): {m.group(0) if m else 'fara clasa'}"
)
# butonul vechi permanent 'Aplica cod' (fara context) a disparut
assert ">\n Aplica cod\n </button>" not in html
def test_bara_bulk_texte_explicite(client):
"""Bara contine contor, buton explicit 'Aplica acest cod la N randuri',
stergere cu N, 'Deselecteaza' si hint-ul cu ce face actiunea."""
acct = _create_account_user("bara.texte@test.com")
_insert_submission(acct)
_login(client, "bara.texte@test.com")
html = client.get("/_fragments/submissions").text
assert "selectate" in html
assert "Aplica acest cod la" in html
assert "Deselecteaza" in html
assert "operatiilor fara cod" in html, "Hint-ul explicativ lipseste"

745
tests/test_web_chips.py Normal file
View File

@@ -0,0 +1,745 @@
"""US-001: stare chips extinsa (JSON versionat, D4) + actiuni add/remove unificate.
Contract nou /form-chips:
- Stare curenta: UN SINGUR hidden `chips_state` = JSON {"v":1,"items":[{"cod","op","den","exclus"}]}.
- chips_action=add + chips_pick (codul ales) + chips_target_index optional:
cu tinta -> leaga codul de operatia de la acel index; fara tinta -> chip liber.
chips_pick=__NEDECLARAT__ + tinta valida -> item exclus=1, fara cod_prestatie.
- chips_action=remove + chips_remove_index: chip legat de operatie -> revine warning
(cod_prestatie="", exclus=0); chip liber -> iese din lista.
- Actiunile vechi (add_flat/add_extra/remove_flat/chips_add_cod_{i}/chips_add_cod_flat
ca actiuni) sunt ELIMINATE.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "chips.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def _create_account_user(email: str, password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service", active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token nu gasit in login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _csrf(client) -> str:
resp = client.get("/?tab=coada")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token nu gasit in dashboard"
return m.group(1)
def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, denumire),
)
conn.commit()
finally:
conn.close()
def _state(items: list[dict]) -> str:
"""Construieste JSON-ul hidden chips_state din itemi scurti {cod,op,den,exclus}."""
return json.dumps({"v": 1, "items": items}, ensure_ascii=False)
def _login_seed(client, email: str) -> None:
_create_account_user(email)
_login(client, email)
# --------------------------------------------------------------------------- #
# add cu tinta -> leaga codul de operatia respectiva #
# --------------------------------------------------------------------------- #
def test_add_cu_tinta_leaga_de_operatie(client):
_login_seed(client, "add.tinta@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
state = _state([
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "add",
"chips_target_index": "0",
"chips_pick": "OE-1",
})
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "OE-1" in html
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m, f"hidden chips_state lipsa din raspuns: {html[:400]}"
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["v"] == 1
items = payload["items"]
assert len(items) == 1
assert items[0]["cod"] == "OE-1"
assert items[0]["op"] == "Op-A"
assert items[0]["exclus"] == 0
# --------------------------------------------------------------------------- #
# add fara tinta -> chip liber #
# --------------------------------------------------------------------------- #
def test_add_fara_tinta_adauga_cod_liber(client):
_login_seed(client, "add.liber@test.com")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": _state([]),
"chips_action": "add",
"chips_pick": "FRN1",
})
assert resp.status_code == 200, resp.text[:400]
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
items = payload["items"]
assert len(items) == 1
assert items[0]["cod"] == "FRN1"
assert items[0]["op"] == ""
# --------------------------------------------------------------------------- #
# add __NEDECLARAT__ cu tinta -> marcheaza exclus #
# --------------------------------------------------------------------------- #
def test_add_nedeclarat_marcheaza_exclus(client):
_login_seed(client, "add.ned@test.com")
csrf = _csrf(client)
state = _state([
{"cod": "", "op": "OP-ITP", "den": "Inspectie tehnica", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "add",
"chips_target_index": "0",
"chips_pick": "__NEDECLARAT__",
})
assert resp.status_code == 200, resp.text[:400]
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
items = payload["items"]
assert len(items) == 1
assert items[0]["exclus"] == 1
assert items[0]["cod"] == "", f"item exclus nu trebuie sa aiba cod_prestatie: {items[0]}"
assert items[0]["op"] == "OP-ITP"
assert "nu se va declara" in resp.text.lower()
def test_add_nedeclarat_fara_tinta_da_eroare(client):
"""Sentinelul __NEDECLARAT__ fara tinta valida -> chips_error explicit, fara modificare."""
_login_seed(client, "add.ned.notinta@test.com")
csrf = _csrf(client)
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": _state([]),
"chips_action": "add",
"chips_pick": "__NEDECLARAT__",
# fara chips_target_index
})
assert resp.status_code == 200, resp.text[:400]
assert "chips-extra-error" in resp.text, (
f"Eroare vizibila lipsa pentru NEDECLARAT fara tinta: {resp.text[:400]}"
)
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"] == [], "NEDECLARAT fara tinta nu trebuie sa modifice starea"
def test_add_nedeclarat_tinta_deja_mapata_da_eroare(client):
"""Tinta trebuie sa fie o operatie NEMAPATA — index spre operatie deja rezolvata -> eroare."""
_login_seed(client, "add.ned.mapata@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
state = _state([
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "add",
"chips_target_index": "0",
"chips_pick": "__NEDECLARAT__",
})
assert resp.status_code == 200
assert "chips-extra-error" in resp.text
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"][0]["cod"] == "OE-1", "Tinta invalida nu trebuie sa strice starea existenta"
# --------------------------------------------------------------------------- #
# remove -> readuce operatia nemapata (warning) #
# --------------------------------------------------------------------------- #
def test_remove_cod_readuce_operatia_nemapata(client):
_login_seed(client, "remove.readuce@test.com")
csrf = _csrf(client)
state = _state([
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "remove",
"chips_remove_index": "0",
})
assert resp.status_code == 200, resp.text[:400]
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
items = payload["items"]
assert len(items) == 1, "Operatia trebuie sa RAMANA (doar codul dispare)"
assert items[0]["cod"] == ""
assert items[0]["op"] == "Op-A"
assert items[0]["exclus"] == 0
def test_remove_pe_nedeclarat_readuce_warning(client):
"""x pe un chip Nedeclarat readuce operatia la warning (nu se sterge operatia)."""
_login_seed(client, "remove.nedeclarat@test.com")
csrf = _csrf(client)
state = _state([
{"cod": "", "op": "OP-ITP", "den": "Inspectie", "exclus": 1},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "remove",
"chips_remove_index": "0",
})
assert resp.status_code == 200
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
payload = json.loads(m.group(1).replace("&#34;", '"'))
items = payload["items"]
assert len(items) == 1
assert items[0]["exclus"] == 0
assert items[0]["cod"] == ""
assert items[0]["op"] == "OP-ITP"
def test_remove_cod_liber_il_sterge_complet(client):
"""Chip liber (fara operatie sursa) e eliminat integral din lista la remove."""
_login_seed(client, "remove.liber@test.com")
csrf = _csrf(client)
state = _state([
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "remove",
"chips_remove_index": "0",
})
assert resp.status_code == 200
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"] == []
# --------------------------------------------------------------------------- #
# dedup (operatie, cod) #
# --------------------------------------------------------------------------- #
def test_dedup_operatie_cod(client):
"""Acelasi cod liber nu se adauga de doua ori (pereche op='',cod deja existenta)."""
_login_seed(client, "dedup.op.cod@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
state = _state([
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "add",
"chips_pick": "OE-1",
})
assert resp.status_code == 200
assert "chips-extra-error" in resp.text or "deja in lista" in resp.text
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert len(payload["items"]) == 1, f"Dedup a esuat, itemi: {payload['items']}"
# --------------------------------------------------------------------------- #
# actiuni vechi eliminate #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("actiune", ["add_flat", "add_extra", "remove_flat"])
def test_actiuni_vechi_eliminate_sunt_no_op(client, actiune):
"""Actiunile eliminate nu mai au ramura dedicata — cad prin fara sa schimbe starea."""
_login_seed(client, f"vechi.{actiune}@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
state = _state([{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": actiune,
"chips_add_cod_flat": "FRN1",
"chips_remove_cod": "OE-1",
})
assert resp.status_code == 200
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"] == [{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}]
# --------------------------------------------------------------------------- #
# chips_state prezent mereu, inclusiv gol #
# --------------------------------------------------------------------------- #
def test_chips_state_gol_ramane_prezent_in_raspuns(client):
_login_seed(client, "state.gol@test.com")
csrf = _csrf(client)
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": _state([]),
"chips_action": "",
})
assert resp.status_code == 200
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m, "hidden chips_state trebuie sa fie mereu prezent, chiar cu lista goala"
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload == {"v": 1, "items": []}
# --------------------------------------------------------------------------- #
# JSON invalid -> eroare vizibila, nu stare goala silentioasa #
# --------------------------------------------------------------------------- #
def test_chips_state_json_invalid_da_eroare_vizibila(client):
_login_seed(client, "state.corupt@test.com")
csrf = _csrf(client)
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": "{not valid json",
"chips_action": "",
})
assert resp.status_code == 200
assert "chips-extra-error" in resp.text or "invalid" in resp.text.lower(), (
f"JSON corupt trebuie sa dea eroare vizibila: {resp.text[:400]}"
)
# --------------------------------------------------------------------------- #
# compat tranzitorie: chips_state lipsa -> fallback pe listele vechi #
# --------------------------------------------------------------------------- #
def test_compat_fallback_pe_liste_vechi_cand_chips_state_lipseste(client):
_login_seed(client, "compat.fallback@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
resp = client.post("/form-chips", data={
"csrf_token": csrf,
# FARA chips_state -> fallback pe listele vechi
"cod_prestatie": [""],
"chip_op_service": ["Op-A"],
"chip_denumire": ["Schimb ulei motor"],
"chips_action": "add",
"chips_target_index": "0",
"chips_pick": "OE-1",
})
assert resp.status_code == 200, resp.text[:400]
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"][0]["cod"] == "OE-1"
assert payload["items"][0]["exclus"] == 0
# =========================================================================== #
# US-002: template unificat -- un container .chips, un select, fara op-row #
# =========================================================================== #
def _post_chips(client, csrf, items, **extra):
data = {
"csrf_token": csrf,
"chips_state": _state(items),
"chips_action": "",
}
data.update(extra)
return client.post("/form-chips", data=data)
def test_render_un_singur_container_chips(client):
"""UN singur container `.chips` -- indiferent de mixul de tipuri de chip."""
_login_seed(client, "un.container@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
{"cod": "", "op": "Op-B", "den": "Vulcanizare", "exclus": 0},
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
{"cod": "", "op": "Op-C", "den": "ITP anual", "exclus": 1},
]
_seed_cod("FRN1", "Sistem de franare")
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert html.count('class="chips"') == 1, (
f"Trebuie sa existe UN SINGUR container .chips, gasit {html.count('class=\"chips\"')}: {html[:800]}"
)
def test_render_fara_op_row_si_fara_butoane_adauga(client):
"""Fara .op-row, fara butoane Adauga/+, fara actiunile vechi in markup."""
_login_seed(client, "fara.oprow@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
{"cod": "", "op": "Op-B", "den": "Vulcanizare", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "op-row" not in html
assert "Adauga" not in html
assert "add_flat" not in html
assert "chips_add_cod_" not in html
def test_chip_warning_pentru_operatie_nemapata(client):
"""Operatie nemapata -> chip warning `warn OP-X . fara cod` cu title complet."""
_login_seed(client, "chip.warning@test.com")
csrf = _csrf(client)
items = [
{"cod": "", "op": "OP-ITP", "den": "Inspectie tehnica periodica", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "OP-ITP" in html
assert "fara cod" in html
assert 'title="OP-ITP' in html
def test_select_are_hx_disabled_elt_this(client):
"""Selectul unic are hx-disabled-elt='this' EXPLICIT (capcana 5.22 -- nu mostenit de la form)."""
_login_seed(client, "select.disabled@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
resp = _post_chips(client, csrf, [])
assert resp.status_code == 200, resp.text[:400]
html = resp.text
m = re.search(r'<select[^>]*name="chips_pick"[^>]*>', html)
assert m, f"selectul chips_pick lipseste: {html[:600]}"
select_tag = m.group(0)
assert 'id="chips-picker"' in select_tag
assert 'hx-trigger="change"' in select_tag
assert 'hx-post="/form-chips"' in select_tag
assert 'hx-target="#chips-section"' in select_tag
assert 'hx-swap="outerHTML"' in select_tag
assert 'hx-include="closest form"' in select_tag
assert 'hx-disabled-elt="this"' in select_tag, (
f"hx-disabled-elt='this' lipseste de pe select (capcana 5.22): {select_tag}"
)
def test_mod_plat_fara_operatii_select_doar_coduri_libere(client):
"""Mod plat (zero operatii din import): doar chips libere + select placeholder generic."""
_login_seed(client, "mod.plat@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "" not in html, "Mod plat nu trebuie sa aiba niciun chip warning"
m = re.search(r'<select[^>]*name="chips_pick"[^>]*>\s*<option value="">([^<]*)</option>', html)
assert m, f"select cu optiune placeholder lipseste: {html[:600]}"
assert "adauga cod RAR" in m.group(1)
def test_has_r_odo_pastrat_dupa_actiuni(client):
"""has_r_odo / data-has-r-odo + chip-warn pe R-ODO/I-ODO raman neschimbate."""
_login_seed(client, "r.odo@test.com")
_seed_cod("R-ODO", "Odometru initial")
csrf = _csrf(client)
items = [
{"cod": "R-ODO", "op": "", "den": "", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert 'data-has-r-odo="true"' in html
assert "chip-warn" in html
assert "R-ODO" in html
def test_chip_nedeclarat_are_x_functional(client):
"""Item exclus=1 -> chip Nedeclarat (line-through) cu buton de sters functional (remove -> warning)."""
_login_seed(client, "nedeclarat.x@test.com")
csrf = _csrf(client)
items = [
{"cod": "", "op": "OP-ITP", "den": "Inspectie", "exclus": 1},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "Nedeclarat" in html
assert 'hx-vals=\'{"chips_action":"remove","chips_remove_index":0}\'' in html
def test_chips_submission_id_ecouat(client):
"""chips_submission_id trimis in form e ecouat ca hidden in raspunsul /form-chips."""
_login_seed(client, "sub.id.ecou@test.com")
csrf = _csrf(client)
resp = _post_chips(client, csrf, [], chips_submission_id="4242")
assert resp.status_code == 200, resp.text[:400]
assert re.search(r'name="chips_submission_id"\s+value="4242"', resp.text), (
f"chips_submission_id nu a fost ecouat: {resp.text[:600]}"
)
# =========================================================================== #
# US-003: sugestii fuzzy/k-NN + optiunea "Nu se declara la RAR" in select #
# =========================================================================== #
def _extrage_select(html: str) -> str:
"""Extrage markup-ul complet al <select name="chips_pick">...</select>."""
m = re.search(r'<select[^>]*name="chips_pick".*?</select>', html, re.DOTALL)
assert m, f"select chips_pick lipseste din raspuns: {html[:600]}"
return m.group(0)
def test_select_are_optgroup_sugestii_pt_tinta(client):
"""Cu tinta activa (operatie nemapata): optgroup 'Sugestii' + __NEDECLARAT__ + nomenclator."""
_login_seed(client, "sugestii.tinta@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
items = [
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
select_html = _extrage_select(resp.text)
assert '<optgroup label="Sugestii">' in select_html, (
f"optgroup Sugestii lipseste cu tinta activa: {select_html[:600]}"
)
assert "OE-1" in select_html.split('<optgroup label="Sugestii">')[1].split("</optgroup>")[0], (
"sugestia fuzzy pentru 'Schimb ulei motor' trebuie sa contina OE-1"
)
assert "__NEDECLARAT__" in select_html
assert "Nu se declara la RAR" in select_html
# Nomenclatorul complet ramane dupa Sugestii/NEDECLARAT.
assert "FRN1" in select_html
def test_select_are_nedeclarat_doar_cu_tinta(client):
"""Optiunea __NEDECLARAT__ apare DOAR cand exista o tinta (operatie nemapata) activa."""
_login_seed(client, "nedeclarat.doar.tinta@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# Cu tinta (operatie nemapata) -> NEDECLARAT prezent.
resp_cu_tinta = _post_chips(client, csrf, [
{"cod": "", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
])
assert "__NEDECLARAT__" in _extrage_select(resp_cu_tinta.text)
# Fara tinta (doar cod liber, nicio operatie nemapata) -> NEDECLARAT absent.
resp_fara_tinta = _post_chips(client, csrf, [
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
])
assert "__NEDECLARAT__" not in _extrage_select(resp_fara_tinta.text)
def test_select_fara_tinta_fara_sugestii(client):
"""Fara tinta: selectul listeaza DOAR nomenclatorul (fara optgroup Sugestii, fara NEDECLARAT)."""
_login_seed(client, "fara.tinta.fara.sugestii@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
select_html = _extrage_select(resp.text)
assert "<optgroup" not in select_html
assert "__NEDECLARAT__" not in select_html
assert "OE-1" in select_html
def test_sugestii_indisponibile_degradare_fara_optgroup(client, monkeypatch):
"""Motor de sugestii care arunca exceptie -> select DOAR cu nomenclatorul (fara optgroup),
fara eroare 500 si fara blocare (degradare gratioasa, GAP inchis din PRD 5.23)."""
_login_seed(client, "sugestii.indisponibile@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
from app.web import routes as _routes
def _boom(*args, **kwargs):
raise RuntimeError("motor de sugestii indisponibil (simulat)")
monkeypatch.setattr(_routes, "suggest_codes", _boom)
monkeypatch.setattr(_routes, "enrich_suggestions", _boom)
items = [
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
select_html = _extrage_select(resp.text)
assert "<optgroup" not in select_html, (
f"optgroup nu trebuie sa apara cand motorul de sugestii arunca exceptie: {select_html[:600]}"
)
assert "OE-1" in select_html, "nomenclatorul complet trebuie sa ramana disponibil"
# =========================================================================== #
# FIX: tinta implicita + sugestii la RANDAREA INITIALA a modalului (fara #
# niciun round-trip /form-chips) — invariant PRD 5.23 §3.1. #
# =========================================================================== #
def _insert_submission(acct: int, *, status: str, payload: dict) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def test_randare_initiala_are_tinta_si_optgroup(client):
"""Deschiderea INITIALA a modalului de detaliu (needs_mapping, operatie nemapata),
FARA niciun /form-chips, trebuie sa aiba deja is-target pe chip-ul warning,
placeholder-ul D2 "Cod pentru OP-X — alege" si optgroup-ul Sugestii + NEDECLARAT."""
acct = _login_seed_ret(client, "randare.initiala@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare")
op = "Schimb ulei motor"
sid = _insert_submission(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0SC001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": op, "denumire": op}],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "is-target" in html, (
f"chip-ul warning al primei operatii nemapate trebuie sa fie tinta la deschiderea initiala: {html[:2000]}"
)
assert f"Cod pentru {op} — alege" in html, (
f"placeholder-ul D2 trebuie sa numeasca operatia tinta la deschiderea initiala: {html[:2000]}"
)
select_html = _extrage_select(html)
assert '<optgroup label="Sugestii">' in select_html, (
f"optgroup Sugestii trebuie sa apara la deschiderea initiala (fara /form-chips): {select_html[:600]}"
)
assert "OE-1" in select_html.split('<optgroup label="Sugestii">')[1].split("</optgroup>")[0], (
"sugestia fuzzy pentru 'Schimb ulei motor' trebuie sa contina OE-1 chiar la randarea initiala"
)
assert "__NEDECLARAT__" in select_html
def _login_seed_ret(client, email: str) -> int:
"""Ca `_login_seed`, dar intoarce account_id (necesar pentru inserarea directa a submission-ului)."""
acct = _create_account_user(email)
_login(client, email)
return acct

View File

@@ -535,8 +535,8 @@ def test_picker_flat_arata_cod_si_denumire(client):
def test_adauga_cod_extra_in_mod_operatii(client): def test_adauga_cod_extra_in_mod_operatii(client):
"""In mod operatii, actiunea add_extra adauga un cod RAR liber.""" """In mod operatii, actiunea unificata 'add' fara tinta adauga un cod RAR liber."""
acct = _create_account_user("add.extra.ops@test.com") _create_account_user("add.extra.ops@test.com")
_login(client, "add.extra.ops@test.com") _login(client, "add.extra.ops@test.com")
_seed_cod("OE-1", "Schimb ulei motor") _seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare") _seed_cod("FRN1", "Sistem de franare")
@@ -547,11 +547,11 @@ def test_adauga_cod_extra_in_mod_operatii(client):
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1"], # chip existent (op mapata) "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["SchimbUlei"], {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0},
"chip_denumire": ["Schimb ulei motor"], ]}),
"chips_action": "add_extra", "chips_action": "add",
"chips_add_cod_flat": "FRN1", # codul extra de adaugat "chips_pick": "FRN1", # codul extra de adaugat, fara tinta -> chip liber
}, },
) )
assert resp.status_code == 200, resp.text[:300] assert resp.status_code == 200, resp.text[:300]
@@ -601,32 +601,36 @@ def test_extra_cod_persistat_la_salvare(client):
def test_extra_cod_validat_nomenclator(client): def test_extra_cod_validat_nomenclator(client):
"""add_extra respinge cod necunoscut in nomenclator (invariant ORA-12899).""" """'add' fara tinta respinge cod necunoscut in nomenclator (invariant ORA-12899)."""
acct = _create_account_user("extra.valid@test.com") _create_account_user("extra.valid@test.com")
_login(client, "extra.valid@test.com") _login(client, "extra.valid@test.com")
_seed_cod("OE-1", "Schimb ulei") _seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client) csrf = _csrf(client)
# add_extra cu cod INVALID (XX-99 nu e in nomenclator) # add fara tinta cu cod INVALID (XX-99 nu e in nomenclator)
resp = client.post( resp = client.post(
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1"], "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["SchimbUlei"], {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
"chip_denumire": ["Schimb ulei"], ]}),
"chips_action": "add_extra", "chips_action": "add",
"chips_add_cod_flat": "XX-99", # cod necunoscut "chips_pick": "XX-99", # cod necunoscut
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# XX-99 NU trebuie sa apara ca chip valid (hidden input cu valoarea XX-99) # XX-99 NU trebuie sa apara in starea persistata (chips_state)
import re as _re import re as _re
hidden_xx99 = _re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="XX-99"', html) m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert hidden_xx99 is None, ( assert m
f"Codul invalid XX-99 a fost adaugat ca chip! HTML: {html[:500]}" payload = json.loads(m.group(1).replace("&#34;", '"'))
coduri = [it["cod"] for it in payload["items"]]
assert "XX-99" not in coduri, (
f"Codul invalid XX-99 a fost adaugat ca chip! items={payload['items']}"
) )
assert "chips-extra-error" in html
def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client): def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client):
@@ -703,12 +707,12 @@ def test_salvare_fara_chip_explicit_nu_e_no_op(client):
def test_picker_by_index_op2_nu_op1(client): def test_picker_by_index_op2_nu_op1(client):
"""Codul ales pe picker-ul op#2 aterizeaza pe op#2, NU pe op#1. """Codul ales pentru op#2 aterizeaza pe op#2, NU pe op#1.
Verifica alinierea by-index in modul operatii: chips_add_op_index=1 + chips_add_cod_1 Verifica alinierea by-index in modul operatii: chips_target_index=1 +
actualizeaza chips[1] (op#2), nu chips[0] (op#1). chips_pick actualizeaza chips[1] (op#2), nu chips[0] (op#1).
""" """
acct = _create_account_user("byindex.op2@test.com") _create_account_user("byindex.op2@test.com")
_login(client, "byindex.op2@test.com") _login(client, "byindex.op2@test.com")
_seed_cod("OE-1", "Schimb ulei") _seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Sistem de franare") _seed_cod("FRN1", "Sistem de franare")
@@ -719,27 +723,25 @@ def test_picker_by_index_op2_nu_op1(client):
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1", ""], # idx 0=OE-1 (mapata), idx 1="" (nemapata) "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["Op-A", "Op-B"], {"cod": "OE-1", "op": "Op-A", "den": "Prima", "exclus": 0},
"chip_denumire": ["Prima", "A doua"], {"cod": "", "op": "Op-B", "den": "A doua", "exclus": 0},
]}),
"chips_action": "add", "chips_action": "add",
"chips_add_op_index": "1", # adauga pe op#2 (idx=1) "chips_target_index": "1", # adauga pe op#2 (idx=1)
"chips_add_cod_1": "FRN1", # picker-ul op#2 contine FRN1 "chips_pick": "FRN1",
}, },
) )
assert resp.status_code == 200, resp.text[:300] assert resp.status_code == 200, resp.text[:300]
html = resp.text html = resp.text
import re as _re import re as _re
hidden_vals = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="([^"]*)"', html) m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert "OE-1" in hidden_vals, f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {hidden_vals}" assert m
assert "FRN1" in hidden_vals, f"FRN1 nu a aterizat pe op#2: {hidden_vals}" payload = json.loads(m.group(1).replace("&#34;", '"'))
# By-index: OE-1 trebuie sa fie INAINTE de FRN1 (idx 0 < idx 1) items = payload["items"]
oe1_pos = hidden_vals.index("OE-1") if "OE-1" in hidden_vals else -1 assert items[0]["cod"] == "OE-1", f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {items}"
frn1_pos = hidden_vals.index("FRN1") if "FRN1" in hidden_vals else -1 assert items[1]["cod"] == "FRN1", f"FRN1 nu a aterizat pe op#2 (idx=1): {items}"
assert oe1_pos < frn1_pos, (
f"FRN1 (op#2, idx=1) trebuie dupa OE-1 (op#1, idx=0) by-index: {hidden_vals}"
)
def test_empty_state_picker_nomenclator_gol(client): def test_empty_state_picker_nomenclator_gol(client):
@@ -774,28 +776,152 @@ def test_empty_state_picker_nomenclator_gol(client):
def test_add_extra_semnal_vizibil_cod_invalid(client): def test_add_extra_semnal_vizibil_cod_invalid(client):
"""add_extra cu cod invalid da semnal vizibil (nu esua silentios). """'add' fara tinta cu cod invalid da semnal vizibil (nu esua silentios).
div.chips-extra-error vizibil cand codul e invalid sau selectul e gol. div.chips-extra-error vizibil cand codul e invalid sau selectul e gol.
""" """
acct = _create_account_user("extra.err.signal@test.com") _create_account_user("extra.err.signal@test.com")
_login(client, "extra.err.signal@test.com") _login(client, "extra.err.signal@test.com")
_seed_cod("OE-1", "Schimb ulei") _seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client) csrf = _csrf(client)
# add_extra cu cod necunoscut in nomenclator # add fara tinta cu cod necunoscut in nomenclator
resp = client.post( resp = client.post(
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1"], "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["SchimbUlei"], {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
"chip_denumire": ["Schimb ulei"], ]}),
"chips_action": "add_extra", "chips_action": "add",
"chips_add_cod_flat": "XX-99", # cod inexistent "chips_pick": "XX-99", # cod inexistent
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "chips-extra-error" in resp.text, ( assert "chips-extra-error" in resp.text, (
f"Semnalul 'chips-extra-error' lipsa pentru cod invalid: {resp.text[:300]}" f"Semnalul 'chips-extra-error' lipsa pentru cod invalid: {resp.text[:300]}"
) )
def test_add_per_op_cod_invalid_afiseaza_mesaj(client):
"""Actiunea 'add' (picker per operatie, cu tinta) cu cod invalid da semnal vizibil."""
_create_account_user("add.op.err@test.com")
_login(client, "add.op.err@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"chips_state": json.dumps({"v": 1, "items": [
{"cod": "", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
]}),
"chips_action": "add",
"chips_target_index": "0",
"chips_pick": "XX-99", # cod inexistent
},
)
assert resp.status_code == 200
assert "chips-extra-error" in resp.text, (
f"Semnal de eroare lipsa la add cu cod invalid: {resp.text[:300]}"
)
def test_add_flat_cod_invalid_afiseaza_mesaj(client):
"""Actiunea 'add_flat' cu cod invalid da semnal vizibil (nu no-op silentios)."""
_create_account_user("add.flat.err@test.com")
_login(client, "add.flat.err@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}),
"chips_action": "add",
"chips_pick": "XX-99",
},
)
assert resp.status_code == 200
assert "chips-extra-error" in resp.text, (
f"Semnal de eroare lipsa la add cu cod invalid: {resp.text[:300]}"
)
def test_add_flat_duplicat_afiseaza_mesaj(client):
"""'add' fara tinta cu un cod deja prezent NU dubleaza chip-ul si afiseaza mesaj."""
_create_account_user("add.flat.dup@test.com")
_login(client, "add.flat.dup@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}),
"chips_action": "add",
"chips_pick": "OE-1", # deja in lista
},
)
assert resp.status_code == 200
import re as _re
m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert len(payload["items"]) == 1, f"Chip OE-1 duplicat: {payload['items']}"
assert "deja in lista" in resp.text, (
f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}"
)
def test_add_extra_duplicat_afiseaza_mesaj(client):
"""'add' fara tinta cu chip liber identic existent afiseaza mesaj (nu no-op)."""
_create_account_user("add.extra.dup@test.com")
_login(client, "add.extra.dup@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Franare")
csrf = _csrf(client)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"chips_state": json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
]}),
"chips_action": "add",
"chips_pick": "FRN1", # chip liber identic deja existent
},
)
assert resp.status_code == 200
assert "deja in lista" in resp.text, (
f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}"
)
def test_add_reusit_marcheaza_chip_nou(client):
"""Adaugare reusita: chip-ul nou e evidentiat (chip-nou) + mesaj de confirmare."""
_create_account_user("add.ok.nou@test.com")
_login(client, "add.ok.nou@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Franare")
csrf = _csrf(client)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"chips_state": json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
]}),
"chips_action": "add",
"chips_pick": "FRN1",
},
)
assert resp.status_code == 200
assert "chip-nou" in resp.text, f"Chip nou neevidentiat: {resp.text[:300]}"
assert "adaugat" in resp.text, f"Mesaj de confirmare lipsa: {resp.text[:300]}"

View File

@@ -125,7 +125,8 @@ def client(monkeypatch):
def test_editabil_arata_select_cod_rar(client): def test_editabil_arata_select_cod_rar(client):
"""needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='cod_prestatie'>.""" """needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='chips_pick'>
(picker-ul unificat de cod RAR; starea chips traieste in hidden name='chips_state', PRD 5.23)."""
acct = _create_account_user("sel1@test.com") acct = _create_account_user("sel1@test.com")
_ins_nomenclator("OE-1", "OE-2") _ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="needs_data") sid = _ins(acct, status="needs_data")
@@ -133,8 +134,11 @@ def test_editabil_arata_select_cod_rar(client):
html = _detaliu(client, sid) html = _detaliu(client, sid)
assert 'name="cod_prestatie"' in html, ( assert 'name="chips_pick"' in html, (
"Formularul de detaliu needs_data trebuie sa contina un select cu name='cod_prestatie'" "Formularul de detaliu needs_data trebuie sa contina un select cu name='chips_pick'"
)
assert 'name="chips_state"' in html, (
"Starea chips trebuie persistata intr-un hidden name='chips_state'"
) )
assert "<select" in html, ( assert "<select" in html, (
"Elementul <select> trebuie sa apara in detaliu pentru starea needs_data" "Elementul <select> trebuie sa apara in detaliu pentru starea needs_data"

View File

@@ -0,0 +1,421 @@
"""US-004: persistarea `exclus` prin salvare (import + Trimiteri), round-trip complet.
Acopera:
- resolve_prestatii: item-exclus EXPLICIT supravietuieste re-rezolvarii (treapta noua
de precedenta) FARA sa strice restul precedentei (IRON RULE, regresiune-cheie).
- Import (web_editeaza_rand / apply_row_override): `exclus` persistat in override_json,
chip Nedeclarat reapare la redeschiderea modalului, x il readuce la warning.
- Rand cu toate operatiile excluse -> stare preview 'excluded' ("Nedeclarat").
- Trimiteri (post_corectie_trimitere): acelasi round-trip pe chips_state; toate
excluse -> needs_data cu motiv explicit.
- Sentinelul __NEDECLARAT__ NU ajunge in payload daca apare direct ca cod_prestatie
in starea hidden (nu doar ales din select), pe AMBELE cai.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Test unitar (fara client): precedenta resolve_prestatii #
# --------------------------------------------------------------------------- #
def test_exclus_supravietuieste_resolve_chiar_cu_mapare_pe_cont():
"""IRON RULE: item cu `exclus=1` explicit pe item RAMANE exclus dupa resolve_prestatii,
chiar daca operatia are (a) o mapare exacta pe cont SAU (b) un cod_prestatie deja
valid pe rand. Regresiune-cheie: restul precedentei (cod valid > exclus de cont >
mapare exacta > reguli text > nemapat) trebuie sa ramana IDENTICA pentru itemii
FARA `exclus` explicit — altfel stricam maparile existente.
"""
from app.mapping import resolve_prestatii
valid_codes = {"OE-1", "IG-1", "FRN1"}
mapping = {"Schimb-Ulei": "OE-1"} # mapare exacta pe cont pentru Schimb-Ulei
excluded_ops_cont = {"Filtru-Aer"} # regula de excludere PE CONT (alt mecanism)
prestatii = [
# (a) exclus explicit + mapare exacta pe cont exista -> exclus CASTIGA
{"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor", "exclus": 1},
# (b) exclus explicit + cod_prestatie deja valid pe rand -> exclus CASTIGA
{"cod_op_service": "Frana", "denumire": "Reparatie frana", "cod_prestatie": "FRN1", "exclus": 1},
# regresiune: FARA exclus explicit, mapare exacta trebuie sa functioneze ca inainte
{"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor 2"},
# regresiune: FARA exclus explicit, regula de excludere PE CONT trebuie sa functioneze
{"cod_op_service": "Filtru-Aer", "denumire": "Filtru aer"},
# regresiune: cod deja valid pe rand, FARA exclus -> pastrat ca atare (cod_valid)
{"cod_op_service": "", "denumire": "", "cod_prestatie": "IG-1"},
# regresiune: nemapat ramane nemapat
{"cod_op_service": "Necunoscut-Op", "denumire": "Operatie necunoscuta"},
]
resolved, unmapped = resolve_prestatii(
prestatii, mapping, valid_codes=valid_codes, text_rules=None, excluded_ops=excluded_ops_cont,
)
item_a = resolved[0]
assert item_a.get("exclus") is True, f"exclus explicit trebuia sa supravietuiasca: {item_a}"
assert not item_a.get("cod_prestatie"), f"item exclus explicit nu trebuie sa aiba cod_prestatie: {item_a}"
item_b = resolved[1]
assert item_b.get("exclus") is True, f"exclus explicit trebuia sa domine cod_prestatie valid: {item_b}"
assert not item_b.get("cod_prestatie"), f"cod_prestatie valid trebuia sters de exclus explicit: {item_b}"
# Regresiune: mapare exacta pe cont NESCHIMBATA pentru itemul fara exclus
item_c = resolved[2]
assert item_c.get("cod_prestatie") == "OE-1", f"mapare exacta pe cont stricata: {item_c}"
assert not item_c.get("exclus"), f"item fara exclus explicit nu trebuie marcat exclus: {item_c}"
# Regresiune: regula de excludere PE CONT (mecanism vechi) NESCHIMBATA
item_d = resolved[3]
assert item_d.get("exclus") is True, f"regula de excludere pe cont stricata: {item_d}"
assert not item_d.get("cod_prestatie")
# Regresiune: cod deja valid pastrat ca atare
item_e = resolved[4]
assert item_e.get("cod_prestatie") == "IG-1", f"cod valid pastrat stricat: {item_e}"
# Regresiune: nemapat ramane nemapat
assert len(unmapped) == 1
assert unmapped[0]["cod_op_service"] == "Necunoscut-Op"
def test_precedenta_neschimbata_pentru_itemi_fara_exclus():
"""Regresiune suplimentara: fara `exclus` explicit pe niciun item, comportamentul
lui resolve_prestatii e IDENTIC cu cel dinaintea treptei noi (cod valid > exclus
de cont > mapare exacta > reguli text > nemapat).
"""
from app.mapping import resolve_prestatii
valid_codes = {"OE-1"}
mapping = {"Op-Mapata": "OE-1"}
text_rules = [{"pattern": "frana", "cod_prestatie": "OE-1", "auto_send": 1, "priority": 1}]
excluded_ops = {"Op-Exclusa"}
prestatii = [
{"cod_op_service": "Op-Mapata", "denumire": "Operatie mapata"},
{"cod_op_service": "Op-Exclusa", "denumire": "Operatie exclusa"},
{"cod_op_service": "Op-Text", "denumire": "Reparatie frana fata"},
{"cod_op_service": "Op-Nemapat", "denumire": "Chestie noua"},
]
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
assert resolved[0]["cod_prestatie"] == "OE-1"
assert not resolved[0].get("exclus")
assert resolved[1]["exclus"] is True
assert not resolved[1].get("cod_prestatie")
assert resolved[2]["cod_prestatie"] == "OE-1" # via regula text
assert resolved[2].get("cod_sursa")
assert len(unmapped) == 1
assert unmapped[0]["cod_op_service"] == "Op-Nemapat"
# --------------------------------------------------------------------------- #
# Fixtures + helpere HTTP (import + Trimiteri, fara auth — cont 1 implicit) #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "editare_rand.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, denumire),
)
conn.commit()
finally:
conn.close()
def _csv_bytes(rows: list[dict]) -> bytes:
import csv as _csv
import io
buf = io.StringIO()
writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
_MAP_COLS = {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
def _upload_and_preview(client: TestClient, rows: list[dict]) -> int:
import io as _io
csv_data = _csv_bytes(rows)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", _io.BytesIO(csv_data), "text/csv")},
data={},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": "YYYY-MM-DD",
})
assert r2.status_code == 200, r2.text
return iid
def _chips_state_din_html(html: str) -> dict:
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m, f"hidden chips_state lipsa din HTML: {html[:400]}"
return json.loads(m.group(1).replace("&#34;", '"'))
# --- Trimiteri: helpere submissions direct in DB (cont 1, fara auth) --------- #
def _insert_submission(*, status: str, payload: dict) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", 1, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _submission_row(sid: int):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
finally:
conn.close()
def _submission_payload(sid: int) -> dict:
from app.db import get_connection
conn = get_connection()
try:
r = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone()
return json.loads(r["payload_json"])
finally:
conn.close()
def _payload_cu_ops(vin: str, ops: list[tuple[str, str]]) -> dict:
return {
"vin": vin,
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": op, "denumire": den} for op, den in ops],
}
# --------------------------------------------------------------------------- #
# Import: salvare + round-trip (web_editeaza_rand / apply_row_override) #
# --------------------------------------------------------------------------- #
_SAMPLE_ROW_1OP = [{
"VIN": "WVWZZZ1KZAW000999",
"Nr": "B009TST",
"Data": "2026-06-10",
"KM": "123456",
"Operatie": "Op-Nemapata",
}]
def test_salvare_pastreaza_exclus_in_override(client):
"""Salvarea unui chip Nedeclarat in modalul de editare (import) persista `exclus=1`
in override_json — la reincarcarea preview-ului randul ramane 'Nedeclarat'."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text
trigger_settle = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" in trigger_settle, f"Salvarea trebuia sa reuseasca: {r.text[:400]}"
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r2.status_code == 200
payload = _chips_state_din_html(r2.text)
item = payload["items"][0]
assert item.get("exclus") == 1, f"exclus nu a fost persistat: {payload}"
assert not item.get("cod"), f"item exclus nu trebuie sa aiba cod: {payload}"
def test_redeschidere_modal_arata_chip_nedeclarat_si_x_readuce_warning(client):
"""Round-trip complet: dupa salvare, redeschiderea modalului arata chipul
'Nedeclarat' (nu warning); actiunea x (/form-chips remove) il readuce la warning."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r2.status_code == 200
html = r2.text
assert "Nedeclarat" in html, f"Chipul Nedeclarat trebuia sa reapara la redeschidere: {html[:600]}"
assert "chip-nedeclarat" in html
# x pe chip Nedeclarat -> /form-chips remove index 0 -> revine la warning.
payload = _chips_state_din_html(html)
r3 = client.post("/form-chips", data={
"chips_state": json.dumps(payload),
"chips_action": "remove",
"chips_remove_index": "0",
})
assert r3.status_code == 200, r3.text
assert "Nedeclarat" not in r3.text, "Dupa x, chipul Nedeclarat nu mai trebuie sa apara"
payload3 = _chips_state_din_html(r3.text)
item3 = payload3["items"][0]
assert not item3.get("exclus"), f"x trebuia sa readuca operatia la warning (exclus=0): {payload3}"
assert not item3.get("cod"), f"operatia trebuia sa ramana fara cod dupa x: {payload3}"
def test_rand_toate_operatiile_excluse_devine_excluded(client):
"""Rand cu unica operatie exclusa -> stare preview 'excluded' ('Nedeclarat')."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text
trigger = json.loads(r.headers.get("HX-Trigger", "{}"))
stare = trigger.get("randSalvat", {}).get("stare", "")
assert "Nedeclarat" in stare, f"randSalvat.stare trebuia sa arate Nedeclarat, got: {stare!r}"
# --------------------------------------------------------------------------- #
# Trimiteri: salvare + round-trip (post_corectie_trimitere) #
# --------------------------------------------------------------------------- #
def test_salvare_trimitere_cu_exclus(client):
"""/trimitere/{id}/corecteaza cu un item mapat + un item exclus -> queued,
payload_json pastreaza AMBELE (itemul exclus adnotat exclus=1, fara cod_prestatie)."""
_seed_cod("OE-1", "Schimb ulei")
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0EX001",
[("SchimbUlei", "Schimb ulei motor"), ("Filtru-Aer", "Filtru aer")],
))
chips_state = json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0},
{"cod": "", "op": "Filtru-Aer", "den": "Filtru aer", "exclus": 1},
]})
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text[:500]
row = _submission_row(sid)
assert row["status"] == "queued", f"status asteptat queued, got {row['status']}: {r.text[:500]}"
prestatii = _submission_payload(sid)["prestatii"]
assert len(prestatii) == 2, f"ambele itemi trebuiau pastrate in payload: {prestatii}"
by_op = {p.get("cod_op_service"): p for p in prestatii}
assert by_op["SchimbUlei"].get("cod_prestatie") == "OE-1"
assert by_op["Filtru-Aer"].get("exclus") is True, f"itemul exclus trebuia adnotat: {prestatii}"
assert not by_op["Filtru-Aer"].get("cod_prestatie"), f"itemul exclus nu trebuie sa aiba cod: {prestatii}"
def test_trimitere_toate_excluse_devine_needs_data(client):
"""Toate operatiile excluse -> needs_data cu motiv explicit (nu se trimite la RAR)."""
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0EX002",
[("Filtru-Aer", "Filtru aer")],
))
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Filtru-Aer", "den": "Filtru aer", "exclus": 1},
]})
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text[:500]
row = _submission_row(sid)
assert row["status"] == "needs_data", f"status asteptat needs_data, got {row['status']}"
assert "excluse" in (row["rar_error"] or "").lower()
prestatii = _submission_payload(sid)["prestatii"]
assert prestatii[0].get("exclus") is True, "itemul exclus trebuie pastrat in payload pt afisare motiv"
# --------------------------------------------------------------------------- #
# Sentinel __NEDECLARAT__ respins explicit daca apare ca cod_prestatie #
# --------------------------------------------------------------------------- #
def test_sentinel_nedeclarat_nu_ajunge_in_payload_import(client):
"""Daca cineva fabrica direct chips_state cu cod=__NEDECLARAT__ (nu prin selectul
care seteaza exclus), salvarea trebuie respinsa — sentinelul nu e in nomenclator."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "__NEDECLARAT__", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 0},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200
assert "necunoscut" in r.text.lower(), f"Sentinelul trebuia respins ca cod necunoscut: {r.text[:400]}"
trigger_settle = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" not in trigger_settle, "Salvarea NU trebuia sa reuseasca cu sentinelul in payload"
# Confirma ca sentinelul NU a ajuns in override (redeschiderea nu-l arata ca si cod).
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
payload2 = _chips_state_din_html(r2.text)
coduri = [it.get("cod") for it in payload2["items"]]
assert "__NEDECLARAT__" not in coduri, f"Sentinelul a ajuns in starea persistata: {payload2}"
def test_sentinel_nedeclarat_nu_ajunge_in_payload_trimitere(client):
"""Aceeasi respingere pe calea Trimiteri (post_corectie_trimitere)."""
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0SN001",
[("Op-Test", "Operatie test")],
))
old_status = _submission_row(sid)["status"]
chips_state = json.dumps({"v": 1, "items": [
{"cod": "__NEDECLARAT__", "op": "Op-Test", "den": "Operatie test", "exclus": 0},
]})
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
assert r.status_code == 200
assert "necunoscut" in r.text.lower(), f"Sentinelul trebuia respins ca cod necunoscut: {r.text[:400]}"
row = _submission_row(sid)
assert row["status"] == old_status, f"Statusul nu trebuia sa se schimbe: {row['status']}"
prestatii = _submission_payload(sid)["prestatii"]
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "__NEDECLARAT__" not in coduri, f"Sentinelul a ajuns in payload: {prestatii}"

View File

@@ -212,7 +212,7 @@ def test_camp_observatii_prezent(client):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_chips_multi_select_prestatii(client): def test_chips_multi_select_prestatii(client):
"""Submission cu cod_prestatie setat afiseaza chip cu hidden input.""" """Submission cu cod_prestatie setat afiseaza chip cu OE-1 in starea hidden chips_state."""
acct = _create_account_user("chips.test@test.com") acct = _create_account_user("chips.test@test.com")
_login(client, "chips.test@test.com") _login(client, "chips.test@test.com")
_seed_cod("OE-1", "Schimb ulei motor") _seed_cod("OE-1", "Schimb ulei motor")
@@ -222,14 +222,13 @@ def test_chips_multi_select_prestatii(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Trebuie sa existe un input (de obicei hidden) cu name="cod_prestatie" si valoarea "OE-1" # Starea chips traieste intr-UN SINGUR hidden JSON versionat (D4, PRD 5.23).
has_cod_prestatie_chip = bool( m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or assert m, "Formularul trebuie sa contina hidden input name='chips_state'"
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html) payload = json.loads(m.group(1).replace("&#34;", '"'))
) coduri = [it["cod"] for it in payload["items"]]
assert has_cod_prestatie_chip, ( assert "OE-1" in coduri, (
"Formularul trebuie sa contina un hidden input cu name='cod_prestatie' value='OE-1' " f"chips_state trebuie sa contina codul OE-1 al prestatiei. items={payload['items']}"
"reprezentand chip-ul de prestatie."
) )
@@ -244,19 +243,19 @@ def test_adauga_sterge_chip(client):
_seed_cod("OE-1", "Schimb ulei motor") _seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client) csrf = _csrf(client)
# POST /form-chips: adauga OE-1 la prima operatie (index 0) # POST /form-chips: adauga OE-1 la prima operatie (index 0), stare curenta = un
# singur hidden chips_state (JSON versionat, D4).
state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]})
resp = client.post( resp = client.post(
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
# Starea curenta: o operatie fara cod "chips_state": state,
"chip_op_service": ["Op-A"],
"chip_denumire": ["Schimb ulei motor"],
"cod_prestatie": [""], # nemaapat initial
# Actiunea
"chips_action": "add", "chips_action": "add",
"chips_add_op_index": "0", "chips_target_index": "0",
"chips_add_cod_0": "OE-1", "chips_pick": "OE-1",
}, },
) )
assert resp.status_code == 200, f"/form-chips a returnat {resp.status_code}: {resp.text[:400]}" assert resp.status_code == 200, f"/form-chips a returnat {resp.status_code}: {resp.text[:400]}"
@@ -266,34 +265,31 @@ def test_adauga_sterge_chip(client):
assert "OE-1" in html, ( assert "OE-1" in html, (
f"Dupa add, OE-1 trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}" f"Dupa add, OE-1 trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}"
) )
# Si hidden input cu valoarea OE-1 # Si in starea hidden chips_state
has_hidden = bool( m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or assert m, f"hidden chips_state lipsa. html[:600]={html[:600]}"
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html) payload = json.loads(m.group(1).replace("&#34;", '"'))
) assert payload["items"][0]["cod"] == "OE-1", (
assert has_hidden, ( f"Dupa add, chips_state trebuie sa contina OE-1. items={payload['items']}"
"Dupa add, trebuie sa existe un input cu name='cod_prestatie' value='OE-1' "
f"in raspunsul /form-chips. html[:600]={html[:600]}"
) )
def test_sterge_chip(client): def test_sterge_chip(client):
"""POST /form-chips cu action=remove sterge chip-ul la indexul dat.""" """POST /form-chips cu action=remove sterge codul chip-ului la indexul dat."""
acct = _create_account_user("form.chips.del@test.com") acct = _create_account_user("form.chips.del@test.com")
_login(client, "form.chips.del@test.com") _login(client, "form.chips.del@test.com")
_seed_cod("OE-1", "Schimb ulei motor") _seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client) csrf = _csrf(client)
# POST /form-chips: sterge chip-ul de la index 0 (OE-1 existent) # POST /form-chips: sterge codul chip-ului de la index 0 (OE-1 existent, mapat pe Op-A)
state = json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]})
resp = client.post( resp = client.post(
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
# Starea curenta: OE-1 mapat pe Op-A "chips_state": state,
"chip_op_service": ["Op-A"],
"chip_denumire": ["Schimb ulei motor"],
"cod_prestatie": ["OE-1"],
# Actiunea: sterge indexul 0
"chips_action": "remove", "chips_action": "remove",
"chips_remove_index": "0", "chips_remove_index": "0",
}, },
@@ -301,15 +297,15 @@ def test_sterge_chip(client):
assert resp.status_code == 200, f"/form-chips remove a returnat {resp.status_code}: {resp.text[:400]}" assert resp.status_code == 200, f"/form-chips remove a returnat {resp.status_code}: {resp.text[:400]}"
html = resp.text html = resp.text
# Dupa remove, OE-1 nu mai apare ca chip (input hidden cu acea valoare) # Dupa remove, OE-1 nu mai apare ca cod in starea chips (operatia RAMANE, nemapata)
has_oe1_chip = bool( m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or assert m
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html) payload = json.loads(m.group(1).replace("&#34;", '"'))
) coduri = [it["cod"] for it in payload["items"]]
assert not has_oe1_chip, ( assert "OE-1" not in coduri, (
"Dupa remove, OE-1 NU mai trebuie sa apara ca chip " f"Dupa remove, OE-1 NU mai trebuie sa apara ca cod in chips_state. items={payload['items']}"
f"(hidden input cu cod_prestatie=OE-1). html[:500]={html[:500]}"
) )
assert payload["items"][0]["op"] == "Op-A", "Operatia trebuie sa ramana (doar codul dispare)"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -437,11 +433,16 @@ def test_form_chips_reveal_r_odo(client):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Test 8: Picker per operatie (E4 binding) -- format op-row # # Test 8: Chip warning pentru operatie nemapata (control unificat, PRD 5.23) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_picker_per_operatie_in_form(client): def test_picker_per_operatie_in_form(client):
"""Operatie nemapata (needs_mapping) -> formularul afiseaza picker pe operatie.""" """Operatie nemapata (needs_mapping) -> formularul afiseaza chip warning + selectul unic.
PRD 5.23 US-002: controlul unificat elimina `.op-row` (rand per operatie cu
picker propriu); operatia nemapata devine un chip warning in containerul
unic `.chips`, iar selectul e unul singur (`chips_pick`, id `chips-picker`).
"""
acct = _create_account_user("picker.op@test.com") acct = _create_account_user("picker.op@test.com")
_login(client, "picker.op@test.com") _login(client, "picker.op@test.com")
# NU seed-uim nicio mapare -> operatia ramane nemapata # NU seed-uim nicio mapare -> operatia ramane nemapata
@@ -455,17 +456,15 @@ def test_picker_per_operatie_in_form(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Operatia REVIZIE PERIODICA trebuie sa apara in form (op-row cu clasa specifica) # Controlul unificat (PRD 5.23): fara .op-row, UN singur container .chips.
# clasa "op-row" din CSS base.html e adaugata NUMAI de chips_prestatii.html assert "op-row" not in html, (
has_op_row = "op-row" in html "op-row a fost eliminat de controlul unificat (PRD 5.23 US-002). "
assert has_op_row, (
"Formularul trebuie sa contina clasa 'op-row' "
"pentru picker-ul per-operatie. "
"Aceasta clasa e adaugata de _chips_prestatii.html. "
f"html[:600]={html[:600]}" f"html[:600]={html[:600]}"
) )
# Operatia REVIZIE PERIODICA trebuie sa apara in context op-row assert 'class="chips"' in html
assert 'id="chips-picker"' in html
# Operatia REVIZIE PERIODICA trebuie sa apara ca chip warning
assert "REVIZIE PERIODICA" in html, ( assert "REVIZIE PERIODICA" in html, (
"Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare (op-row). " "Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare "
f"html[:500]={html[:500]}" f"(chip warning). html[:500]={html[:500]}"
) )

View File

@@ -280,8 +280,8 @@ def test_optional_nu_forteaza(client):
def test_fara_select_vechi_redundant(client): def test_fara_select_vechi_redundant(client):
"""Detaliu needs_data NU mai contine <select name='cod_prestatie'>. """Detaliu needs_data NU mai contine <select name='cod_prestatie'>.
Formularul editabil foloseste NUMAI chips (hidden inputs cod_prestatie), Formularul editabil foloseste NUMAI chips (stare in hidden name='chips_state',
fara vechiul select dublu. Chips functioneaza ca singura sursa de cod_prestatie. JSON versionat, PRD 5.23), fara vechiul select dublu.
Nota: <select name="cod_prestatie"> RAMANE in formularul /repune pentru starea error. Nota: <select name="cod_prestatie"> RAMANE in formularul /repune pentru starea error.
""" """
@@ -302,15 +302,14 @@ def test_fara_select_vechi_redundant(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Chipurile trebuie sa fie prezente (hidden input cu name="cod_prestatie") # Chipurile trebuie sa fie prezente (hidden input cu name="chips_state", JSON versionat)
has_chip_hidden = ( has_chip_hidden = re.search(r'<input[^>]+type=["\']hidden["\'][^>]+name=["\']chips_state["\']', html)
re.search(r'<input[^>]+type=["\']hidden["\'][^>]+name=["\']cod_prestatie["\']', html) or
re.search(r'<input[^>]+name=["\']cod_prestatie["\'][^>]+type=["\']hidden["\']', html)
)
assert has_chip_hidden, ( assert has_chip_hidden, (
"Chips: trebuie sa existe input hidden cu name='cod_prestatie' (din _chips_prestatii.html). " "Chips: trebuie sa existe input hidden cu name='chips_state' (din _chips_prestatii.html). "
f"html[:600]={html[:600]}" f"html[:600]={html[:600]}"
) )
# Cod-ul OE-1 trebuie sa apara in stare (JSON), nu ca input separat
assert "OE-1" in html, f"OE-1 trebuie sa apara in chips_state. html[:600]={html[:600]}"
# Vechiul <select name="cod_prestatie"> NU trebuie sa existe in sectiunea editabila. # Vechiul <select name="cod_prestatie"> NU trebuie sa existe in sectiunea editabila.
# (needs_data nu are formular /repune, deci niciun select cu name="cod_prestatie" legal) # (needs_data nu are formular /repune, deci niciun select cu name="cod_prestatie" legal)

View File

@@ -198,3 +198,20 @@ def test_cod_invalid_respins(client):
assert resp.status_code == 200 assert resp.status_code == 200
assert "necunoscut" in resp.text.lower() assert "necunoscut" in resp.text.lower()
assert _text_rules(acct) == [] assert _text_rules(acct) == []
def test_sectiune_reguli_afiseaza_exemplu_nu_descriere(client):
"""Capul sectiunii = un exemplu concret de regula, nu paragraf descriptiv."""
_create_account_user("exemplu.regula@test.com")
_login(client, "exemplu.regula@test.com")
resp = client.get("/?tab=mapari")
assert resp.status_code == 200
html = resp.text
# Exemplul concret e prezent (pattern + cod, formatate)
assert "regula-exemplu" in html, "Blocul de exemplu (regula-exemplu) lipseste"
assert "verificare" in html
assert "OE-2" in html
# Paragraful descriptiv vechi a disparut
assert "nu egal, ci substring" not in html, "Descrierea veche inca prezenta"

View File

@@ -538,3 +538,155 @@ def test_base_html_deschide_modal_pentru_btn_editeaza():
".btn-editeaza (sau sa verifice hx-target='#detaliu-modal-body') pentru a " ".btn-editeaza (sau sa verifice hx-target='#detaliu-modal-body') pentru a "
"apela open() si instala focus-trap-ul." "apela open() si instala focus-trap-ul."
) )
# --------------------------------------------------------------------------- #
# Persistarea chips-urilor prestatii la salvarea din modal #
# --------------------------------------------------------------------------- #
_ROWS_ITP = [
{
"VIN": "WVWZZZ3CZ9E123456",
"Nr": "TM789BC",
"Data": "2026-05-15",
"KM": "82500",
"Operatie": "OP-ITP",
},
]
def _seed_nomenclator_extra() -> None:
"""Nomenclator cu mai multe coduri pentru testele de chips (fara mapare OP-ITP)."""
from app.db import get_connection
conn = get_connection()
try:
for cod, nume in (("R-FRANE", "Reparatie frane"), ("OE-2", "Verificare"), ("OE-3", "Reglaj")):
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, nume),
)
conn.commit()
finally:
conn.close()
def _preview_row0(client: TestClient, iid: int) -> dict:
"""Recalculeaza preview-ul si intoarce randul 0 rezolvat (direct pe DB)."""
from app.db import get_connection
from app.web.routes import _preview_one_row
conn = get_connection()
try:
result, row = _preview_one_row(conn, iid, 1, 0)
assert row is not None and not isinstance(result, str)
return row
finally:
conn.close()
def test_salvare_chips_persista_si_deblocheaza_randul(client):
"""Bug reprodus: cod ales in editor pentru operatie nemapata + coduri extra,
Salveaza -> randul ramanea needs_mapping si chips-urile se pierdeau la redeschidere.
Dupa fix: chips-urile din form se persista in override -> randul devine ok,
iar redeschiderea editorului arata codurile alese.
"""
_seed_nomenclator_extra()
iid = _upload_and_preview(client, rows=_ROWS_ITP)
row = _preview_row0(client, iid)
assert row["resolved_status"] == "needs_mapping"
# Salvare cu chips: OP-ITP -> OE-2 + cod extra OE-3 (fara operatie).
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"cod_prestatie": ["OE-2", "OE-3"],
"chip_op_service": ["OP-ITP", ""],
"chip_denumire": ["Inspectie Tehnica Periodica", ""],
})
assert r.status_code == 200, r.text
assert "inchideModal" in r.headers.get("HX-Trigger-After-Settle", ""), \
"Salvarea cu chips valide trebuie sa inchida modalul (succes)"
row = _preview_row0(client, iid)
assert row["resolved_status"] == "ok", f"Randul trebuie deblocat, e {row['resolved_status']}"
coduri = [p.get("cod_prestatie") for p in row["resolved"]["prestatii"]]
assert coduri == ["OE-2", "OE-3"], coduri
# Redeschiderea editorului arata chips-urile salvate.
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r2.status_code == 200
assert "OE-2" in r2.text and "OE-3" in r2.text
def test_salvare_chips_cod_din_picker_neaprobat_se_aplica(client):
"""Cod ales in pickerul per-operatie dar ne-aprobat prin '+ Adauga' se aplica la salvare."""
_seed_nomenclator_extra()
iid = _upload_and_preview(client, rows=_ROWS_ITP)
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"cod_prestatie": "",
"chip_op_service": "OP-ITP",
"chip_denumire": "Inspectie Tehnica Periodica",
"chips_add_cod_0": "OE-2",
})
assert r.status_code == 200, r.text
row = _preview_row0(client, iid)
assert row["resolved_status"] == "ok"
assert row["resolved"]["prestatii"][0]["cod_prestatie"] == "OE-2"
def test_salvare_chips_cod_necunoscut_respins(client):
"""Cod inexistent in nomenclator -> modal re-randat cu eroare, override neatins."""
_seed_nomenclator_extra()
iid = _upload_and_preview(client, rows=_ROWS_ITP)
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"cod_prestatie": "XX-INEXISTENT",
"chip_op_service": "OP-ITP",
"chip_denumire": "Inspectie",
})
assert r.status_code == 200
assert "inchideModal" not in r.headers.get("HX-Trigger-After-Settle", "")
assert "necunoscut" in r.text
row = _preview_row0(client, iid)
assert row["resolved_status"] == "needs_mapping", "Codul invalid nu trebuie persistat"
def test_salvare_fara_sectiune_chips_lasa_prestatiile_neatinse(client):
"""Form fara inputuri de chips (doar campuri de continut) nu atinge prestatiile."""
_seed_op1()
iid = _upload_and_preview(client)
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"data_prestatie": "2026-06-15"})
assert r.status_code == 200
row = _preview_row0(client, iid)
assert row["resolved"]["prestatii"][0]["cod_prestatie"] == "R-FRANE"
def test_commit_foloseste_prestatiile_editate_in_modal(client):
"""Confirmarea batch-ului trimite prestatiile din override (chips editate), re-rezolvate."""
import json as _json
_seed_nomenclator_extra()
iid = _upload_and_preview(client, rows=_ROWS_ITP)
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"cod_prestatie": "OE-2",
"chip_op_service": "OP-ITP",
"chip_denumire": "Inspectie Tehnica Periodica",
})
assert r.status_code == 200, r.text
csrf = _get_csrf(client)
rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
assert rc.status_code == 200, rc.text
from app.db import get_connection
conn = get_connection()
try:
sub = conn.execute(
"SELECT payload_json FROM submissions WHERE batch_id=?", (iid,)
).fetchone()
finally:
conn.close()
assert sub is not None, "Randul editat trebuie enqueued la confirmare"
payload = _json.loads(sub["payload_json"])
assert payload["prestatii"][0]["cod_prestatie"] == "OE-2"

View File

@@ -539,7 +539,7 @@ def test_clasa_contor_card(client):
def test_clasa_lista_slim(client): def test_clasa_lista_slim(client):
""".lista-trimiteri-slim + .trimitere-slim: separator --line2, padding, tinta min-height:44px. """.lista-trimiteri-slim + .trimitere-slim: separator --line2, padding, tinta min-height:44px.
Sub-elemente: .slim-vin (mono) si .slim-meta (muted 11px). Sub-elemente: .slim-vin (mono) si .c-meta (celulele cod/operatie/data).
""" """
_create_account_user("ls2@test.com") _create_account_user("ls2@test.com")
_login(client, "ls2@test.com") _login(client, "ls2@test.com")
@@ -552,8 +552,8 @@ def test_clasa_lista_slim(client):
assert "var(--line2)" in bloc, ".trimitere-slim nu foloseste var(--line2) ca separator" assert "var(--line2)" in bloc, ".trimitere-slim nu foloseste var(--line2) ca separator"
assert "min-height:44px" in bloc, ".trimitere-slim nu are tinta min-height:44px" assert "min-height:44px" in bloc, ".trimitere-slim nu are tinta min-height:44px"
assert ".slim-vin" in bloc, ".slim-vin lipseste din bloc" assert ".slim-vin" in bloc, ".slim-vin lipseste din bloc"
assert ".slim-meta" in bloc, ".slim-meta lipseste din bloc" assert ".c-meta" in bloc, ".c-meta lipseste din bloc"
assert "var(--muted)" in bloc, ".slim-meta nu foloseste var(--muted)" assert "var(--muted)" in bloc, "componentele slim nu folosesc var(--muted)"
def test_clasa_camp_slim(client): def test_clasa_camp_slim(client):
@@ -791,12 +791,17 @@ def test_login_branded_nu_schelet(client):
# ============================================================ # ============================================================
def _citeste_landing() -> str: def _citeste_landing() -> str:
"""Returneaza continutul landing.html (template static; variabilele Jinja2 nu """Returneaza landing.html + partial-urile comune (antet/footer/stil sunt
include-uri partajate cu paginile statice publice; variabilele Jinja2 nu
afecteaza copy-ul de limita/plan/buton verificat mai jos).""" afecteaza copy-ul de limita/plan/buton verificat mai jos)."""
from pathlib import Path from pathlib import Path
p = Path(__file__).parent.parent / "app" / "web" / "templates" / "landing.html" tdir = Path(__file__).parent.parent / "app" / "web" / "templates"
assert p.exists(), f"landing.html negasit la {p}" parts = []
return p.read_text(encoding="utf-8") for nume in ("landing.html", "_lp_header.html", "_lp_footer.html", "_lp_style.html"):
p = tdir / nume
assert p.exists(), f"{nume} negasit la {p}"
parts.append(p.read_text(encoding="utf-8"))
return "\n".join(parts)
def test_landing_limita_60(): def test_landing_limita_60():
@@ -818,8 +823,10 @@ def test_landing_trial_pro_nu_premium():
Verifica sectiunea PRICING (subtitle) si sectiunea AUTH (lista beneficii).""" Verifica sectiunea PRICING (subtitle) si sectiunea AUTH (lista beneficii)."""
html = _citeste_landing() html = _citeste_landing()
assert "Pro gratuit 30 de zile" in html, \ assert "30 de zile Pro gratuit" in html, \
"'Pro gratuit 30 de zile' lipseste din landing — verifica sectiunile PRICING + AUTH (5.17 T7)" "'30 de zile Pro gratuit' lipseste din landing — verifica sectiunea TRIAL BENEFIT (5.17 T7)"
assert "Ai Pro gratuit primele 30 de zile, apoi pachetul ales" in html, \
"copy-ul trial din sectiunea AUTH lipseste din landing (5.17 T7)"
assert "Premium gratuit 30 de zile" not in html, \ assert "Premium gratuit 30 de zile" not in html, \
"'Premium gratuit 30 de zile' inca in landing — trial-ul e pe Pro, nu Premium (5.17 T7)" "'Premium gratuit 30 de zile' inca in landing — trial-ul e pe Pro, nu Premium (5.17 T7)"

View File

@@ -186,15 +186,16 @@ def test_operatie_contine_cod_rar(client):
def test_pill_eticheta_scurta(client): def test_pill_eticheta_scurta(client):
"""Pill-ul de Stare foloseste eticheta scurta; textul lung in title.""" """Statusul e bulina colorata (dot-stare); textul complet al starii in title."""
acct = _create_account_user("pill@test.com") acct = _create_account_user("pill@test.com")
_insert_submission(acct, "sent", id_prezentare=70001) _insert_submission(acct, "sent", id_prezentare=70001)
_login(client, "pill@test.com") _login(client, "pill@test.com")
resp = client.get("/_fragments/submissions") resp = client.get("/_fragments/submissions")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# eticheta scurta in pill # bulina cu clasa de stare, fara text in celula
assert ">Finalizat<" in html assert "dot-stare" in html
assert "s-sent" in html
# textul lung pastrat ca tooltip (title) # textul lung pastrat ca tooltip (title)
assert 'title="Declarate la RAR' in html assert 'title="Declarate la RAR' in html
@@ -213,6 +214,34 @@ def test_detaliu_trimitere(client):
assert "99001" in html # nr prezentare RAR assert "99001" in html # nr prezentare RAR
def test_detaliu_arata_toate_codurile_cand_prima_prestatie_e_exclusa(client):
"""Modalul arata codurile declarate cand operatia originala e exclusa (item 0).
Rand importat editat: item 0 = operatia originala cu exclus=True, fara cod;
codurile adaugate sunt itemii urmatori. Detaliul nu trebuie sa afiseze 'nemapat'.
"""
acct = _create_account_user("codmulti@test.com")
sid = _insert_submission(acct, "sent", id_prezentare=99002, payload={
"vin": "WVWZZZ3CZ9E123456",
"nr_inmatriculare": "TM789BC",
"data_prestatie": "2026-05-15",
"odometru_final": "82500",
"prestatii": [
{"cod_prestatie": None, "cod_op_service": "OP-ITP",
"denumire": "Inspectie Tehnica Periodica", "exclus": True},
{"cod_prestatie": "OE-2"},
{"cod_prestatie": "OE-4"},
{"cod_prestatie": "OE-8"},
],
})
_login(client, "codmulti@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
assert "OE-2, OE-4, OE-8" in html
assert "nemapat" not in html
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tabel trimiteri — eticheta umana sub pill, fara chevron, # Tabel trimiteri — eticheta umana sub pill, fara chevron,
# cod RAR simplu (fara prefix), randul declanseaza modalul. Teste de randare. # cod RAR simplu (fara prefix), randul declanseaza modalul. Teste de randare.
@@ -229,8 +258,8 @@ def test_eticheta_umana_apare_sub_pill(client):
resp = client.get("/_fragments/submissions") resp = client.get("/_fragments/submissions")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert "eticheta-problema" in html # containerul textului uman sub pill assert "c-err" in html # sub-linia compacta de eroare
assert "s-error" in html # colorat ca problema (error/needs_*) assert "lipsa odometru" in html # textul brut al erorii, nu eticheta explicativa
# NU randeaza cod brut de catalog pe rand # NU randeaza cod brut de catalog pe rand
assert "COD_" not in html assert "COD_" not in html
assert "RAR_EROARE" not in html assert "RAR_EROARE" not in html
@@ -444,16 +473,17 @@ def test_rand_slim_vin_operatie_pill(client):
assert "slim-vin" in html, "slim-vin lipseste — linia 1 placuta" assert "slim-vin" in html, "slim-vin lipseste — linia 1 placuta"
assert "B777ZZZ" in html, "placuta (nr. inmatriculare) lipseste de pe rand" assert "B777ZZZ" in html, "placuta (nr. inmatriculare) lipseste de pe rand"
# L2: cod RAR · operatie · data (slim-meta / slim-rand2) # Celulele: cod RAR · operatie · data (c-meta cu c-cod/c-op/c-data)
assert "slim-meta" in html, "slim-meta lipseste — linia 2" assert "c-meta" in html, "c-meta lipseste — celulele cod/operatie/data"
assert "slim-rand2" in html, "slim-rand2 lipseste — linia 2 (cod RAR · operatie · data)" assert "c-cod" in html and "c-op" in html, "celulele cod/operatie lipsesc"
# VIN integral nu mai e pe rand — traieste in modalul de detaliu. # VIN integral nu mai e pe rand — traieste in modalul de detaliu.
assert "000777" not in html, "VIN scurt nu mai trebuie randat pe randul slim (2 linii)" assert "000777" not in html, "VIN scurt nu mai trebuie randat pe randul slim (2 linii)"
# Pill de stare: clasa CSS + eticheta scurta # Status: bulina cu clasa de stare; numele coloanei in header
assert "s-sent" in html, "clasa pill s-sent lipseste" assert "s-sent" in html, "clasa de stare s-sent lipseste"
assert "Finalizat" in html, "eticheta scurta stare_scurt lipseste" assert "dot-stare" in html, "bulina de status lipseste"
assert "Status" in html, "headerul coloanei Status lipseste"
def test_filtre_paginare_pastrate(client): def test_filtre_paginare_pastrate(client):

View File

@@ -136,3 +136,128 @@ def test_placuta_lipsa_nu_genereaza_rand_gol(client):
assert m.group(1).strip() != "", "slim-vin afiseaza '' izolat ca identificator" assert m.group(1).strip() != "", "slim-vin afiseaza '' izolat ca identificator"
assert "fara numar" in html, "fallback 'fara numar' lipseste cand placuta+VIN absente" assert "fara numar" in html, "fallback 'fara numar' lipseste cand placuta+VIN absente"
# --------------------------------------------------------------------------- #
# Tabel compact desktop (PRD 5.22): header coloane, bulina status, T/P, #
# badge +N multi-cod, sub-linie eroare bruta. #
# --------------------------------------------------------------------------- #
def _ins_full(acct: int, *, payload: dict, status: str = "queued",
rar_env: str = "test", rar_error: str | None = None) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_env, rar_error) "
"VALUES (?, ?, ?, ?, ?, ?)",
(f"k-{os.urandom(5).hex()}", acct, status,
json.dumps(payload), rar_env, rar_error),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _payload_min(coduri: list[str] | None = None) -> dict:
return {
"vin": "WVWZZZ1JZXW000002",
"nr_inmatriculare": "B22TST",
"data_prestatie": "2026-06-21",
"odometru_final": "200",
"prestatii": [{"cod_prestatie": c} for c in (coduri or ["R-X"])],
}
def test_desktop_header_coloane(client):
"""Lista are un rand de header cu numele coloanelor (Status, Mediu etc.)."""
acct = _create_account_user("head.col@test.com")
_ins(acct, nr="B77HDR")
_login(client, "head.col@test.com")
html = client.get("/_fragments/submissions").text
assert "trimiteri-head" in html, "Randul de header lipseste"
for col in ("Vehicul", "Cod RAR", "Operatie", "Status", "Mediu"):
assert col in html, f"Coloana '{col}' lipseste din header"
def test_status_bulina_cu_tooltip(client):
"""Statusul e o bulina colorata (dot-stare) cu numele starii in title."""
acct = _create_account_user("dot.status@test.com")
_ins_full(acct, payload=_payload_min(), status="sent")
_login(client, "dot.status@test.com")
html = client.get("/_fragments/submissions").text
m = re.search(r'<span[^>]*class="dot-stare[^"]*s-sent[^"]*"[^>]*title="([^"]+)"', html) or \
re.search(r'<span[^>]*class="dot-stare[^"]*"[^>]*class[^>]*>', html)
assert "dot-stare" in html, "Bulina de status (dot-stare) lipseste"
assert m, f"Bulina fara title (tooltip cu starea): {html[html.find('dot-stare')-100:html.find('dot-stare')+200]}"
def test_mediu_litera_t_p(client):
"""Mediul RAR e litera compacta: T (testare, contur) / P (productie, fill)."""
acct = _create_account_user("env.litera@test.com")
_ins_full(acct, payload=_payload_min(), rar_env="test")
_ins_full(acct, payload=_payload_min(), rar_env="prod")
_login(client, "env.litera@test.com")
html = client.get("/_fragments/submissions").text
assert re.search(r'class="env-l env-l-test"[^>]*>\s*T\s*<', html), "Litera T (testare) lipseste"
assert re.search(r'class="env-l env-l-prod"[^>]*>\s*P\s*<', html), "Litera P (productie) lipseste"
def test_multicod_badge_plus_n(client):
"""Mai multe coduri RAR: primul cod + badge +N cu toate codurile in title."""
acct = _create_account_user("multicod@test.com")
_ins_full(acct, payload=_payload_min(["OE-1", "OE-2", "OE-8"]))
_ins_full(acct, payload=_payload_min(["OE-4"]))
_login(client, "multicod@test.com")
html = client.get("/_fragments/submissions").text
m = re.search(r'<span class="cod-plus" title="([^"]+)">\+2</span>', html)
assert m, "Badge-ul +2 lipseste pentru trimiterea cu 3 coduri"
for cod in ("OE-1", "OE-2", "OE-8"):
assert cod in m.group(1), f"Codul {cod} lipseste din tooltip-ul badge-ului"
# un singur cod -> fara badge
assert not re.search(r'OE-4[^<]*</span><span class="cod-plus"', html)
def test_sublinie_eroare_bruta_doar_pe_error(client):
"""Sub-linia rosie contine textul brut al erorii RAR, doar pe error/needs_data."""
acct = _create_account_user("err.brut@test.com")
_ins_full(
acct, payload=_payload_min(), status="error",
rar_error=json.dumps({
"cod": "RAR_EROARE_SERVER",
"problema": "RAR a esuat la inregistrarea prezentarii",
"cauza": "ORA-12899: value too large for column PRESTATII.COD",
"fix": "x", "field": None,
"message": "ORA-12899: value too large for column PRESTATII.COD",
}),
)
_ins_full(
acct,
payload={**_payload_min(), "prestatii": [{"cod_op_service": "IGIENIZARE", "denumire": "Igienizare AC"}]},
status="needs_mapping",
rar_error=json.dumps({"unmapped": [{"cod_op_service": "IGIENIZARE"}]}),
)
_login(client, "err.brut@test.com")
html = client.get("/_fragments/submissions").text
err_cells = re.findall(r'class="c-err"[^>]*>([^<]*)<', html)
assert len(err_cells) == 1, f"Exact o sub-linie de eroare asteptata, gasite {len(err_cells)}"
assert "ORA-12899" in err_cells[0], f"Textul brut RAR lipseste: {err_cells[0]!r}"
def test_mobil_pastreaza_doua_linii_cu_bulina_si_litera(client):
"""CSS-ul mobil: header ascuns, rand pe 2 linii prin grid-areas (veh / meta),
cu actiune + mediu + status in dreapta."""
_create_account_user("mobil.css@test.com")
_login(client, "mobil.css@test.com")
html = client.get("/?tab=coada").text
assert re.search(r'\.trimiteri-head\s*\{\s*display\s*:\s*none', html), \
"Headerul de coloane nu e ascuns pe mobil"
m = re.search(r'grid-template-areas\s*:\s*"check id\s+veh\s+act env status"\s*"check meta meta act env status"', html)
assert m, "Grid-areas pe 2 linii (id+veh/meta) lipseste din CSS-ul mobil"

View File

@@ -0,0 +1,97 @@
"""Prestatiile excluse de la declarare nu ajung niciodata la RAR.
content['prestatii'] pastreaza itemii exclusi (round-trip in editor), dar
payload-ul trimis efectiv la RAR (build_rar_payload, apelat din process_one)
trebuie sa contina DOAR partea declarabila. Un item exclus/fara cod nu produce
niciodata {codPrestatie: null} in payload.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
@pytest.fixture()
def env(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.db import get_connection, init_db
init_db()
conn = get_connection()
settings = get_settings()
yield conn, settings
conn.close()
get_settings.cache_clear()
_CONTENT_CU_EXCLUS = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [
{"cod_prestatie": "OE-1", "cod_op_service": "100"},
{"cod_prestatie": None, "cod_op_service": "200", "denumire": "Spalare", "exclus": True},
],
"sistem_reparat": "null",
}
def _insert(conn, content):
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, retry_count) "
"VALUES (?, 'queued', ?, 0)",
(f"key-{os.urandom(4).hex()}", json.dumps(content)),
)
return int(cur.lastrowid)
class FakeRarCapturePayload:
"""RAR mock care retine payload-ul trimis efectiv la postPrezentare."""
def __init__(self):
self.sent_payload = None
def get_finalizate(self, token):
return []
def post_prezentare(self, token, payload):
self.sent_payload = payload
return {"id": 999}
def test_process_one_nu_trimite_item_exclus_la_rar(env):
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn, _CONTENT_CU_EXCLUS)
rar = FakeRarCapturePayload()
claimed = {"id": sid, "account_id": None, "content": _CONTENT_CU_EXCLUS}
status = process_one(conn, settings, rar, "tok", claimed)
assert status == "sent"
assert rar.sent_payload is not None
coduri = [p["codPrestatie"] for p in rar.sent_payload["prestatii"]]
assert None not in coduri
assert coduri == ["OE-1"]
def test_process_one_pastreaza_content_intact_in_db(env):
"""content['prestatii'] din DB nu se ingusteaza — doar payload-ul RAR."""
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn, _CONTENT_CU_EXCLUS)
rar = FakeRarCapturePayload()
claimed = {"id": sid, "account_id": None, "content": dict(_CONTENT_CU_EXCLUS)}
process_one(conn, settings, rar, "tok", claimed)
row = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone()
saved = json.loads(row["payload_json"])
# payload_json in DB nu e atins de process_one (doar payload-ul RAR e ingustat) —
# ramane cu ambele prestatii, inclusiv cea exclusa.
assert len(saved["prestatii"]) == 2