feat(5.15+5.14): CLOSE — fix-uri code-review + embeddings functional

5.15 (propagare design + dashboard editare) si 5.14 (mapare LLM distilata)
inchise dupa /code-review high. 8 buguri reparate TDD:

- HIGH modal nu se deschidea pe randul slim (base.html: trimitere-slim)
- HIGH /repune trunchia prestatii (declaratie incompleta la RAR) -> iterare
  peste existing, codes pozitional
- HIGH embeddings incarca model ~230MB degeaba pe corpus gol -> poarta has_corpus()
- HIGH picker chips gol pe re-render eroare -> conn/account_id pe toate ramurile
- MED obs re-derivat dupa stergere explicita -> _merge_override pastreaza obs=''
- MED mapare salvata fara denumire poluă GOLD -> _record_gold_validation guard
- MED typo nome_prestatie -> nume_prestatie in select /repune
- MED bucketare timp +3h gresita iarna -> SQLite localtime + TZ=Europe/Bucharest

Embeddings WIRE-uit functional (PRD #15, decizie user): ensure_embeddings_corpus
construieste corpus din nomenclator, gated pe AUTOPASS_EMBEDDINGS_ENABLED (default
off). Marime model corectata ~50MB->~230MB (estimare PRD gresita).

Cleanup: hoist load_* din bucla bulk-fix; import re la top.
Regresie: 1256 passed, 1 deselected (live), 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-28 20:48:34 +00:00
parent 9e42e7ed6f
commit 3fc53534e2
53 changed files with 9684 additions and 384 deletions

View File

@@ -19,3 +19,9 @@ AUTOPASS_WORKER_USE_TEST_CREDS=false
# --- RAR --- # --- RAR ---
# test | prod # test | prod
AUTOPASS_RAR_ENV=test AUTOPASS_RAR_ENV=test
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
# false = dezactivat (default; /mapari instant, sugestii din GOLD/SILVER + fuzzy).
# true = sugestii semantice. Prima cerere /mapari lazy-load-eaza modelul fastembed/ONNX
# (~230MB pe disc) sincron -> hang la prima cerere. Doar API-ul il incarca.
AUTOPASS_EMBEDDINGS_ENABLED=false

View File

@@ -67,7 +67,7 @@ Flux: validare (`validation.py`) → mapare operatie→cod (`mapping.py`) → en
- **Idempotenta = hash de continut canonic** server-side (`idempotency.py`), pentru ca RAR accepta duplicate si nu are nr. comanda. `build_key` normalizeaza INTOTDEAUNA `account_id` la `account_or_default` (None == 1) INAINTE de hash — altfel acelasi rand logic primeste chei diferite pe canalele API vs import (OV-2). `canonicalize_row` normeaza VIN/nr/odometru (strip ".0" din coercion Excel) inainte de validare si de cheie. - **Idempotenta = hash de continut canonic** server-side (`idempotency.py`), pentru ca RAR accepta duplicate si nu are nr. comanda. `build_key` normalizeaza INTOTDEAUNA `account_id` la `account_or_default` (None == 1) INAINTE de hash — altfel acelasi rand logic primeste chei diferite pe canalele API vs import (OV-2). `canonicalize_row` normeaza VIN/nr/odometru (strip ".0" din coercion Excel) inainte de validare si de cheie.
- **`FINALIZATA` e terminal la RAR** — fara anulare/corectie prin API. De aceea reconcilierea anti-duplicat: pe eroare **ambigua** (timeout / TransportError / 502/503/504 / 429 / 408) sau rand `sending` orfan, worker-ul cauta in finalizate (match pe vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara a re-trimite (`reconcile.py`). **EXCEPTIE: un RAR 500 cu mesaj** (`RarError.rar_message`, ex. `ORA-12899`) e un esec DEFINITIV (RAR a raspuns „am esuat", nu o pierdere de raspuns) → worker-ul NU reconciliaza si NU reincearca, marcheaza `error` cu mesajul RAR (`RAR_EROARE_SERVER`). Altfel ar marca fals `sent` pe un record PARTIAL pe care RAR (ne-tranzactional) il lasa la esec. - **`FINALIZATA` e terminal la RAR** — fara anulare/corectie prin API. De aceea reconcilierea anti-duplicat: pe eroare **ambigua** (timeout / TransportError / 502/503/504 / 429 / 408) sau rand `sending` orfan, worker-ul cauta in finalizate (match pe vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara a re-trimite (`reconcile.py`). **EXCEPTIE: un RAR 500 cu mesaj** (`RarError.rar_message`, ex. `ORA-12899`) e un esec DEFINITIV (RAR a raspuns „am esuat", nu o pierdere de raspuns) → worker-ul NU reconciliaza si NU reincearca, marcheaza `error` cu mesajul RAR (`RAR_EROARE_SERVER`). Altfel ar marca fals `sent` pe un record PARTIAL pe care RAR (ne-tranzactional) il lasa la esec.
- **Creds RAR per cont**: durabile in `accounts.rar_creds_enc` (canal web, fallback re-login) SAU efemere in `submissions.rar_creds_enc` (canal API, sterse dupa primul login reusit). Worker incearca submission-ul intai, apoi fallback la cont. Purjarea sterge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_enc`. - **Creds RAR per cont**: durabile in `accounts.rar_creds_enc` (canal web, fallback re-login) SAU efemere in `submissions.rar_creds_enc` (canal API, sterse dupa primul login reusit). Worker incearca submission-ul intai, apoi fallback la cont. Purjarea sterge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_enc`.
- **Auth API-key** (`auth.py`): identifica CONTUL ROAAUTO, separat de credentialele RAR. Stocam doar SHA-256 al cheii. Enforcement prin `AUTOPASS_REQUIRE_API_KEY`: `false` (dev) → fara cheie merge pe cont id=1, cheie invalida → 401; `true` (prod) → cheie obligatorie pe `/v1/*` protejat. POST-urile + rutele de import sunt account-scoped; GET-urile de listare sunt momentan globale + neprotejate (de remediat — vezi ROADMAP). - **Auth API-key** (`auth.py`): identifica CONTUL ROAAUTO, separat de credentialele RAR. Stocam doar SHA-256 al cheii. Enforcement prin `AUTOPASS_REQUIRE_API_KEY`: `false` (dev) → fara cheie merge pe cont id=1, cheie invalida → 401; `true` (prod) → cheie obligatorie pe `/v1/*` protejat. POST-urile + rutele de import sunt account-scoped; GET-urile de listare sunt si ele account-scoped (5.15/US-011: fragmentele `_fragments/submissions|trimitere|mapari|status|jurnal|nomenclator|trimiteri-versiune` sub `require_login` + scope, 404-before-leak pe id strain; `GET /v1/prezentari(/{id})`/`/v1/mapari`/`/v1/audit/export` filtrate pe cont). `GET /v1/nomenclator` ramane public intentionat (coduri RAR publice, fara PII).
- **Mapare coloane retinuta per `(account_id, signature_coloane)`** (`column_mappings`): la urmatorul fisier cu aceleasi coloane, pentru acelasi cont, maparea se reaplica automat. Un cont poate avea mai multe formate memorate simultan. - **Mapare coloane retinuta per `(account_id, signature_coloane)`** (`column_mappings`): la urmatorul fisier cu aceleasi coloane, pentru acelasi cont, maparea se reaplica automat. Un cont poate avea mai multe formate memorate simultan.
- **Mapare operatie→cod**: prestatie poate veni cu `cod_prestatie` (cod RAR direct) sau `cod_op_service` (cod intern) + `denumire`. Nerezolvat → submission `needs_mapping` (nu se trimite), apare in editorul web cu sugestie fuzzy; la salvarea maparii se re-rezolva automat submission-urile blocate. - **Mapare operatie→cod**: prestatie poate veni cu `cod_prestatie` (cod RAR direct) sau `cod_op_service` (cod intern) + `denumire`. Nerezolvat → submission `needs_mapping` (nu se trimite), apare in editorul web cu sugestie fuzzy; la salvarea maparii se re-rezolva automat submission-urile blocate.
- **`cod_prestatie` e VALIDAT fata de nomenclator la ingestie** (`resolve_prestatii(..., valid_codes)`): un cod direct NECUNOSCUT in nomenclator NU se mai trimite raw — e promovat la `cod_op_service` (denumire=cod) si tratat ca operatie de mapat. Motiv (confirmat live 2026-06-23): RAR accepta NUMAI coduri din nomenclator (coloana `COD_PRESTATIE` max 5 car.); un cod necunoscut da **HTTP 500** (`ORA-12899`), iar RAR **NU e tranzactional** → lasa un record PARTIAL `FINALIZATA` (terminal) chiar pe esec, pe care reconcilierea worker-ului l-ar marca fals `sent`. Comportamentul la cod necunoscut/nemapat: `on_unmapped_error` (camp boolean top-level pe `POST /v1/prezentari` + `/valideaza`) = `false` (intra in editor, `needs_mapping`) sau `true` (respinge fara enqueue → `submission_id=null` + `erori`). Default = `accounts.on_unmapped_error_default` (implicit `false`/`0`); precedenta cerere > cont > `false`. - **`cod_prestatie` e VALIDAT fata de nomenclator la ingestie** (`resolve_prestatii(..., valid_codes)`): un cod direct NECUNOSCUT in nomenclator NU se mai trimite raw — e promovat la `cod_op_service` (denumire=cod) si tratat ca operatie de mapat. Motiv (confirmat live 2026-06-23): RAR accepta NUMAI coduri din nomenclator (coloana `COD_PRESTATIE` max 5 car.); un cod necunoscut da **HTTP 500** (`ORA-12899`), iar RAR **NU e tranzactional** → lasa un record PARTIAL `FINALIZATA` (terminal) chiar pe esec, pe care reconcilierea worker-ului l-ar marca fals `sent`. Comportamentul la cod necunoscut/nemapat: `on_unmapped_error` (camp boolean top-level pe `POST /v1/prezentari` + `/valideaza`) = `false` (intra in editor, `needs_mapping`) sau `true` (respinge fara enqueue → `submission_id=null` + `erori`). Default = `accounts.on_unmapped_error_default` (implicit `false`/`0`); precedenta cerere > cont > `false`.

209
DESIGN.md
View File

@@ -29,9 +29,11 @@ sistemul sa ramana discret.
``` ```
--bg: #0f1218 fundal aplicatie --bg: #0f1218 fundal aplicatie
--card: #181c24 suprafete (carduri, modal, inputuri pe fundal) --card: #181c24 suprafete (carduri, modal, inputuri pe fundal)
--card2: #0f1218 fundal input slim / carduri-contor (= --bg, nivelul cel mai adanc)
--ink: #e6e9ef text principal --ink: #e6e9ef text principal
--muted: #8b93a7 text secundar (label-uri, coduri, „by") --muted: #8b93a7 text secundar (label-uri, coduri, „by")
--line: #262b36 borduri, separatoare --line: #262b36 borduri, separatoare
--line2: #1f2530 separator subtire lista slim (mai subtil decat --line)
--accent:#2E74D6 azur ROMFAST — butoane primare, pill activ, linkuri, focus --accent:#2E74D6 azur ROMFAST — butoane primare, pill activ, linkuri, focus
--ok: #2FBF8F sent / succes --ok: #2FBF8F sent / succes
--warn: #E0A93B sending / atentie / Lipsa cod --warn: #E0A93B sending / atentie / Lipsa cod
@@ -43,10 +45,12 @@ sistemul sa ramana discret.
``` ```
--bg: #f5f7fa fundal (alb-rece ca romfast.ro) --bg: #f5f7fa fundal (alb-rece ca romfast.ro)
--card: #ffffff suprafete --card: #ffffff suprafete
--card2: #f5f7fa fundal input slim / carduri-contor (= --bg)
--ink: #1a1d24 text principal --ink: #1a1d24 text principal
--muted: #5c6473 text secundar --muted: #5c6473 text secundar
--line: #e2e5ea borduri --line: #e2e5ea borduri
--accent:#1F66C9 azur, variantă mai inchisa pentru contrast AA pe alb --line2: #eaedf2 separator subtire lista slim (mai subtil decat --line)
--accent:#1F66C9 azur, varianta mai inchisa pentru contrast AA pe alb
--ok: #15803d verde AA pe alb --ok: #15803d verde AA pe alb
--warn: #b45309 chihlimbar AA pe alb --warn: #b45309 chihlimbar AA pe alb
--err: #dc2626 rosu AA pe alb --err: #dc2626 rosu AA pe alb
@@ -60,15 +64,99 @@ Aceleasi neutre-calde inchise; doar accentul difera de azur.
``` ```
--bg: #0e1416 fundal petrol-inchis --bg: #0e1416 fundal petrol-inchis
--card: #161e20 suprafete --card: #161e20 suprafete
--card2: #0e1416 fundal input/contor (= --bg)
--ink: #e6e9ef text principal --ink: #e6e9ef text principal
--muted: #8b93a7 text secundar --muted: #8b93a7 text secundar
--line: #232c2e borduri --line: #232c2e borduri
--line2: #1c2426 separator subtire (intre --bg si --line)
--accent:#0E7C7B teal petrol — butoane, pill activ, linkuri, focus --accent:#0E7C7B teal petrol — butoane, pill activ, linkuri, focus
--ok: #2FBF8F sent --ok: #2FBF8F sent
--warn: #E0A93B atentie --warn: #E0A93B atentie
--err: #E05D5D eroare --err: #E05D5D eroare
``` ```
### Paleta — Grafit (`[data-theme="grafit"]`, tema selectabila — adaugata PRD 5.15)
Similara cu dark, accent azur deschis (preluat din landing, `--infot`). Distinta de dark la cererea
userului (D2). Mapare landing->app: `--text->--ink`, `--sub->--muted`, `--okt->--ok`,
`--errt->--err`, `--infot->--accent`.
```
--bg: #0f1218 fundal (identic cu dark)
--card: #181c24 suprafete
--card2: #0f1218 fundal input/contor (= --bg)
--ink: #e6e9ef text principal
--muted: #8b93a7 text secundar
--line: #262b36 borduri
--line2: #1f2530 separator subtire
--accent:#6ea2ec azur deschis (landing --infot) — linkuri, focus, pill activ
--ok: #2FBF8F sent / succes
--warn: #E0A93B atentie
--err: #E05D5D eroare
```
### Paleta — Cobalt (`[data-theme="cobalt"]`, tema selectabila — adaugata PRD 5.15)
Fundal bleumarin adanc, accent albastru viu. Atmosfera tehnica/corporatista rece.
```
--bg: #080d1c fundal bleumarin adanc
--card: #111a33 suprafete
--card2: #0b1226 fundal input/contor
--ink: #e9ecfb text principal (usor albastrat)
--muted: #8a93b8 text secundar
--line: #1d2747 borduri
--line2: #161f3a separator subtire
--accent:#8aa0ff albastru viu (landing --infot)
--ok: #2fd0a6 sent / succes (teal mai saturat)
--warn: #E0A93B atentie
--err: #f06a7a eroare (roz saturat pe bleumarin)
```
### Paleta — Cupru (`[data-theme="cupru"]`, tema selectabila — adaugata PRD 5.15)
Fundal cald ciocolata, accent chihlimbar. Atmosfera artizanala/calda.
```
--bg: #15110b fundal maro inchis-cald
--card: #211a12 suprafete
--card2: #15110b fundal input/contor (= --bg)
--ink: #efe6d6 text principal (crem cald)
--muted: #a89a85 text secundar
--line: #36291c borduri
--line2: #281e14 separator subtire
--accent:#dfa45c chihlimbar cald (landing --infot)
--ok: #67b98c sent / succes (verde muted-cald)
--warn: #c97d2e atentie (chihlimbar mai inchis)
--err: #e2685a eroare (coral pe maro)
```
### Paleta — Hartie (`[data-theme="hartie"]`, tema selectabila — adaugata PRD 5.15)
Fundal crem cald (hartie vintage), accent albastru clasic. Similara cu light, distinta la cererea
userului. Ambele teme luminoase (hartie + light) respecta contrast AA.
```
--bg: #f3efe6 fundal crem cald
--card: #fffdf7 suprafete (crem-alb)
--card2: #f3efe6 fundal input/contor (= --bg)
--ink: #1e1a13 text principal (maro-inchis, AA pe crem)
--muted: #6a6052 text secundar
--line: #e2dccc borduri
--line2: #ece6d9 separator subtire (mai deschis decat line)
--accent:#1F5FBF albastru clasic (landing --infot = --accent) — 6.5:1 pe --bg, AA
--ok: #1c7d5d sent / succes (verde AA pe crem)
--warn: #b45309 atentie (chihlimbar AA pe crem)
--err: #bd463c eroare (rosu AA pe crem)
```
### Tokeni noi adaugati la PRD 5.15 (in toate cele 7 teme)
```
--card2 fundal input slim si carduri-contor (US-001/002); pe dark = --bg (cel mai adanc nivel)
--line2 separator subtire intre randuri lista slim (US-001/002); mai subtil decat --line
```
### Culori de brand (doar wordmark, NU variabile de UI) ### Culori de brand (doar wordmark, NU variabile de UI)
``` ```
@@ -109,17 +197,27 @@ Inlocuieste comutatorul binar soare/luna cu un **buton ciclic** (pattern ca demo
singur buton care roteste la fiecare click prin setul de teme, cu iconita + tooltip/`aria-label` singur buton care roteste la fiecare click prin setul de teme, cu iconita + tooltip/`aria-label`
care arata tema curenta („Tema: Light" etc.). care arata tema curenta („Tema: Light" etc.).
Ordinea ciclului: **Light → Dark → Petrol → Auto → (inapoi la Light)**. Ordinea ciclului (PRD 5.15 — teme aditive D2):
**Light → Dark → Petrol → Grafit → Cobalt → Cupru → Hartie → Auto → (inapoi la Light)**.
- `Light``data-theme="light"` (azur pe alb) - `Light` `data-theme="light"` (azur pe alb) — ☀
- `Dark``data-theme="dark"` (azur pe inchis, comportamentul implicit actual) - `Dark` `data-theme="dark"` (azur pe inchis, comportamentul implicit actual) — ☾
- `Petrol``data-theme="petrol"` (teal pe petrol-inchis) - `Petrol``data-theme="petrol"` (teal pe petrol-inchis) — ◐
- `Auto` → urmeaza `prefers-color-scheme`; rezolva la Light azur sau Dark azur in functie de OS - `Grafit``data-theme="grafit"` (azur deschis pe negru-grafit, similar dark) — ◑
(nu seteaza `data-theme` fix, ci il deriva la paint). - `Cobalt``data-theme="cobalt"` (albastru viu pe bleumarin adanc) — ◆
- `Cupru``data-theme="cupru"` (chihlimbar pe maro cald) — ◇
- `Hartie``data-theme="hartie"` (albastru clasic pe crem cald, similar light) — ○
- `Auto` → urmeaza `prefers-color-scheme`; rezolva la `light` (OS light) sau `dark` (OS dark). — ◉
Persistenta: preferinta explicita (inclusiv „Auto") in `localStorage`, doar la click. Scriptul Persistenta: preferinta explicita (inclusiv „Auto") in `localStorage`, doar la click. Scriptul
anti-FOUC din `<head>` trebuie sa rezolve „Auto"→light/dark inainte de primul paint (fara blink). anti-FOUC din `<head>` cunoaste toate cele 7+1 stari; valori vechi (light/dark/petrol) raman
Iconite: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto. Default la prima vizita = Auto (OS-aware), ca azi. valide fara migrare fortata; valoare lipsa/necunoscuta → auto (fallback sigur, fara blink).
Implementare DRY (E2 PRD 5.15): configuratia temelor traieste intr-o singura structura JS
`var THEMES = [...]` (sursa de adevar), din care se DERIVA `CYCLE`/`VALID`/`ICONS`/`LABELS`/`NEXT`.
Adaugarea unei teme noi = O singura intrare in `THEMES`.
Default la prima vizita = Auto (OS-aware), ca inainte.
## Componente — note de aplicare ## Componente — note de aplicare
@@ -133,6 +231,99 @@ Iconite: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto. Default la prima vizita = Au
- **Suprafete de stare** (banner, flash, eroare-3n): raman pe `color-mix` peste `--err/--warn/--ok`, - **Suprafete de stare** (banner, flash, eroare-3n): raman pe `color-mix` peste `--err/--warn/--ok`,
deci se adapteaza automat la noua paleta si la light/dark. deci se adapteaza automat la noua paleta si la light/dark.
## Componente slim (PRD 5.15 US-002)
Adaugate in `base.html` (sectiunea `SENTINEL-COMPONENTE-SLIM`). Toate culorile exclusiv prin
`var(--token)` — zero hex hardcodat. Consumate de US-003 (dashboard), US-004 (lista), US-007 (formular).
### `.contor-card`
Card cifra-contor compact: fundal `--card2`, bordura `--line`, `border-radius:8px`, padding 10-12px.
```html
<div class="contor-card">
<div class="contor-cifra s-ok">847</div> <!-- variante de culoare prin .s-ok/.s-err/.s-queued -->
<div class="contor-label">Trimise (total)</div>
<div class="contor-sub">luna 124 · azi 9</div> <!-- optional: sub-linie mono -->
</div>
```
Sub-elemente:
- `.contor-cifra``font-size:22px; font-weight:700`; culoare prin `.s-*` existente
- `.contor-label``font-size:11px; color:var(--muted)`
- `.contor-sub` — IBM Plex Mono, `font-size:10px; color:var(--muted)`
### `.lista-trimiteri-slim` + `.trimitere-slim`
Lista compacta cu separator `--line2`. Randul este clickabil (rol button), tinta `min-height:44px`.
```html
<ul class="lista-trimiteri-slim">
<li class="trimitere-slim" role="button" tabindex="0">
<div>
<div class="slim-vin">WBA8E9...K7F2</div>
<div class="slim-meta">Inspectie tehnica · 09:42</div>
</div>
<span class="pill s-sent">Trimis</span>
</li>
</ul>
```
Sub-elemente:
- `.slim-vin` — IBM Plex Mono, `font-size:13px; font-weight:500; color:var(--ink)`
- `.slim-meta``font-size:11px; color:var(--muted)` (operatie + ora)
### `.camp-slim` + macro `camp(slim=True)`
Varianta compacta de camp formular: label 11px muted deasupra, input `height:30px`, fundal `--card2`.
Integrata in macro-ul `camp` din `_macros.html` prin flagul `slim=False` (default — randarea
actuala ramane neschimbata).
```jinja2
{{ camp('vin', 'VIN (serie sasiu)', vin, slim=True) }}
```
Pentru campuri mono (VIN, odometru, nr. inmatriculare): adauga clasa `camp-mono` pe input
(via `style=""` sau atribut `class=""` direct — macro-ul nu il pune automat, consumatorul decide).
### `.chips` + `.chip` + `.chip-del`
Prestatii multi-select: container `.chips` (fundal `--card2`), item `.chip` (accent 18%, IBM Plex
Mono 11px), buton de stergere `.chip-del` (accesibil cu `aria-label`).
```html
<div class="chips" role="group" aria-label="Prestatii selectate">
<span class="chip">
<button class="chip-del" aria-label="Sterge codul REV2" type="button">&times;</button>
REV2
</span>
<span class="chip chip-warn"> <!-- varianta warn pentru R-ODO/I-ODO -->
<button class="chip-del" aria-label="Sterge codul R-ODO" type="button">&times;</button>
R-ODO
</span>
</div>
```
Clase aditionale:
- `.chip-warn` — fundal `--warn` 22% (pentru coduri R-ODO/I-ODO care cer odometruInitial)
### `.add-code` + `.op-row` (picker E4)
Buton dashed pentru adaugare cod (`.add-code`) si randul operatie<->cod (`.op-row`, `.op-row-name`,
`.op-row-warn`). Folosite de picker-ul E4 din US-007 (formular editare).
```html
<div class="op-row">
<span class="op-row-name">REVIZIE PERIODICA</span>
<span class="chip">REV2 <button class="chip-del" ...>&times;</button></span>
<button class="add-code" type="button">+ alt cod</button>
</div>
<div class="op-row op-row-warn"> <!-- bordura warn: lipsa cod -->
<span class="op-row-name">SCHIMBARE PLACUTE FRANA</span>
<button class="add-code" type="button">alege cod RAR</button>
</div>
```
## Ce NU schimbam ## Ce NU schimbam
- Mecanismul light/dark existent (anti-FOUC, persistenta `localStorage`, comutator) — il pastram, - Mecanismul light/dark existent (anti-FOUC, persistenta `localStorage`, comutator) — il pastram,

View File

@@ -3,10 +3,17 @@
FROM python:3.12-slim FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 PYTHONDONTWRITEBYTECODE=1 \
# Fus orar RO: SQLite 'localtime' (bucketare contoare azi/luna, E7) depinde de TZ.
# tzdata ofera baza de fusuri; TZ alege Europe/Bucharest (DST-aware, UTC+2/+3).
TZ=Europe/Bucharest
WORKDIR /app WORKDIR /app
# tzdata = necesar pentru ca 'localtime' din SQLite sa rezolve Europe/Bucharest.
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

View File

@@ -186,6 +186,14 @@ def _resolve_row_for_preview(
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}]
# obs derive-on-empty (D7/E3 PRD 5.15): daca obs e gol si avem operatie,
# copiem denumirea operatiei in obs (nu o mutam — op_service ramane neatins).
# DERIVE-ON-EMPTY: doar cand obs e gol, ca sa fie idempotent la re-preview/re-editare.
obs_curent = str(mapped.get("obs") or "").strip()
if not obs_curent and operatie_val:
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["obs"] = obs_denumire
# Canonicalizare: normalizeaza VIN/nr/odometru # Canonicalizare: normalizeaza VIN/nr/odometru
canon = canonicalize_row(mapped) canon = canonicalize_row(mapped)
mapped.update({ mapped.update({
@@ -257,8 +265,9 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) ->
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza # Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
# aici (raman in panoul de mapare). # aici (raman in panoul de mapare). obs = text liber, se trateaza ca non-canonic
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final") # (doar .strip(), fara canonicalize_row) — urmeaza ramura `else` din _merge_override.
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs")
def _merge_override(current: dict[str, Any], fields: dict[str, str | None]) -> dict[str, Any]: def _merge_override(current: dict[str, Any], fields: dict[str, str | None]) -> dict[str, Any]:
@@ -279,7 +288,15 @@ def _merge_override(current: dict[str, Any], fields: dict[str, str | None]) -> d
continue continue
s = str(val).strip() s = str(val).strip()
if s == "": if s == "":
out.pop(camp, None) # empty = clear if camp == "obs":
# obs e camp DERIVAT (copiaza denumirea operatiei cand e gol). Empty =
# STERGERE EXPLICITA a userului -> pastram obs='' in override ca
# derive-on-empty sa NU il re-deriveze (override aplicat ULTIMUL
# suprascrie derivarea, in preview si la commit). Un pop ar fi pierdut
# semnalul "sters explicit" -> obs re-derivat silentios din denumire.
out["obs"] = ""
else:
out.pop(camp, None) # empty = clear (revine la valoarea din fisier)
else: else:
raw[camp] = s raw[camp] = s
if raw: if raw:
@@ -1078,6 +1095,13 @@ def commit_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}]
# obs derive-on-empty (D7/E3 PRD 5.15): copiere denumire in obs daca obs e gol.
# Identic cu logica din _resolve_row_for_preview (override aplicat tot ultimul).
obs_curent = str(mapped.get("obs") or "").strip()
if not obs_curent and operatie_val:
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["obs"] = obs_denumire
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview) # Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
prestatii = mapped.get("prestatii") or [] prestatii = mapped.get("prestatii") or []
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules) resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
@@ -1180,12 +1204,14 @@ def commit_import(
class RandEditIn(BaseModel): class RandEditIn(BaseModel):
"""Campuri de continut editabile in preview. None = ne-trimis (neschimbat); """Campuri de continut editabile in preview. None = ne-trimis (neschimbat);
"" = sterge override-ul (revine la valoarea din fisier).""" "" = sterge override-ul (revine la valoarea din fisier).
obs = text liber fara validare de continut (US-005 PRD 5.15)."""
vin: str | None = None vin: str | None = None
nr_inmatriculare: str | None = None nr_inmatriculare: str | None = None
data_prestatie: str | None = None data_prestatie: str | None = None
odometru_initial: str | None = None odometru_initial: str | None = None
odometru_final: str | None = None odometru_final: str | None = None
obs: str | None = None
@router.post("/{import_id}/rand/{row_index}/editeaza") @router.post("/{import_id}/rand/{row_index}/editeaza")

View File

@@ -104,6 +104,13 @@ class Settings(BaseSettings):
worker_retry_max_s: int = 300 worker_retry_max_s: int = 300
worker_max_retries: int = 8 # peste atat -> error + banner worker_max_retries: int = 8 # peste atat -> error + banner
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
# DEZACTIVAT implicit: prima folosire lazy-load-eaza modelul fastembed/ONNX
# (~230MB pe disc) sincron in thread-ul de cerere -> hang la prima cerere /mapari.
# Activeaza explicit in productie (start.sh/Docker/.env) cand vrei sugestii semantice.
# OFF pastreaza suita de teste rapida si /mapari instant (cade pe GOLD/SILVER+fuzzy).
embeddings_enabled: bool = False
@property @property
def rar_base_url(self) -> str: def rar_base_url(self) -> str:
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test

246
app/embeddings.py Normal file
View File

@@ -0,0 +1,246 @@
"""Modul embedding in-proces pentru sugestie cod RAR -- L14-S4.
Design (PRD 5.14, Decision #16/#16b):
- Model multilingv via fastembed/ONNX (~230MB pe disc, quantizat, fara torch)
- Lazy load la prima folosire, NU la import si NU pe /healthz
- Worker NU incarca modelul (API-only)
- Degradare gratioasa: daca modelul nu se incarca -> is_available()=False,
suggest_nearest() -> [] fara exceptie, ingestia NU e blocata
- Embeddings = DOAR sugestie (nu intra in lantul de enqueue/resolve_prestatii)
- NU apelat din resolve_prestatii/load_mapping (wiring vine in L14-S6 DUPA 5.15)
API public (nivel modul):
index_corpus(items) -> None
suggest_nearest(text, top_k) -> [{cod, similaritate}]
is_available() -> bool
Clase (pentru teste / injectare backend):
EmbeddingEngine(backend) -- motor testabil cu backend injectabil
FastEmbedBackend() -- backend real fastembed/ONNX
"""
from __future__ import annotations
import logging
import math
from typing import Protocol, runtime_checkable
log = logging.getLogger(__name__)
# Modelul ales: paraphrase-multilingual-MiniLM-L12-v2
# ~230MB pe disc (ONNX quantizat), 384 dim, multilingv (ro/en/etc.), suportat de
# fastembed, fara torch. (Estimarea initiala din PRD de ~50MB a fost gresita.)
FASTEMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
# --------------------------------------------------------------------------- #
# Protocol backend (mockabil in teste) #
# --------------------------------------------------------------------------- #
@runtime_checkable
class EmbeddingBackend(Protocol):
"""Interfata minimala pentru un backend de embedding."""
def embed(self, texts: list[str]) -> list[list[float]]:
"""Intoarce o lista de vectori (cate unul per text)."""
...
# --------------------------------------------------------------------------- #
# Backend real: fastembed/ONNX #
# --------------------------------------------------------------------------- #
class FastEmbedBackend:
"""Backend fastembed/ONNX. Lazy-load la constructie.
Arunca ImportError daca fastembed nu e instalat, sau orice exceptie
de la TextEmbedding (download esuat, ONNX incompatibil etc.).
Apelantul (_load_engine) prinde aceste exceptii.
"""
def __init__(self, model_name: str = FASTEMBED_MODEL):
from fastembed import TextEmbedding # import tardiv -- nu blocheaza la import modul
self._model = TextEmbedding(model_name=model_name)
def embed(self, texts: list[str]) -> list[list[float]]:
# fastembed.embed() intoarce un generator de numpy arrays
return [vec.tolist() for vec in self._model.embed(texts)]
# --------------------------------------------------------------------------- #
# Motor de embedding (testabil, backend injectabil) #
# --------------------------------------------------------------------------- #
def _cosine_similarity(a: list[float], b: list[float]) -> float:
"""Similaritate cosine intre doi vectori. Returneaza 0.0 pe vectori nuli."""
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(x * x for x in b))
if na == 0.0 or nb == 0.0:
return 0.0
return dot / (na * nb)
class EmbeddingEngine:
"""Motor de embedding cu corpus indexat si cautare NN cosine.
Parametri:
backend: instanta EmbeddingBackend (real sau mock).
None => degradare gratioasa (is_available=False).
"""
def __init__(self, backend: EmbeddingBackend | None = None):
self._backend = backend
self._corpus_vecs: list[list[float]] = []
self._corpus_items: list[dict] = []
self._corpus_sig: str | None = None
def is_available(self) -> bool:
"""True daca backend-ul e disponibil si gata de folosire."""
return self._backend is not None
def has_corpus(self) -> bool:
"""True daca un corpus a fost indexat (suggest_nearest poate produce ceva)."""
return bool(self._corpus_items)
def corpus_signature(self) -> str | None:
"""Semnatura corpusului indexat (None daca gol). Apelantul re-indexeaza
doar cand semnatura nomenclatorului s-a schimbat (evita re-embed inutil)."""
return self._corpus_sig
def index_corpus(self, items: list[dict], signature: str | None = None) -> None:
"""Vectorizeaza corpus [{denumire, cod}] si il pastreaza in memorie.
Ignora silentios daca backend-ul lipseste, corpus-ul e gol sau apare
orice exceptie la vectorizare (degradare gratioasa).
"""
self._corpus_vecs = []
self._corpus_items = []
self._corpus_sig = None
if not items or not self.is_available():
return
try:
texts = [str(item["denumire"]) for item in items]
vecs = self._backend.embed(texts)
self._corpus_vecs = vecs
self._corpus_items = list(items)
self._corpus_sig = signature
except Exception as exc:
log.warning("embeddings: index_corpus esuat: %s", exc)
# corpus ramane gol -- suggest_nearest va returna []
def suggest_nearest(
self,
denumire: str,
top_k: int = 3,
) -> list[dict]:
"""Returneaza top_k vecini cosine [{cod, similaritate}].
Returneaza [] daca backend-ul lipseste, corpus-ul e gol sau apare
orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
"""
if not self.is_available() or not self._corpus_items:
return []
try:
query_vecs = self._backend.embed([str(denumire)])
query_vec = query_vecs[0]
scored = [
{
"cod": item["cod"],
"similaritate": _cosine_similarity(query_vec, vec),
}
for item, vec in zip(self._corpus_items, self._corpus_vecs)
]
scored.sort(key=lambda r: r["similaritate"], reverse=True)
return scored[:top_k]
except Exception as exc:
log.warning("embeddings: suggest_nearest esuat: %s", exc)
return []
# --------------------------------------------------------------------------- #
# Singleton global cu lazy load (API-only, NU worker) #
# --------------------------------------------------------------------------- #
_engine: EmbeddingEngine | None = None
def _load_engine() -> EmbeddingEngine:
"""Lazy load: construieste engine-ul la prima folosire.
Captureaza ORICE exceptie la incarcare (import, download, ONNX init)
si returneaza un engine degradat (backend=None) -- ingestia continua
pe exact+fuzzy, embedding = sugestie dezactivata.
"""
try:
backend = FastEmbedBackend()
log.info("embeddings: backend fastembed incarcat (%s)", FASTEMBED_MODEL)
return EmbeddingEngine(backend=backend)
except ImportError:
log.warning(
"embeddings: fastembed nu e instalat -- sugestii NN dezactivate"
)
except Exception as exc:
log.warning(
"embeddings: incarcare backend esuata (%s) -- sugestii NN dezactivate",
exc,
)
return EmbeddingEngine(backend=None)
def _get_engine() -> EmbeddingEngine:
"""Returneaza engine-ul global (lazy-init)."""
global _engine
if _engine is None:
_engine = _load_engine()
return _engine
# --------------------------------------------------------------------------- #
# API public la nivel de modul (wiring L14-S6) #
# --------------------------------------------------------------------------- #
def is_available() -> bool:
"""True daca modelul e incarcat si gata de folosire."""
return _get_engine().is_available()
def has_corpus() -> bool:
"""True daca un corpus a fost indexat in motorul global.
NU forteaza incarcarea modelului: daca engine-ul nu a fost initializat inca
(`_engine is None`), corpus-ul e gol prin definitie -> False, fara cost.
Apelantii (ex. enrich_suggestions) folosesc asta ca poarta ieftina inainte de
a atinge calea scumpa (is_available/suggest_nearest, care lazy-load ~230MB).
"""
if _engine is None:
return False
return _engine.has_corpus()
def corpus_signature() -> str | None:
"""Semnatura corpusului global indexat (None daca engine ne-initializat/gol).
NU forteaza incarcarea modelului: `_engine is None` -> None fara cost.
"""
if _engine is None:
return None
return _engine.corpus_signature()
def index_corpus(items: list[dict], signature: str | None = None) -> None:
"""Vectorizeaza corpus [{denumire, cod}] in motorul global.
Silentios pe eroare (degradare gratioasa).
"""
_get_engine().index_corpus(items, signature=signature)
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]:
"""Returneaza top_k sugestii [{cod, similaritate}] sau [] la eroare.
Sigur de apelat indiferent de starea backend-ului.
"""
return _get_engine().suggest_nearest(denumire, top_k=top_k)

View File

@@ -14,6 +14,7 @@ unit-testabile direct. Cele cu `conn` sunt helpere de persistenta.
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import unicodedata import unicodedata
from typing import Any from typing import Any
@@ -483,10 +484,18 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
entry["denumire"] = item.get("denumire") entry["denumire"] = item.get("denumire")
entry["_ids"].add(r["id"]) entry["_ids"].add(r["id"])
# Indexeaza corpusul embeddings o data inainte de bucla (no-op cand flagul e off).
ensure_embeddings_corpus(conn, nomenclator)
out: list[dict] = [] out: list[dict] = []
for entry in agg.values(): for entry in agg.values():
entry["blocked"] = len(entry.pop("_ids")) entry["blocked"] = len(entry.pop("_ids"))
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5) entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
# L14-S6: imbogatire sugestii cu GOLD partajat > SILVER > embeddings (Eng-F2).
# SUGGESTION-ONLY: nu intra in resolve_prestatii/load_mapping (#13).
enriched = enrich_suggestions(conn, entry["denumire"])
entry["sugestie_principala"] = enriched["sugestie_principala"]
entry["surse_sugestie"] = enriched["surse"]
out.append(entry) out.append(entry)
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"])) out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
return out return out
@@ -561,6 +570,148 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
) )
# Prag minim de similaritate cosine pentru sugestia din embeddings NN.
# Sub acest scor, sugestia NN e prea incerta si nu o afisam (previne recomandari
# irelevante cand corpus-ul e mic sau neindexat corect).
EMB_MIN_SIMILARITATE = 0.5
def _corpus_signature(nomenclator: list[dict]) -> str:
"""Semnatura stabila a nomenclatorului pentru cache-ul corpusului embeddings.
Hash pe perechile (cod, denumire) sortate dupa cod -> se schimba la orice
add/remove/redenumire de cod, ramane stabila altfel (evita re-embed inutil).
"""
pairs = sorted(
(str(n.get("cod_prestatie") or ""), str(n.get("nume_prestatie") or ""))
for n in nomenclator
)
blob = "".join(f"{c}{d}" for c, d in pairs)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
"""Construieste/actualizeaza corpusul embeddings din nomenclator (Stratul 2 PRD 5.14).
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default OFF): cand e dezactivat, e un
no-op total (nu atinge modelul, nu interogheaza nomenclatorul) -> /mapari instant
+ suita de teste rapida; sugestiile cad pe GOLD/SILVER + fuzzy.
Cand e activat: indexeaza corpusul {denumire=nume_prestatie, cod=cod_prestatie}
o singura data (lazy-load modelul ~230MB la prima chemare), re-indexeaza doar
cand semnatura nomenclatorului s-a schimbat. Degradare gratioasa: orice eroare
(model absent, embed esuat) lasa corpusul gol -> enrich_suggestions cade pe restul.
Apelat de apelantii care imbogatesc sugestii (pending_unmapped,
_nemapate_pentru_submission) INAINTE de bucla de enrich_suggestions, NU din
enrich_suggestions (care ramane o interogare ieftina cu garda has_corpus()).
"""
from .config import get_settings
if not get_settings().embeddings_enabled:
return
try:
from . import embeddings as _emb
nomen = nomenclator if nomenclator is not None else load_nomenclator(conn)
if not nomen:
return
sig = _corpus_signature(nomen)
if _emb.corpus_signature() == sig and _emb.has_corpus():
return # deja indexat pe acelasi nomenclator -> nimic de facut
items = [
{"denumire": str(n["nume_prestatie"]), "cod": str(n["cod_prestatie"])}
for n in nomen
if n.get("nume_prestatie") and n.get("cod_prestatie")
]
_emb.index_corpus(items, signature=sig)
except Exception:
pass # degradare gratioasa (#16b): esecul indexarii nu blocheaza editorul
def enrich_suggestions(
conn,
denumire: str | None,
*,
include_embeddings: bool = True,
) -> dict:
"""Imbogateste sugestiile cu GOLD partajat, SILVER LLM si embeddings NN.
Precedenta Eng-F2 (pentru sugestie-only, nu auto-send):
shared GOLD > SILVER > embeddings
(Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping;
nu apare in needs_mapping, deci nu e in precedenta de sugestie.)
Returneaza:
{
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None}
}
INVARIANTE:
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13).
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie (#4).
- Degradare gratioasa pe embeddings (#16b): daca motorul nu e disponibil sau arunca,
returneaza sugestia disponibila din celelalte surse, fara exceptie.
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store
importa normalize_for_match din mapping).
"""
sugestie_principala: dict | None = None
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None}
if not denumire:
return {"sugestie_principala": sugestie_principala, "surse": surse}
# Colecteaza TOATE sursele (fara short-circuit) in `surse`: editorul le poate afisa
# toate, independent de care castiga ca sugestie principala.
# Precedenta Eng-F2 se aplica DOAR la alegerea sugestiei_principale.
# 1. GOLD partajat cross-account (validat de om, cel mai de incredere)
try:
from .shared_store import lookup_shared_gold
row_gold = lookup_shared_gold(conn, denumire)
if row_gold and row_gold["cod_prestatie"]:
surse["gold_partajat"] = str(row_gold["cod_prestatie"])
except Exception:
pass # degradare gratioasa
# 2. SILVER LLM (bootstrap, nevalidat de om; is_nul = supresie)
try:
from .shared_store import lookup_suggestion
row_silver = lookup_suggestion(conn, denumire)
if row_silver and not row_silver["is_nul"] and row_silver["cod_prestatie"]:
surse["silver"] = str(row_silver["cod_prestatie"])
except Exception:
pass # degradare gratioasa
# 3. Embeddings NN (similaritate semantica, degradare gratioasa #16b)
if include_embeddings:
try:
from . import embeddings as _emb
# Poarta IEFTINA: nu atinge is_available()/suggest_nearest cand corpus-ul
# e gol — `is_available()` lazy-load-eaza modelul de ~230MB (30-120s in
# thread-ul de cerere). Corpusul se construieste de apelant prin
# ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand
# flagul e off, has_corpus() ramane False si calea e un no-op real.
if _emb.has_corpus():
nn = _emb.suggest_nearest(str(denumire), top_k=1)
# Prag minim: similaritate prea mica = sugestie inutila.
# Evita recomandari irelevante cand corpus-ul e mic/partial.
if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
surse["embedding"] = str(nn[0]["cod"])
except Exception:
pass # degradare gratioasa (#16b): motorul absent nu blocheaza
# Alege sugestia principala in ordinea de precedenta: GOLD > SILVER > embeddings
if surse["gold_partajat"]:
sugestie_principala = {"cod_prestatie": surse["gold_partajat"], "sursa": "gold_partajat"}
elif surse["silver"]:
sugestie_principala = {"cod_prestatie": surse["silver"], "sursa": "silver"}
elif surse["embedding"]:
sugestie_principala = {"cod_prestatie": surse["embedding"], "sursa": "embedding"}
return {"sugestie_principala": sugestie_principala, "surse": surse}
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.

View File

@@ -125,6 +125,10 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
# altfel ar expune denumirea RAR drept op. de service, ceea ce e semantic incorect. # altfel ar expune denumirea RAR drept op. de service, ceea ce e semantic incorect.
op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else "" op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else ""
# obs: text liber observatii (camp RAR, optional). Conventie goala "" (nu EMPTY).
# US-005 PRD 5.15: obs traieste in payload_json (nu coloana separata).
obs = _clean_str(data.get("obs"))
return { return {
"vehicul_nr": nr or EMPTY, "vehicul_nr": nr or EMPTY,
"vin": vin or EMPTY, "vin": vin or EMPTY,
@@ -137,4 +141,5 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
# 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,
"obs": obs,
} }

View File

@@ -200,6 +200,42 @@ CREATE TABLE IF NOT EXISTS operation_text_rules (
); );
CREATE INDEX IF NOT EXISTS idx_text_rules_account ON operation_text_rules(account_id); CREATE INDEX IF NOT EXISTS idx_text_rules_account ON operation_text_rules(account_id);
-- Sugestii de mapare (strat SILVER, L14-S3 PRD 5.14).
-- Etichete LLM/embedding — bootstrap; citita DOAR de suggest_codes/pending_unmapped,
-- NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
-- Cheia = denumire normalizata (fara diacritice, uppercase, spatii colapsate).
-- is_nul=1: non-operatie (ITP, discount, nr. inmatriculare) -> suprima (#4), cod NULL.
-- INSERT OR IGNORE la re-seed: nu suprascrie randuri existente (#2).
CREATE TABLE IF NOT EXISTS mapping_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
denumire_normalizata TEXT NOT NULL UNIQUE,
cod_prestatie TEXT, -- NULL cand is_nul=1 (supresie)
is_nul INTEGER NOT NULL DEFAULT 0 CHECK (is_nul IN (0, 1)),
source TEXT NOT NULL, -- 'llm', 'embedding', etc. (#5)
confidence REAL NOT NULL DEFAULT 0.0 CHECK (confidence >= 0.0 AND confidence <= 1.0),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_mapping_suggestions_cod
ON mapping_suggestions(cod_prestatie) WHERE cod_prestatie IS NOT NULL;
-- Mapari validate de oameni (strat GOLD partajat cross-account, L14-S3 PRD 5.14).
-- Confirmarile umane din ORICE cont contribuie la acest store (#8).
-- cross-account = suggestion-only (pre-completeaza editorul, F-A/#11), NU auto-send.
-- Auto-send DOAR din operations_mapping (GOLD propriu per-cont, Eng-F2).
-- Cheia = denumire_normalizata (NU cod_op_service: spatii de chei diferite, #14).
CREATE TABLE IF NOT EXISTS shared_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
denumire_normalizata TEXT NOT NULL UNIQUE,
cod_prestatie TEXT NOT NULL, -- cod RAR valid (GOLD = validat de om)
source TEXT NOT NULL DEFAULT 'human', -- 'human', 'human_import' (#5)
provenance TEXT, -- detalii: cont, email, batch (#5)
confidence REAL NOT NULL DEFAULT 1.0,
confirmations INTEGER NOT NULL DEFAULT 1, -- contor confirmari din orice cont
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici. -- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
CREATE TABLE IF NOT EXISTS worker_heartbeat ( CREATE TABLE IF NOT EXISTS worker_heartbeat (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),

139
app/shared_store.py Normal file
View File

@@ -0,0 +1,139 @@
"""Store partajat pentru sugestii (SILVER) si mapari validate de oameni (GOLD cross-account).
Straturi (L14-S3, PRD 5.14):
- mapping_suggestions (SILVER): sugestii LLM/embedding, citite DOAR de suggest/pending_unmapped,
NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
- shared_mappings (GOLD partajat): mapari validate de om din orice cont; pot pre-completa
editorul (suggestion-only cross-account, F-A/#11); auto-send DOAR GOLD propriu
(operations_mapping per-cont, Eng-F2).
Invariante:
- INSERT OR IGNORE la seed: nu suprascrie randuri existente (#2).
- NUL = is_nul=1, cod_prestatie NULL; NU se promoveaza la cod RAR (#4).
- source/confidence pe fiecare rand (provenienta + rollback batch model prost, #5).
- Wiring in resolve_prestatii/load_mapping vine in L14-S6; modulul de fata e API pur.
"""
from __future__ import annotations
import sqlite3
from typing import Any
from .mapping import normalize_for_match
def seed_suggestions(
conn: sqlite3.Connection,
items: list[dict[str, Any]],
) -> int:
"""Insereaza sugestii in mapping_suggestions (SILVER). INSERT OR IGNORE.
Nu suprascrie randuri deja existente (#2): re-rularea seeder-ului e sigura.
Fiecare item trebuie sa contina:
- 'denumire': str — text brut (se normalizeaza intern cu normalize_for_match)
- 'source': str — 'llm', 'embedding', etc.
Optional:
- 'cod_prestatie': str | None — ignorat cand is_nul=True
- 'is_nul': bool — True pt non-operatii (supresie, #4); cod_prestatie stocat NULL
- 'confidence': float — 0..1 (default 0.0)
Returneaza numarul de randuri inserate efectiv (0 daca toate existau deja).
"""
inserted = 0
for item in items:
den_norm = normalize_for_match(item.get("denumire") or "")
if not den_norm:
continue
is_nul = 1 if item.get("is_nul") else 0
# NUL -> cod NULL obligatoriu (supresie stricta, #4)
cod = None if is_nul else ((item.get("cod_prestatie") or "") or None)
if cod:
cod = cod.strip().upper()
source = str(item.get("source") or "llm")
confidence = float(item.get("confidence") or 0.0)
cur = conn.execute(
"""
INSERT OR IGNORE INTO mapping_suggestions
(denumire_normalizata, cod_prestatie, is_nul, source, confidence)
VALUES (?, ?, ?, ?, ?)
""",
(den_norm, cod, is_nul, source, confidence),
)
inserted += cur.rowcount
return inserted
def lookup_suggestion(
conn: sqlite3.Connection,
denumire: str,
) -> sqlite3.Row | None:
"""Cauta sugestie SILVER dupa denumire normalizata.
Returneaza randul din mapping_suggestions sau None daca nu exista.
NOTA: apelantul trebuie sa verifice is_nul inainte de a folosi cod_prestatie.
"""
den_norm = normalize_for_match(denumire)
if not den_norm:
return None
return conn.execute(
"SELECT * FROM mapping_suggestions WHERE denumire_normalizata = ?",
(den_norm,),
).fetchone()
def lookup_shared_gold(
conn: sqlite3.Connection,
denumire: str,
) -> sqlite3.Row | None:
"""Cauta mapare GOLD partajata dupa denumire normalizata.
Returneaza randul din shared_mappings sau None daca nu exista.
NOTA (F-A/#11): acest GOLD partajat e suggestion-only cross-account;
auto-send vine DOAR din operations_mapping (GOLD propriu per-cont).
"""
den_norm = normalize_for_match(denumire)
if not den_norm:
return None
return conn.execute(
"SELECT * FROM shared_mappings WHERE denumire_normalizata = ?",
(den_norm,),
).fetchone()
def record_human_validation(
conn: sqlite3.Connection,
denumire: str,
cod_prestatie: str,
*,
source: str = "human",
provenance: str | None = None,
confidence: float = 1.0,
) -> None:
"""Inregistreaza o mapare validata de om in GOLD partajat (shared_mappings).
Daca denumirea exista deja: incrementeaza confirmations + actualizeaza updated_at.
Daca nu exista: insert nou cu confirmations=1.
Apelat la confirmarea umana a unei mapari (din editorul needs_mapping).
Wiring efectiv vine in L14-S6 (dupa 5.15); aceasta functie e API-ul store.
NOTA: NU intra in operations_mapping (GOLD per-cont) — acela e gestionat
separat de editorul existent. Ambele pot coexista.
"""
den_norm = normalize_for_match(denumire)
if not den_norm:
return
cod = (cod_prestatie or "").strip().upper()
if not cod:
return
conn.execute(
"""
INSERT INTO shared_mappings
(denumire_normalizata, cod_prestatie, source, provenance, confidence, confirmations)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(denumire_normalizata) DO UPDATE SET
confirmations = confirmations + 1,
updated_at = datetime('now')
""",
(den_norm, cod, source, provenance, confidence),
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
{#
_chips_prestatii.html — sectiunea de prestatii chips (E4, server-driven via /form-chips).
Re-randata de endpoint-ul /form-chips la fiecare add/remove de chip.
Inclusa si din _form_editare.html pentru randarea initiala.
Starea chip-urilor traieste in input-uri hidden din form (NU in DB mid-edit).
Fiecare operatie are un picker propriu cand e nemapata (E4 binding op<->cod).
Reveal odometru initial semnalat prin data-has-r-odo="true" si chip-warn pe R-ODO/I-ODO.
Context vars (toate cu defaults):
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire}
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru picker
has_r_odo — True daca orice chip e R-ODO sau I-ODO (server-computed)
form_chips_url — URL pentru HTMX; default '/form-chips'
chips_section_id — ID div (default 'chips-section')
csrf_token — CSRF (trecut prin hx-include din form parinte)
#}
{% set _chips_url = form_chips_url or '/form-chips' %}
{% set _sec_id = chips_section_id or 'chips-section' %}
{% set _chips = prestatii_chips or [] %}
{% set _has_ops = _chips | selectattr('cod_op_service') | list | length > 0 %}
{# US-009: chips_submission_id e setat din _detaliu_ctx cand chips sunt randate in modalul de detaliu.
Lipseste cand _chips_prestatii.html e rerandat via /form-chips (stateless, fara submission). #}
{% set _sub_id = chips_submission_id if chips_submission_id is defined else none %}
<div id="{{ _sec_id }}" data-has-r-odo="{{ 'true' if has_r_odo else 'false' }}"
aria-live="polite" aria-label="Prestatii cod RAR">
{# ===== Input-uri hidden pentru starea curenta a chip-urilor =====
TOATE itemele emit 3 hidden inputs (cod poate fi "" pentru unmapped).
Paralele index-by-index: cod_prestatie[i], chip_op_service[i], chip_denumire[i].
Filtrate la submit de post_corectie_trimitere (coduri goale = neschimbate). #}
{% for chip in _chips %}
<input type="hidden" name="cod_prestatie" value="{{ chip.cod_prestatie or '' }}">
<input type="hidden" name="chip_op_service" value="{{ chip.cod_op_service or '' }}">
<input type="hidden" name="chip_denumire" value="{{ chip.denumire or '' }}">
{% endfor %}
<div class="camp-slim" style="margin-bottom:8px;">
<label>Prestatii — cod RAR pe fiecare operatie</label>
{% if _has_ops %}
{# ===== Mod operatii: UN picker PE operatie (E4 binding) ===== #}
{% for chip in _chips %}
{% if chip.cod_op_service %}
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
{% set _nemapat = not chip.cod_prestatie %}
<div class="op-row {% if _nemapat %}op-row-warn{% endif %}" style="margin-bottom:6px;">
<span class="op-row-name">
{{ chip.cod_op_service }}
{% if chip.denumire and chip.denumire != chip.cod_op_service %}
<span class="muted" style="font-weight:400;font-size:11px;"> — {{ chip.denumire }}</span>
{% endif %}
{% if _nemapat %}
<span style="color:var(--warn);font-size:10px;font-weight:400;"> · lipsa cod</span>
{% endif %}
</span>
<span style="display:flex;align-items:center;gap:8px;">
{% if chip.cod_prestatie %}
{# ===== Operatie mapata: chip cu × ===== #}
<span class="chip {% if _is_warn %}chip-warn{% endif %}"
aria-label="Prestatie {{ chip.cod_prestatie }} adaugata pentru {{ chip.cod_op_service }}">
{{ chip.cod_prestatie }}
<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","chips_remove_index":{{ loop.index0 }}}'
aria-label="Sterge codul {{ chip.cod_prestatie }} pentru {{ chip.cod_op_service }}">
&times;
</button>
</span>
{# US-009: "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
(in modalul de detaliu, nu la re-randarea stateless via /form-chips).
Reuse EXACT save_mapping + reresolve_account via endpoint dedicat.
hx-include="closest form" propaga csrf_token din form-ul parinte. #}
<span id="save-rule-slot-{{ loop.index0 }}" class="save-rule-slot">
{% if _sub_id and chip.cod_op_service and chip.cod_prestatie %}
<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;"
hx-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
hx-include="closest form"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-vals='{"salveaza_op":{{ chip.cod_op_service | tojson }},"salveaza_cod":{{ chip.cod_prestatie | tojson }}}'
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
salveaza ca regula
</button>
{% endif %}
</span>
{% else %}
{# ===== Operatie nemapata: picker galben cu "alege cod RAR" ===== #}
<select name="chips_add_cod_{{ loop.index0 }}"
id="picker-op-{{ loop.index0 }}"
aria-label="Alege cod RAR pentru {{ chip.cod_op_service }}"
style="min-width:160px;font-size:11px;height:26px;">
<option value="">— alege cod RAR —</option>
{% for n in (nomenclator_rar or []) %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add","chips_add_op_index":{{ loop.index0 }}}'
aria-label="Adauga cod RAR pentru {{ chip.cod_op_service }}">
+ Adauga
</button>
{% endif %}
</span>
</div>
{% endif %}
{% endfor %}
{% 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 }}</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>
{% endif %}
</div>
{% endif %}
{# Hint discret fara chips (debut) #}
{% if not _chips %}
<div style="font-size:10px;color:var(--muted);padding:4px 0;">
Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare.
</div>
{% endif %}
</div>
</div>

View File

@@ -1,17 +1,22 @@
{# _form_editare.html — partial partajat: campurile vehicul/data/odometru. {# _form_editare.html — partial partajat slim: campurile vehicul/data/odo + obs + chips prestatii.
US-005 (PRD 5.12): extras DRY din _trimitere_detaliu.html; refolosit si de US-007 (PRD 5.15): redesign slim cu VIN unic, Observatii textarea, chips prestatii (E4),
_preview_rand.html (US-006) pentru editarea randurilor de import in modal. si reveal dinamic odometru initial cand chips contin R-ODO/I-ODO (D10c, E6 server-driven).
Inclus cu {% include "_form_editare.html" %} INSIDE un <form> element al Inclus cu {% include "_form_editare.html" %} INSIDE un <form> element al
template-ului parinte. Acel parinte pune form-ul, CSRF-ul si orice campuri template-ului parinte. Acel parinte pune form-ul, CSRF-ul si orice campuri
suplimentare (ex. select cod_prestatie din _trimitere_detaliu.html). suplimentare.
Necesita din context (setate de parinte inainte de include): Variabile necesare din context (setate de parinte inainte de include):
form_nr — valoare curenta nr_inmatriculare form_nr — valoare curenta nr_inmatriculare
form_vin — valoare curenta vin form_vin — valoare curenta vin
form_data — valoare curenta data_prestatie (YYYY-MM-DD sau brut) form_data — valoare curenta data_prestatie (YYYY-MM-DD sau brut)
form_odo_final — valoare curenta odometru_final form_odo_final — valoare curenta odometru_final
form_odo_initial — valoare curenta odometru_initial form_odo_initial — valoare curenta odometru_initial
obs_val — valoare curenta obs (Observatii), text liber (default '')
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire} (default [])
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru picker (default [])
has_r_odo — True daca chips contin R-ODO/I-ODO (server-computed, default False)
form_chips_url — URL pentru HTMX chip endpoint (default '/form-chips')
err_map — dict {field_name: mesaj_eroare} (poate fi {}) err_map — dict {field_name: mesaj_eroare} (poate fi {})
fix_map — dict {field_name: hint_fix} (poate fi {}) fix_map — dict {field_name: hint_fix} (poate fi {})
vin_context — string VIN pentru aria-label (poate fi '') vin_context — string VIN pentru aria-label (poate fi '')
@@ -19,23 +24,78 @@
#} #}
{% from "_macros.html" import camp, icon %} {% from "_macros.html" import camp, icon %}
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #} {# === 1. VIN — camp unic (fara "Confirma VIN"; contractul RAR cere un singur VIN) === #}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr, {{ camp('vin', 'VIN (serie sasiu)', form_vin, slim=True, mono=True,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{{ camp('vin', 'VIN (serie sasiu)', form_vin,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }} err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{# Restul campurilor in grila responsiva existenta. #} {# === 2. Data prestatie + Nr. inmatriculare — grila 2 coloane === #}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:0 16px;"> <div style="display:grid; grid-template-columns:1fr 1fr; gap:0 12px;">
{{ camp('data_prestatie', 'Data prestatie', form_data, tip='date', {{ camp('data_prestatie', 'Data prestatiei', form_data, tip='date', slim=True,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }} err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{{ camp('odometru_final', 'Odometru final', form_odo_final, {{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr, slim=True, mono=True,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }} err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
</div> </div>
{# Buton primar parametrizat. {# === 3. Observatii (obs) — textarea liber, US-005 === #}
<div class="camp-slim">
<label for="c-obs">Observatii (operatiile efectuate)</label>
<textarea id="c-obs" name="obs" rows="2"
aria-label="Observatii (operatiile efectuate){% if vin_context %} (VIN: {{ vin_context }}){% endif %}"
placeholder="ex: Revizie; schimbare placute frana">{{ obs_val or '' }}</textarea>
</div>
{# === 4. Prestatii chips (E4 server-driven, US-007) === #}
{% set form_chips_url = form_chips_url or '/form-chips' %}
{% set chips_section_id = 'chips-section' %}
{% include "_chips_prestatii.html" %}
{# === 5. Odometru final — intotdeauna vizibil === #}
{{ camp('odometru_final', 'Odometru final (km)', form_odo_final, slim=True, mono=True,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{# === 6. Odometru initial — reveal dinamic server cand chips contin R-ODO/I-ODO (D10c) ===
has_r_odo=True (server-computed din lista de chips): sectiune vizibila cu marcaj warn.
has_r_odo=False: hint discret, campul optional si vizual neutru. #}
{% if has_r_odo %}
<div class="odo-initial-warn"
style="border-left:2px solid var(--warn);padding-left:10px;margin-left:-2px;">
<div class="camp-slim">
<label for="c-odometru_initial" style="color:var(--warn);">
Odometru initial (km) · necesar pentru R-ODO
</label>
<input id="c-odometru_initial" type="text" name="odometru_initial"
value="{{ form_odo_initial or '' }}"
class="camp-mono"
required
aria-required="true"
style="border-color:color-mix(in srgb,var(--warn) 50%,var(--line));{% if err_map.get('odometru_initial') %}border-color:var(--err);{% endif %}"
aria-label="Odometru initial (VIN: {{ vin_context or '' }}) — necesar pentru R-ODO"
{% if err_map.get('odometru_initial') %}aria-invalid="true"{% endif %}>
{% if err_map.get('odometru_initial') %}
<div class="s-error" style="font-size:12px;margin-top:2px;">{{ err_map.get('odometru_initial') }}</div>
{% endif %}
</div>
</div>
{% else %}
{# Hint discret cand nu e necesar #}
<div class="camp-slim">
<label for="c-odometru_initial" style="color:var(--muted);">Odometru initial (km)</label>
<input id="c-odometru_initial" type="text" name="odometru_initial"
value="{{ form_odo_initial or '' }}"
class="camp-mono"
style="{% if err_map.get('odometru_initial') %}border-color:var(--err);{% endif %}"
aria-label="Odometru initial (optional){% if vin_context %} (VIN: {{ vin_context }}){% endif %}"
{% if err_map.get('odometru_initial') %}aria-invalid="true"{% endif %}>
{% if err_map.get('odometru_initial') %}
<div class="s-error" style="font-size:12px;margin-top:2px;">{{ err_map.get('odometru_initial') }}</div>
{% endif %}
<span style="font-size:10px;color:var(--muted);font-style:italic;">
Odometru initial se cere doar pentru coduri R-ODO / I-ODO.
</span>
</div>
{% endif %}
{# === 7. Buton primar parametrizat ===
with_cancel=True (modal editare preview): Salveaza + Anuleaza pe ACELASI rand, with_cancel=True (modal editare preview): Salveaza + Anuleaza pe ACELASI rand,
sistemul .act (desktop = text alaturat; mobil = doua iconite Lucide 44px alaturate). sistemul .act (desktop = text alaturat; mobil = doua iconite Lucide 44px alaturate).
Implicit (ex. _trimitere_detaliu): un singur buton text, neschimbat. #} Implicit (ex. _trimitere_detaliu): un singur buton text, neschimbat. #}

View File

@@ -18,9 +18,13 @@
vin_context — string VIN pentru aria-label cu context (default '') vin_context — string VIN pentru aria-label cu context (default '')
id_prefix — prefix pentru id="" al input-ului (default 'c'; preview poate folosi 'e-N') id_prefix — prefix pentru id="" al input-ului (default 'c'; preview poate folosi 'e-N')
#} #}
{% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c') %} {% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c', slim=False, mono=False) %}
<div style="margin-bottom:10px;"> {# slim=False: randare clasica (neschimbata). slim=True: varianta compacta (.camp-slim) din US-002 PRD 5.15:
<label for="{{ id_prefix }}-{{ nome }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label> label 11px muted deasupra, input ~30px, fundal --card2.
mono=True (valid numai cu slim=True): adauga clasa 'camp-mono' pe input pentru campuri
VIN/odometru/nr (IBM Plex Mono, prin .camp-slim .camp-mono din base.html). #}
<div {% if slim %}class="camp-slim"{% else %}style="margin-bottom:10px;"{% endif %}>
<label for="{{ id_prefix }}-{{ nome }}"{% if not slim %} class="muted" style="font-size:12px; display:block;"{% endif %}>{{ eticheta }}</label>
{% if tip == 'date' %} {% if tip == 'date' %}
{# D#10/R3: degradare grijulie pentru valori ne-YYYY-MM-DD. {# D#10/R3: degradare grijulie pentru valori ne-YYYY-MM-DD.
Daca valoarea nu e in formatul corect, inputul ramane gol + hint + hidden cu valoarea bruta Daca valoarea nu e in formatul corect, inputul ramane gol + hint + hidden cu valoarea bruta
@@ -28,7 +32,8 @@
{%- set _dp_ok = (valoare and valoare|length == 10 and valoare[4:5] == '-' and valoare[7:8] == '-') -%} {%- set _dp_ok = (valoare and valoare|length == 10 and valoare[4:5] == '-' and valoare[7:8] == '-') -%}
<input id="{{ id_prefix }}-{{ nome }}" type="date" name="{{ nome }}" <input id="{{ id_prefix }}-{{ nome }}" type="date" name="{{ nome }}"
value="{{ valoare if _dp_ok else '' }}" value="{{ valoare if _dp_ok else '' }}"
style="width:100%; {% if err_map.get(nome) %}border-color:var(--err);{% endif %}" {% if slim and mono %}class="camp-mono"{% endif %}
style="{% if not slim %}width:100%; {% endif %}{% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
aria-label="{{ eticheta }}{% if vin_context %} (VIN: {{ vin_context }}){% endif %}" aria-label="{{ eticheta }}{% if vin_context %} (VIN: {{ vin_context }}){% endif %}"
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}> {% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
{% if not _dp_ok and valoare %} {% if not _dp_ok and valoare %}
@@ -38,7 +43,8 @@
{% else %} {% else %}
<input id="{{ id_prefix }}-{{ nome }}" type="{{ tip }}" name="{{ nome }}" <input id="{{ id_prefix }}-{{ nome }}" type="{{ tip }}" name="{{ nome }}"
value="{{ valoare or '' }}" value="{{ valoare or '' }}"
style="width:100%; {% if err_map.get(nome) %}border-color:var(--err);{% endif %}" {% if slim and mono %}class="camp-mono"{% endif %}
style="{% if not slim %}width:100%; {% endif %}{% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
{% if vin_context %}aria-label="{{ eticheta }} (VIN: {{ vin_context }})"{% endif %} {% if vin_context %}aria-label="{{ eticheta }} (VIN: {{ vin_context }})"{% endif %}
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}> {% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
{% endif %} {% endif %}

View File

@@ -37,7 +37,8 @@
<tbody> <tbody>
{% for e in pending %} {% for e in pending %}
{% set top = e.suggestions[0] if e.suggestions else None %} {% set top = e.suggestions[0] if e.suggestions else None %}
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %} {# L14-S6: pre-selectare din sugestie_principala (GOLD/SILVER/embedding) > fuzzy #}
{% set preselect = e.sugestie_principala.cod_prestatie if e.sugestie_principala else (top.cod_prestatie if (top and top.score >= 60) else '') %}
{# 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="{{ e.cod_op_service }} {{ e.denumire or '' }} <tr data-dt-row="{{ e.cod_op_service }} {{ e.denumire or '' }}
{%- for s in e.suggestions[:3] %} {{ s.cod_prestatie }}{% endfor %}"> {%- for s in e.suggestions[:3] %} {{ s.cod_prestatie }}{% endfor %}">
@@ -45,6 +46,8 @@
<form id="map-rez-{{ loop.index }}" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML"> <form id="map-rez-{{ loop.index }}" hx-post="/mapari" 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 '' }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}"> <input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
{# L14-S6: denumire pt record_human_validation in GOLD partajat #}
<input type="hidden" name="denumire" value="{{ e.denumire or '' }}">
</form> </form>
<div><strong>{{ e.cod_op_service }}</strong> <div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="submission-uri blocate">{{ e.blocked }} blocate</span></div> <span class="pill" title="submission-uri blocate">{{ e.blocked }} blocate</span></div>

View File

@@ -4,7 +4,7 @@
hx-swap="outerHTML" hx-swap="outerHTML"
{% if oob %}hx-swap-oob="outerHTML"{% endif %}> {% if oob %}hx-swap-oob="outerHTML"{% endif %}>
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) --> {# Banner cont in asteptare de activare (mereu vizibil cand contul e inactiv) #}
{% if not account_active %} {% if not account_active %}
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn); <div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn);
background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px; font-size:13px;"> background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px; font-size:13px;">
@@ -14,50 +14,68 @@
</div> </div>
{% endif %} {% endif %}
<!-- Rand 1: doua bife binare + ultima autentificare --> {# === D6: Strip sanatate mereu-vizibil DEASUPRA contoarelor ===
<div style="display:flex; gap:28px; flex-wrap:wrap; align-items:center; font-size:14px;"> Verde: worker viu + RAR ok → "Declaratiile curg normal"
Rosu: worker oprit SAU RAR inaccesibil → "Blocat: ... — declaratiile NU pleaca"
{# Bifa: glifa (✓/✗) + culoare + text — accesibil (nu doar culoare, design review) #} Glife accesibile ✓/✗ (nu doar culoare). Layout: glifa+text stanga, ultima auth dreapta.
{% macro bifa(ok, text, tip) %} #}
<span title="{{ tip }}" style="display:inline-flex; align-items:center; gap:7px;"> <div id="strip-sanatate"
{% if ok %} role="status"
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span> aria-live="polite"
<span class="s-sent">{{ text }}</span> style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;
{% else %} padding:10px 14px; border-radius:8px; margin-bottom:14px;
<span class="s-error" aria-hidden="true" style="font-weight:bold;">&#10007;</span> {% if sanatate_ok %}background:color-mix(in srgb, var(--ok) 13%, transparent); border:1px solid color-mix(in srgb, var(--ok) 30%, transparent);
<span class="s-error">{{ text }}</span> {% else %}background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);
{% endif %} {% endif %}">
</span> <div style="display:flex; align-items:center; gap:9px;">
{% endmacro %} {% if sanatate_ok %}
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--ok);">&#10003;</span>
{{ bifa(worker_ok, worker_lbl[0], worker_lbl[1]) }} {% else %}
{{ bifa(rar_ok, rar_lbl[0], rar_lbl[1]) }} <span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--err);">&#10007;</span>
{% endif %}
<span style="display:inline-flex; align-items:center; gap:6px;"> <span style="font-weight:700; font-size:13px;">{{ sanatate_text }}</span>
<span class="muted">{{ eticheta_ultima_auth }}:</span> </div>
<span>{{ last_login }}</span> <span style="font:400 11px/1.4 'IBM Plex Mono',ui-monospace,monospace; color:var(--muted); white-space:nowrap;">
{{ eticheta_ultima_auth }}: {{ last_login }}
</span> </span>
</div> </div>
<!-- Rand 2: contoare coada --> {# === D4: 3 carduri-contor (mockup exact: Trimise / In coada / De corectat) ===
<div style="margin-top:10px; display:flex; gap:20px; flex-wrap:wrap; font-size:14px;"> Responsive: flex-wrap => 3 pe rand desktop, 2/stivuite pe mobil (min-width:120px).
<span><span class="muted">In asteptare:</span> <span class="s-queued">{{ counts_queued }}</span></span> Trimise: all-time (cifra mare) + sub-linie "luna N · azi N" (D4 + E7).
<span><span class="muted">Declarate la RAR:</span> <span class="s-sent">{{ counts_sent }}</span></span> De corectat: rosu cand >0 (s-error), muted cand 0.
<span><span class="muted">Blocate:</span> #}
<span class="{{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</span> <div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:14px;">
</span>
{# Trimise (all-time principal, luna/azi secundar) #}
<div class="contor-card" style="flex:1; min-width:120px;">
<div class="contor-cifra">{{ counts_sent }}</div>
<div class="contor-label">Trimise (total)</div>
<div class="contor-sub">luna {{ sent_month }} &middot; azi {{ sent_today }}</div>
</div>
{# In coada (accent/albastru) #}
<div class="contor-card" style="flex:1; min-width:120px;">
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
<div class="contor-label">In coada</div>
</div>
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
<a href="/" class="contor-card"
style="flex:1; min-width:120px; text-decoration:none; display:block; cursor:pointer;"
aria-label="De corectat: {{ blocate_total }} — click pentru lista de trimiteri">
<div class="contor-cifra {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
<div class="contor-label">De corectat</div>
</a>
</div> </div>
{# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #} {# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping ===
Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ.
{# === Rand 3: navigatie rapida sub contoare (US-005) ===
Linkurile Trimiteri + Mapari apar pe FIECARE pagina sub status-bar.
Marcajul activ vine din variabila de context tab_activ (transmisa de dashboard via ?tab=
sau default 'acasa'). Badge-ul Mapari = mapari_badge (aceeasi sursa: counts.needs_mapping).
#} #}
{% set _tab = tab_activ | default('acasa') %} {% set _tab = tab_activ | default('acasa') %}
<nav class="status-nav" aria-label="Navigatie rapida" <nav class="status-nav" aria-label="Navigatie rapida"
style="margin-top:10px; display:flex; gap:8px 16px; flex-wrap:wrap; font-size:13px; border-top:1px solid var(--line); padding-top:8px;"> style="display:flex; gap:8px 16px; flex-wrap:wrap; font-size:13px; border-top:1px solid var(--line); padding-top:8px;">
<a href="/" <a href="/"
{% if _tab == 'acasa' or _tab == '' %}aria-current="page"{% endif %} {% if _tab == 'acasa' or _tab == '' %}aria-current="page"{% endif %}
class="status-nav-link{% if _tab == 'acasa' or _tab == '' %} status-nav-activ{% endif %}">Trimiteri</a> class="status-nav-link{% if _tab == 'acasa' or _tab == '' %} status-nav-activ{% endif %}">Trimiteri</a>

View File

@@ -12,9 +12,22 @@
{# Versiunea datelor cu care s-a randat tabelul; pollerul "Date noi" o compara. #} {# Versiunea datelor cu care s-a randat tabelul; pollerul "Date noi" o compara. #}
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span> <span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
{% if bulk_message %}
{# Sumar actiune bulk (US-010 PRD 5.15): afisat dupa bulk-fix, disparut la urmatoarea reincarcare. #}
<div class="bulk-message" role="status" aria-live="polite"
style="font-size:13px; color:var(--ink); background:var(--card2);
border:1px solid var(--line); border-radius:6px;
padding:6px 10px; margin-bottom:8px;">
{{ bulk_message }}
</div>
{% endif %}
{% if rows %} {% if rows %}
{# Form de stergere bulk. Selectia opereaza DOAR pe randuri blocate {# Form bulk cu DOUA actiuni: (1) aplica cod RAR la selectate (bulk-fix, US-010),
(gestionabil); sent/sending/queued nu au checkbox (read-only). #} (2) sterge selectate (sterge-bulk). Selectia opereaza DOAR pe randuri blocate
(gestionabil); sent/sending/queued nu au checkbox (read-only).
Butonul "Aplica cod" foloseste hx-post propriu (override form action).
hx-disinherit="hx-confirm" pe form => butonul aplica-cod NU mosteneste confirmare. #}
<form id="bulk-trimiteri" <form id="bulk-trimiteri"
hx-post="/trimiteri/sterge-bulk" hx-post="/trimiteri/sterge-bulk"
hx-target="#submissions-wrap" hx-target="#submissions-wrap"
@@ -23,30 +36,47 @@
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; margin-bottom:8px;"> <div style="display:flex; justify-content:flex-end; align-items:center;
gap:6px; margin-bottom:8px; flex-wrap:wrap;">
{# Bulk-fix: input cod + buton aplica (US-010 PRD 5.15) #}
<input type="text" name="cod_prestatie" id="bulk-fix-cod"
placeholder="Cod RAR (ex: OE-1)"
autocomplete="off" autocapitalize="characters"
style="width:120px; font-size:12px; padding:3px 7px;
border:1px solid var(--line); border-radius:5px;
background:var(--card2); color:var(--ink);"
aria-label="Cod RAR de aplicat la randurile selectate">
<button type="button"
hx-post="/trimiteri/bulk-fix"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="background:var(--card); color:var(--accent); border-color:var(--accent);
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;"
aria-label="Aplica codul RAR la randurile blocate selectate">
Aplica cod
</button>
{# Separator vizual #}
<span style="color:var(--muted); font-size:11px; padding:0 2px;" aria-hidden="true">|</span>
{# Bulk-delete: pastreaza exact comportamentul existent #}
<button type="submit" id="bulk-sterge-btn" <button type="submit" id="bulk-sterge-btn"
style="background:var(--card); color:var(--err); border-color:var(--err); font-size:13px; padding:4px 10px;"> style="background:var(--card); color:var(--err); border-color:var(--err);
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;">
Sterge selectate Sterge selectate
</button> </button>
</div> </div>
<div class="tablewrap tabel-trimiteri">
<table> {# Lista slim trimiteri (US-004, PRD 5.15).
<thead><tr> Inlocuieste tabelul cu randuri compacte: VIN mono + operatie·ora + pill.
<th class="col-chk"><span class="muted" title="Selecteaza randuri blocate">&#10003;</span></th> Nr. inmatriculare, data prestatie si nr. prezentare RAR raman accesibile
<th class="col-id">#</th> pe linia meta discreta (linia 3) si in modalul de detaliu. #}
<th class="col-stare">Stare</th> <ul class="lista-trimiteri-slim" role="list"
<th class="col-vehicul">Vehicul</th> aria-label="Lista trimiteri">
<th class="col-operatie">Operatie</th>
<th class="col-data">Data prestatie</th>
<th class="col-rar">Nr. prezentare RAR</th>
<th class="col-actualizat">Actualizat</th>
</tr></thead>
<tbody>
{% for r in rows %} {% for r in rows %}
{# Randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body). {# Randul slim: stanga = VIN mono scurt (L1) + operatie·ora muted (L2) + meta (L3);
dreapta = pill de stare. Click deschide modalul global (#detaliu-modal-body).
Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #} Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
<tr id="trimitere-row-{{ r.id }}" <li id="trimitere-row-{{ r.id }}"
class="trimitere-row" class="trimitere-slim"
data-detaliu-id="{{ r.id }}" data-detaliu-id="{{ r.id }}"
hx-get="/_fragments/trimitere/{{ r.id }}" hx-get="/_fragments/trimitere/{{ r.id }}"
hx-target="#detaliu-modal-body" hx-target="#detaliu-modal-body"
@@ -55,47 +85,61 @@
aria-haspopup="dialog" aria-haspopup="dialog"
style="cursor:pointer;" style="cursor:pointer;"
title="Click pentru detaliul complet"> title="Click pentru detaliul complet">
<td class="col-chk" onclick="event.stopPropagation();">
{# Zona checkbox — nu declanseaza modalul (stopPropagation).
Vizibila DOAR pe randurile gestionabile (error/needs_data/needs_mapping).
Latimea fixa previne reflow la prezenta/absenta checkbox-ului. #}
<div style="flex:0 0 22px; display:flex; align-items:center;" onclick="event.stopPropagation();">
{% 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 %}
</td> </div>
<td class="col-id muted" data-eticheta="#">{{ r.id }}</td>
<td class="col-stare" data-eticheta="Stare"> {# Bloc text principal — stanga, ocupa spatiul ramas #}
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span> <div style="flex:1 1 auto; min-width:0;">
{# Eticheta umana scurta sub pill — text mic, `s-error` pe error/needs_*
(singurele stari pe care `eticheta_problema` e ne-goala). {# Linia 1: VIN mono scurt (slim-vin).
Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #} Guard: vin_scurt='—' inseamna VIN lipsa; fallback la vehicul_nr. #}
{% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
{% endif %}
</td>
<td class="col-vehicul" data-eticheta="Vehicul">
{{ r.prez.vehicul_nr }}
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %} {% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
{# VIN pe rand separat sub nr (element block, nu span inline) #} <div class="slim-vin">{{ r.prez.vin_scurt }}</div>
<div class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</div>
{% endif %}
</td>
<td class="col-operatie" data-eticheta="Operatie">
<div>{{ r.prez.operatie }}</div>
{# Doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip muted discret;
cand nemapat afiseaza "nemapat" muted. #}
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
<div class="cod-rar-sub"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
{% else %} {% else %}
<div class="muted cod-rar-sub">nemapat</div> <div class="slim-vin muted">{{ r.prez.vehicul_nr }}</div>
{% endif %} {% endif %}
</td>
<td class="col-data" data-eticheta="Data prestatie">{{ r.prez.data_prestatie }}</td> {# Linia 2: Operatie · ora/data (slim-meta muted) #}
<td class="col-rar" data-eticheta="Nr. prezentare RAR">{{ r.id_prezentare or '—' }}</td> <div class="slim-meta">{{ r.prez.operatie }} · {{ r.updated_at }}</div>
<td class="col-actualizat muted" data-eticheta="Actualizat">{{ r.updated_at }}</td>
</tr> {# Cod RAR sau indicatorul 'nemapat': discret sub operatie.
Mentine compatibilitatea cu testele cod_rar: OE-2 vizibil, fara prefix 'cod RAR:'. #}
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
<div class="slim-meta"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
{% else %}
<div class="slim-meta muted cod-rar-sub">nemapat</div>
{% endif %}
{# Linia meta discreta: nr inmatriculare · data prestatie · nr prezentare RAR.
Accesibila pe rand; informatia completa e in modalul de detaliu. #}
<div class="slim-meta" style="opacity:0.7;">
{{ r.prez.vehicul_nr -}}
{%- if r.prez.data_prestatie and r.prez.data_prestatie != '—' %} · {{ r.prez.data_prestatie }}{% endif -%}
{%- if r.id_prezentare %} · #{{ r.id_prezentare }}{% endif %}
</div>
{# Eticheta umana scurta sub pill — text mic, s-error pe error/needs_*.
Afisata DOAR pe randuri cu problema (eticheta_problema ne-goala).
Starea transmisa prin TEXT, nu doar culoare. #}
{% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
<div class="eticheta-problema s-error" style="font-size:10px; margin-top:2px;">{{ r.eticheta_problema }}</div>
{% endif %}
</div>
{# Pill de stare — dreapta, flex:none #}
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}"
style="flex:0 0 auto; white-space:nowrap;">{{ r.stare_scurt }}</span>
</li>
{% endfor %} {% endfor %}
</tbody> </ul>
</table>
</div>
</form> </form>
{# {#

View File

@@ -106,32 +106,10 @@
hx-disabled-elt="find button"> hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator. {# Cleanup B (US-009 PRD 5.15): vechiul <select name="cod_prestatie"> eliminat.
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). Chips din _form_editare.html (via _chips_prestatii.html) il inlocuiesc complet:
RAMANE in _trimitere_detaliu.html (D#5 — logica specifica acestui modal). #} emit hidden inputs name="cod_prestatie" + picker per-operatie (E4, US-007).
{% if nomenclator_rar %} post_corectie_trimitere foloseste form.getlist("cod_prestatie") → compatibil. #}
<div style="margin:0 0 12px;">
<label for="c-cod-prestatie" class="muted" style="font-size:12px; display:block;">Operatie RAR (cod prestatie)</label>
{% if prez.operatie and prez.operatie != '—' %}
<div class="muted" style="font-size:12px; margin-bottom:4px;">{{ prez.operatie }}</div>
{% endif %}
<select id="c-cod-prestatie" name="cod_prestatie" style="max-width:380px; width:100%;"
aria-label="Alege operatia RAR din nomenclator">
<option value="">— pastrat ({{ cod_afis }}) —</option>
{% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
{% else %}
{# Operatie + cod RAR read-only deasupra campurilor (fara eticheta „Cod RAR"). #}
<div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie</div>
<div>{{ prez.operatie }} &middot; {{ cod_afis }}</div>
</div>
{% endif %}
{# Operatie service (cod intern + denumire venita prin API/import), distinct de {# Operatie service (cod intern + denumire venita prin API/import), distinct de
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. operatia RAR mapata. op_service_cod="" cand lipseste → randul absent.
@@ -190,7 +168,7 @@
{% for item in nomenclator_rar %} {% for item in nomenclator_rar %}
<option value="{{ item.cod_prestatie }}" <option value="{{ item.cod_prestatie }}"
{% if item.cod_prestatie == cod_prestatie_curent %}selected{% endif %}> {% if item.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
{{ item.cod_prestatie }} — {{ item.nome_prestatie }} {{ item.cod_prestatie }} — {{ item.nume_prestatie }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>

View File

@@ -16,13 +16,16 @@
<script> <script>
// Anti-FOUC: citeste preferinta tema din localStorage inainte de primul // Anti-FOUC: citeste preferinta tema din localStorage inainte de primul
// paint; seteaza data-theme pe <html> sincron, fara blink. // paint; seteaza data-theme pe <html> sincron, fara blink.
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto. // Cunoaste TOATE cele 7+1 teme: light/dark/petrol/grafit/cobalt/cupru/hartie + auto.
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink). // Valori legacy (light/dark/petrol) raman valide — fara migrare fortata.
// Valoare lipsa/necunoscuta -> auto (fallback sigur, fara blink).
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink):
// auto + dark OS -> 'dark' | auto + light OS -> 'light' (comportament existent pastrat).
(function() { (function() {
var VALID = {light:1, dark:1, petrol:1, auto:1}; var VALID = {light:1, dark:1, petrol:1, grafit:1, cobalt:1, cupru:1, hartie:1, auto:1};
try { try {
var t = localStorage.getItem('theme'); var t = localStorage.getItem('theme');
if (!t || !VALID[t]) t = 'auto'; // fallback: valoare lipsa sau legacy -> auto if (!t || !VALID[t]) t = 'auto'; // fallback: valoare lipsa sau necunoscuta -> auto
if (t === 'auto') { if (t === 'auto') {
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
} }
@@ -100,16 +103,32 @@
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2"); src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
} }
/* Paleta dark (default) — accent azur ROMFAST */ /* Paleta dark (default) — accent azur ROMFAST.
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --card2: fundal input/contor (= --bg, nivelul cel mai adanc).
--line2: separator subtire (intre --bg si --line). */
:root { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; } --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */ /* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea; [data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --card2:#f5f7fa; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea; --line2:#eaedf2;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; } --ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
/* Paleta Petrol — tema intunecata alternativa, accent teal #0E7C7B. /* Paleta Petrol — tema intunecata alternativa, accent teal #0E7C7B.
Wordmark-ul FAST #2E74D6 coexista armonios: ambele sunt reci/saturate, contrast AA pe --card #161e20. */ Wordmark-ul FAST #2E74D6 coexista armonios: ambele sunt reci/saturate, contrast AA pe --card #161e20. */
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e; [data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --card2:#0e1416; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e; --line2:#1c2426;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; } --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
/* Paleta Grafit — similara cu dark, accent azur deschis (#6ea2ec = landing --infot).
Distinta de dark la cererea userului (D2 PRD 5.15). */
[data-theme="grafit"] { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#6ea2ec; }
/* Paleta Cobalt — fundal bleumarin adanc, accent albastru viu (#8aa0ff = landing --infot). */
[data-theme="cobalt"] { --bg:#080d1c; --card:#111a33; --card2:#0b1226; --ink:#e9ecfb; --muted:#8a93b8; --line:#1d2747; --line2:#161f3a;
--ok:#2fd0a6; --warn:#E0A93B; --err:#f06a7a; --accent:#8aa0ff; }
/* Paleta Cupru — fundal cald ciocolata, accent chihlimbar (#dfa45c = landing --infot). */
[data-theme="cupru"] { --bg:#15110b; --card:#211a12; --card2:#15110b; --ink:#efe6d6; --muted:#a89a85; --line:#36291c; --line2:#281e14;
--ok:#67b98c; --warn:#c97d2e; --err:#e2685a; --accent:#dfa45c; }
/* Paleta Hartie — fundal crem cald, accent albastru clasic (#1F5FBF = landing --infot = --accent).
Similara cu light, distinta la cererea userului (D2 PRD 5.15). */
[data-theme="hartie"] { --bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052; --line:#e2dccc; --line2:#ece6d9;
--ok:#1c7d5d; --warn:#b45309; --err:#bd463c; --accent:#1F5FBF; }
* { box-sizing:border-box; } * { box-sizing:border-box; }
/* CONVENTIE BREAKPOINT: un singur prag mobil la 768px. /* CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o
@@ -675,6 +694,62 @@
.sticky-bar { padding:10px 12px; gap:10px; } .sticky-bar { padding:10px 12px; gap:10px; }
.sticky-bar button { width:100%; min-height:44px; } .sticky-bar button { width:100%; min-height:44px; }
} }
/* === SENTINEL-COMPONENTE-SLIM: inceput componente slim US-002 (PRD 5.15).
Testele ancoreaza pe acest marker. Nu muta/sterge. === */
/* .contor-card — card cifra contor: fundal --card2, bordura --line, radius 8px, padding 10-12px.
Variante de culoare a cifrei prin clasele .s-* existente (verde/accent/rosu). */
.contor-card { background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:10px 12px; }
.contor-cifra { font-size:22px; font-weight:700; line-height:1; }
.contor-label { font-size:11px; color:var(--muted); margin-top:5px; }
.contor-sub { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:10px; color:var(--muted); margin-top:3px; }
/* .lista-trimiteri-slim + .trimitere-slim — lista compacta cu separator --line2.
Randul e clickabil (rol button), tinta min-height:44px pe mobil. */
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
.trimitere-slim { display:flex; align-items:center; justify-content:space-between; gap:12px;
padding:11px 14px; border-bottom:1px solid var(--line2); min-height:44px; cursor:pointer; }
.trimitere-slim:last-child { border-bottom:none; }
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.slim-vin { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:13px; font-weight:500; color:var(--ink); }
.slim-meta { font-size:11px; color:var(--muted); margin-top:3px; }
/* .camp-slim — varianta compacta camp formular: label 11px muted deasupra, input ~30px, fundal --card2.
Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
.camp-slim { margin-bottom:8px; }
.camp-slim label { font-size:11px; color:var(--muted); display:block; margin-bottom:4px; }
.camp-slim input, .camp-slim textarea, .camp-slim select { background:var(--card2); height:30px; width:100%;
padding:0 10px; border:1px solid var(--line); border-radius:6px; font:inherit; color:var(--ink); }
.camp-slim textarea { height:auto; min-height:48px; padding:8px 10px; resize:vertical; }
.camp-slim .camp-mono { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:12px; }
/* .chips + .chip — prestatii multi-select cu buton de stergere accesibil (.chip-del).
Fundal accent 18%, font IBM Plex Mono 11px. */
.chips { min-height:30px; display:flex; align-items:center; gap:6px; flex-wrap:wrap;
padding:4px 8px; border:1px solid var(--line); border-radius:6px; background:var(--card2); }
.chip { display:inline-flex; align-items:center; gap:5px; padding:3px 8px; border-radius:5px;
background:color-mix(in srgb, var(--accent) 18%, transparent); color:var(--accent);
font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:11px; font-weight:600; }
.chip .chip-del { background:transparent; border:none; color:inherit; opacity:.7; cursor:pointer;
padding:0; font-size:13px; line-height:1; display:inline-flex;
align-items:center; justify-content:center; min-width:16px; min-height:16px; }
.chip .chip-del:hover, .chip .chip-del:focus-visible { opacity:1; }
.chip .chip-del:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
/* Varianta chip warn (ex. R-ODO necesita odometruInitial) */
.chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); }
/* .add-code — buton dashed pentru adaugare cod in chipbox */
.add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent;
border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line));
border-radius:5px; color:var(--accent); font:500 10px inherit; cursor:pointer; }
.add-code:hover, .add-code:focus-visible { border-style:solid; }
/* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
padding:8px 10px; border:1px solid var(--line); border-radius:6px;
background:var(--card2); margin-bottom:8px; }
.op-row-name { font-size:12px; font-weight:500; color:var(--ink); }
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); }
/* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */
@media (max-width:767px) {
.trimitere-slim { padding:12px 14px; }
}
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim US-002 === */
</style> </style>
</head> </head>
<body> <body>
@@ -749,18 +824,36 @@
</div> </div>
</div> </div>
<script> <script>
// Comutator tema ciclic: click cicleaza Light->Dark->Petrol->Auto. // Comutator tema ciclic (DRY E2 — PRD 5.15): config traieste intr-o singura structura
// Separare init (sincronizare iconita/label) de persistenta (doar la click explicit). // sursa-de-adevar THEMES din care se DERIVA CYCLE/VALID/ICONS/LABELS/NEXT.
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat. // Adaugarea unei teme noi = O singura intrare in THEMES.
// Ciclu: Light->Dark->Petrol->Grafit->Cobalt->Cupru->Hartie->Auto->(inapoi la Light).
// 'auto' se rezolva la paint prin anti-FOUC (dark OS -> 'dark', light OS -> 'light').
(function() { (function() {
var btn = document.getElementById('tema-toggle'); var btn = document.getElementById('tema-toggle');
if (!btn) return; if (!btn) return;
var CYCLE = ['light', 'dark', 'petrol', 'auto']; // SURSA DE ADEVAR UNICA: adaugarea unei teme = o singura intrare aici.
var VALID = {light:1, dark:1, petrol:1, auto:1}; // Iconite: ☀ Light | ☾ Dark | ◐ Petrol | ◑ Grafit | ◆ Cobalt | ◇ Cupru | ○ Hartie | ◉ Auto
// Iconite per tema: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto var THEMES = [
var ICONS = {light:'&#9728;', dark:'&#9790;', petrol:'&#9680;', auto:'&#9689;'}; {id:'light', label:'Light', icon:'&#9728;'},
var LABELS = {light:'Light', dark:'Dark', petrol:'Petrol', auto:'Auto'}; {id:'dark', label:'Dark', icon:'&#9790;'},
var NEXT = {light:'Dark', dark:'Petrol', petrol:'Auto', auto:'Light'}; {id:'petrol', label:'Petrol', icon:'&#9680;'},
{id:'grafit', label:'Grafit', icon:'&#9681;'},
{id:'cobalt', label:'Cobalt', icon:'&#9670;'},
{id:'cupru', label:'Cupru', icon:'&#9671;'},
{id:'hartie', label:'Hartie', icon:'&#9675;'},
{id:'auto', label:'Auto', icon:'&#9689;'},
];
// Derivate din THEMES (nu literali separati — DRY E2):
var CYCLE = THEMES.map(function(t) { return t.id; });
var VALID = THEMES.reduce(function(a, t) { a[t.id] = 1; return a; }, {});
var ICONS = THEMES.reduce(function(a, t) { a[t.id] = t.icon; return a; }, {});
var LABELS = THEMES.reduce(function(a, t) { a[t.id] = t.label; return a; }, {});
var NEXT = (function() {
var n = {};
THEMES.forEach(function(t, i) { n[t.id] = THEMES[(i + 1) % THEMES.length].label; });
return n;
})();
function _stored() { function _stored() {
try { var v = localStorage.getItem('theme'); return (v && VALID[v]) ? v : 'auto'; } catch(e) { return 'auto'; } try { var v = localStorage.getItem('theme'); return (v && VALID[v]) ? v : 'auto'; } catch(e) { return 'auto'; }
} }
@@ -1049,7 +1142,7 @@
document.body.addEventListener('htmx:beforeRequest', function(evt) { document.body.addEventListener('htmx:beforeRequest', function(evt) {
var elt = evt.detail && evt.detail.elt; var elt = evt.detail && evt.detail.elt;
if (!elt || !elt.classList) return; if (!elt || !elt.classList) return;
if (elt.classList.contains('trimitere-row') || elt.classList.contains('btn-editeaza')) open(elt); if (elt.classList.contains('trimitere-row') || elt.classList.contains('trimitere-slim') || elt.classList.contains('btn-editeaza')) open(elt);
}); });
// Dupa swap-ul fragmentului (sau re-render corectie/mapare): muta focusul in modal. // Dupa swap-ul fragmentului (sau re-render corectie/mapare): muta focusul in modal.
body.addEventListener('htmx:afterSettle', function() { body.addEventListener('htmx:afterSettle', function() {
@@ -1083,7 +1176,7 @@
// Tastatura pe rand (role=button): Enter/Space deschid modalul. // Tastatura pe rand (role=button): Enter/Space deschid modalul.
document.body.addEventListener('keydown', function(evt) { document.body.addEventListener('keydown', function(evt) {
var t = evt.target; var t = evt.target;
if (!(t && t.classList && t.classList.contains('trimitere-row'))) return; if (!(t && t.classList && (t.classList.contains('trimitere-row') || t.classList.contains('trimitere-slim')))) return;
if (evt.key === 'Enter' || evt.key === ' ' || evt.key === 'Spacebar') { if (evt.key === 'Enter' || evt.key === ' ' || evt.key === 'Spacebar') {
evt.preventDefault(); evt.preventDefault();
t.click(); t.click();

View File

@@ -14,8 +14,13 @@ services:
environment: environment:
AUTOPASS_DB_PATH: /data/autopass.db AUTOPASS_DB_PATH: /data/autopass.db
AUTOPASS_RAR_ENV: prod AUTOPASS_RAR_ENV: prod
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
TZ: ${TZ:-Europe/Bucharest}
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)} AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false} AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false}
# Embeddings (sugestie mapare, Stratul 2): prima cerere /mapari lazy-load-eaza
# modelul ~230MB. Doar API-ul il incarca (worker-ul nu). Default off.
AUTOPASS_EMBEDDINGS_ENABLED: ${AUTOPASS_EMBEDDINGS_ENABLED:-false}
restart: always restart: always
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8010/healthz').status==200 else 1)"] test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8010/healthz').status==200 else 1)"]

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,67 @@
<!-- plan sub /autoplan --> <!-- plan sub /autoplan -->
# PRD 5.14 — Mapare automata operatii service prin distilare LLM # PRD 5.14 — Mapare automata operatii service prin distilare LLM
**Stare**: inchis (2026-06-28; CLOSE dupa `/code-review high` -> embeddings „mort dar scump" reparat + WIRE functional la decizia user: corpus din nomenclator gated pe `AUTOPASS_EMBEDDINGS_ENABLED`; marime model corectata ~50MB->~230MB; regresie 1256 passed)
## Stories de executie (decompozitie lead, 2026-06-28)
> PRD-ul a fost aprobat prin /autoplan ca DESIGN (Decision Audit Trail #11-20). Aici lead-ul
> il sparge in stories atomice executabile (ROADMAP §5.4), FARA a re-deschide deciziile.
> **Secventiere fata de 5.15 (D9 + cerinta user "prioritate design 5.15"):** partile DISJUNCTE
> de fisier ruleaza in PARALEL cu 5.15 acum; integrarea in editor (`mapping.py`/`routes.py`)
> ASTEAPTA 5.15 si se aplica PESTE designul 5.15, fara sa-l suprascrie.
| Story | Tip | Fisiere (disjunct?) | Depinde de |
|-------|-----|---------------------|-----------|
| **L14-S1** Layer 1 etichetator offline | tool | `tools/mapare-llm/or_label.py` + teste (mock OpenRouter) — DISJUNCT | — |
| **L14-S2** Temporal holdout (GATE Premisa 1) | tool | `tools/mapare-llm/holdout.py` + raport — DISJUNCT | — |
| **L14-S3** Schema suggestions + shared store | backend | `app/schema.sql` (aditiv), store module nou, seeder, teste — owns schema.sql | — |
| **L14-S4** Modul embeddings in-proces | backend | `app/embeddings.py` NOU + teste — DISJUNCT (modul; fara wiring) | — |
| **L14-S5** Set held-out eval (BLOCANT auto-send) | tool | `tools/mapare-llm/heldout_eval.py` + metodologie — DISJUNCT | — |
| **L14-S6** Integrare Layer 2/3 in editor | backend+UI | `app/mapping.py`, `app/web/routes.py` (editor) — **DUPA 5.15** | L14-S3,S4; 5.15 US-007/US-009 |
**Invariante de respectat (din Decision Audit Trail):** auto-send DOAR GOLD propriu (F-A/#11);
silver in tabela SEPARATA, niciodata in resolve_prestatii (#13); seeder INSERT OR IGNORE, nu
clobber uman (#2); scrub PII inainte de LLM (#3); NUL = ancore negative + supresie (#4);
provenance source/confidence (#5); embeddings doar SUGESTIE + degradare gratioasa (#16b);
held-out etichetat de OM = blocant pt orice auto-send peste GOLD (#19); tier "Inalta" sters din v1 (#17).
**Rezultat GATE Premisa 1 (L14-S2, 2026-06-28) — VERDICT: SLABA.** Validarea temporala STRICTA e
imposibila (CSV-urile `docs/operatii-service/*.csv` au doar frecvente agregate, fara timestamp). Proxy
Zipf + leave-first-out pe 155.195 operatii: pentru 90% acoperire de volum e nevoie de **4.368 denumiri
distincte (25.4% din total)**, nu "cateva sute"; leave-first-out (limita superioara de stationaritate)
= **88.9% agregat, SUB 90%**. Implicatie: etichetarea offline (L14-S1) trebuie sa proceseze ordine de
MII de denumiri per client; coada `needs_mapping` ramane semnificativa chiar dupa bootstrap. Premisa nu e
falsa, dar randamentul auto-rezolvarii e mai mic decat estima PRD-ul. NU blocheaza build-ul (piesele sunt
utile + auto-send ramane conservator pe GOLD), dar recalibreaza asteptarile de acoperire. Tool: `tools/mapare-llm/holdout.py`.
**Raport VERIFY 5.14** (subagent independent context curat, 2026-06-28) — **VERDICT: PASS, zero FAIL,
zero regresie 5.15.** `pytest -q -m "not live"`**1245 passed, 0 failed**. Invariante confirmate cu cod+test:
- **F1/#11/#17 auto-send DOAR GOLD propriu**: `load_mapping` citeste EXCLUSIV `operations_mapping` al
contului; `resolve_prestatii` nu atinge DB (primeste `mapping` dict); singura cale spre `queued` =
GOLD propriu. SILVER/GOLD-partajat/embedding = sugestie. Teste `test_f1_*` PASS. Tier "Inalta" sters (#17).
- **#13 separare structurala**: grep confirma — `shared_store`/`mapping_suggestions`/`shared_mappings`
apar DOAR in `enrich_suggestions` (apelat din `pending_unmapped`), niciodata in `resolve_prestatii`/`load_mapping`.
- **#16b degradare gratioasa**: `is_available()=False``suggest_nearest=[]` fara exceptie; ingestia nu se blocheaza.
- **#2** seeder INSERT OR IGNORE (nu clobber uman); **#4** NUL nu devine cod; **#5** provenance source/confidence;
**#3** scrub PII nr/VIN inainte de LLM (`or_common.scrub`); **#19** held-out cu `cod_gold` GOL + kill-criterion
(`wrong_code_rate<0.5%` AND `coverage>50%`) — toate PASS cu teste.
- **GATE Premisa 1**: verdict **SLABA** documentat onest (proxy Zipf, fara pretentie de validare temporala).
- fastembed 0.8.0 INSTALAT; testul real de embedding trece.
**Riscuri reziduale (LOW, non-blocant)**: (1) fastembed 0.8.0 foloseste mean-pooling (warning) — relevant doar
daca se persista corpusul de vectori intre versiuni (acum re-indexat la nevoie din nomenclator); (2) `record_human_validation`
ON CONFLICT nu suprascrie `cod_prestatie` (by design — corectie = override per-cont sau DELETE explicit);
(3) lazy-load fastembed la prima cerere `/mapari` cand `AUTOPASS_EMBEDDINGS_ENABLED=true` (~230MB, cateva
zeci de secunde daca modelul nu e in cache — acceptat la decizia CLOSE). **CLOSE 2026-06-28: embeddings WIRE-uit
functional** (era „mort dar scump"): `ensure_embeddings_corpus(conn)` construieste corpusul din nomenclator
(`nume_prestatie`->`cod_prestatie`), apelat in `pending_unmapped` + `_nemapate_pentru_submission` inainte de
bucla, gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default OFF). Re-index doar la schimbarea semnaturii
nomenclatorului. Corpusul se construieste din nomenclator (18 coduri largi), NU per-confirmare umana — sugestia
embedding e similaritate denumire-prezentare vs. nume_prestatie RAR.
---
## Problema ## Problema
La ingestie (canal API si import web), o prestatie poate veni cu `cod_op_service` La ingestie (canal API si import web), o prestatie poate veni cu `cod_op_service`
@@ -169,9 +230,13 @@ cheie pentru reviziile plan: unde fix se aseaza bara treptei „inalta".
- Stratul 1: tool CLI offline `tools/mapare-llm/` (exista: `or_common.py`, - Stratul 1: tool CLI offline `tools/mapare-llm/` (exista: `or_common.py`,
`or_modeltest.py`; de adaugat `or_label.py` cu grupare + propagare). `or_modeltest.py`; de adaugat `or_label.py` cu grupare + propagare).
- Stratul 2: `suggest_from_corpus` + similaritate embeddings in `app/mapping.py`, - Stratul 2: similaritate embeddings in `app/mapping.py` (`enrich_suggestions` ->
apelata in `pending_unmapped` pentru sugestia din editor. Model embedding incarcat `suggest_nearest`), apelata in `pending_unmapped` / `_nemapate_pentru_submission`
la pornire / serviciu, vectori pre-calculati pe baza. pentru sugestia din editor. Corpusul se construieste din nomenclator via
`ensure_embeddings_corpus` (gated pe `AUTOPASS_EMBEDDINGS_ENABLED`, default off):
lazy-load model fastembed/ONNX (~230MB) la prima cerere /mapari cand flagul e activ,
re-index doar la schimbarea nomenclatorului (semnatura). Off -> no-op (cade pe
GOLD/SILVER + fuzzy). SUGGESTION-ONLY: NU intra in resolve_prestatii/enqueue (#13).
- Stratul 3: store partajat (tabela noua `shared_mappings` sau coloana de scope pe - Stratul 3: store partajat (tabela noua `shared_mappings` sau coloana de scope pe
`operations_mapping`), seed la confirmare umana; override per-cont. `operations_mapping`), seed la confirmare umana; override per-cont.
- Validare `valid_codes` pe tot lantul (exista). - Validare `valid_codes` pe tot lantul (exista).
@@ -208,6 +273,17 @@ cheie pentru reviziile plan: unde fix se aseaza bara treptei „inalta".
| 8 | Eng | **SUPERSEDED:** corpus partajat cross-account (strat GOLD comun), NU per-cont izolat; override per-cont pe conflict | TASTE | P1, P2 | cerinta utilizator: validarea unui service ajuta toate; muncă compusa. Conflictul de vocabular rezolvat prin override + provenienta | (vechi: corpus strict per-cont) | | 8 | Eng | **SUPERSEDED:** corpus partajat cross-account (strat GOLD comun), NU per-cont izolat; override per-cont pe conflict | TASTE | P1, P2 | cerinta utilizator: validarea unui service ajuta toate; muncă compusa. Conflictul de vocabular rezolvat prin override + provenienta | (vechi: corpus strict per-cont) |
| 9 | Eng | Furnizor etichetare = OpenRouter free, ensemble NVIDIA (super-120b + nano-9b); aruncat ultra-550b | MECHANICAL | P3 | masurat 2026-06-28: doar NVIDIA routeaza fiabil; ultra 4-5x lent fara castig | Groq (cap atins) / ultra | | 9 | Eng | Furnizor etichetare = OpenRouter free, ensemble NVIDIA (super-120b + nano-9b); aruncat ultra-550b | MECHANICAL | P3 | masurat 2026-06-28: doar NVIDIA routeaza fiabil; ultra 4-5x lent fara castig | Groq (cap atins) / ultra |
| 10 | Eng | Etichetare prioritizata pe frecventa + grupare pe similaritate (eticheteaza reprezentant, propaga) | MECHANICAL | P2 | acopera mult mai mult volum per apel; critic pe cap free ~50/zi | etichetare alfabetica | | 10 | Eng | Etichetare prioritizata pe frecventa + grupare pe similaritate (eticheteaza reprezentant, propaga) | MECHANICAL | P2 | acopera mult mai mult volum per apel; critic pe cap free ~50/zi | etichetare alfabetica |
| 11 | CEO | **F-A: cross-account GOLD = suggestion-only**, nu auto-send cross-cont; doar GOLD PROPRIU (validat de omul contului) auto-trimite | GATE (user) | P1 | prima-intalnire cross-cont = FINALIZATA gresit ireversibil; override per-cont e post-hoc | cross-account auto-send (PRD scris) |
| 12 | CEO | Premisa 1 (90% repeat) validata cu **temporal holdout INAINTE** de build | GATE (user) | P1 | concentrare-in-corpus != future-repeats-past; ieftin de verificat | build pe asumtie |
| 13 | Eng | **Strat SILVER in TABELA SEPARATA** (mapping_suggestions), citita DOAR de suggest_codes/pending_unmapped; NICIODATA de load_mapping/resolve_prestatii | MECHANICAL | P5,P1 | scope-column pe operations_mapping auto-trimite silver (8+ call-site); separare structurala | scope column pe operations_mapping |
| 14 | Eng | Shared store = tabela noua pe cheia `denumire_normalizata` (NU coloana pe operations_mapping: cheie diferita cod_op_service + UNIQUE) | MECHANICAL | P5 | spatii de chei diferite; conflict UNIQUE | scope column |
| 15 | Eng | **Embeddings Layer 2 RAMANE in v1** (utilizatorul a respins amanarea la gate; mentine Decision #6). Recomandarea ambelor voci era amanare la v2 | USER CHALLENGE -> override user | P3,P5 | voci: 2GB pe ipoteza nemasurata, 18 clase acoperite de exact+fuzzy. User: vrea castig pe coada RO + control infra | (amanare v2) |
| 16 | Eng | Embeddings = **IN-PROCES fastembed/ONNX** (~230MB pe disc, ONNX quantizat, fara torch; estimarea initiala de ~50MB a fost gresita — modelul multilingv `paraphrase-multilingual-MiniLM-L12-v2` are ~231MB chiar quantizat), in procesul API; model BAKED in imaginea Docker (sau volum cache) -> ZERO dependenta de retea la runtime. NU serviciu separat. Lazy-load la pornire, nu pe /healthz; worker NU incarca modelul | TASTE (user, revizuit) | P5,P3 | user: "embedding in interiorul aplicatiei, nu mai depind de alte resurse". Mai simplu + mai robust decat serviciu HTTP; ruleaza identic local si in Docker/LXC | serviciu separat Ollama/HTTP (revocat) / sentence-transformers+torch |
| 16b | Eng | **Degradare gratioasa**: daca modelul nu se incarca -> ingestia NU se blocheaza, NU auto-trimite; cade pe exact+fuzzy, incertul -> needs_mapping. Embeddings raman doar SUGESTIE (consecinta F-A), in afara verdictului de enqueue (invariant dry-run/commit, Eng-F8) | MECHANICAL | P1 | esecul incarcarii modelului nu trebuie sa rupa coada; fara retea la runtime | block ingest pe model lipsa |
| 17 | Eng | **Tier "Inalta" auto-send STERS din v1**; GOLD auto-trimite, restul (silver/NN/LLM-unanim) = needs_mapping 1-click | MECHANICAL | P1 | fara ground-truth; unanimitate same-family = eroare corelata, nu validitate | tier Inalta pe unanimitate LLM |
| 18 | Eng | sklearn classifier scos din v1 | MECHANICAL | P5 | al doilea artefact antrenabil + pickle, castig marginal pe 18 clase | sklearn in v1 |
| 19 | Eng | **Set held-out etichetat de OM = BLOCANT** pt orice tier auto-send peste GOLD propriu | MECHANICAL | P1 | "antrenare pe test" invalideaza orice precizie raportata | prag din etichete LLM |
| 20 | CEO | OpenRouter: free OK pt bootstrap unic; credit mic ($5-20) pt drift steady-state (nu arhitecta pe cap 50/zi) | TASTE | P3 | juggling free > cost credit in timp eng | totul pe free tier |
## Istoric review (pre-pivot) ## Istoric review (pre-pivot)
@@ -221,4 +297,92 @@ e goala momentan si se completeaza la urmatoarea rulare.
## GSTACK REVIEW REPORT ## GSTACK REVIEW REPORT
(de completat la rularea reviziilor pe versiunea pivotata — plan-ceo / plan-eng / plan-design) Rulat prin `/autoplan` 2026-06-28 (SELECTIVE EXPANSION). Voci: Claude subagent independent
(CEO + Eng) + analiza orchestrator pe cod. **Codex INDISPONIBIL** (usage limit, reset 18 iul)
-> mod single-reviewer. UI scope: NU (editorul needs_mapping exista deja). DX scope: borderline
(CLI intern operator) -> Phase 3.5 sarit, considerente DX in Eng.
### Decizii GATE (confirmate de utilizator)
- **F-A: cross-account = suggestion-only.** Maparile validate de orice cont PRE-COMPLETEAZA
editorul needs_mapping (1-click) dar NU auto-trimit. Doar exact-match pe GOLD-ul PROPRIU
(validat de omul contului) auto-trimite. Elimina riscul de FINALIZATA gresit cross-tenant.
- **Premisa 1 validata cu temporal holdout INAINTE de build** (corpus primele N luni/client ->
hit-rate exact pe lunile urmatoare). Ieftin, datele exista.
### Consens CEO (single-reviewer; Codex N/A)
| Dimensiune | Claude | Verdict |
|---|---|---|
| Premise valide | NO (P1, P5) | flagged |
| Problema corecta | PARTIAL | flagged |
| Scope calibrat | NO (over-eng) | flagged |
| Alternative explorate | NO | flagged |
| Riscuri piata | PARTIAL | flagged |
| Traiectorie 6 luni | AT RISK | flagged |
### Consens Eng (single-reviewer; Codex N/A)
| Dimensiune | Claude | Verdict |
|---|---|---|
| Arhitectura | PARTIAL | flagged |
| Acoperire teste | NO | flagged |
| Footprint/perf | NO (2GB torch) | flagged |
| Siguranta F1 | INTENT-OK | flagged |
| Cai de eroare | PARTIAL | flagged |
| Risc deploy | NO | flagged |
### Constatari portante (severitate)
- **F-A / Eng-F1 (CRITIC):** auto-send DOAR pe GOLD. Strat SILVER in TABELA SEPARATA
(`mapping_suggestions`), citita doar de suggest_codes/pending_unmapped, NICIODATA de
load_mapping/resolve_prestatii. `auto_send` col e moarta (mapping.py:436); singura cale
spre `queued` (auto-send, mapping.py:414) trebuie sa fie GOLD. Separarea = structurala.
- **F-B (CRITIC):** toate masuratorile sunt ACORD (100% vs Groq, 87% unanim), nu ACURATETE
vs ground-truth. Same-family NVIDIA = eroare corelata. Niciun tier auto-send peste GOLD
pana nu exista set held-out etichetat de OM (esantion aleator stratificat).
- **Eng-F2 (HIGH):** shared store pe cheia `denumire_normalizata` (NU `cod_op_service`) ->
tabela noua obligatorie; precedenta override pinnata: account override > account GOLD >
shared GOLD > text rules > unmapped.
- **F-C / Eng-F3 (HIGH):** embeddings Layer 2 = over-engineering pe 18 clase Zipf-head.
AMANAT v2. Daca se construieste: fastembed/ONNX (~230MB pe disc, ONNX quantizat;
estimarea initiala de ~50MB a fost gresita), API-process-only, lazy, nu pe
/healthz. NU in resolve_prestatii (altfel worker-ul ar avea nevoie de torch).
- **Eng-F4 (HIGH):** tier "Inalta" sters din v1 (consecinta F-A + lipsa ground-truth).
- **F-D (HIGH):** Premisa 1 nevalidata temporal -> gate (rezolvat).
- **F-E (HIGH):** fara metrica de succes/baseline/kill-criterion -> de instrumentat
(% linii auto-rezolvate la rata cod-gresit < 0.X%).
- **MEDIUM:** NUL short-circuit inainte de suggest_codes + structura separata (Eng-F6);
OpenRouter 429 resumabil + group radius conservator + provenance (Eng-F7); divergenta
dry-run/commit (Eng-F8); credit mic vs free-tier (F-F); omisiune silentioasa NUL (F-G);
calitate embedding RO de verificat (F-H); versionare cheie normalizare; drop sklearn v1.
### Teme cross-faza (semnalate independent in ambele faze)
1. Auto-send DOAR GOLD; silver/embeddings/unanimitate-LLM = sugestie (CEO F-A/F-B + Eng F1/F4).
2. Embeddings over-engineered pe 18 clase; amana sau fastembed (CEO F-C + Eng F3).
3. Fara set ground-truth; masoara precizia inainte de orice tier auto-send (CEO F-B/F-E + Eng F4).
### NU in scope (amanat)
- **sklearn classifier** peste embeddings (v2; embeddings raman doar NN suggestion in v1).
- Orice tier auto-send peste exact-match GOLD propriu (pana la set held-out).
- LLM generativ local la runtime (deja non-obiectiv PRD).
- Tier "Inalta" calibrat (re-introdus doar cu eval cross-family + ground-truth).
**Embeddings Layer 2 RAMANE in v1** (override user la gate), IN-PROCES (fastembed/ONNX,
model baked in imagine), DOAR sugestie, cu fallback gratios pe exact+fuzzy daca modelul nu
incarca. Zero dependenta de retea la runtime. Vezi audit #15/#16/#16b.
### Ce exista deja (de refolosit, nu rescris)
- `resolve_prestatii` / `classify_prezentare` / `reresolve_account` (mapping.py): precedenta
cod direct > exact mapping > text rules > unmapped; garda valid_codes (ORA-12899).
- `suggest_codes` (rapidfuzz token_sort) + `pending_unmapped`: punct de injectie sugestii.
- `operation_text_rules` (substring) + `operations_mapping` (GOLD per-cont).
- `tools/mapare-llm/` (or_common.py, or_modeltest.py) + pattern `*-partial.json` resumabil.
- Scrub PII (F3), `normalize_for_match`, seed nomenclator (18 coduri).
### Artefact test plan
`~/.gstack/projects/romfast-rar-autopass/mmarius-main-test-plan-20260628.md`
(test F1-regression CRITIC + precedenta override + NUL + idempotenta seed + held-out eval).
### Stare review
Aprobat prin `/autoplan` (vezi Decision Audit Trail #11-20 + #16b). Plan livrabil:
v1 = Layer 1 (etichetare offline) + Layer 2 (embeddings ca SERVICIU SEPARAT configurabil,
doar sugestie, fallback gratios) + Layer 3 (GOLD propriu auto-send + shared suggestion-only)
+ exact/fuzzy existent + temporal holdout + metrica de succes + set held-out (blocant pt
orice auto-send peste GOLD). v2 = sklearn classifier (dupa masurare).

View File

@@ -1,6 +1,6 @@
# PRD 5.15 — Propagare design landing in aplicatie (dashboard compact + editare slim, VIN unic, prestatii multi-select) # PRD 5.15 — Propagare design landing in aplicatie (dashboard compact + editare slim, VIN unic, prestatii multi-select)
**Stare**: draft **Stare**: inchis (2026-06-28; CLOSE dupa `/code-review high` -> 8 buguri reparate TDD; regresie 1256 passed, 1 deselected live; E2E browser real ramane OPEN — mediu sandbox fara Playwright)
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`. > Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Sistemul de design al landing-ului: `app/web/templates/landing.html` (commit 41aa385), `DESIGN.md`. > Sistemul de design al landing-ului: `app/web/templates/landing.html` (commit 41aa385), `DESIGN.md`.
@@ -124,24 +124,24 @@ acelasi produs, coerent vizual.
- **Fisiere**: `app/web/templates/base.html`, `DESIGN.md`, `tests/test_tema.py` (~3 fisiere) - **Fisiere**: `app/web/templates/base.html`, `DESIGN.md`, `tests/test_tema.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_tema.py``test_cele_4_teme_definite`, `test_tokeni_card2_line2_in_toate_temele`, `test_anti_fouc_4_stari`, `test_migrare_localStorage_legacy` - **Test intai (RED)**: `tests/test_tema.py``test_cele_4_teme_definite`, `test_tokeni_card2_line2_in_toate_temele`, `test_anti_fouc_4_stari`, `test_migrare_localStorage_legacy`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] Pastram temele EXISTENTE light/dark/petrol si ADAUGAM 4 teme noi grafit/cobalt/cupru/hartie, - [x] Pastram temele EXISTENTE light/dark/petrol si ADAUGAM 4 teme noi grafit/cobalt/cupru/hartie,
definite prin token-urile EXISTENTE (`--bg/--card/--ink/--muted/--line/--ok/--warn/--err/--accent`) definite prin token-urile EXISTENTE (`--bg/--card/--ink/--muted/--line/--ok/--warn/--err/--accent`)
+ DOUA noi `--card2` (fundal input/contor) si `--line2` (separator subtire). `--card2`/`--line2` + DOUA noi `--card2` (fundal input/contor) si `--line2` (separator subtire). `--card2`/`--line2`
primesc valori si in light/dark/petrol (fallback rezonabil). Maparea landing->app pentru cele 4 primesc valori si in light/dark/petrol (fallback rezonabil). Maparea landing->app pentru cele 4
noi: `--text->--ink`, `--sub->--muted`, `--okt->--ok`, `--errt->--err`, `--infot->--accent`. noi: `--text->--ink`, `--sub->--muted`, `--okt->--ok`, `--errt->--err`, `--infot->--accent`.
- [ ] Selectorul ciclic parcurge TOATE: light -> dark -> petrol -> grafit -> cobalt -> cupru -> - [x] Selectorul ciclic parcurge TOATE: light -> dark -> petrol -> grafit -> cobalt -> cupru ->
hartie -> Auto, afiseaza eticheta temei curente, persistenta `localStorage` (D2). hartie -> Auto, afiseaza eticheta temei curente, persistenta `localStorage` (D2).
- [ ] **DRY (E2)**: config-ul de teme traieste intr-o SINGURA structura sursa-de-adevar - [x] **DRY (E2)**: config-ul de teme traieste intr-o SINGURA structura sursa-de-adevar
(`THEMES` ordonata, cu `{id,label,icon}`) din care se DERIVA `CYCLE`/`NEXT`/`ICONS`/`LABELS` (`THEMES` ordonata, cu `{id,label,icon}`) din care se DERIVA `CYCLE`/`NEXT`/`ICONS`/`LABELS`
(azi 5 literali paraleli la base.html:758-765) SI setul anti-FOUC `VALID` (azi separat la (azi 5 literali paraleli la base.html:758-765) SI setul anti-FOUC `VALID` (azi separat la
base.html:22). Adaugarea unei teme noi = o singura intrare; test ca derivatele acopera base.html:22). Adaugarea unei teme noi = o singura intrare; test ca derivatele acopera
toate temele (prinde o intrare ICONS/LABELS lipsa, nu doar token CSS lipsa). toate temele (prinde o intrare ICONS/LABELS lipsa, nu doar token CSS lipsa).
- [ ] "Auto" pastrat: urmeaza `prefers-color-scheme`, rezolva la dark/grafit sau light/hartie - [x] "Auto" pastrat: urmeaza `prefers-color-scheme`, rezolva la dark/grafit sau light/hartie
(decizie minora: Auto -> dark + hartie pentru light, sau dark/grafit — aliniaza cu I2). (decizie minora: Auto -> dark + hartie pentru light, sau dark/grafit — aliniaza cu I2).
- [ ] Script anti-FOUC in `<head>` seteaza `data-theme` sincron pre-paint pentru toate starile; - [x] Script anti-FOUC in `<head>` seteaza `data-theme` sincron pre-paint pentru toate starile;
valoare necunoscuta -> Auto, fara blink. Valorile vechi raman valide (nu se mapeaza fortat). valoare necunoscuta -> Auto, fara blink. Valorile vechi raman valide (nu se mapeaza fortat).
- [ ] Contrast AA pentru text principal in toate temele (light + hartie sunt cele luminoase). - [x] Contrast AA pentru text principal in toate temele (light + hartie sunt cele luminoase).
- [ ] `DESIGN.md` actualizat: sectiunea cromatica + selector tema reflecta toate temele. - [x] `DESIGN.md` actualizat: sectiunea cromatica + selector tema reflecta toate temele.
- **Verificare E2E**: browser pe `/` (dashboard logat) — ciclare prin toate temele, persistenta la - **Verificare E2E**: browser pe `/` (dashboard logat) — ciclare prin toate temele, persistenta la
refresh, fara FOUC; toate temele selectabile. refresh, fara FOUC; toate temele selectabile.
@@ -153,24 +153,24 @@ chips **pentru ca** dashboard-ul si formularul sa le consume DRY, identic cu moc
- **Fisiere**: `app/web/templates/base.html`, `DESIGN.md`, `tests/test_web_responsive.py` (~3 fisiere) - **Fisiere**: `app/web/templates/base.html`, `DESIGN.md`, `tests/test_web_responsive.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_responsive.py``test_clasa_contor_card`, `test_clasa_lista_slim`, `test_clasa_camp_slim`, `test_clasa_chips` - **Test intai (RED)**: `tests/test_web_responsive.py``test_clasa_contor_card`, `test_clasa_lista_slim`, `test_clasa_camp_slim`, `test_clasa_chips`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] `.contor-card` (sau nume aliniat conventiei): cifra mare bold + eticheta mica muted, fundal - [x] `.contor-card` (sau nume aliniat conventiei): cifra mare bold + eticheta mica muted, fundal
`--card2`, bordura `--line`, radius 8px, padding 10-12px; variante de culoare a cifrei prin `--card2`, bordura `--line`, radius 8px, padding 10-12px; variante de culoare a cifrei prin
`.s-*` existente (verde/accent/rosu). `.s-*` existente (verde/accent/rosu).
- [ ] `.lista-trimiteri-slim` cu rand `.trimitere-slim`: stanga = VIN mono (linia 1) + operatie·ora - [x] `.lista-trimiteri-slim` cu rand `.trimitere-slim`: stanga = VIN mono (linia 1) + operatie·ora
muted (linia 2, 11px); dreapta = pill de stare; separator `--line2`; padding 10-14px. muted (linia 2, 11px); dreapta = pill de stare; separator `--line2`; padding 10-14px.
Randul ramane clickabil (rol button) si pastreaza tinta 44px pe mobil. Randul ramane clickabil (rol button) si pastreaza tinta 44px pe mobil.
- [ ] Varianta slim de camp formular: label 11px muted deasupra, input ~30px inaltime, fundal - [x] Varianta slim de camp formular: label 11px muted deasupra, input ~30px inaltime, fundal
`--card2`, mono pentru VIN/odometru/nr; integrata in macro-ul `camp` din `_macros.html` `--card2`, mono pentru VIN/odometru/nr; integrata in macro-ul `camp` din `_macros.html`
printr-un flag (`slim=True`), fara a rupe randarea actuala (default neschimbat). printr-un flag (`slim=True`), fara a rupe randarea actuala (default neschimbat).
- [ ] `.chips` + `.chip` (cu buton `×` de stergere) pentru prestatii multi-select; accesibil - [x] `.chips` + `.chip` (cu buton `×` de stergere) pentru prestatii multi-select; accesibil
(buton real cu `aria-label`), stilat ca in mockup (accent 18%, font 10-11px). (buton real cu `aria-label`), stilat ca in mockup (accent 18%, font 10-11px).
- [ ] **Doar tokeni, fara hex hardcodat (criteriu din mockup)**: toate culorile componentelor noi - [x] **Doar tokeni, fara hex hardcodat (criteriu din mockup)**: toate culorile componentelor noi
(contor, lista slim, chips, strip, picker) folosesc EXCLUSIV variabile CSS (contor, lista slim, chips, strip, picker) folosesc EXCLUSIV variabile CSS
(`var(--errt)`/`var(--okt)`/`var(--accent)`/`var(--card2)`/`var(--line2)` etc.), NU hex literal (`var(--errt)`/`var(--okt)`/`var(--accent)`/`var(--card2)`/`var(--line2)` etc.), NU hex literal
si NU inline-styles copiate ca-atare din `landing.html`. Cifra "De corectat" rosie = token si NU inline-styles copiate ca-atare din `landing.html`. Cifra "De corectat" rosie = token
(`var(--errt)`), nu `#E05D5D` hardcodat, ca sa ramana AA pe temele luminoase (hartie/light). (`var(--errt)`), nu `#E05D5D` hardcodat, ca sa ramana AA pe temele luminoase (hartie/light).
Referinta: `docs/mockups/prd-5.15-mockups.html`. Referinta: `docs/mockups/prd-5.15-mockups.html`.
- [ ] Zero regresie vizuala pe componentele existente (`.card/.pill/.act/.tabel-trimiteri`). - [x] Zero regresie vizuala pe componentele existente (`.card/.pill/.act/.tabel-trimiteri`).
- **Verificare E2E**: pagina de proba/sandbox sau direct in US-003/004/007; vizual pe un esantion de teme + 390/1280. - **Verificare E2E**: pagina de proba/sandbox sau direct in US-003/004/007; vizual pe un esantion de teme + 390/1280.
### US-003: Dashboard Acasa — carduri-contor inlocuiesc bara de status ### US-003: Dashboard Acasa — carduri-contor inlocuiesc bara de status
@@ -183,7 +183,7 @@ chips **pentru ca** dashboard-ul si formularul sa le consume DRY, identic cu moc
`tests/test_web_dashboard.py` (~5 fisiere) `tests/test_web_dashboard.py` (~5 fisiere)
- **Test intai (RED)**: `tests/test_web_status.py``test_strip_sanatate_mereu_vizibil`, `test_strip_rosu_worker_oprit`, `test_trei_contoare_card`, `test_trimise_all_time_luna_azi`, `test_fara_bara_veche` - **Test intai (RED)**: `tests/test_web_status.py``test_strip_sanatate_mereu_vizibil`, `test_strip_rosu_worker_oprit`, `test_trei_contoare_card`, `test_trimise_all_time_luna_azi`, `test_fara_bara_veche`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] **Strip de sanatate mereu-vizibil, DEASUPRA contoarelor** (D6): o linie compacta colorata — - [x] **Strip de sanatate mereu-vizibil, DEASUPRA contoarelor** (D6): o linie compacta colorata —
verde "declaratiile curg" cand worker viu + RAR ok; **rosu** + text explicit cand worker verde "declaratiile curg" cand worker viu + RAR ok; **rosu** + text explicit cand worker
oprit SAU RAR inaccesibil ("Blocat: worker oprit" / "Blocat: RAR inaccesibil"), cu ultima oprit SAU RAR inaccesibil ("Blocat: worker oprit" / "Blocat: RAR inaccesibil"), cu ultima
autentificare RAR. Glife accesibile ✓/✗ (nu doar culoare). Invariant zero-silent-failures: autentificare RAR. Glife accesibile ✓/✗ (nu doar culoare). Invariant zero-silent-failures:
@@ -192,14 +192,14 @@ chips **pentru ca** dashboard-ul si formularul sa le consume DRY, identic cu moc
stanga, "Ultima autentificare RAR: ..." mono muted la dreapta. Copy: rosu "Blocat: worker oprit stanga, "Ultima autentificare RAR: ..." mono muted la dreapta. Copy: rosu "Blocat: worker oprit
— declaratiile NU pleaca" (sau "... RAR inaccesibil"), verde "Declaratiile curg normal". — declaratiile NU pleaca" (sau "... RAR inaccesibil"), verde "Declaratiile curg normal".
Referinta: `docs/mockups/prd-5.15-mockups.html`. Referinta: `docs/mockups/prd-5.15-mockups.html`.
- [ ] Sub strip: card "Trimiteri RAR AUTOPASS" cu 3 contoare slim: **In coada** (queued, accent), - [x] Sub strip: card "Trimiteri RAR AUTOPASS" cu 3 contoare slim: **In coada** (queued, accent),
**Trimise** (sent, verde), **De corectat** (blocate = needs_data + needs_mapping + error, rosu). **Trimise** (sent, verde), **De corectat** (blocate = needs_data + needs_mapping + error, rosu).
- [ ] **Stari goale + ierarhie contor (criteriu din mockup)**: cifra principala a contorului "Trimise" - [x] **Stari goale + ierarhie contor (criteriu din mockup)**: cifra principala a contorului "Trimise"
e **all-time** (cifra mare bold), iar "luna asta"/"azi" sunt o sub-linie mono secundara e **all-time** (cifra mare bold), iar "luna asta"/"azi" sunt o sub-linie mono secundara
(`luna {n} · azi {n}`) — NU "luna asta" ca cifra principala (corecteaza framing-ul din mockup-ul (`luna {n} · azi {n}`) — NU "luna asta" ca cifra principala (corecteaza framing-ul din mockup-ul
landing). Contorul "De corectat" la 0 se afiseaza **muted, nu rosu** (rosu doar cand exista landing). Contorul "De corectat" la 0 se afiseaza **muted, nu rosu** (rosu doar cand exista
blocate — pastreaza pattern-ul `_status.html:47`). Referinta: `docs/mockups/prd-5.15-mockups.html`. blocate — pastreaza pattern-ul `_status.html:47`). Referinta: `docs/mockups/prd-5.15-mockups.html`.
- [ ] Cardul **Trimise** afiseaza trei valori temporale (D4): all-time (cifra principala) + "luna asta" - [x] Cardul **Trimise** afiseaza trei valori temporale (D4): all-time (cifra principala) + "luna asta"
+ "azi" (sub-linie secundara). `_status_counts` extins cu `sent_today`/`sent_month`. + "azi" (sub-linie secundara). `_status_counts` extins cu `sent_today`/`sent_month`.
**Sursa de timp**: NU exista coloana `sent_at`; folosim `status='sent' AND date(updated_at)=...`. **Sursa de timp**: NU exista coloana `sent_at`; folosim `status='sent' AND date(updated_at)=...`.
Justificare (verificat): un rand `sent` nu mai primeste scrieri ulterioare pana la purge-delete Justificare (verificat): un rand `sent` nu mai primeste scrieri ulterioare pana la purge-delete
@@ -213,11 +213,11 @@ chips **pentru ca** dashboard-ul si formularul sa le consume DRY, identic cu moc
**Caveat reconcile (E6 outside-voice)**: pe reconciliere (raspuns pierdut) worker-ul **Caveat reconcile (E6 outside-voice)**: pe reconciliere (raspuns pierdut) worker-ul
marcheaza `sent` cu `updated_at` = momentul reconcilierii, nu al inserarii RAR — pentru marcheaza `sent` cu `updated_at` = momentul reconcilierii, nu al inserarii RAR — pentru
randurile reconciliate (rare) `updated_at` poate diferi de momentul real al trimiterii. randurile reconciliate (rare) `updated_at` poate diferi de momentul real al trimiterii.
- [ ] Navigarea existenta (Trimiteri/Mapari + badge needs_mapping) se pastreaza. Click pe contorul - [x] Navigarea existenta (Trimiteri/Mapari + badge needs_mapping) se pastreaza. Click pe contorul
**De corectat** deep-link-eaza in lista filtrata pe blocate (`?status=` existent din 5.x), **De corectat** deep-link-eaza in lista filtrata pe blocate (`?status=` existent din 5.x),
nu intr-o pagina noua. nu intr-o pagina noua.
- [ ] Scoped pe cont; poll-ul existent (`/_fragments/status`) randeaza noul antet fara a pierde tab-ul. - [x] Scoped pe cont; poll-ul existent (`/_fragments/status`) randeaza noul antet fara a pierde tab-ul.
- [ ] Responsive: cele 3 contoare pe un rand pe desktop, stivuite/2-pe-rand pe mobil, fara overflow. - [x] Responsive: cele 3 contoare pe un rand pe desktop, stivuite/2-pe-rand pe mobil, fara overflow.
- **Verificare E2E**: browser pe `/` — contoare corecte vs date din DB, sanatate worker mort/viu, - **Verificare E2E**: browser pe `/` — contoare corecte vs date din DB, sanatate worker mort/viu,
poll pastreaza starea. poll pastreaza starea.
@@ -230,14 +230,14 @@ si mai usor de scanat, pastrand filtrele si actiunile.
`tests/test_web_submissions.py`, `tests/test_web_responsive.py` (~4 fisiere) `tests/test_web_submissions.py`, `tests/test_web_responsive.py` (~4 fisiere)
- **Test intai (RED)**: `tests/test_web_submissions.py``test_rand_slim_vin_operatie_pill`, `test_filtre_paginare_pastrate`, `test_bulk_doar_blocate`, `test_click_deschide_detaliu` - **Test intai (RED)**: `tests/test_web_submissions.py``test_rand_slim_vin_operatie_pill`, `test_filtre_paginare_pastrate`, `test_bulk_doar_blocate`, `test_click_deschide_detaliu`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] Fiecare rand: stanga VIN mono scurt (`vin_scurt`) linia 1 + operatie + ora/data muted linia 2; - [x] Fiecare rand: stanga VIN mono scurt (`vin_scurt`) linia 1 + operatie + ora/data muted linia 2;
dreapta pill de stare (`stare_css`/`stare_scurt`). Nr. inmatriculare, data completa si nr. dreapta pill de stare (`stare_css`/`stare_scurt`). Nr. inmatriculare, data completa si nr.
prezentare RAR raman accesibile (linie meta discreta si/sau in modalul de detaliu). prezentare RAR raman accesibile (linie meta discreta si/sau in modalul de detaliu).
- [ ] Filtre (data/vehicul/stare — `_coada.html`), paginarea numerotata si bulk-delete pe randuri - [x] Filtre (data/vehicul/stare — `_coada.html`), paginarea numerotata si bulk-delete pe randuri
blocate (checkbox doar pe `gestionabil`) raman FUNCTIONALE. blocate (checkbox doar pe `gestionabil`) raman FUNCTIONALE.
- [ ] Click pe rand deschide `/_fragments/trimitere/{id}` in modal (neschimbat). - [x] Click pe rand deschide `/_fragments/trimitere/{id}` in modal (neschimbat).
- [ ] Slim layout consistent desktop si <=1024px (cardurile responsive existente nu regreseaza). - [x] Slim layout consistent desktop si <=1024px (cardurile responsive existente nu regreseaza).
- [ ] Pill-urile de stare folosesc maparea din `labels.py` (zero etichete noi). Eticheta "Eroare VIN" - [x] Pill-urile de stare folosesc maparea din `labels.py` (zero etichete noi). Eticheta "Eroare VIN"
din mockup-ul landing e DOAR ilustrativa — se foloseste `stare_scurt` existent (ex. "De corectat"). din mockup-ul landing e DOAR ilustrativa — se foloseste `stare_scurt` existent (ex. "De corectat").
- **Verificare E2E**: browser — filtrare + paginare + click detaliu + bulk pe blocate, pe 4 teme, - **Verificare E2E**: browser — filtrare + paginare + click detaliu + bulk pe blocate, pe 4 teme,
pe 390/820/1280. pe 390/820/1280.
@@ -253,20 +253,20 @@ sa corectez/completez ce s-a facut, separat de codurile RAR.
`tests/test_web_corectie*.py`, `tests/test_import_review.py` (~6 fisiere) `tests/test_web_corectie*.py`, `tests/test_import_review.py` (~6 fisiere)
- **Test intai (RED)**: `tests/test_web_corectie_obs.py``test_obs_editabil_persistat_corecteaza`, `test_obs_persistat_preview_editeaza`, `test_obs_optional_gol_ok`, `test_import_concateneaza_operatie_in_obs` - **Test intai (RED)**: `tests/test_web_corectie_obs.py``test_obs_editabil_persistat_corecteaza`, `test_obs_persistat_preview_editeaza`, `test_obs_optional_gol_ok`, `test_import_concateneaza_operatie_in_obs`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] `obs` traieste in `payload_json` (camp `obs` din contractul RAR); fara coloana noua / migrare (D5). - [x] `obs` traieste in `payload_json` (camp `obs` din contractul RAR); fara coloana noua / migrare (D5).
- [ ] `obs` adaugat in `EDIT_FIELDS`; `corecteaza` si `editeaza` (preview) accepta si persista `obs`. - [x] `obs` adaugat in `EDIT_FIELDS`; `corecteaza` si `editeaza` (preview) accepta si persista `obs`.
- [ ] `obs` optional (text liber, fara validare de continut, doar trim); apare in `payload_view`. - [x] `obs` optional (text liber, fara validare de continut, doar trim); apare in `payload_view`.
- [ ] `obs` se include in payload-ul trimis la RAR (camp `obs`). **`obs` e EXCLUS din cheia de - [x] `obs` se include in payload-ul trimis la RAR (camp `obs`). **`obs` e EXCLUS din cheia de
idempotenta** (`idempotency.py:98`) — deci editarea DOAR a `obs` NU schimba cheia si NU poate idempotenta** (`idempotency.py:98`) — deci editarea DOAR a `obs` NU schimba cheia si NU poate
crea duplicat (D8). NU recalcula/forta cheia pe baza `obs`. (Corecteaza formularea anterioara.) crea duplicat (D8). NU recalcula/forta cheia pe baza `obs`. (Corecteaza formularea anterioara.)
- [ ] **La import** (D7): denumirea operatiei RAMANE in `op_service` (sursa pentru maparea op->cod); - [x] **La import** (D7): denumirea operatiei RAMANE in `op_service` (sursa pentru maparea op->cod);
daca fisierul NU are coloana Observatii, denumirea operatiei se **COPIAZA** (nu se muta) si in daca fisierul NU are coloana Observatii, denumirea operatiei se **COPIAZA** (nu se muta) si in
`obs`; daca are coloana Observatii, se pastreaza textul ei. Format de concatenare definit `obs`; daca are coloana Observatii, se pastreaza textul ei. Format de concatenare definit
(denumiri separate prin "; "). Fluxul needs_mapping ramane neatins. (denumiri separate prin "; "). Fluxul needs_mapping ramane neatins.
- [ ] **Idempotent (E3)**: copierea operatiei in `obs` e DERIVE-ON-EMPTY (doar cand `obs` e gol) - [x] **Idempotent (E3)**: copierea operatiei in `obs` e DERIVE-ON-EMPTY (doar cand `obs` e gol)
ca re-importul/re-editarea sa NU dubleze textul ("Schimb ulei; Schimb ulei"). Test dedicat ca re-importul/re-editarea sa NU dubleze textul ("Schimb ulei; Schimb ulei"). Test dedicat
anti-dublu-concat. anti-dublu-concat.
- [ ] **Cuplaj preview-import**: `obs` se adauga in `EDIT_FIELDS` (`import_router.py:261`); `_merge_override` - [x] **Cuplaj preview-import**: `obs` se adauga in `EDIT_FIELDS` (`import_router.py:261`); `_merge_override`
il propaga (obs e free-text, cade pe ramura ne-canonicalizata — fara strip "0", doar trim). il propaga (obs e free-text, cade pe ramura ne-canonicalizata — fara strip "0", doar trim).
- **Verificare E2E**: `POST /trimitere/{id}/corecteaza` cu `obs` -> persistat -> vizibil in detaliu; - **Verificare E2E**: `POST /trimitere/{id}/corecteaza` cu `obs` -> persistat -> vizibil in detaliu;
optional proba live RAR ca `obs` apare in FINALIZATA. optional proba live RAR ca `obs` apare in FINALIZATA.
@@ -285,7 +285,7 @@ comanda poate avea mai multe prestatii, asa cum accepta RAR.
handler-e cer un rewrite real (nu "fara schimbare de logica"). handler-e cer un rewrite real (nu "fara schimbare de logica").
- **Test intai (RED)**: `tests/test_web_corectie_prestatii.py``test_mai_multe_coduri_acceptate`, `test_cod_invalid_respins`, `test_lista_goala_needs_mapping`, `test_idempotency_recalculat`, `test_odometru_initial_conditionat_R_ODO` - **Test intai (RED)**: `tests/test_web_corectie_prestatii.py``test_mai_multe_coduri_acceptate`, `test_cod_invalid_respins`, `test_lista_goala_needs_mapping`, `test_idempotency_recalculat`, `test_odometru_initial_conditionat_R_ODO`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] Handler-ele de editare accepta o LISTA de `cod_prestatie`, inlocuind selectul unic. **NU - [x] Handler-ele de editare accepta o LISTA de `cod_prestatie`, inlocuind selectul unic. **NU
reconstrui lista cu itemi goi**: handler-ele de azi injecteaza codul DOAR in `prestatii[0]` reconstrui lista cu itemi goi**: handler-ele de azi injecteaza codul DOAR in `prestatii[0]`
(`routes.py:1146-1164`, `1288-1324`) — multi-select le rescrie ca: pastreaza itemii existenti (`routes.py:1146-1164`, `1288-1324`) — multi-select le rescrie ca: pastreaza itemii existenti
cu `cod_op_service`/`denumire` (invariant D7) si seteaza/adauga `cod_prestatie` pe ei. cu `cod_op_service`/`denumire` (invariant D7) si seteaza/adauga `cod_prestatie` pe ei.
@@ -294,18 +294,18 @@ comanda poate avea mai multe prestatii, asa cum accepta RAR.
ELIMINA acel `pop`: cand se seteaza un cod direct, `cod_op_service`/`denumire` RAMAN pe item ELIMINA acel `pop`: cand se seteaza un cod direct, `cod_op_service`/`denumire` RAMAN pe item
(altfel rupe D7 si US-009). **Test de regresie obligatoriu** (IRON RULE): op_service (altfel rupe D7 si US-009). **Test de regresie obligatoriu** (IRON RULE): op_service
supravietuieste unui /repune cu cod. supravietuieste unui /repune cu cod.
- [ ] **Pereche operatie<->cod definita**: cand exista operatii (`cod_op_service`), fiecare cod-chip - [x] **Pereche operatie<->cod definita**: cand exista operatii (`cod_op_service`), fiecare cod-chip
se ataseaza unei operatii (1 operatie -> 1 cod, ca azi, dar acum N operatii -> N coduri); se ataseaza unei operatii (1 operatie -> 1 cod, ca azi, dar acum N operatii -> N coduri);
cand NU exista operatie (cod direct, ex. corectie pura), chip-urile sunt coduri libere intr-o cand NU exista operatie (cod direct, ex. corectie pura), chip-urile sunt coduri libere intr-o
lista fara `op_service`. Aceasta pereche e ce consuma US-009 (salvare mapare op->cod). lista fara `op_service`. Aceasta pereche e ce consuma US-009 (salvare mapare op->cod).
- [ ] Fiecare cod e validat fata de nomenclator (`valid_codes`); cod necunoscut -> respins cu - [x] Fiecare cod e validat fata de nomenclator (`valid_codes`); cod necunoscut -> respins cu
mesaj (NU se trimite raw — invariant ORA-12899 din CLAUDE.md/contract). mesaj (NU se trimite raw — invariant ORA-12899 din CLAUDE.md/contract).
- [ ] Lista goala de coduri -> ramane `needs_mapping` (nu se trimite fara cod). - [x] Lista goala de coduri -> ramane `needs_mapping` (nu se trimite fara cod).
- [ ] **Coduri duplicate** -> dedupare **PER-ITEM, nu "dupa cod"** (E4): doua operatii distincte - [x] **Coduri duplicate** -> dedupare **PER-ITEM, nu "dupa cod"** (E4): doua operatii distincte
pot mapa legitim la acelasi cod RAR; deduparea naiva dupa cod ar sterge o operatie reala si pot mapa legitim la acelasi cod RAR; deduparea naiva dupa cod ar sterge o operatie reala si
ar distruge contextul op->cod cerut de US-009. Dedup = acelasi (op, cod) de 2x, nu acelasi cod. ar distruge contextul op->cod cerut de US-009. Dedup = acelasi (op, cod) de 2x, nu acelasi cod.
- [ ] Recalcul idempotenta dupa editare (mecanism existent), cu prinderea coliziunii ca azi. - [x] Recalcul idempotenta dupa editare (mecanism existent), cu prinderea coliziunii ca azi.
- [ ] Se pastreaza regula `odometruInitial` obligatoriu cand lista contine `R-ODO`/`I-ODO` - [x] Se pastreaza regula `odometruInitial` obligatoriu cand lista contine `R-ODO`/`I-ODO`
(contract §payload) — validare existenta, doar verificata pe lista. (contract §payload) — validare existenta, doar verificata pe lista.
- **Verificare E2E**: `POST /corecteaza` cu 2 coduri valide -> `queued` cu `prestatii` de lungime 2; - **Verificare E2E**: `POST /corecteaza` cu 2 coduri valide -> `queued` cu `prestatii` de lungime 2;
cu un cod invalid -> respins; optional live RAR cu 2 prestatii -> FINALIZATA. cu un cod invalid -> respins; optional live RAR cu 2 prestatii -> FINALIZATA.
@@ -320,28 +320,28 @@ e compact si imi arata clar codurile RAR si observatiile, ca in mockup.
`tests/test_web_preview_edit.py`, `tests/test_web_detaliu*.py` (~6 fisiere) `tests/test_web_preview_edit.py`, `tests/test_web_detaliu*.py` (~6 fisiere)
- **Test intai (RED)**: `tests/test_web_form_editare_slim.py``test_un_singur_vin`, `test_camp_observatii_prezent`, `test_chips_multi_select_prestatii`, `test_adauga_sterge_chip`, `test_form_slim_in_ambele_modale` - **Test intai (RED)**: `tests/test_web_form_editare_slim.py``test_un_singur_vin`, `test_camp_observatii_prezent`, `test_chips_multi_select_prestatii`, `test_adauga_sterge_chip`, `test_form_slim_in_ambele_modale`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] Formularul foloseste varianta slim de camp (US-002): VIN, Data prestatiei, Nr. inmatriculare, - [x] Formularul foloseste varianta slim de camp (US-002): VIN, Data prestatiei, Nr. inmatriculare,
Observatii (textarea), prestatii (chips), Odometru — un SINGUR camp VIN (fara "Confirma VIN"). Observatii (textarea), prestatii (chips), Odometru — un SINGUR camp VIN (fara "Confirma VIN").
- [ ] Observatii = textarea liber, legat de `obs` (US-005). - [x] Observatii = textarea liber, legat de `obs` (US-005).
- [ ] Prestatii = chips multi-select. **Binding op<->cod (E4)**: cand exista operatii - [x] Prestatii = chips multi-select. **Binding op<->cod (E4)**: cand exista operatii
(`cod_op_service`), UN picker PE operatie (eticheta op + chip-ul ei de cod), pastrand (`cod_op_service`), UN picker PE operatie (eticheta op + chip-ul ei de cod), pastrand
perechea per-item; lista plata de coduri libere DOAR pentru cazul fara operatie (corectie perechea per-item; lista plata de coduri libere DOAR pentru cazul fara operatie (corectie
pura). Fiecare cod ca chip cu `×`; lista se trimite ca `cod_prestatie` multiplu (US-006). pura). Fiecare cod ca chip cu `×`; lista se trimite ca `cod_prestatie` multiplu (US-006).
- [ ] Acelasi `_form_editare.html` slujeste ambele modale (detaliu `/corecteaza` si preview - [x] Acelasi `_form_editare.html` slujeste ambele modale (detaliu `/corecteaza` si preview
`/editeaza`), fara duplicare; degradare fara JS rezonabila (chips ca lista, picker = select). `/editeaza`), fara duplicare; degradare fara JS rezonabila (chips ca lista, picker = select).
- [ ] **Require dinamic odometruInitial** (D10c): cand lista de chips contine `R-ODO` sau `I-ODO`, - [x] **Require dinamic odometruInitial** (D10c): cand lista de chips contine `R-ODO` sau `I-ODO`,
formularul DEZVALUIE si cere `odometru_initial` (contract §payload), previne 400 RAR si un formularul DEZVALUIE si cere `odometru_initial` (contract §payload), previne 400 RAR si un
drum `needs_data`. Cand niciun chip R-ODO/I-ODO -> campul ramane optional/ascuns. drum `needs_data`. Cand niciun chip R-ODO/I-ODO -> campul ramane optional/ascuns.
- [ ] **Editare keyboard-first** (D10d): in picker, Enter adauga chip-ul selectat; sageti - [x] **Editare keyboard-first** (D10d): in picker, Enter adauga chip-ul selectat; sageti
navigheaza optiunile; Esc inchide modalul; focus-ul revine logic dupa adaugare/stergere. navigheaza optiunile; Esc inchide modalul; focus-ul revine logic dupa adaugare/stergere.
- [ ] Stilizare fidela mockup-ului pe toate temele; tinte 44px pe mobil; a11y (label-uri, aria, - [x] Stilizare fidela mockup-ului pe toate temele; tinte 44px pe mobil; a11y (label-uri, aria,
anunt de chip adaugat/sters pentru screen-reader). anunt de chip adaugat/sters pentru screen-reader).
- [ ] **HTMX server-driven PRIMARY (E6)**: chips add/remove via `hx-post` care re-randeaza - [x] **HTMX server-driven PRIMARY (E6)**: chips add/remove via `hx-post` care re-randeaza
partial-ul chips+form; reveal-ul conditional `odometruInitial` rezulta GRATIS din re-randarea partial-ul chips+form; reveal-ul conditional `odometruInitial` rezulta GRATIS din re-randarea
server (server computeaza din lista de chips, fara ramura JS); navigare tastatura = server (server computeaza din lista de chips, fara ramura JS); navigare tastatura =
`<select>`/`<datalist>` nativ. JS custom DOAR ca progressive enhancement (snappiness), nu `<select>`/`<datalist>` nativ. JS custom DOAR ca progressive enhancement (snappiness), nu
calea principala. Elimina path-ul dublu JS/no-JS pe care formularea anterioara il cerea. calea principala. Elimina path-ul dublu JS/no-JS pe care formularea anterioara il cerea.
- [ ] **Referinta vizuala (criteriu din mockup)**: `docs/mockups/prd-5.15-mockups.html` defineste - [x] **Referinta vizuala (criteriu din mockup)**: `docs/mockups/prd-5.15-mockups.html` defineste
aspectul-tinta — VIN unic (FARA al doilea camp "Confirma VIN" din mockup-ul landing); Observatii aspectul-tinta — VIN unic (FARA al doilea camp "Confirma VIN" din mockup-ul landing); Observatii
ca textarea slim; picker PE operatie cu DOUA stari vizuale: (a) operatie mapata = chip cod cu `×` ca textarea slim; picker PE operatie cu DOUA stari vizuale: (a) operatie mapata = chip cod cu `×`
+ "+ alt cod" + link "salveaza regula op->cod" (US-009); (b) operatie ne-mapata = picker galben + "+ alt cod" + link "salveaza regula op->cod" (US-009); (b) operatie ne-mapata = picker galben
@@ -361,8 +361,8 @@ fisiere fierbinti (base.html) si nu vreau regresii pe teme/liste/formular.
(~3 fisiere) (~3 fisiere)
- **Test intai (RED)**: completare scenarii lipsa (componente noi pe TOATE temele; slim list desktop+mobil) - **Test intai (RED)**: completare scenarii lipsa (componente noi pe TOATE temele; slim list desktop+mobil)
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] `pytest -q -m "not live"` verde (fara regresii fata de baseline). - [x] `pytest -q -m "not live"` verde (fara regresii fata de baseline).
- [ ] **Test de tema robust, nu esantion**: un test parametrizat verifica fiecare token critic - [x] **Test de tema robust, nu esantion**: un test parametrizat verifica fiecare token critic
(`--card2`, `--line2`, `--accent`, `--ok`, `--err`) e DEFINIT in TOATE cele 7+1 stari (`--card2`, `--line2`, `--accent`, `--ok`, `--err`) e DEFINIT in TOATE cele 7+1 stari
(light/dark/petrol/grafit/cobalt/cupru/hartie/Auto). Ancorare pe SENTINEL CSS (nu felii (light/dark/petrol/grafit/cobalt/cupru/hartie/Auto). Ancorare pe SENTINEL CSS (nu felii
fixe `[idx:idx+N]`) — vezi regresia false-green din ROADMAP 5.13. fixe `[idx:idx+N]`) — vezi regresia false-green din ROADMAP 5.13.
@@ -379,11 +379,11 @@ fisiere fierbinti (base.html) si nu vreau regresii pe teme/liste/formular.
`reresolve_account` — fara logica noua), `tests/test_web_mapare_din_chip.py` (~3 fisiere) `reresolve_account` — fara logica noua), `tests/test_web_mapare_din_chip.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_mapare_din_chip.py``test_salveaza_regula_din_chip`, `test_reresolve_deblocheaza_frate`, `test_optional_nu_forteaza` - **Test intai (RED)**: `tests/test_web_mapare_din_chip.py``test_salveaza_regula_din_chip`, `test_reresolve_deblocheaza_frate`, `test_optional_nu_forteaza`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] Cand operatia (`op_service`) e cunoscuta si userul adauga un cod RAR prin chip, apare optiunea - [x] Cand operatia (`op_service`) e cunoscuta si userul adauga un cod RAR prin chip, apare optiunea
"salveaza ca regula op->cod"; la confirmare reuse EXACT `save_mapping` + `reresolve_account` "salveaza ca regula op->cod"; la confirmare reuse EXACT `save_mapping` + `reresolve_account`
(acelasi mecanism ca maparea inline din 5.7), scoped pe cont + CSRF. (acelasi mecanism ca maparea inline din 5.7), scoped pe cont + CSRF.
- [ ] Re-rezolvarea deblocheaza si alte submission-uri `needs_mapping` cu aceeasi operatie (pe `batch_id`). - [x] Re-rezolvarea deblocheaza si alte submission-uri `needs_mapping` cu aceeasi operatie (pe `batch_id`).
- [ ] Optional: daca userul nu vrea sa salveze, editarea ramane one-off (fara regula). Se compune - [x] Optional: daca userul nu vrea sa salveze, editarea ramane one-off (fara regula). Se compune
cu 5.14 (auto-maparea umple, salvarea din chip ramane fallback-ul uman). cu 5.14 (auto-maparea umple, salvarea din chip ramane fallback-ul uman).
- **Verificare E2E**: adaug cod la operatie nemapata + salveaza regula -> al doilea rand cu aceeasi - **Verificare E2E**: adaug cod la operatie nemapata + salveaza regula -> al doilea rand cu aceeasi
operatie se rezolva automat. operatie se rezolva automat.
@@ -397,10 +397,10 @@ de corectat/zi nu vreau sa intru in fiecare individual.
existenta din `_submissions` + `submissions_admin`), `tests/test_web_bulk_fix.py` (~3 fisiere) existenta din `_submissions` + `submissions_admin`), `tests/test_web_bulk_fix.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_bulk_fix.py``test_bulk_remapeaza_selectie`, `test_bulk_doar_blocate`, `test_bulk_scoped_cont` - **Test intai (RED)**: `tests/test_web_bulk_fix.py``test_bulk_remapeaza_selectie`, `test_bulk_doar_blocate`, `test_bulk_scoped_cont`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] Pe randurile blocate (checkbox existent pe `gestionabil`), o actiune bulk noua: aplica un cod - [x] Pe randurile blocate (checkbox existent pe `gestionabil`), o actiune bulk noua: aplica un cod
RAR / o remapare la toata selectia intr-o singura cerere (reuse forma `#bulk-trimiteri`). RAR / o remapare la toata selectia intr-o singura cerere (reuse forma `#bulk-trimiteri`).
- [ ] Scoped pe cont (404-before-409 ca la bulk-delete); doar randuri blocate eligibile. - [x] Scoped pe cont (404-before-409 ca la bulk-delete); doar randuri blocate eligibile.
- [ ] Fiecare rand re-validat + idempotenta recalculata individual (un cod invalid pe un rand nu - [x] Fiecare rand re-validat + idempotenta recalculata individual (un cod invalid pe un rand nu
pica tot lotul — sumar "N reusite, M esuate" ca la salvarea mapcoloane D#12). pica tot lotul — sumar "N reusite, M esuate" ca la salvarea mapcoloane D#12).
- **Verificare E2E**: selectez 3 randuri needs_mapping + aplic un cod -> toate 3 -> `queued`. - **Verificare E2E**: selectez 3 randuri needs_mapping + aplic un cod -> toate 3 -> `queued`.
- **Verificare E2E**: rulare completa documentata in Raportul VERIFY. - **Verificare E2E**: rulare completa documentata in Raportul VERIFY.
@@ -416,12 +416,12 @@ azi GET-urile de listare sunt globale + neprotejate (scurgere VIN/PII cross-cont
- **Test intai (RED)**: `test_get_listare_scoped_cont` — un cont NU vede randuri ale altui cont; - **Test intai (RED)**: `test_get_listare_scoped_cont` — un cont NU vede randuri ale altui cont;
`test_get_listare_neautentificat_401`; `test_get_detaliu_scoped` (404-before-leak pe id strain). `test_get_listare_neautentificat_401`; `test_get_detaliu_scoped` (404-before-leak pe id strain).
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] GET-urile de listare (trimiteri + orice listare globala) devin account-scoped, refolosind - [x] GET-urile de listare (trimiteri + orice listare globala) devin account-scoped, refolosind
mecanismul de scope existent (ca POST-urile + bulk-delete: 404-before-409 pe id strain). mecanismul de scope existent (ca POST-urile + bulk-delete: 404-before-409 pe id strain).
- [ ] Un cont nu poate enumera/citi VIN/PII al altui cont prin listare sau detaliu. - [x] Un cont nu poate enumera/citi VIN/PII al altui cont prin listare sau detaliu.
- [ ] Enforcement aliniat cu `AUTOPASS_REQUIRE_API_KEY` (dev vs prod), fara a rupe contul id=1 - [x] Enforcement aliniat cu `AUTOPASS_REQUIRE_API_KEY` (dev vs prod), fara a rupe contul id=1
implicit in dev. implicit in dev.
- [ ] Actualizeaza nota din CLAUDE.md ("GET-urile de listare ... de remediat") cand e inchis. - [x] Actualizeaza nota din CLAUDE.md ("GET-urile de listare ... de remediat") cand e inchis.
- **Verificare E2E**: doua conturi cu trimiteri; contul A nu vede niciun rand al contului B in - **Verificare E2E**: doua conturi cu trimiteri; contul A nu vede niciun rand al contului B in
listare, filtre, paginare sau detaliu. listare, filtre, paginare sau detaliu.
@@ -435,11 +435,11 @@ rafinarile mobil (390px) viitoare merita efortul (premisa nevalidata din TODOS 5
`tests/test_device_mix.py` (~3 fisiere) `tests/test_device_mix.py` (~3 fisiere)
- **Test intai (RED)**: `test_device_mix_inregistrat`, `test_device_mix_fara_pii`. - **Test intai (RED)**: `test_device_mix_inregistrat`, `test_device_mix_fara_pii`.
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] La acces dashboard, clasifica grosier viewport/UA in desktop/mobil si inregistreaza in - [x] La acces dashboard, clasifica grosier viewport/UA in desktop/mobil si inregistreaza in
`app_events` (semnal agregat, FARA PII suplimentar). Reuse tabela existenta — fara migrare `app_events` (semnal agregat, FARA PII suplimentar). Reuse tabela existenta — fara migrare
daca `app_events` poarta semnalul. daca `app_events` poarta semnalul.
- [ ] Un mod simplu de citire a raportului (query/admin), suficient pentru a decide investitia mobil. - [x] Un mod simplu de citire a raportului (query/admin), suficient pentru a decide investitia mobil.
- [ ] Zero PII nou; aliniat retentiei `app_events` existente. - [x] Zero PII nou; aliniat retentiei `app_events` existente.
- **Verificare E2E**: acces dashboard de pe doua viewport-uri -> doua evenimente clasificate corect. - **Verificare E2E**: acces dashboard de pe doua viewport-uri -> doua evenimente clasificate corect.
## 4. Riscuri ## 4. Riscuri
@@ -504,8 +504,36 @@ Val 6: [US-008] regresie + E2E final (dupa toate)
## Raport VERIFY ## Raport VERIFY
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. Verificator independent (context curat, subagent Sonnet) — 2026-06-28. **VERDICT: PASS** (12/12 stories),
> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E pe RAR test). Lipseste pana la VERIFY. cu 1 FAIL documentar remediat de lead + 1 OPEN limitat de mediu.
- **Suita completa**: `python3 -m pytest -q -m "not live"`**1230 passed, 1 deselected, 0 failed** (118s).
Baseline initial 992 → +238 teste, zero regresii.
- **AC per story (US-001..US-012)**: toate PASS cu dovezi (fisier:linie + test care le acopera).
Puncte verificate explicit: 7+1 teme cu `--card2`/`--line2` in toate (US-001, DRY `THEMES`);
componente slim doar cu tokeni, zero hex (US-002, ancorat pe `SENTINEL-COMPONENTE-SLIM`);
strip sanatate D6 + 3 contoare + `sent_today`/`sent_month` bucketate timp local RO `+3 hours` (US-003, E7);
lista slim cu filtre/paginare/bulk pastrate (US-004); `obs` editabil + EXCLUS din cheia idempotenta
(`idempotency.py:98`) + concat derive-on-empty anti-dublu (US-005, D8/E3); prestatii multi-cod via
`getlist` + **E1 IRON RULE** (`cod_op_service` supravietuieste `/repune` — test dedicat) + dedup per-item
(US-006, E4); form slim VIN unic + picker chips pe operatie + reveal odo server-driven + select vechi
redundant ELIMINAT (US-007/cleanup B); test tema parametrizat 5 tokeni x 7 teme ancorat pe selectori
`[data-theme]` (US-008, anti false-green); salvare mapare din chip reuse `save_mapping`+`reresolve_account`
(US-009); bulk-fix sumar "N reusite/M esuate" scoped (US-010); account-scope GET-listari 404-before-leak
(US-011); device-mix fara PII reuse `app_events` (US-012).
- **Fidelitate mockup** (`docs/mockups/prd-5.15-mockups.html`, cod-level): D6 strip, contoare D4,
picker E4 cu 2 stari (mapata=chip+×+salveaza / nemapata=select galben "lipsa cod"), reveal odo
border-left warn — toate conforme; toate culorile prin `var(--token)`, fara hex.
- **Regresia de aur**: testele `POST /v1/prezentari` + worker + import→commit raman verzi in suita;
E1 confirmat cu test. Live RAR real (`FINALIZATA`) = opt-in, indisponibil fara creds in sandbox (documentat).
**FAIL 1 (remediat de lead)**: nota CLAUDE.md "GET-urile de listare globale + neprotejate (de remediat)"
nu fusese actualizata (teammates instruiti sa NU atinga CLAUDE.md). **Remediat**: `CLAUDE.md:70` actualizat
sa reflecte scope-ul implementat de US-011.
**OPEN (mediu)**: E2E Playwright pe 390/820/1280 (grafit/hartie/petrol) — browserul MCP a returnat
"already in use" in sandbox (ca la livrabilele anterioare). Serverul porneste OK (`/healthz` ok),
ACs acoperite functional de pytest (`test_web_responsive.py`). Recomandat: rulat de operator cu browser real.
--- ---

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
markers =
live: Teste live care ating endpointul real RAR (skip implicit, opt-in cu AUTOPASS_LIVE_RAR=1)
slow: Teste lente care descarca modele / resurse externe (skip cu -m "not slow")

View File

@@ -20,3 +20,8 @@ openpyxl==3.1.5
# Migrare DBF (tools/import_dbf.py). Necesar doar pentru import optional, nu pentru runtime. # Migrare DBF (tools/import_dbf.py). Necesar doar pentru import optional, nu pentru runtime.
dbfread==2.0.7 dbfread==2.0.7
# Embeddings in-proces pentru sugestie cod RAR (L14-S4, PRD 5.14 Decision #16).
# Model multilingv (~230MB pe disc, ONNX quantizat), fastembed fara torch, lazy-load la runtime.
# Degradare gratioasa daca lipseste la runtime (is_available()=False, suggest_nearest=[]).
fastembed>=0.8.0

View File

@@ -14,10 +14,34 @@ variabila exportata explicit in shell. Testele care chiar verifica enforcement-u
import hashlib import hashlib
import os import os
import pytest
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false") os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false") os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
@pytest.fixture(autouse=True)
def _reset_embeddings_singleton():
"""Reseteaza singleton-ul global de embeddings intre teste (izolare de ordine).
`enrich_suggestions` foloseste `embeddings.has_corpus()` ca poarta; un test care
indexeaza corpusul pe singleton-ul global (ex. test_module_level_index_corpus)
altfel l-ar lasa populat -> teste ulterioare care cheama pending_unmapped ar primi
sugestii embedding spurioase. Resetam la None inainte si dupa fiecare test.
"""
try:
import app.embeddings as _emb
_emb._engine = None
except Exception:
pass
yield
try:
import app.embeddings as _emb
_emb._engine = None
except Exception:
pass
def make_test_cui(seed: str = "") -> str: def make_test_cui(seed: str = "") -> str:
"""Factory centralizat (D#14, PRD 5.12 US-001): genereaza un CUI de test unic din seed. """Factory centralizat (D#14, PRD 5.12 US-001): genereaza un CUI de test unic din seed.

226
tests/test_api_scope.py Normal file
View File

@@ -0,0 +1,226 @@
"""US-011 (PRD 5.15): account-scope pe GET-urile de listare API (securitate).
Verifica:
- GET /v1/prezentari: un cont nu vede submissions ale altui cont
- GET /v1/prezentari/{id}: 404-before-leak pe id strain
- Unauthenticated access cu require_api_key=True -> 401
- Filtre si paginare nu sparg scope-ul
Legatura cu implementare: resolve_account_id + account_scope_clause (mecanisme
existente, reutilizate). Testele LOCK DOWN comportamentul deja implementat
si verifica alinierea cu AUTOPASS_REQUIRE_API_KEY (dev vs prod).
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_with_key(name: str):
"""Creeaza cont + cheie API. Intoarce (account_id, plaintext_key)."""
from app.accounts import create_account
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
key = create_api_key(conn, acct_id)
conn.commit()
return acct_id, key
finally:
conn.close()
def _insert_submission(acct: int, vin: str = "WVWZZZ1JZXW000777") -> int:
from app.db import get_connection
conn = get_connection()
try:
p = {
"vin": vin,
"nr_inmatriculare": "B777TST",
"data_prestatie": "2026-06-18",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"api-scope-{acct}-{os.urandom(4).hex()}", acct, "sent", json.dumps(p)),
)
conn.commit()
rid = cur.lastrowid
assert rid is not None
return int(rid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
"""Client cu DB izolata, REQUIRE_API_KEY=false (dev default)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "api-scope.db"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
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()
@pytest.fixture()
def client_prod(monkeypatch):
"""Client cu require_api_key=True (comportament productie)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "api-scope-prod.db"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
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()
# ---------------------------------------------------------------------------
# Test 1: cross-account isolation pe listare submissions (dev mode)
# ---------------------------------------------------------------------------
def test_get_listare_scoped_cont(client):
"""Un cont NU vede submissions (VIN/PII) ale altui cont in GET /v1/prezentari.
Contul A are un submission cu VIN_A; contul B cu cheie proprie nu trebuie
sa vada VIN_A in listarea sa.
"""
VIN_A = "WVWZZZ1JZXWAPI1AA"
VIN_B = "WVWZZZ1JZXWAPI2BB"
acct_a, key_a = _create_account_with_key("ApiContA")
acct_b, key_b = _create_account_with_key("ApiContB")
_insert_submission(acct_a, vin=VIN_A)
_insert_submission(acct_b, vin=VIN_B)
# Cont B listeaza cu propria cheie
resp = client.get("/v1/prezentari", headers={"X-API-Key": key_b})
assert resp.status_code == 200
data = resp.json()
vins = [s["prezentare"]["vin"] for s in data["submissions"] if s.get("prezentare")]
assert VIN_B in vins, "Contul B ar trebui sa vada propriul submission"
assert VIN_A not in vins, (
"Scurgere cross-account: VIN-ul contului A vizibil contului B prin API"
)
# ---------------------------------------------------------------------------
# Test 2: unauthenticated 401 in prod mode
# ---------------------------------------------------------------------------
def test_get_listare_neautentificat_401(client_prod):
"""Fara cheie API cu require_api_key=True, GET /v1/prezentari -> 401."""
resp = client_prod.get("/v1/prezentari")
assert resp.status_code == 401, (
f"Asteptat 401 fara cheie API in mod prod, primit {resp.status_code}."
)
def test_get_listare_cheie_invalida_401(client_prod):
"""Cheie API invalida (prezenta dar gresita) -> 401, indiferent de flag."""
resp = client_prod.get("/v1/prezentari", headers={"X-API-Key": "rfak_invalida_xxx"})
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Test 3: 404-before-leak pe detaliu id strain
# ---------------------------------------------------------------------------
def test_get_detaliu_scoped_404(client):
"""GET /v1/prezentari/{id} pe un id al altui cont -> 404 (fara leak).
Acelasi 404 pentru id inexistent = nu confirmam existenta.
"""
acct_a, key_a = _create_account_with_key("DetApiA")
acct_b, key_b = _create_account_with_key("DetApiB")
sid_a = _insert_submission(acct_a, vin="WVWZZZ1JZXWDET100")
# Cont B incearca sa acceseze submission-ul contului A
resp = client.get(f"/v1/prezentari/{sid_a}", headers={"X-API-Key": key_b})
assert resp.status_code == 404, (
f"Asteptat 404, primit {resp.status_code}. "
"Nu trebuie confirmata existenta unui submission al altui cont."
)
# Id inexistent -> acelasi 404
resp2 = client.get("/v1/prezentari/999999", headers={"X-API-Key": key_b})
assert resp2.status_code == 404
def test_get_detaliu_neautentificat_401(client_prod):
"""GET /v1/prezentari/{id} fara cheie API in prod -> 401."""
resp = client_prod.get("/v1/prezentari/1")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Test 4: contul implicit id=1 in dev (nu trebuie spart de scope)
# ---------------------------------------------------------------------------
def test_get_listare_cont_implicit_dev(client):
"""In dev (require_api_key=False), fara cheie -> cont implicit id=1.
Contul 1 vede propriile submissions (NULL account_id = cont 1 prin
account_scope_clause). NU trebuie sa vada submissions cu alt account_id.
"""
# Inserare submission pt cont id=1 (account_id NULL = legacy cont 1)
from app.db import get_connection
conn = get_connection()
try:
p = json.dumps({
"vin": "WVWZZZ1JZXWDEV001",
"nr_inmatriculare": "B001DEV",
"data_prestatie": "2026-06-18",
"odometru_final": "10000",
"prestatii": [{"cod_prestatie": "OE-1"}],
})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, NULL, 'sent', ?)",
("dev-null-key-001", p),
)
conn.commit()
finally:
conn.close()
# Fara cheie in dev -> cont implicit 1, vede submission-ul cu account_id NULL
resp = client.get("/v1/prezentari")
assert resp.status_code == 200
vins = [s["prezentare"]["vin"] for s in resp.json()["submissions"] if s.get("prezentare")]
assert "WVWZZZ1JZXWDEV001" in vins, "Contul implicit 1 trebuie sa vada submissions NULL"
# ---------------------------------------------------------------------------
# Test 5: izolare status filter nu sparge scope-ul
# ---------------------------------------------------------------------------
def test_get_listare_filtru_status_nu_sparge_scope(client):
"""Filtrul ?status= nu poate scoate randuri din alt cont."""
VIN_A = "WVWZZZ1JZXWSTA1AA"
acct_a, key_a = _create_account_with_key("StatApiA")
acct_b, key_b = _create_account_with_key("StatApiB")
_insert_submission(acct_a, vin=VIN_A)
# Cont B filtreaza dupa status 'sent' - nu trebuie sa vada VIN_A
resp = client.get("/v1/prezentari?status=sent", headers={"X-API-Key": key_b})
assert resp.status_code == 200
vins = [s["prezentare"]["vin"] for s in resp.json()["submissions"] if s.get("prezentare")]
assert VIN_A not in vins, (
"Filtrul status a scos date din alt cont (scurgere cross-account prin filtru)."
)

View File

@@ -49,11 +49,13 @@ def test_dashboard_renders_with_rar_state(client):
assert r.status_code == 200 assert r.status_code == 200
# Dupa US-003 bara de status e incarcata via HTMX (hx-trigger=load, every 15s) # Dupa US-003 bara de status e incarcata via HTMX (hx-trigger=load, every 15s)
assert "/_fragments/status" in r.text, "Dashboard-ul trebuie sa referenceze fragmentul de status" assert "/_fragments/status" in r.text, "Dashboard-ul trebuie sa referenceze fragmentul de status"
# Fragmentul de status contine starea worker (eticheta umana, nu "worker oprit" brut) # Fragmentul de status contine starea de sanatate (text uman, nu brut tehnic)
rs = client.get("/_fragments/status") rs = client.get("/_fragments/status")
assert rs.status_code == 200 assert rs.status_code == 200
# eticheta_worker(False) => "Trimitere automata: oprita" → fragmentul afiseaza "oprita" # US-003 D6: strip sanatate unificat — "declaratiile" apare in orice stare (curg/blocat)
assert "oprita" in rs.text or "Trimitere automata" in rs.text assert "declaratiile" in rs.text.lower(), (
f"Strip sanatate lipseste din fragment. HTML: {rs.text[:500]}"
)
# Tab-ul Nomenclator e accesat via /_fragments/nomenclator # Tab-ul Nomenclator e accesat via /_fragments/nomenclator
rn = client.get("/_fragments/nomenclator") rn = client.get("/_fragments/nomenclator")
assert rn.status_code == 200 assert rn.status_code == 200

152
tests/test_device_mix.py Normal file
View File

@@ -0,0 +1,152 @@
"""Teste US-012 (PRD 5.15): Analytics device-mix — validare premisa mobil, fara PII.
TDD: RED inainte de implementare.
Semnal: la acces dashboard -> eveniment 'device_mix' in app_events cu cod 'desktop'/'mobil'.
Zero PII: nu se stocheaza UA brut, IP sau VIN.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixture #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "device_mix.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
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 _events_device_mix():
from app.db import get_connection
conn = get_connection()
try:
return conn.execute(
"SELECT * FROM app_events WHERE tip='device_mix' ORDER BY id"
).fetchall()
finally:
conn.close()
# --------------------------------------------------------------------------- #
# test_device_mix_inregistrat #
# --------------------------------------------------------------------------- #
UA_DESKTOP = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0 Safari/537.36"
)
UA_MOBIL_ANDROID = (
"Mozilla/5.0 (Linux; Android 13; Pixel 7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0 Mobile Safari/537.36"
)
UA_MOBIL_IPHONE = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) "
"Version/17.0 Mobile/15E148 Safari/604.1"
)
def test_device_mix_inregistrat_desktop(client):
"""Acces dashboard cu UA desktop -> eveniment device_mix cod='desktop'."""
r = client.get("/", headers={"User-Agent": UA_DESKTOP})
assert r.status_code == 200
events = _events_device_mix()
assert len(events) >= 1, "Trebuie cel putin un eveniment device_mix dupa acces dashboard"
# ultimul eveniment clasificat ca desktop
ev = events[-1]
assert ev["tip"] == "device_mix"
assert ev["cod"] == "desktop", f"Clasificare gresita: {ev['cod']!r}"
def test_device_mix_inregistrat_mobil_android(client):
"""Acces dashboard cu UA Android Mobile -> eveniment device_mix cod='mobil'."""
r = client.get("/", headers={"User-Agent": UA_MOBIL_ANDROID})
assert r.status_code == 200
events = _events_device_mix()
assert len(events) >= 1
ev = events[-1]
assert ev["tip"] == "device_mix"
assert ev["cod"] == "mobil", f"Clasificare gresita Android: {ev['cod']!r}"
def test_device_mix_inregistrat_mobil_iphone(client):
"""Acces dashboard cu UA iPhone -> eveniment device_mix cod='mobil'."""
r = client.get("/", headers={"User-Agent": UA_MOBIL_IPHONE})
assert r.status_code == 200
events = _events_device_mix()
assert len(events) >= 1
ev = events[-1]
assert ev["tip"] == "device_mix"
assert ev["cod"] == "mobil", f"Clasificare gresita iPhone: {ev['cod']!r}"
# --------------------------------------------------------------------------- #
# test_device_mix_fara_pii #
# --------------------------------------------------------------------------- #
def test_device_mix_fara_pii(client):
"""Evenimentul device_mix nu contine UA brut, IP sau alte PII."""
r = client.get("/", headers={"User-Agent": UA_MOBIL_ANDROID})
assert r.status_code == 200
events = _events_device_mix()
assert len(events) >= 1
ev = events[-1]
# Campul mesaj: doar eticheta grosiera, nu UA brut
mesaj = ev["mesaj"] or ""
assert UA_MOBIL_ANDROID not in mesaj, "UA brut nu trebuie stocat in mesaj"
assert "Android" not in mesaj, "Fragment UA nu trebuie stocat in mesaj"
assert "Mozilla" not in mesaj, "Fragment UA nu trebuie stocat in mesaj"
# context_json: daca exista, nu contine UA brut / IP
ctx_raw = ev["context_json"]
if ctx_raw:
ctx = json.loads(ctx_raw)
ctx_str = json.dumps(ctx)
assert UA_MOBIL_ANDROID not in ctx_str, "UA brut nu trebuie in context_json"
assert "Mozilla" not in ctx_str, "Fragment UA nu trebuie in context_json"
# IP-uri tipice nu apar (testclient trimite 127.0.0.1/testclient)
for ip_fragment in ["127.0.0.1", "testclient", "192.168."]:
assert ip_fragment not in ctx_str, f"IP {ip_fragment!r} nu trebuie in context_json"
# codul este doar eticheta grosiera
assert ev["cod"] in ("desktop", "mobil"), f"Cod neasteptat: {ev['cod']!r}"
def test_device_mix_fara_pii_desktop(client):
"""Evenimentul device_mix pentru desktop nu contine UA brut."""
r = client.get("/", headers={"User-Agent": UA_DESKTOP})
assert r.status_code == 200
events = _events_device_mix()
assert len(events) >= 1
ev = events[-1]
mesaj = ev["mesaj"] or ""
assert UA_DESKTOP not in mesaj, "UA brut desktop nu trebuie in mesaj"
assert "Windows NT" not in mesaj, "Fragment UA nu trebuie in mesaj"
assert ev["cod"] == "desktop"

233
tests/test_embeddings.py Normal file
View File

@@ -0,0 +1,233 @@
"""
Teste pentru app/embeddings.py -- modul embedding in-proces (L14-S4).
Structura:
(a) backend MOCK (vectori deterministi) -- index + suggest_nearest
(b) degradare gratioasa: backend None/broken -> is_available()=False,
suggest_nearest()=[] fara exceptie
(c) test real fastembed, skip daca nu e instalat (marker slow)
"""
import math
import pytest
from app import embeddings as emb
from app.embeddings import EmbeddingEngine
# --------------------------------------------------------------------------- #
# Helpers #
# --------------------------------------------------------------------------- #
def _vec(text: str, dim: int = 8) -> list:
"""Vector determinist bazat pe hash-ul textului (mock pur, fara retea)."""
h = abs(hash(text))
components = [(h >> (i * 5)) & 0x1F for i in range(dim)]
norm = math.sqrt(sum(c * c for c in components)) or 1.0
return [c / norm for c in components]
class MockBackend:
"""Backend embedding determinist pentru teste."""
def embed(self, texts: list) -> list:
return [_vec(t) for t in texts]
# --------------------------------------------------------------------------- #
# (a) Mock backend -- index + suggest_nearest #
# --------------------------------------------------------------------------- #
def test_index_and_suggest_nearest_mock():
"""Cel mai apropiat vecin al unui text identic == el insusi."""
corpus = [
{"denumire": "SCHIMB ULEI", "cod": "OE-3"},
{"denumire": "REPARATIE MOTOR", "cod": "OE-1"},
{"denumire": "VERIFICARE DIRECTIE", "cod": "OE-4"},
]
engine = EmbeddingEngine(backend=MockBackend())
engine.index_corpus(corpus)
results = engine.suggest_nearest("SCHIMB ULEI", top_k=1)
assert results, "Trebuie sa returneze cel putin un rezultat"
assert results[0]["cod"] == "OE-3"
assert 0.0 <= results[0]["similaritate"] <= 1.0 + 1e-9
def test_suggest_nearest_top_k_respects_limit():
"""suggest_nearest(top_k=2) nu returneaza mai mult de 2 rezultate."""
corpus = [
{"denumire": "SCHIMB ULEI MOTOR", "cod": "OE-3"},
{"denumire": "REVIZIE COMPLETA", "cod": "OE-3"},
{"denumire": "REPARATIE MOTOR", "cod": "OE-1"},
{"denumire": "INLOCUIT FRANA", "cod": "OE-2"},
]
engine = EmbeddingEngine(backend=MockBackend())
engine.index_corpus(corpus)
results = engine.suggest_nearest("ULEI MOTOR", top_k=2)
assert len(results) <= 2
def test_suggest_nearest_sorted_descending():
"""Rezultatele sunt sortate descrescator dupa similaritate."""
corpus = [
{"denumire": "SCHIMB ULEI", "cod": "OE-3"},
{"denumire": "REPARATIE MOTOR", "cod": "OE-1"},
{"denumire": "VERIFICARE FRANURI", "cod": "OE-2"},
]
engine = EmbeddingEngine(backend=MockBackend())
engine.index_corpus(corpus)
results = engine.suggest_nearest("SCHIMB ULEI", top_k=3)
scores = [r["similaritate"] for r in results]
assert scores == sorted(scores, reverse=True)
def test_suggest_nearest_returns_dict_with_required_keys():
"""Fiecare rezultat contine 'cod' si 'similaritate'."""
corpus = [{"denumire": "SCHIMB ULEI", "cod": "OE-3"}]
engine = EmbeddingEngine(backend=MockBackend())
engine.index_corpus(corpus)
results = engine.suggest_nearest("SCHIMB ULEI", top_k=1)
assert results
assert "cod" in results[0]
assert "similaritate" in results[0]
def test_index_empty_corpus():
"""suggest_nearest pe corpus gol returneaza []."""
engine = EmbeddingEngine(backend=MockBackend())
engine.index_corpus([])
assert engine.suggest_nearest("CEVA", top_k=3) == []
def test_suggest_nearest_before_index():
"""suggest_nearest fara index_corpus returneaza []."""
engine = EmbeddingEngine(backend=MockBackend())
assert engine.suggest_nearest("CEVA", top_k=3) == []
def test_engine_is_available_with_backend():
"""is_available() = True cand backend-ul e furnizat."""
engine = EmbeddingEngine(backend=MockBackend())
assert engine.is_available() is True
# --------------------------------------------------------------------------- #
# (b) Degradare gratioasa -- backend None / arunca #
# --------------------------------------------------------------------------- #
def test_is_available_false_when_backend_none():
"""is_available() = False cand backend = None."""
engine = EmbeddingEngine(backend=None)
assert engine.is_available() is False
def test_suggest_nearest_returns_empty_when_backend_none():
"""suggest_nearest = [] fara exceptie cand backend = None."""
engine = EmbeddingEngine(backend=None)
result = engine.suggest_nearest("CEVA", top_k=3)
assert result == []
def test_index_corpus_no_exception_when_backend_none():
"""index_corpus nu arunca exceptie cand backend = None."""
engine = EmbeddingEngine(backend=None)
engine.index_corpus([{"denumire": "CEVA", "cod": "OE-1"}]) # nu arunca
def test_suggest_nearest_no_exception_on_backend_error():
"""suggest_nearest prinde exceptia din backend si returneaza []."""
class BrokenBackend:
def embed(self, texts):
raise RuntimeError("backend broke")
corpus = [{"denumire": "SCHIMB ULEI", "cod": "OE-3"}]
engine = EmbeddingEngine(backend=BrokenBackend())
engine.index_corpus(corpus) # index poate esua silentios
# suggest_nearest nu trebuie sa arunce exceptie
result = engine.suggest_nearest("SCHIMB ULEI", top_k=1)
assert result == []
def test_index_corpus_no_exception_on_backend_error():
"""index_corpus nu arunca exceptie cand backend-ul arunca la embed."""
class BrokenBackend:
def embed(self, texts):
raise ValueError("embed error")
engine = EmbeddingEngine(backend=BrokenBackend())
engine.index_corpus([{"denumire": "CEVA", "cod": "OE-1"}])
# corpus ramane gol, suggest_nearest returneaza []
assert engine.suggest_nearest("CEVA") == []
# --------------------------------------------------------------------------- #
# API la nivel de modul (singleton global) #
# --------------------------------------------------------------------------- #
def test_module_level_is_available_no_exception():
"""Apelul global is_available() nu arunca exceptie."""
result = emb.is_available()
assert isinstance(result, bool)
def test_module_level_suggest_nearest_no_exception():
"""Apelul global suggest_nearest() nu arunca exceptie."""
result = emb.suggest_nearest("SCHIMB ULEI MOTOR", top_k=3)
assert isinstance(result, list)
def test_module_level_index_corpus_no_exception():
"""Apelul global index_corpus() nu arunca exceptie."""
corpus = [{"denumire": "REPARATIE", "cod": "OE-1"}]
emb.index_corpus(corpus) # nu trebuie sa arunce
# --------------------------------------------------------------------------- #
# (c) Test real fastembed -- skip daca modelul nu e descarcat #
# --------------------------------------------------------------------------- #
try:
import fastembed as _fe
_FASTEMBED_AVAILABLE = True
except ImportError:
_FASTEMBED_AVAILABLE = False
@pytest.mark.skipif(not _FASTEMBED_AVAILABLE, reason="fastembed nu e instalat")
def test_fastembed_backend_is_available_type():
"""is_available() returneaza bool (indiferent daca modelul e descarcat sau nu)."""
result = emb.is_available()
assert isinstance(result, bool)
@pytest.mark.slow
@pytest.mark.skipif(not _FASTEMBED_AVAILABLE, reason="fastembed nu e instalat")
def test_fastembed_real_embedding_similarity():
"""Test real end-to-end: denumiri similare au similaritate mai mare decat cele diferite.
Necesita download model la prima rulare (~220MB). Skip cu: pytest -m 'not slow'.
"""
from app.embeddings import EmbeddingEngine, FastEmbedBackend
backend = FastEmbedBackend()
engine = EmbeddingEngine(backend=backend)
corpus = [
{"denumire": "schimb ulei motor", "cod": "OE-3"},
{"denumire": "reparatie motor cutie viteze", "cod": "OE-1"},
{"denumire": "verificare directie volan", "cod": "OE-4"},
]
engine.index_corpus(corpus)
results = engine.suggest_nearest("schimb ulei", top_k=3)
assert results, "Trebuie sa returneze cel putin un rezultat"
# 'schimb ulei' trebuie sa fie mai aproape de 'schimb ulei motor' (OE-3)
assert results[0]["cod"] == "OE-3", (
f"Asteptat OE-3 ca primul rezultat, primit: {results}"
)

527
tests/test_heldout_eval.py Normal file
View File

@@ -0,0 +1,527 @@
"""Teste TDD pentru tools/mapare-llm/heldout_eval.py (L14-S5).
Fixture sintetic cu predictii+ground_truth cunoscute. Verifica:
- precizie globala
- precizie per-cod (TP/FP/FN per eticheta)
- rata cod-gresit (critic: cod gresit = FINALIZATA ireversibil)
- esantionare stratificata determinista (acelasi seed = aceleasi rezultate)
- kill-criterion (pass/fail pe praguri definite)
Rulare: python3 -m pytest tests/test_heldout_eval.py -v
"""
from __future__ import annotations
import os
import sys
import csv
# Adaugam tools/mapare-llm/ la sys.path (pattern din test_holdout.py)
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS_DIR = os.path.abspath(os.path.join(HERE, "..", "tools", "mapare-llm"))
if TOOLS_DIR not in sys.path:
sys.path.insert(0, TOOLS_DIR)
import pytest
import heldout_eval as he
# ---------------------------------------------------------------------------
# Fixture sintetic pentru eval_predictions
# ---------------------------------------------------------------------------
# 6 intrari; 3 corecte, 1 cod-gresit (critic), 1 NUL fals-negativ, 1 nerezolvat
PREDS = [
{"denumire": "REVIZIE PERIODICA", "cod_pred": "OE-3"}, # corect
{"denumire": "SCHIMB ULEI MOTOR", "cod_pred": "OE-1"}, # GRESIT: gold=OE-3 (cod gresit!)
{"denumire": "DISCOUNT 10%", "cod_pred": "NUL"}, # corect
{"denumire": "VOPSIRE BARA FATA", "cod_pred": "OE-1"}, # corect
{"denumire": "DIAGNOSTICARE OBD", "cod_pred": "?"}, # nerezolvat
{"denumire": "D/R BARA FATA", "cod_pred": "OE-2"}, # GRESIT: gold=OE-1 (cod gresit!)
]
GOLD = [
{"denumire": "REVIZIE PERIODICA", "cod_gold": "OE-3"},
{"denumire": "SCHIMB ULEI MOTOR", "cod_gold": "OE-3"}, # pred=OE-1, gold=OE-3 -> COD GRESIT
{"denumire": "DISCOUNT 10%", "cod_gold": "NUL"},
{"denumire": "VOPSIRE BARA FATA", "cod_gold": "OE-1"},
{"denumire": "DIAGNOSTICARE OBD", "cod_gold": "OE-4"},
{"denumire": "D/R BARA FATA", "cod_gold": "OE-1"}, # pred=OE-2, gold=OE-1 -> COD GRESIT
]
# total=6, correct=3 (REVIZIE, DISCOUNT, VOPSIRE)
# wrong_code=2 (SCHIMB ULEI: OE-1 vs OE-3; D/R BARA: OE-2 vs OE-1)
# coverage_count=5 (pred!="?"), coverage_rate=5/6
# global_precision=3/6=0.50
# wrong_code_rate=2/6
# ---------------------------------------------------------------------------
# Sectiunea 1: eval_predictions — precizie globala
# ---------------------------------------------------------------------------
class TestEvalPrecizie:
"""Verifica metricile globale returnate de eval_predictions."""
def test_total_items(self):
"""total = numarul de intrari din ground_truth."""
m = he.eval_predictions(PREDS, GOLD)
assert m["total"] == 6
def test_correct_count(self):
"""3 predictii corecte din 6."""
m = he.eval_predictions(PREDS, GOLD)
assert m["correct"] == 3
def test_global_precision(self):
"""global_precision = correct / total = 3/6 = 0.50."""
m = he.eval_predictions(PREDS, GOLD)
assert abs(m["global_precision"] - 0.50) < 1e-9
def test_campuri_obligatorii(self):
"""Rezultatul contine toate campurile definite."""
m = he.eval_predictions(PREDS, GOLD)
obligatorii = [
"total", "correct", "global_precision",
"wrong_code_count", "wrong_code_rate",
"coverage_count", "coverage_rate",
"per_cod", "confusion_matrix",
]
for camp in obligatorii:
assert camp in m, f"Camp lipsa: {camp}"
def test_empty_inputs(self):
"""Input gol -> metrics cu valori zero, fara exceptie."""
m = he.eval_predictions([], [])
assert m["total"] == 0
assert m["global_precision"] == 0.0
assert m["wrong_code_rate"] == 0.0
def test_all_correct(self):
"""Toate corecte -> precision 1.0, wrong_code_rate 0.0."""
preds = [
{"denumire": "REVIZIE", "cod_pred": "OE-3"},
{"denumire": "ITP", "cod_pred": "NUL"},
]
gold = [
{"denumire": "REVIZIE", "cod_gold": "OE-3"},
{"denumire": "ITP", "cod_gold": "NUL"},
]
m = he.eval_predictions(preds, gold)
assert m["global_precision"] == 1.0
assert m["wrong_code_rate"] == 0.0
def test_predictie_lipsa_tratata_ca_nerezolvata(self):
"""Daca o denumire din gold nu e in predictions -> pred='?' (nerezolvat)."""
preds = [
{"denumire": "REVIZIE", "cod_pred": "OE-3"},
# SCHIMB ULEI lipseste din predictii
]
gold = [
{"denumire": "REVIZIE", "cod_gold": "OE-3"},
{"denumire": "SCHIMB ULEI", "cod_gold": "OE-3"},
]
m = he.eval_predictions(preds, gold)
assert m["total"] == 2
assert m["correct"] == 1 # doar REVIZIE
assert m["coverage_count"] == 1 # SCHIMB ULEI e "?"
# ---------------------------------------------------------------------------
# Sectiunea 2: eval_predictions — rata cod-gresit (CRITIC)
# ---------------------------------------------------------------------------
class TestWrongCodeRate:
"""
'Cod gresit' = pred in VALID_RAR, gold in VALID_RAR, pred != gold.
Aceasta situatie ar produce FINALIZATA ireversibil cu cod eronat.
"""
def test_wrong_code_count(self):
"""2 cod-gresit din 6 intrari."""
m = he.eval_predictions(PREDS, GOLD)
assert m["wrong_code_count"] == 2
def test_wrong_code_rate(self):
"""wrong_code_rate = 2/6."""
m = he.eval_predictions(PREDS, GOLD)
assert abs(m["wrong_code_rate"] - 2 / 6) < 1e-9
def test_nul_gresit_nu_e_cod_gresit(self):
"""pred=NUL si gold=OE-3 NU e 'cod gresit' (item merge la needs_mapping, nu la FINALIZATA)."""
preds = [{"denumire": "REVIZIE", "cod_pred": "NUL"}]
gold = [{"denumire": "REVIZIE", "cod_gold": "OE-3"}]
m = he.eval_predictions(preds, gold)
# pred=NUL nu genereaza FINALIZATA -> wrong_code_count=0
assert m["wrong_code_count"] == 0
def test_zero_wrong_code_pe_fixture_corect(self):
"""Pe fixture 'all correct', wrong_code_count = 0."""
preds = [{"denumire": "X", "cod_pred": "OE-1"}]
gold = [{"denumire": "X", "cod_gold": "OE-1"}]
m = he.eval_predictions(preds, gold)
assert m["wrong_code_count"] == 0
assert m["wrong_code_rate"] == 0.0
# ---------------------------------------------------------------------------
# Sectiunea 3: eval_predictions — acoperire (coverage)
# ---------------------------------------------------------------------------
class TestCoverage:
"""coverage = fractia de intrari cu pred != '?' (are un raspuns, fie cod fie NUL)."""
def test_coverage_count(self):
"""5 din 6 au pred != '?'."""
m = he.eval_predictions(PREDS, GOLD)
assert m["coverage_count"] == 5
def test_coverage_rate(self):
"""coverage_rate = 5/6."""
m = he.eval_predictions(PREDS, GOLD)
assert abs(m["coverage_rate"] - 5 / 6) < 1e-9
def test_coverage_zero_pe_toate_nerezolvate(self):
"""Daca toate pred='?' -> coverage=0."""
preds = [{"denumire": "X", "cod_pred": "?"}]
gold = [{"denumire": "X", "cod_gold": "OE-3"}]
m = he.eval_predictions(preds, gold)
assert m["coverage_count"] == 0
assert m["coverage_rate"] == 0.0
# ---------------------------------------------------------------------------
# Sectiunea 4: eval_predictions — per_cod (TP/FP/FN + precision/recall)
# ---------------------------------------------------------------------------
class TestPerCod:
"""Verifica metricile per eticheta (precizie + recall per cod)."""
def test_per_cod_returnat(self):
"""per_cod e un dict cu chei = etichete prezente."""
m = he.eval_predictions(PREDS, GOLD)
assert isinstance(m["per_cod"], dict)
assert len(m["per_cod"]) > 0
def test_per_cod_campuri(self):
"""Fiecare cod are tp, fp, fn, precision, recall."""
m = he.eval_predictions(PREDS, GOLD)
for cod, stats in m["per_cod"].items():
assert "tp" in stats, f"tp lipsa pentru {cod}"
assert "fp" in stats, f"fp lipsa pentru {cod}"
assert "fn" in stats, f"fn lipsa pentru {cod}"
assert "precision" in stats, f"precision lipsa pentru {cod}"
assert "recall" in stats, f"recall lipsa pentru {cod}"
def test_per_cod_oe1_precision(self):
"""OE-1: pred pt [VOPSIRE(corect), D/R BARA(gresit, gold=OE-1 dar pred=OE-2)].
Wait - pred=OE-1 pt VOPSIRE(gold=OE-1 corect) si SCHIMB ULEI(gold=OE-3 gresit).
TP=1(VOPSIRE), FP=1(SCHIMB ULEI pred=OE-1 dar gold=OE-3), FN=1(D/R BARA pred=OE-2 nu OE-1).
precision_OE1 = 1/(1+1) = 0.50
recall_OE1 = 1/(1+1) = 0.50
"""
m = he.eval_predictions(PREDS, GOLD)
oe1 = m["per_cod"].get("OE-1", {})
# TP: VOPSIRE BARA FATA (pred=OE-1, gold=OE-1)
# FP: SCHIMB ULEI MOTOR (pred=OE-1, gold=OE-3)
# FN: D/R BARA FATA (gold=OE-1, pred=OE-2)
assert oe1.get("tp") == 1
assert oe1.get("fp") == 1
assert oe1.get("fn") == 1
assert abs(oe1.get("precision") - 0.50) < 1e-9
assert abs(oe1.get("recall") - 0.50) < 1e-9
def test_per_cod_oe3_precision(self):
"""OE-3: pred pt [REVIZIE(corect)]. gold=OE-3 pt [REVIZIE, SCHIMB ULEI].
TP=1(REVIZIE), FP=0, FN=1(SCHIMB ULEI pred=OE-1).
precision=1.0, recall=0.50
"""
m = he.eval_predictions(PREDS, GOLD)
oe3 = m["per_cod"].get("OE-3", {})
assert oe3.get("tp") == 1
assert oe3.get("fp") == 0
assert oe3.get("fn") == 1
assert abs(oe3.get("precision") - 1.0) < 1e-9
assert abs(oe3.get("recall") - 0.50) < 1e-9
def test_per_cod_precision_none_pe_necunoscut(self):
"""Daca un cod e doar in gold (niciodata prezis) -> precision=None sau 0."""
# OE-4 e gold pt DIAGNOSTICARE, dar pred='?' -> FN=1, TP=0, FP=0
m = he.eval_predictions(PREDS, GOLD)
oe4 = m["per_cod"].get("OE-4", {})
# Precision nedefinita (0/0): None sau 0.0 ambele OK
assert oe4.get("tp") == 0
assert oe4.get("fp") == 0
assert oe4.get("fn") == 1
assert oe4.get("precision") is None or oe4.get("precision") == 0.0
# ---------------------------------------------------------------------------
# Sectiunea 5: eval_predictions — matrice confuzie
# ---------------------------------------------------------------------------
class TestConfusionMatrix:
"""Matricea confuzie indexata ca 'gold->pred'."""
def test_confusion_matrix_returnat(self):
"""confusion_matrix e un dict."""
m = he.eval_predictions(PREDS, GOLD)
assert isinstance(m["confusion_matrix"], dict)
def test_confusion_matrix_cod_gresit_prezent(self):
"""Cazul 'gold=OE-3, pred=OE-1' (SCHIMB ULEI) -> cheie 'OE-3->OE-1' cu count 1."""
m = he.eval_predictions(PREDS, GOLD)
assert m["confusion_matrix"].get("OE-3->OE-1") == 1
def test_confusion_matrix_corect(self):
"""Cazul corect 'gold=OE-3, pred=OE-3' (REVIZIE) -> cheie 'OE-3->OE-3' cu count 1."""
m = he.eval_predictions(PREDS, GOLD)
assert m["confusion_matrix"].get("OE-3->OE-3") == 1
# ---------------------------------------------------------------------------
# Sectiunea 6: sample_stratified — esantionare stratificata determinista
# ---------------------------------------------------------------------------
# Fixture: 20 iteme cu frecvente Zipf-like (suficient pt 3 strate)
SAMPLE_ROWS = [(f"op_{i:02d}", max(1, 2000 - i * 100)) for i in range(20)]
# Sortat descrescator: op_00=2000, op_01=1900, ..., op_19=100
# n=20, head_end = max(1, round(20*0.20)) = 4
# mid_end = max(5, round(20*0.50)) = 10
# cap = [op_00..op_03] (4 items)
# mijloc = [op_04..op_09] (6 items)
# coada = [op_10..op_19] (10 items)
class TestSampleStratified:
"""Verifica proprietatile esantionarii stratificate."""
def test_determinist_acelasi_seed(self):
"""Acelasi seed -> acelasi rezultat (determinist)."""
r1 = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
r2 = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
assert r1 == r2
def test_seed_diferit_rezultat_diferit(self):
"""Seed diferit -> (de obicei) rezultat diferit."""
r1 = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
r2 = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=999)
# Nu garanteaza 100% diferenta, dar pe 20 items e practic garantat
assert r1 != r2
def test_items_din_input(self):
"""Toate itemele returnate exista in inputul original."""
result = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
input_set = {(d, n) for d, n in SAMPLE_ROWS}
for item in result:
assert (item["denumire"], item["nr"]) in input_set
def test_campuri_obligatorii(self):
"""Fiecare item are: denumire, nr, strat."""
result = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
for item in result:
assert "denumire" in item
assert "nr" in item
assert "strat" in item
def test_strat_valid(self):
"""Valorile strat sunt exclusiv din {'cap', 'mijloc', 'coada'}."""
result = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
for item in result:
assert item["strat"] in ("cap", "mijloc", "coada")
def test_toate_stratele_reprezentate(self):
"""Cand n_sample e suficient de mare, toate 3 stratele apar in rezultat."""
# n_sample=15 dintr-un total de 20 -> toate stratele au cel putin 1 item
result = he.sample_stratified(SAMPLE_ROWS, n_sample=15, seed=42)
strate_prezente = {item["strat"] for item in result}
assert "cap" in strate_prezente
assert "mijloc" in strate_prezente
assert "coada" in strate_prezente
def test_dimensiune_aproape_de_n_sample(self):
"""Dimensiunea rezultatului e aproape de n_sample (+/- 3 datorita rotunjirii)."""
n_sample = 9
result = he.sample_stratified(SAMPLE_ROWS, n_sample=n_sample, seed=42)
assert abs(len(result) - n_sample) <= 3
def test_fara_duplicate(self):
"""Niciun item nu apare de doua ori in esantion."""
result = he.sample_stratified(SAMPLE_ROWS, n_sample=15, seed=42)
denumiri = [item["denumire"] for item in result]
assert len(denumiri) == len(set(denumiri))
def test_input_gol(self):
"""Input gol -> returneaza lista goala fara exceptie."""
result = he.sample_stratified([], n_sample=10, seed=42)
assert result == []
def test_n_sample_mai_mare_decat_corpus(self):
"""Cand n_sample > len(rows), returneaza cel mult len(rows) items."""
result = he.sample_stratified(SAMPLE_ROWS, n_sample=1000, seed=42)
assert len(result) <= len(SAMPLE_ROWS)
# ---------------------------------------------------------------------------
# Sectiunea 7: export_for_labeling — fisier CSV pt etichetare umana
# ---------------------------------------------------------------------------
class TestExportForLabeling:
"""Exportul CSV contine denumire, nr, strat si coloana cod_gold GOALA."""
def test_fisier_creat(self, tmp_path):
"""Fisierul este creat la calea specificata."""
sample = he.sample_stratified(SAMPLE_ROWS, n_sample=5, seed=42)
path = str(tmp_path / "esantion.csv")
he.export_for_labeling(sample, path)
assert os.path.exists(path)
def test_header_csv(self, tmp_path):
"""CSV-ul are header-ul corect: denumire;nr;strat;cod_gold."""
sample = he.sample_stratified(SAMPLE_ROWS, n_sample=5, seed=42)
path = str(tmp_path / "esantion.csv")
he.export_for_labeling(sample, path)
with open(path, encoding="utf-8-sig") as f:
reader = csv.DictReader(f, delimiter=";")
coloane = reader.fieldnames
assert "denumire" in coloane
assert "nr" in coloane
assert "strat" in coloane
assert "cod_gold" in coloane
def test_cod_gold_gol(self, tmp_path):
"""Coloana cod_gold e goala (de completat de operator uman)."""
sample = he.sample_stratified(SAMPLE_ROWS, n_sample=5, seed=42)
path = str(tmp_path / "esantion.csv")
he.export_for_labeling(sample, path)
with open(path, encoding="utf-8-sig") as f:
reader = csv.DictReader(f, delimiter=";")
for row in reader:
# Coloana cod_gold trebuie sa fie vida (nu etichetata de cod!)
assert row["cod_gold"] == "", (
"cod_gold nu trebuie pre-completat: ar fi 'antrenare pe test' "
"(Decision #19 PRD 5.14)"
)
def test_n_randuri_egal_cu_sample(self, tmp_path):
"""CSV-ul are exact atatea randuri cat esantionul."""
sample = he.sample_stratified(SAMPLE_ROWS, n_sample=5, seed=42)
path = str(tmp_path / "esantion.csv")
he.export_for_labeling(sample, path)
with open(path, encoding="utf-8-sig") as f:
rows = list(csv.DictReader(f, delimiter=";"))
assert len(rows) == len(sample)
# ---------------------------------------------------------------------------
# Sectiunea 8: kill_criterion — pragul de acceptanta (F-E, PRD 5.14)
# ---------------------------------------------------------------------------
class TestKillCriterion:
"""
Kill-criterion (F-E): sistemul TRECE daca
wrong_code_rate < wrong_code_threshold (default 0.5%)
SI coverage_rate > coverage_threshold (default 50%).
Justificare threshold 0.5% (0.005):
Un service cu 200 operatii/zi auto-rezolvate = 1 FINALIZATA gresita/zi.
FINALIZATA e ireversibila (cf. PRD 5.14 Premisa 3 / invariant CLAUDE.md).
Pragul poate fi RELAXAT empiric; nu INASPRIT post-hoc (sesizare-in-timp).
"""
def test_trece_cand_sub_prag(self):
"""Trece cand wrong_code_rate < threshold si coverage_rate > min_coverage."""
metrics = {
"wrong_code_rate": 0.003, # 0.3% < 0.5%
"coverage_rate": 0.70, # 70% > 50%
}
r = he.kill_criterion(metrics)
assert r["passes"] is True
def test_esueaza_cand_wrong_code_prea_mare(self):
"""Esueaza cand wrong_code_rate >= threshold."""
metrics = {
"wrong_code_rate": 0.02, # 2% > 0.5% -> FAIL
"coverage_rate": 0.70,
}
r = he.kill_criterion(metrics)
assert r["passes"] is False
assert "wrong_code" in r["reason"].lower() or "cod gresit" in r["reason"].lower()
def test_esueaza_cand_coverage_prea_mica(self):
"""Esueaza cand coverage_rate < min_coverage_threshold."""
metrics = {
"wrong_code_rate": 0.001,
"coverage_rate": 0.30, # 30% < 50% -> FAIL
}
r = he.kill_criterion(metrics)
assert r["passes"] is False
assert "acoperire" in r["reason"].lower() or "coverage" in r["reason"].lower()
def test_esueaza_pe_ambele_conditii(self):
"""Esueaza cand ambele conditii sunt incalcate."""
metrics = {
"wrong_code_rate": 0.05,
"coverage_rate": 0.10,
}
r = he.kill_criterion(metrics)
assert r["passes"] is False
def test_campuri_obligatorii_in_rezultat(self):
"""Rezultatul are: passes, reason, wrong_code_rate, coverage_rate, thresholds."""
metrics = {"wrong_code_rate": 0.001, "coverage_rate": 0.80}
r = he.kill_criterion(metrics)
for camp in ("passes", "reason", "wrong_code_rate", "coverage_rate", "thresholds"):
assert camp in r, f"Camp lipsa: {camp}"
def test_threshold_customizabil(self):
"""Pragurile pot fi suprascrise."""
metrics = {"wrong_code_rate": 0.05, "coverage_rate": 0.80}
# Cu threshold mai lax, trece
r = he.kill_criterion(metrics, wrong_code_threshold=0.10)
assert r["passes"] is True
def test_exact_pe_prag_nu_trece(self):
"""Pe prag exact (egalitate), nu trece (< e strict)."""
threshold = he.DEFAULT_WRONG_CODE_THRESHOLD
metrics = {"wrong_code_rate": threshold, "coverage_rate": 0.80}
r = he.kill_criterion(metrics)
# wrong_code_rate = threshold -> NU < threshold -> FAIL
assert r["passes"] is False
def test_reason_descrie_starea(self):
"""reason e un string non-gol care descrie de ce trece/esueaza."""
metrics = {"wrong_code_rate": 0.001, "coverage_rate": 0.80}
r = he.kill_criterion(metrics)
assert isinstance(r["reason"], str)
assert len(r["reason"]) > 0
# ---------------------------------------------------------------------------
# Sectiunea 9: constante si metadate modul
# ---------------------------------------------------------------------------
class TestModulMetadata:
"""Verifica existenta constantelor documentate."""
def test_valid_rar_definit(self):
"""VALID_RAR e un set non-gol de coduri RAR."""
assert hasattr(he, "VALID_RAR")
assert isinstance(he.VALID_RAR, frozenset)
assert len(he.VALID_RAR) >= 18
def test_nul_in_all_labels_nu_in_valid_rar(self):
"""NUL e eticheta speciala (supresie), NU e cod RAR valid."""
assert "NUL" not in he.VALID_RAR
# NUL trebuie sa fie accesibil totusi
assert hasattr(he, "NUL")
assert he.NUL == "NUL"
def test_default_seed(self):
"""DEFAULT_SEED exista si e intreg."""
assert hasattr(he, "DEFAULT_SEED")
assert isinstance(he.DEFAULT_SEED, int)
def test_default_thresholds_in_range(self):
"""Pragurile default sunt in (0, 1)."""
assert 0 < he.DEFAULT_WRONG_CODE_THRESHOLD < 1
assert 0 < he.DEFAULT_COVERAGE_THRESHOLD < 1

286
tests/test_holdout.py Normal file
View File

@@ -0,0 +1,286 @@
"""Teste TDD pentru tools/mapare-llm/holdout.py.
Verifica logica de split + calcul hit-rate pe un fixture SINTETIC (nu pe date reale).
Fixture-ul nu testeaza numerele efective pe CSV-uri, ci CORECTITUDINEA functiilor.
"""
from __future__ import annotations
import sys
import os
# Adaugam tools/mapare-llm/ in path pentru import direct al holdout.py
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools', 'mapare-llm'))
import pytest
# Fixture sintetic: 5 denumiri cu frecvente diferite
# Total volum = 100 + 80 + 50 + 30 + 10 + 1 + 1 = 272
FIXTURE = [
("Revizie motor", 100),
("Schimb ulei", 80),
("Reglat frane", 50),
("Diagnosticare", 30),
("Curatenie interior", 10),
("Altceva rar A", 1),
("Altceva rar B", 1),
]
FIXTURE_TOTAL_VOL = sum(n for _, n in FIXTURE) # 272
FIXTURE_DISTINCT = len(FIXTURE) # 7
# ---------------------------------------------------------------------------
# compute_volume_coverage
# ---------------------------------------------------------------------------
def test_compute_volume_coverage_sorted_descrescator():
"""Primul element trebuie sa fie cel cu NR cel mai mare."""
from holdout import compute_volume_coverage
rows = [("A", 10), ("B", 90), ("C", 0)]
result = compute_volume_coverage([r for r in rows if r[1] > 0])
assert result[0]["denumire"] == "B"
assert result[0]["nr"] == 90
def test_compute_volume_coverage_cumul():
"""Acoperirea cumulativa e corecta."""
from holdout import compute_volume_coverage
rows = [("A", 90), ("B", 9), ("C", 1)] # total=100
result = compute_volume_coverage(rows)
# Ordine: A(90), B(9), C(1) dupa sortare desc
assert result[0]["denumire"] == "A"
assert abs(result[0]["cumulative_volume_frac"] - 0.90) < 1e-9
assert result[0]["cumulative_count"] == 1
assert result[1]["denumire"] == "B"
assert abs(result[1]["cumulative_volume_frac"] - 0.99) < 1e-9
assert result[1]["cumulative_count"] == 2
assert result[2]["denumire"] == "C"
assert abs(result[2]["cumulative_volume_frac"] - 1.0) < 1e-9
assert result[2]["cumulative_count"] == 3
def test_compute_volume_coverage_gol():
"""Lista goala -> lista goala (fara exceptii)."""
from holdout import compute_volume_coverage
assert compute_volume_coverage([]) == []
# ---------------------------------------------------------------------------
# corpus_size_for_threshold
# ---------------------------------------------------------------------------
def test_corpus_size_for_90pct():
"""Gaseste corect numarul de etichete pentru 90% acoperire."""
from holdout import corpus_size_for_threshold
rows = [("A", 90), ("B", 9), ("C", 1)] # total=100
# A singur = 90% -> 1 eticheta suficienta
assert corpus_size_for_threshold(rows, threshold=0.90) == 1
def test_corpus_size_for_99pct():
"""Prag 99%: necesita 2 etichete (A+B = 99/100)."""
from holdout import corpus_size_for_threshold
rows = [("A", 90), ("B", 9), ("C", 1)]
assert corpus_size_for_threshold(rows, threshold=0.99) == 2
def test_corpus_size_for_100pct():
"""Prag 100%: necesita toate etichetele."""
from holdout import corpus_size_for_threshold
rows = [("A", 90), ("B", 9), ("C", 1)]
assert corpus_size_for_threshold(rows, threshold=1.0) == 3
# ---------------------------------------------------------------------------
# compute_hit_rate_at_k
# ---------------------------------------------------------------------------
def test_compute_hit_rate_at_k_1():
"""Top-1 eticheta (A=90): hit-rate = 90/100 = 0.90."""
from holdout import compute_hit_rate_at_k
rows = [("A", 90), ("B", 9), ("C", 1)]
assert abs(compute_hit_rate_at_k(rows, k=1) - 0.90) < 1e-9
def test_compute_hit_rate_at_k_2():
"""Top-2 etichete (A+B=99): hit-rate = 0.99."""
from holdout import compute_hit_rate_at_k
rows = [("A", 90), ("B", 9), ("C", 1)]
assert abs(compute_hit_rate_at_k(rows, k=2) - 0.99) < 1e-9
def test_compute_hit_rate_at_k_depasit():
"""k mai mare decat numarul de randuri: hit-rate = 1.0."""
from holdout import compute_hit_rate_at_k
rows = [("A", 90), ("B", 10)]
assert abs(compute_hit_rate_at_k(rows, k=100) - 1.0) < 1e-9
def test_compute_hit_rate_at_k_gol():
"""Lista goala: hit-rate = 0.0 (fara ZeroDivisionError)."""
from holdout import compute_hit_rate_at_k
assert compute_hit_rate_at_k([], k=10) == 0.0
# ---------------------------------------------------------------------------
# leave_one_out_hit_rate
# ---------------------------------------------------------------------------
def test_leave_one_out_hit_rate_formula():
"""Hit-rate leave-first-out: (total_vol - total_distinct) / total_vol.
Interpretare: pe oricare aparitie, dupa prima, e un hit (deja in corpus).
Singletonii (NR=1) contribuie 0 hit-uri.
"""
from holdout import leave_one_out_hit_rate
rows = [("A", 10), ("B", 5), ("C", 1)] # total=16, distinct=3
# formula: (16 - 3) / 16 = 0.8125
assert abs(leave_one_out_hit_rate(rows) - 13 / 16) < 1e-9
def test_leave_one_out_hit_rate_toate_singletons():
"""Toti singletons: hit-rate = 0 (fiecare aparitie e prima)."""
from holdout import leave_one_out_hit_rate
rows = [("A", 1), ("B", 1), ("C", 1)]
assert leave_one_out_hit_rate(rows) == 0.0
def test_leave_one_out_hit_rate_gol():
"""Lista goala: returneaza 0.0 fara exceptie."""
from holdout import leave_one_out_hit_rate
assert leave_one_out_hit_rate([]) == 0.0
# ---------------------------------------------------------------------------
# singleton_stats
# ---------------------------------------------------------------------------
def test_singleton_stats_calcul():
"""Statistici singletons corecte."""
from holdout import singleton_stats
rows = [("A", 100), ("B", 1), ("C", 1)] # total=102, 2 singletons
stats = singleton_stats(rows)
assert stats["singleton_count"] == 2
assert stats["total_distinct"] == 3
assert abs(stats["singleton_volume_frac"] - 2 / 102) < 1e-9
assert abs(stats["singleton_distinct_frac"] - 2 / 3) < 1e-9
def test_singleton_stats_fara_singletons():
"""Fara singletons: toate fractiile singleton = 0."""
from holdout import singleton_stats
rows = [("A", 5), ("B", 10)]
stats = singleton_stats(rows)
assert stats["singleton_count"] == 0
assert stats["singleton_volume_frac"] == 0.0
# ---------------------------------------------------------------------------
# normalize_for_match: cheia de potrivire refolosita din app/mapping.py
# ---------------------------------------------------------------------------
def test_normalize_for_match_diacritice():
"""normalize_for_match trateaza diacriticele identic (din app/mapping.py)."""
from holdout import normalize_key
# Variante cu si fara diacritice -> aceeasi cheie normalizata
assert normalize_key("Reparație motor") == normalize_key("Reparatie motor")
assert normalize_key("REPARATIE MOTOR") == normalize_key("Reparatie motor")
def test_normalize_for_match_spatii():
"""Spatiile multiple se colapseza."""
from holdout import normalize_key
assert normalize_key("revizie periodica") == normalize_key("REVIZIE PERIODICA")
# ---------------------------------------------------------------------------
# run_holdout: structura si verdict
# ---------------------------------------------------------------------------
def test_run_holdout_campuri_obligatorii():
"""run_holdout returneaza toate campurile asteptate."""
from holdout import run_holdout
result = run_holdout(FIXTURE, client_name="test_client")
campuri = [
"client", "total_distinct", "total_volume",
"coverage_at_100", "coverage_at_500", "coverage_at_1000",
"labels_for_90pct", "frac_for_90pct",
"leave_one_out_hit_rate",
"singleton_count", "singleton_distinct_frac", "singleton_volume_frac",
"verdict", "nota",
]
for camp in campuri:
assert camp in result, f"Camp lipsa: {camp}"
def test_run_holdout_client_name():
"""client_name se pastreaza corect in rezultat."""
from holdout import run_holdout
result = run_holdout(FIXTURE, client_name="test_client")
assert result["client"] == "test_client"
def test_run_holdout_verdict_valid():
"""Verdict e unul din valorile definite."""
from holdout import run_holdout
result = run_holdout(FIXTURE, client_name="test_client")
assert result["verdict"] in ("SUSTINUTA", "SLABA", "NEVALIDABILA")
def test_run_holdout_total_volum():
"""total_volume = suma NR din fixture."""
from holdout import run_holdout
result = run_holdout(FIXTURE, client_name="test_client")
assert result["total_volume"] == FIXTURE_TOTAL_VOL
def test_run_holdout_distinct():
"""total_distinct = numarul de randuri din fixture."""
from holdout import run_holdout
result = run_holdout(FIXTURE, client_name="test_client")
assert result["total_distinct"] == FIXTURE_DISTINCT
def test_run_holdout_verdict_sustinuta_pe_zipf_puternic():
"""Pe distributie Zipf puternica (o denumire = 95% din volum), verdict SUSTINUTA."""
from holdout import run_holdout
rows = [("REVIZIE", 9500)] + [(f"altceva_{i}", 1) for i in range(500)]
result = run_holdout(rows, client_name="zipf")
assert result["verdict"] == "SUSTINUTA"
def test_run_holdout_verdict_slaba_pe_distributie_plata():
"""Pe distributie uniforma (50 denumiri cu aceeasi frecventa), poate fi SLABA/NEVALIDABILA."""
from holdout import run_holdout
rows = [(f"op_{i}", 100) for i in range(100)] # 100 denumiri cu NR egal
result = run_holdout(rows, client_name="uniform")
# 90% din 100*100=10000 = 9000; necesita 90 din 100 denumiri = 90% -> NEVALIDABILA
assert result["verdict"] in ("SLABA", "NEVALIDABILA")

View File

@@ -0,0 +1,578 @@
"""TDD L14-S6 — Integrare Layer 2/3 in editor (suggestion-only, DUPA 5.15).
Scenarii acoperite:
- F1-regression CRITIC: SILVER/shared GOLD NU auto-trimit (resolve_prestatii neschimbat)
- pending_unmapped include sugestie GOLD partajat > SILVER > embeddings (precedenta Eng-F2)
- record_human_validation apelat la confirmare umana (POST /mapari -> shared_mappings)
- Degradare gratioasa cand embeddings indisponibil (mock is_available=False)
- Separare structurala #13: resolve_prestatii/load_mapping NU citesc tabelele de sugestii
"""
from __future__ import annotations
import os
import tempfile
import pytest
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def env(monkeypatch):
"""DB temporara cu schema initiata, auth dezactivata (mod dev)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "l14_s6_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
@pytest.fixture()
def conn(env):
from app.db import get_connection
c = get_connection()
# Seed nomenclator (OE-1, OE-2, OE-3, OE-4 suficient pentru teste)
c.executemany(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
[
("OE-1", "REPARATIE MOTOR"),
("OE-2", "INTRETINERE"),
("OE-3", "REVIZIE PERIODICA"),
("OE-4", "REGLARE"),
],
)
c.commit()
yield c
c.close()
@pytest.fixture()
def client(env):
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
# --------------------------------------------------------------------------- #
# F1-regression CRITIC: SILVER/shared GOLD NU auto-trimit #
# --------------------------------------------------------------------------- #
def test_f1_silver_nu_auto_trimite(conn):
"""CRITICAL F1: un cod in SILVER (mapping_suggestions) NU produce auto-trimitere.
resolve_prestatii cu mapping gol + SILVER existent -> operatie ramane nemapata.
Submissionul ar ramane needs_mapping, NU queued.
"""
from app.shared_store import seed_suggestions
from app.mapping import resolve_prestatii
seed_suggestions(conn, [
{"denumire": "Revizie periodica", "cod_prestatie": "OE-3", "source": "llm", "confidence": 0.95},
])
conn.commit()
# resolve_prestatii cu mapping gol -> SILVER nu se vede
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP-REV", "denumire": "Revizie periodica"}],
{}, # operations_mapping gol
)
# Operatia ramane nemapata (SILVER nu e in resolve, #13)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1
def test_f1_shared_gold_nu_auto_trimite(conn):
"""CRITICAL F1: un cod in shared_mappings (GOLD partajat) NU produce auto-trimitere.
resolve_prestatii cu mapping gol + shared GOLD existent -> operatie ramane nemapata.
"""
from app.shared_store import record_human_validation
from app.mapping import resolve_prestatii
record_human_validation(conn, "Schimb ulei motor", "OE-3")
conn.commit()
# resolve_prestatii cu mapping gol -> GOLD partajat nu se vede
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP-ULEI", "denumire": "Schimb ulei motor"}],
{}, # operations_mapping gol
)
# Operatia ramane nemapata (GOLD partajat nu e in resolve, #13)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1
def test_f1_load_mapping_nu_citeste_shared_gold(conn):
"""Separare #13: load_mapping NU returneaza coduri din shared_mappings."""
from app.shared_store import record_human_validation
from app.mapping import load_mapping
record_human_validation(conn, "Revizie anuala", "OE-3")
conn.commit()
mapping = load_mapping(conn, account_id=1)
# GOLD partajat nu trebuie sa apara in load_mapping (citit de resolve_prestatii)
assert "Revizie anuala" not in mapping
# Maparea propriu-zisa (operations_mapping) ramane goala
assert len(mapping) == 0
# --------------------------------------------------------------------------- #
# enrich_suggestions: GOLD partajat > SILVER > embeddings #
# --------------------------------------------------------------------------- #
def test_enrich_fara_surse_returneaza_none(conn):
"""Fara GOLD/SILVER/embedding -> sugestie_principala = None."""
from app.mapping import enrich_suggestions
result = enrich_suggestions(conn, "Operatie inexistenta")
assert result["sugestie_principala"] is None
assert result["surse"]["gold_partajat"] is None
assert result["surse"]["silver"] is None
assert result["surse"]["embedding"] is None
def test_enrich_include_gold_partajat(conn):
"""enrich_suggestions returneaza sugestie GOLD partajat cand shared_mappings are match."""
from app.shared_store import record_human_validation
from app.mapping import enrich_suggestions
record_human_validation(conn, "Schimb ulei", "OE-3")
conn.commit()
result = enrich_suggestions(conn, "Schimb ulei")
assert result["sugestie_principala"] is not None
assert result["sugestie_principala"]["cod_prestatie"] == "OE-3"
assert result["sugestie_principala"]["sursa"] == "gold_partajat"
assert result["surse"]["gold_partajat"] == "OE-3"
def test_enrich_include_silver(conn):
"""enrich_suggestions returneaza sugestie SILVER cand mapping_suggestions are match."""
from app.shared_store import seed_suggestions
from app.mapping import enrich_suggestions
seed_suggestions(conn, [
{"denumire": "Reparatie motor", "cod_prestatie": "OE-1", "source": "llm", "confidence": 0.9},
])
conn.commit()
result = enrich_suggestions(conn, "Reparatie motor")
assert result["sugestie_principala"] is not None
assert result["sugestie_principala"]["cod_prestatie"] == "OE-1"
assert result["sugestie_principala"]["sursa"] == "silver"
assert result["surse"]["silver"] == "OE-1"
def test_enrich_precedenta_gold_peste_silver(conn):
"""Precedenta Eng-F2: GOLD partajat castiga fata de SILVER cand ambele exista."""
from app.shared_store import seed_suggestions, record_human_validation
from app.mapping import enrich_suggestions
# SILVER spune OE-1, GOLD spune OE-3
seed_suggestions(conn, [
{"denumire": "Verificare tehnica", "cod_prestatie": "OE-1", "source": "llm", "confidence": 0.8},
])
record_human_validation(conn, "Verificare tehnica", "OE-3")
conn.commit()
result = enrich_suggestions(conn, "Verificare tehnica")
assert result["sugestie_principala"] is not None
assert result["sugestie_principala"]["cod_prestatie"] == "OE-3"
assert result["sugestie_principala"]["sursa"] == "gold_partajat"
# SILVER prezent dar nu castiga
assert result["surse"]["silver"] == "OE-1"
assert result["surse"]["gold_partajat"] == "OE-3"
def test_enrich_degradare_embeddings_indisponibil(conn, monkeypatch):
"""Degradare gratioasa (#16b): cand embeddings nu e disponibil, nu eroare."""
import app.embeddings as emb_mod
monkeypatch.setattr(emb_mod, "is_available", lambda: False)
from app.mapping import enrich_suggestions
# Fara surse -> sugestie_principala = None, fara exceptie
result = enrich_suggestions(conn, "Operatie demo", include_embeddings=True)
assert result["sugestie_principala"] is None
assert result["surse"]["embedding"] is None
def test_enrich_corpus_gol_nu_incarca_modelul(conn, monkeypatch):
"""Bug fix (code-review): enrich_suggestions NU lazy-load-eaza modelul de 220MB
cand corpus-ul embeddings e gol.
Implementarea veche apela `is_available()` neconditionat -> `_get_engine()` ->
`_load_engine()` -> `FastEmbedBackend()` (incarcare sincrona 30-120s) chiar daca
`index_corpus` nu a fost apelat niciodata in productie -> corpus gol ->
`suggest_nearest` ar fi returnat [] oricum (zero beneficiu, cost mare).
Fix: poarta `has_corpus()` (ieftina, nu construieste engine-ul cand `_engine is None`).
"""
import app.embeddings as emb_mod
# Engine ne-initializat -> corpus gol prin definitie.
monkeypatch.setattr(emb_mod, "_engine", None, raising=False)
incarcari = {"n": 0}
orig_load = emb_mod._load_engine
def _spy_load():
incarcari["n"] += 1
return orig_load()
monkeypatch.setattr(emb_mod, "_load_engine", _spy_load)
from app.mapping import enrich_suggestions
result = enrich_suggestions(conn, "Operatie oarecare", include_embeddings=True)
assert result["surse"]["embedding"] is None
assert incarcari["n"] == 0, (
"Modelul de embeddings NU trebuie incarcat cand corpus-ul e gol "
f"(index_corpus nu e wired). _load_engine apelat de {incarcari['n']} ori."
)
class _FakeEmbedBackend:
"""Backend embedding determinist (3 dimensiuni keyword) — fara model real 230MB."""
def embed(self, texts):
out = []
for t in texts:
tl = str(t).lower()
out.append([
1.0 if "ulei" in tl else 0.0,
1.0 if "motor" in tl else 0.0,
1.0 if "frana" in tl else 0.0,
])
return out
def test_embeddings_functional_cand_flag_activ(conn, monkeypatch):
"""PRD #15: cu AUTOPASS_EMBEDDINGS_ENABLED=true, embeddings produce efectiv o sugestie.
Wire-uieste ensure_embeddings_corpus (corpus din nomenclator) + enrich_suggestions.
Backend injectat (determinist) -> nu incarca modelul real de 230MB.
"""
import app.embeddings as emb_mod
from app.embeddings import EmbeddingEngine
from app.config import get_settings
# Activeaza flagul + injecteaza backend fals in singleton-ul global.
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "true")
get_settings.cache_clear()
monkeypatch.setattr(emb_mod, "_engine", EmbeddingEngine(backend=_FakeEmbedBackend()))
# Nomenclatorul (din fixtura conn) are OE-1..OE-4; adaug coduri cu denumiri keyword.
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
("UL-1", "Schimb ulei"),
)
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
("FR-1", "Placute frana"),
)
conn.commit()
from app.mapping import ensure_embeddings_corpus, enrich_suggestions
ensure_embeddings_corpus(conn)
assert emb_mod.has_corpus(), "corpusul trebuie indexat cand flagul e activ"
# "schimbat uleiul motor" -> vector [1,1,0] -> cel mai apropiat = UL-1 (Schimb ulei).
result = enrich_suggestions(conn, "schimbat uleiul motor", include_embeddings=True)
assert result["surse"]["embedding"] == "UL-1", (
f"embeddings trebuie sa sugereze UL-1, got {result['surse']}"
)
get_settings.cache_clear()
def test_embeddings_flag_off_ramane_noop(conn, monkeypatch):
"""Cu flagul off (default), ensure_embeddings_corpus e no-op total (nu indexeaza)."""
import app.embeddings as emb_mod
from app.embeddings import EmbeddingEngine
from app.config import get_settings
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "false")
get_settings.cache_clear()
# Engine cu backend disponibil, dar flagul off -> NU se indexeaza nimic.
monkeypatch.setattr(emb_mod, "_engine", EmbeddingEngine(backend=_FakeEmbedBackend()))
from app.mapping import ensure_embeddings_corpus
ensure_embeddings_corpus(conn)
assert not emb_mod.has_corpus(), "flag off -> corpusul NU trebuie indexat"
get_settings.cache_clear()
def test_enrich_silver_nul_ignorat(conn):
"""SILVER cu is_nul=1 (non-operatie) NU apare ca sugestie."""
from app.shared_store import seed_suggestions
from app.mapping import enrich_suggestions
seed_suggestions(conn, [
{"denumire": "ITP CT 12 ABC", "is_nul": True, "source": "llm", "confidence": 0.99},
])
conn.commit()
result = enrich_suggestions(conn, "ITP CT 12 ABC")
assert result["sugestie_principala"] is None
assert result["surse"]["silver"] is None
# --------------------------------------------------------------------------- #
# pending_unmapped: include sugestie_principala #
# --------------------------------------------------------------------------- #
def test_pending_unmapped_include_sugestie_principala(conn):
"""pending_unmapped returneaza entries cu sugestie_principala din GOLD/SILVER."""
from app.shared_store import record_human_validation
from app.mapping import pending_unmapped
import json
record_human_validation(conn, "Schimb ulei motor", "OE-3")
conn.commit()
# Creeaza un submission needs_mapping cu "Schimb ulei motor"
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, 'key-test-001')",
(json.dumps({
"vin": "WVWZZZ1KZAW001111",
"prestatii": [{"cod_op_service": "OP-ULEI", "denumire": "Schimb ulei motor"}],
}),),
)
conn.commit()
pending = pending_unmapped(conn, account_id=1)
assert len(pending) == 1
entry = pending[0]
# sugestie_principala adaugat de enrich_suggestions (L14-S6)
assert "sugestie_principala" in entry
sp = entry["sugestie_principala"]
assert sp is not None
assert sp["cod_prestatie"] == "OE-3"
assert sp["sursa"] == "gold_partajat"
def test_pending_unmapped_fara_surse_sugestie_principala_none(conn, monkeypatch):
"""pending_unmapped -> sugestie_principala = None cand nu exista nicio sursa.
Dezactiveaza embeddings prin poarta reala `has_corpus`=False (gate-ul folosit de
enrich_suggestions dupa wiring), independent de starea singleton-ului global lasata
de alte teste (izolare de ordine).
"""
import app.embeddings as emb_mod
monkeypatch.setattr(emb_mod, "has_corpus", lambda: False)
monkeypatch.setattr(emb_mod, "is_available", lambda: False)
from app.mapping import pending_unmapped
import json
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, 'key-test-002')",
(json.dumps({
"vin": "WVWZZZ1KZAW002222",
"prestatii": [{"cod_op_service": "OP-FARA-SURSA", "denumire": "Operatie de nisa"}],
}),),
)
conn.commit()
pending = pending_unmapped(conn, account_id=1)
assert len(pending) == 1
entry = pending[0]
assert "sugestie_principala" in entry
assert entry["sugestie_principala"] is None
# --------------------------------------------------------------------------- #
# record_human_validation apelat la confirmare umana #
# --------------------------------------------------------------------------- #
def test_record_human_validation_la_post_mapari(env, client):
"""POST /mapari (tab Mapari) -> record_human_validation scrie in shared_mappings.
Testul verifica ca GOLD partajat se populeaza automat la confirmarea umana
din interfata de mapari.
"""
from app.db import get_connection
import json
# Creeaza un submission needs_mapping
conn_setup = get_connection()
try:
conn_setup.executemany(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
[("OE-3", "REVIZIE PERIODICA"), ("OE-1", "REPARATIE")],
)
conn_setup.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, 'key-hv-001')",
(json.dumps({
"vin": "WVWZZZ1KZAW003333",
"prestatii": [{"cod_op_service": "OP-REV", "denumire": "Revizie anuala"}],
}),),
)
conn_setup.commit()
finally:
conn_setup.close()
# POST /mapari cu denumire (L14-S6: form include denumire hidden)
resp = client.post(
"/mapari",
data={
"cod_op_service": "OP-REV",
"cod_prestatie": "OE-3",
"denumire": "Revizie anuala",
"csrf_token": "",
},
)
assert resp.status_code == 200, resp.text
# Verifica ca shared_mappings contine intrarea
conn_check = get_connection()
try:
from app.shared_store import lookup_shared_gold
row = lookup_shared_gold(conn_check, "Revizie anuala")
assert row is not None, "record_human_validation nu a scris in shared_mappings"
assert row["cod_prestatie"] == "OE-3"
finally:
conn_check.close()
def test_record_human_validation_la_mapeaza_inline(env, client):
"""POST /trimitere/{id}/mapeaza -> record_human_validation scrie in shared_mappings.
Testul verifica ca GOLD partajat se populeaza la maparea inline din panoul de detaliu.
"""
from app.db import get_connection
import json
# Setup submission needs_mapping
conn_setup = get_connection()
try:
conn_setup.executemany(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
[("OE-1", "REPARATIE"), ("OE-3", "REVIZIE")],
)
conn_setup.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, 'key-inline-001')",
(json.dumps({
"vin": "WVWZZZ1KZAW004444",
"data_prestatie": "2026-06-15",
"odometru_final": 100000,
"prestatii": [{"cod_op_service": "OP-REP", "denumire": "Reparatie chiulasa"}],
}),),
)
conn_setup.commit()
# Preia ID-ul submission-ului
sid = conn_setup.execute("SELECT id FROM submissions WHERE idempotency_key='key-inline-001'").fetchone()["id"]
finally:
conn_setup.close()
resp = client.post(
f"/trimitere/{sid}/mapeaza",
data={
"cod_op_service": "OP-REP",
"cod_prestatie": "OE-1",
"csrf_token": "",
},
)
assert resp.status_code == 200, resp.text
# Verifica shared_mappings
conn_check = get_connection()
try:
from app.shared_store import lookup_shared_gold
row = lookup_shared_gold(conn_check, "Reparatie chiulasa")
assert row is not None, "record_human_validation nu a scris in shared_mappings pentru mapeaza inline"
assert row["cod_prestatie"] == "OE-1"
finally:
conn_check.close()
def test_mapare_salvata_fara_denumire_nu_polueaza_gold(env, client):
"""Bug fix (code-review 5.15): editarea unei mapari salvate FARA denumire NU scrie
o intrare bogus in GOLD partajat (cheiata pe cod_op_service in loc de denumire umana).
Formularul din _mapari.html nu trimite denumire; vechiul fallback `denumire or
cod_op_service` scria shared_mappings cheiat pe cod_op_service -> lookup_shared_gold
(pe denumirea umana) nu il potrivea niciodata -> poluare. Fix: _record_gold_validation
sare scrierea cand denumire lipseste sau == cod_op_service.
"""
from app.db import get_connection
conn_setup = get_connection()
try:
conn_setup.execute(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
("OE-1", "REPARATIE"),
)
conn_setup.commit()
finally:
conn_setup.close()
# Editare mapare salvata FARA denumire (ca formularul real din _mapari.html).
resp = client.post(
"/mapari/salvate",
data={
"cod_op_service": "OP-SALV",
"cod_prestatie": "OE-1",
"csrf_token": "",
},
)
assert resp.status_code == 200, resp.text
conn_check = get_connection()
try:
from app.shared_store import lookup_shared_gold
# NICIO intrare bogus cheiata pe cod_op_service.
assert lookup_shared_gold(conn_check, "OP-SALV") is None, (
"GOLD partajat poluat cu cod_op_service ca si cheie (denumire lipsa)"
)
finally:
conn_check.close()
# --------------------------------------------------------------------------- #
# Separare structurala #13 (redundant cu test_shared_store dar explicit L14) #
# --------------------------------------------------------------------------- #
def test_separare_silver_din_resolve_prestatii():
"""#13: resolve_prestatii nu citeste mapping_suggestions (SILVER)."""
from app.mapping import resolve_prestatii
# Apelam fara conn (pur) — SILVER nu e parametru si nu e accesat
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP-TEST", "denumire": "Test silver"}],
{}, # mapping gol
)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1
def test_separare_shared_gold_din_resolve_prestatii():
"""#13: resolve_prestatii nu citeste shared_mappings (GOLD partajat)."""
from app.mapping import resolve_prestatii
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP-TEST2", "denumire": "Test gold partajat"}],
{}, # mapping gol
)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1

491
tests/test_or_label.py Normal file
View File

@@ -0,0 +1,491 @@
"""Teste pentru or_label.py — etichetator batch offline OpenRouter (L14-S1).
TDD: aceste teste TREBUIE sa fie RED inainte de implementare, GREEN dupa.
Fara apeluri LLM reale — or_common.call() este MOCK-at in toate testele
care ating API-ul. Testeaza: grupare+propagare, vot ensemble, scrub PII,
resumabilitate, format output.
Rulare: python3 -m pytest tests/test_or_label.py -v
"""
import sys
import os
import json
# Setam cheia inainte de import (or_common.py o citeste la nivel de modul).
# Valoarea nu conteaza in teste (call() e mock-at).
os.environ.setdefault("OPENROUTER_KEY", "test-key-mock")
# Adaugam calea tools/mapare-llm/ la sys.path ca sa putem importa or_label
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS_DIR = os.path.abspath(os.path.join(HERE, "..", "tools", "mapare-llm"))
if TOOLS_DIR not in sys.path:
sys.path.insert(0, TOOLS_DIR)
import or_label # subject under test
import or_common as oc # pentru VALID, CODURI, scrub
# ---------------------------------------------------------------------------
# Grupare pe similaritate + propagare cod
# ---------------------------------------------------------------------------
class TestGroupBySimilarity:
"""Verifica logica de grupare greedy pe fuzz.token_sort_ratio."""
def test_similar_strings_grouped_in_one(self):
"""Denumiri aproape identice -> un singur reprezentant, ceilalti membri."""
# Scoruri masurate: token_sort_ratio("REGLAT DIRECTIE","REGLAT DIRECTIA")=93
# token_sort_ratio("REGLAT DIRECTIE","REGLARE DIRECTIE")=90
corpus = [
("REGLAT DIRECTIE", 100), # reprezentant (frecventa maxima)
("REGLAT DIRECTIA", 80), # similar: 93 >= 85
("REGLARE DIRECTIE", 60), # similar: 90 >= 85
]
groups = or_label.group_by_similarity(corpus, threshold=85)
assert len(groups) == 1
g = groups[0]
assert g["rep"] == "REGLAT DIRECTIE"
assert len(g["members"]) == 2
member_names = [m[0] for m in g["members"]]
assert "REGLAT DIRECTIA" in member_names
assert "REGLARE DIRECTIE" in member_names
def test_distinct_strings_separate_groups(self):
"""Denumiri foarte diferite -> grupuri separate."""
corpus = [
("REVIZIE", 100),
("D/R BARA FATA", 80),
("SCHIMB ULEI MOTOR", 60),
]
groups = or_label.group_by_similarity(corpus, threshold=85)
assert len(groups) == 3
def test_representative_is_highest_frequency(self):
"""Reprezentantul = cel cu frecventa maxima (primul in sorted desc)."""
corpus = [
("INLOCUIT FILTRU AER", 300), # frecventa maxima
("INLOCUIRE FILTRU AER", 100), # similar: 92 >= 85
]
groups = or_label.group_by_similarity(corpus, threshold=85)
assert len(groups) == 1
assert groups[0]["rep"] == "INLOCUIT FILTRU AER"
assert groups[0]["freq"] == 300
def test_singleton_group(self):
"""O denumire fara vecini -> grup cu 0 membri."""
corpus = [("REVIZIE", 100)]
groups = or_label.group_by_similarity(corpus, threshold=85)
assert len(groups) == 1
assert groups[0]["rep"] == "REVIZIE"
assert groups[0]["members"] == []
def test_below_threshold_not_grouped(self):
"""Similaritate sub threshold -> grupuri separate."""
# D/R BARA FATA vs D/R BARA SPATE = 81 < 85
corpus = [
("D/R BARA FATA", 200),
("D/R BARA SPATE", 180),
]
groups = or_label.group_by_similarity(corpus, threshold=85)
assert len(groups) == 2
# ---------------------------------------------------------------------------
# Vot ensemble (acord/dezacord) — fara apeluri LLM
# ---------------------------------------------------------------------------
class TestEnsembleVote:
"""Verifica logica de vot pe coduri (nu self-confidence)."""
def test_unanim_cod_rar(self):
"""Ambele modele de acord pe cod RAR -> confidence high, sursa unanim."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "OE-3",
"nvidia/nemotron-nano-9b-v2:free": "OE-3",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
assert cod == "OE-3"
assert confidence == "high"
assert "unanim" in sursa
def test_unanim_nul_marcat_separat(self):
"""Ambele spun NUL -> NUL confidence high, NUL nu e promovat la cod RAR."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "NUL",
"nvidia/nemotron-nano-9b-v2:free": "NUL",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
assert cod == "NUL"
assert confidence == "high"
# NUL nu este in codurile OE-* (nu e promovat)
rar_codes = {c.split("=")[0] for c in oc.CODURI.replace(", ", ",").split(",")} - {"NUL"}
assert cod not in rar_codes
assert "nul" in sursa.lower()
def test_dezacord_total(self):
"""Modele nu se inteleg -> needs_mapping."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "OE-2",
"nvidia/nemotron-nano-9b-v2:free": "OE-4",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
assert confidence == "needs_mapping"
assert "dezacord" in sursa
def test_parse_fail_partial(self):
"""Un model intoarce '?' (parse-fail), altul cod valid -> dezacord (conservator)."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "OE-1",
"nvidia/nemotron-nano-9b-v2:free": "?",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
# Conservator: fara unanimitate -> needs_mapping
assert confidence == "needs_mapping"
def test_toate_parse_fail(self):
"""Ambele modele intorc '?' -> needs_mapping."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "?",
"nvidia/nemotron-nano-9b-v2:free": "?",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
assert confidence == "needs_mapping"
def test_cod_invalid_returnat_de_llm(self):
"""LLM returneaza cod necunoscut (nu e in VALID) -> needs_mapping."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "OE-99",
"nvidia/nemotron-nano-9b-v2:free": "OE-99",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
assert confidence == "needs_mapping"
# ---------------------------------------------------------------------------
# Scrub PII — refoloseste or_common.scrub (F3)
# ---------------------------------------------------------------------------
class TestScrubPII:
"""Scrub-ul PII e integrat in or_common.call() si testat independent."""
def test_nr_inmatriculare_scrubbed(self):
"""Nr de inmatriculare (ex: CT 12 ABC) este scrubuit."""
s = "ITP CT 12 ABC"
assert "[NR]" in oc.scrub(s)
def test_vin_scrubbed(self):
"""VIN (17 char alfanumeric) este scrubuit."""
vin = "WVWZZZ1KZAM000001" # 17 caractere, format VIN
s = f"VERIFICAT {vin}"
assert "[VIN]" in oc.scrub(s)
def test_text_normal_nemodificat(self):
"""Text fara PII ramane neatins."""
s = "REVIZIE PERIODICA MOTOR"
assert oc.scrub(s) == s
def test_scrub_in_batch_call(self, monkeypatch):
"""or_common.call() aplica scrub intern inainte de trimitere."""
trimis = []
def mock_urlopen(req, timeout=None):
import io
body_str = req.data.decode()
trimis.append(body_str)
# Simuleaza raspuns LLM
resp = json.dumps({
"choices": [{"message": {"content": json.dumps({"rez": [{"i": 1, "cod": "NUL"}]})}}]
}).encode()
class FakeResp:
def __enter__(self): return self
def __exit__(self, *a): pass
def read(self): return resp
def __iter__(self): return iter([resp])
import urllib.request
r = FakeResp()
r.read = lambda: resp
# urllib.request.urlopen returneaza context manager
class CM:
def __enter__(self_): return self_
def __exit__(self_, *a): pass
def read(self_): return resp
import json as _json
class FakeFile:
def read(self_): return resp
# Patch-uim json.load
monkeypatch.setattr("json.load", lambda f: _json.loads(resp))
return CM()
batch = ["ITP CT 12 ABC"]
# Verificam ca scrub e aplicat in continut trimis
# (nu putem usor mock-ui urlopen, asa ca testam scrub() direct)
scrubbed = oc.scrub("ITP CT 12 ABC")
assert "[NR]" in scrubbed
# Deci batch-ul trimis nu va contine nr original
assert "CT 12 ABC" not in scrubbed
# ---------------------------------------------------------------------------
# Resumabilitate
# ---------------------------------------------------------------------------
class TestResumabil:
"""Etichetatorul reia de unde a ramas din partial.json."""
def test_skip_already_labeled(self, monkeypatch):
"""Reprezentantii deja in partial NU sunt retrimisi la LLM."""
call_reps = []
def mock_call(model, batch, **kw):
call_reps.extend(batch)
return ["OE-1"] * len(batch), {"ms": 100, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "REVIZIE", "freq": 5000, "members": []}]
# REVIZIE e deja in partial
partial = {
"REVIZIE": {
"cod": "OE-3",
"confidence": "high",
"sursa": "ensemble-unanim",
"votes": {},
}
}
result = or_label.label_groups(groups, partial, batch_size=20, pace=0)
# LLM nu trebuia apelat pentru REVIZIE
assert "REVIZIE" not in call_reps
# Codul din partial e pastrat
assert result["REVIZIE"]["cod"] == "OE-3"
def test_labels_new_reps(self, monkeypatch):
"""Reprezentantii noi (nu in partial) sunt etichetati."""
call_count = [0]
def mock_call(model, batch, **kw):
call_count[0] += 1
return ["OE-1"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "D/R BARA FATA", "freq": 3000, "members": []}]
partial = {}
result = or_label.label_groups(groups, partial, batch_size=20, pace=0)
# LLM a fost apelat (cel putin o data per model)
assert call_count[0] >= len(or_label.MODELS)
assert "D/R BARA FATA" in result
assert result["D/R BARA FATA"]["cod"] == "OE-1"
def test_partial_mixt(self, monkeypatch):
"""Partial cu unii etichetati, altii noi -> eticheteaza doar cei noi."""
labeled_batches = []
def mock_call(model, batch, **kw):
labeled_batches.extend(batch)
return ["OE-2"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [
{"rep": "REVIZIE", "freq": 5000, "members": []}, # deja in partial
{"rep": "D/R BARA FATA", "freq": 3000, "members": []}, # nou
]
partial = {
"REVIZIE": {"cod": "OE-3", "confidence": "high",
"sursa": "ensemble-unanim", "votes": {}}
}
result = or_label.label_groups(groups, partial, batch_size=20, pace=0)
# Doar D/R BARA FATA trebuie trimis la LLM
assert "REVIZIE" not in labeled_batches
assert "D/R BARA FATA" in labeled_batches
# Partial complet: ambele chei prezente
assert "REVIZIE" in result
assert "D/R BARA FATA" in result
# REVIZIE pastrat din partial
assert result["REVIZIE"]["cod"] == "OE-3"
def test_load_partial_fisier_gol(self, tmp_path):
"""load_partial pe fisier inexistent intoarce dict gol."""
result = or_label.load_partial(str(tmp_path / "inexistent.json"))
assert result == {}
def test_save_si_load_partial(self, tmp_path):
"""save_partial + load_partial sunt inversele una alteia."""
path = str(tmp_path / "partial.json")
data = {
"REVIZIE": {"cod": "OE-3", "confidence": "high",
"sursa": "ensemble-unanim", "votes": {}}
}
or_label.save_partial(path, data)
loaded = or_label.load_partial(path)
assert loaded == data
# ---------------------------------------------------------------------------
# Format output si propagare
# ---------------------------------------------------------------------------
class TestOutputFormat:
"""expand_to_all produce outputul cu campurile cerute si propagare corecta."""
def test_campuri_obligatorii(self, monkeypatch):
"""Fiecare intrare are: denumire, cod, sursa, confidence."""
def mock_call(model, batch, **kw):
return ["OE-3"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "REVIZIE", "freq": 5000,
"members": [("REVIZIE MICA", 100)]}]
partial = {}
partial = or_label.label_groups(groups, partial, batch_size=20, pace=0)
results = or_label.expand_to_all(groups, partial)
assert len(results) == 2 # reprezentant + 1 membru
for row in results:
assert "denumire" in row
assert "cod" in row
assert "sursa" in row
assert "confidence" in row
assert "grup_rep" in row
def test_reprezentant_cu_sursa_ensemble(self, monkeypatch):
"""Reprezentantul are sursa 'ensemble-*', nu 'propagat'."""
def mock_call(model, batch, **kw):
return ["OE-3"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "REVIZIE", "freq": 5000, "members": []}]
partial = {}
partial = or_label.label_groups(groups, partial, batch_size=20, pace=0)
results = or_label.expand_to_all(groups, partial)
row = results[0]
assert row["denumire"] == "REVIZIE"
assert row["sursa"].startswith("ensemble-")
assert row["sursa"] != "propagat"
def test_membru_primeste_sursa_propagat(self, monkeypatch):
"""Membrii grupului au sursa='propagat' si codul reprezentantului."""
def mock_call(model, batch, **kw):
return ["OE-3"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "REVIZIE", "freq": 5000,
"members": [("REVIZIE MICA", 100), ("REVIZIE AUTO", 80)]}]
partial = {}
partial = or_label.label_groups(groups, partial, batch_size=20, pace=0)
results = or_label.expand_to_all(groups, partial)
assert len(results) == 3
membri = [r for r in results if r["sursa"] == "propagat"]
assert len(membri) == 2
for m in membri:
assert m["cod"] == "OE-3" # propagat de la reprezentant
assert m["grup_rep"] == "REVIZIE"
def test_nul_propagat_ca_nul_nu_ca_cod_rar(self, monkeypatch):
"""NUL este propagat ca NUL la membri, nu convertit la cod RAR."""
def mock_call(model, batch, **kw):
return ["NUL"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "ITP", "freq": 50,
"members": [("ITP + RAR", 30)]}]
partial = {}
partial = or_label.label_groups(groups, partial, batch_size=20, pace=0)
results = or_label.expand_to_all(groups, partial)
rar_codes = {c.split("=")[0] for c in oc.CODURI.replace(", ", ",").split(",")} - {"NUL"}
for row in results:
assert row["cod"] == "NUL"
assert row["cod"] not in rar_codes
def test_dezacord_propagat_ca_needs_mapping(self, monkeypatch):
"""Dezacordul ensemble se propaga la membri ca needs_mapping."""
call_n = [0]
def mock_call(model, batch, **kw):
call_n[0] += 1
# Modelele dau coduri diferite in functie de ordinea apelului
cod = "OE-1" if call_n[0] % 2 == 1 else "OE-3"
return [cod] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "REGLAT DIRECTIE", "freq": 200,
"members": [("REGLAT DIRECTIA", 150)]}]
partial = {}
partial = or_label.label_groups(groups, partial, batch_size=20, pace=0)
results = or_label.expand_to_all(groups, partial)
# Ambii (rep + member) trebuie sa aiba needs_mapping
for row in results:
assert row["confidence"] == "needs_mapping"
# ---------------------------------------------------------------------------
# Integrare end-to-end (fara apeluri reale)
# ---------------------------------------------------------------------------
class TestRunIntegrare:
"""Verifica run() cu corpus mock si LLM mock."""
def test_run_produce_fisier_output(self, tmp_path, monkeypatch):
"""run() salveaza fisierul de output JSON."""
def mock_corpus():
return [("REVIZIE", 5000), ("D/R BARA FATA", 3000)]
def mock_call(model, batch, **kw):
return ["OE-3"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "corpus_by_freq", mock_corpus)
monkeypatch.setattr(or_label.oc, "call", mock_call)
out = str(tmp_path / "final.json")
partial = str(tmp_path / "partial.json")
results = or_label.run(n=2, output_path=out, partial_path=partial,
threshold=85, batch_size=20, pace=0)
assert os.path.exists(out)
loaded = json.load(open(out, encoding="utf-8"))
assert len(loaded) >= 2
# Toate intrarile au campurile cerute
for row in loaded:
assert "denumire" in row
assert "cod" in row
def test_run_resumabil(self, tmp_path, monkeypatch):
"""run() cu partial existent sare intrarile deja etichetate."""
call_count = [0]
def mock_corpus():
return [("REVIZIE", 5000), ("D/R BARA FATA", 3000)]
def mock_call(model, batch, **kw):
call_count[0] += 1
return ["OE-1"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "corpus_by_freq", mock_corpus)
monkeypatch.setattr(or_label.oc, "call", mock_call)
partial_path = str(tmp_path / "partial.json")
# Pre-populam partial cu REVIZIE
or_label.save_partial(partial_path, {
"REVIZIE": {"cod": "OE-3", "confidence": "high",
"sursa": "ensemble-unanim", "votes": {}}
})
out = str(tmp_path / "final.json")
results = or_label.run(n=2, output_path=out, partial_path=partial_path,
threshold=85, batch_size=20, pace=0)
# LLM apelat DOAR pentru D/R BARA FATA (nu si REVIZIE)
# call_count = 2 (un apel per model, pentru un singur representant)
assert call_count[0] == len(or_label.MODELS)

290
tests/test_shared_store.py Normal file
View File

@@ -0,0 +1,290 @@
"""TDD pentru L14-S3 — shared_store: SILVER (mapping_suggestions) + GOLD partajat (shared_mappings).
Scenarii acoperite:
- seed_suggestions: idempotent (INSERT OR IGNORE, nu clobberuie randuri existente)
- seed_suggestions: NUL marcat -> cod_prestatie NULL, is_nul=1 (supresie, #4)
- lookup_suggestion: cauta pe denumire_normalizata
- lookup_shared_gold: cauta pe denumire_normalizata
- record_human_validation: insert nou + increment confirmations la al doilea apel
- provenance/source/confidence pastrate (#5)
"""
from __future__ import annotations
import os
import tempfile
import pytest
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def env(monkeypatch):
"""DB temporara cu schema initiata."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "shared_store_test.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
@pytest.fixture()
def conn(env):
from app.db import get_connection
c = get_connection()
yield c
c.close()
# --------------------------------------------------------------------------- #
# seed_suggestions — strat SILVER #
# --------------------------------------------------------------------------- #
def test_seed_suggestions_inserteaza(conn):
"""seed_suggestions insereaza un rand si returneaza 1 (numarul de randuri inserate)."""
from app.shared_store import seed_suggestions, lookup_suggestion
n = seed_suggestions(conn, [
{"denumire": "Schimb ulei motor", "cod_prestatie": "OE-3", "source": "llm", "confidence": 0.9},
])
conn.commit()
assert n == 1
row = lookup_suggestion(conn, "Schimb ulei motor")
assert row is not None
assert row["cod_prestatie"] == "OE-3"
assert row["source"] == "llm"
assert abs(row["confidence"] - 0.9) < 0.001
assert row["is_nul"] == 0
def test_seed_suggestions_idempotent(conn):
"""seed_suggestions de doua ori cu acelasi item -> al doilea INSERT OR IGNORE, n=0 la re-seed."""
from app.shared_store import seed_suggestions, lookup_suggestion
n1 = seed_suggestions(conn, [
{"denumire": "Verificare faruri", "cod_prestatie": "OE-2", "source": "llm", "confidence": 0.8},
])
conn.commit()
n2 = seed_suggestions(conn, [
{"denumire": "Verificare faruri", "cod_prestatie": "OE-2", "source": "llm", "confidence": 0.8},
])
conn.commit()
assert n1 == 1
assert n2 == 0 # INSERT OR IGNORE: randul deja exista
row = lookup_suggestion(conn, "Verificare faruri")
assert row is not None
assert row["cod_prestatie"] == "OE-2"
def test_seed_suggestions_nu_clobberuie_randul_existent(conn):
"""Re-seed cu cod diferit -> INSERT OR IGNORE pastreaza valoarea veche (#2)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{"denumire": "Reparatie motor", "cod_prestatie": "OE-1", "source": "llm", "confidence": 0.85},
])
conn.commit()
# Al doilea seed cu alt cod: trebuie ignorat (nu suprascrie)
seed_suggestions(conn, [
{"denumire": "Reparatie motor", "cod_prestatie": "OE-2", "source": "llm", "confidence": 0.5},
])
conn.commit()
row = lookup_suggestion(conn, "Reparatie motor")
assert row is not None
assert row["cod_prestatie"] == "OE-1" # valoarea veche pastrata
def test_seed_suggestions_nul_marcat_fara_cod(conn):
"""is_nul=True -> cod_prestatie NULL, is_nul=1 in DB (supresie, #4)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{"denumire": "ITP CT 12 ABC", "is_nul": True, "source": "llm", "confidence": 0.95},
])
conn.commit()
row = lookup_suggestion(conn, "ITP CT 12 ABC")
assert row is not None
assert row["is_nul"] == 1
assert row["cod_prestatie"] is None # NUL nu se promoveaza la cod (#4)
def test_seed_suggestions_nul_cu_cod_explicit_tot_nul(conn):
"""Daca is_nul=True, cod_prestatie e ignorat si stocat NULL (supresie stricta, #4)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{
"denumire": "DISCOUNT MATERIALE 5%",
"cod_prestatie": "OE-1", # ignorat cand is_nul=True
"is_nul": True,
"source": "llm",
"confidence": 0.99,
},
])
conn.commit()
row = lookup_suggestion(conn, "DISCOUNT MATERIALE 5%")
assert row is not None
assert row["is_nul"] == 1
assert row["cod_prestatie"] is None # cod explicit ignorat cand is_nul
def test_seed_suggestions_normalizare_diacritice(conn):
"""Lookup pe forma cu diacritice gaseste randul seedat fara diacritice (normalize_for_match)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{"denumire": "Înlocuit filtru aer", "cod_prestatie": "OE-3", "source": "llm", "confidence": 0.7},
])
conn.commit()
# Lookup cu accentele, fara accente, uppercase — trebuie sa gaseasca acelasi rand
row1 = lookup_suggestion(conn, "Înlocuit filtru aer")
row2 = lookup_suggestion(conn, "Inlocuit filtru aer")
row3 = lookup_suggestion(conn, "INLOCUIT FILTRU AER")
assert row1 is not None and row1["cod_prestatie"] == "OE-3"
assert row2 is not None and row2["cod_prestatie"] == "OE-3"
assert row3 is not None and row3["cod_prestatie"] == "OE-3"
def test_lookup_suggestion_lipseste_returneaza_none(conn):
"""lookup_suggestion pe o denumire care nu exista -> None."""
from app.shared_store import lookup_suggestion
row = lookup_suggestion(conn, "Denumire inexistenta")
assert row is None
# --------------------------------------------------------------------------- #
# record_human_validation + lookup_shared_gold — strat GOLD partajat #
# --------------------------------------------------------------------------- #
def test_record_human_validation_insert(conn):
"""Prima confirmare umana creeaza un rand nou in shared_mappings."""
from app.shared_store import record_human_validation, lookup_shared_gold
record_human_validation(
conn,
denumire="Schimb ulei",
cod_prestatie="oe-3", # se normalizeaza la OE-3
source="human",
provenance="cont_2/user@test.com",
confidence=1.0,
)
conn.commit()
row = lookup_shared_gold(conn, "Schimb ulei")
assert row is not None
assert row["cod_prestatie"] == "OE-3" # normalizat uppercase
assert row["source"] == "human"
assert row["provenance"] == "cont_2/user@test.com"
assert abs(row["confidence"] - 1.0) < 0.001
assert row["confirmations"] == 1
def test_record_human_validation_increment_confirmations(conn):
"""A doua confirmare umana pe aceeasi denumire -> confirmations += 1."""
from app.shared_store import record_human_validation, lookup_shared_gold
record_human_validation(conn, "Revizie anuala", "OE-3")
conn.commit()
record_human_validation(conn, "Revizie anuala", "OE-3")
conn.commit()
row = lookup_shared_gold(conn, "Revizie anuala")
assert row is not None
assert row["confirmations"] == 2
def test_record_human_validation_normalizare(conn):
"""Lookup pe diacritice sau uppercase gaseste acelasi rand GOLD."""
from app.shared_store import record_human_validation, lookup_shared_gold
record_human_validation(conn, "Înlocuit garnitura chiulasa", "OE-1")
conn.commit()
row1 = lookup_shared_gold(conn, "Înlocuit garnitura chiulasa")
row2 = lookup_shared_gold(conn, "Inlocuit garnitura chiulasa")
row3 = lookup_shared_gold(conn, "INLOCUIT GARNITURA CHIULASA")
assert row1 is not None and row1["cod_prestatie"] == "OE-1"
assert row2 is not None
assert row3 is not None
def test_lookup_shared_gold_lipseste_returneaza_none(conn):
"""lookup_shared_gold pe denumire inexistenta -> None."""
from app.shared_store import lookup_shared_gold
row = lookup_shared_gold(conn, "Operatie fara GOLD")
assert row is None
def test_provenance_source_confidence_pastrate(conn):
"""source, provenance, confidence sunt stocate si returnate corect (#5)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{
"denumire": "Reglat directie",
"cod_prestatie": "OE-2",
"source": "embedding",
"confidence": 0.73,
},
])
conn.commit()
row = lookup_suggestion(conn, "Reglat directie")
assert row is not None
assert row["source"] == "embedding"
assert abs(row["confidence"] - 0.73) < 0.001
# --------------------------------------------------------------------------- #
# Separare structurala (#13): tabelele noi NU sunt citite de resolve_prestatii#
# --------------------------------------------------------------------------- #
def test_mapping_suggestions_nu_e_folosita_de_resolve_prestatii():
"""resolve_prestatii NU citeste din mapping_suggestions — separare structurala (#13).
Daca un cod e in SILVER dar nu in operations_mapping, resolve_prestatii
NU il gaseste -> submission ramane needs_mapping (om in bucla).
"""
from app.mapping import resolve_prestatii
# Apelam resolve_prestatii fara niciun mapping -> operatia e nemapata
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP_SILVER", "denumire": "Reglat faruri"}],
{}, # operations_mapping gol
)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1
# Nu exista cale prin care SILVER sa ajunga in resolve fara wiring explicit (L14-S6)
def test_shared_mappings_nu_e_folosita_de_resolve_prestatii():
"""resolve_prestatii NU citeste din shared_mappings — separare structurala (#13).
Chiar daca GOLD partajat exista, resolve_prestatii nu il vede fara wiring explicit.
"""
from app.mapping import resolve_prestatii
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP_GOLD", "denumire": "Revizie periodica"}],
{}, # operations_mapping gol
)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1

View File

@@ -186,3 +186,187 @@ def test_fragmente_fara_fundal_hardcodat():
"Fragmente cu fundal hardcodat dark (nu adapteaza la tema light):\n" "Fragmente cu fundal hardcodat dark (nu adapteaza la tema light):\n"
+ "\n".join(vinovate) + "\n".join(vinovate)
) )
# ── US-001 (PRD 5.15): Teme aditive + tokeni --card2/--line2 ──────────────────
def test_cele_4_teme_definite(client):
"""Cele 4 teme noi (grafit/cobalt/cupru/hartie) au blocuri CSS [data-theme="..."]
cu tokenul minim: --bg/--card/--ink/--muted/--line/--ok/--err/--accent."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
for tema in ("grafit", "cobalt", "cupru", "hartie"):
blk = re.search(
r'\[data-theme=["\']' + tema + r'["\']\]\s*\{([^}]+)\}',
html, re.DOTALL,
)
assert blk, f"Bloc CSS [data-theme=\"{tema}\"] negasit in HTML"
block = blk.group(1)
for var in ("--bg", "--card", "--ink", "--muted", "--line", "--ok", "--err", "--accent"):
assert var in block, (
f"Token {var} lipseste din blocul CSS [data-theme=\"{tema}\"]"
)
def test_tokeni_card2_line2_in_toate_temele(client):
"""--card2 si --line2 sunt definiti in TOATE cele 7 teme:
dark (:root), light, petrol, grafit, cobalt, cupru, hartie."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
# dark e in :root
root_blk = re.search(r':root\s*\{([^}]+)\}', html, re.DOTALL)
assert root_blk, ":root CSS block negasit"
root_block = root_blk.group(1)
for var in ("--card2", "--line2"):
assert var in root_block, f"{var} lipseste din :root (dark)"
for tema in ("light", "petrol", "grafit", "cobalt", "cupru", "hartie"):
blk = re.search(
r'\[data-theme=["\']' + tema + r'["\']\]\s*\{([^}]+)\}',
html, re.DOTALL,
)
assert blk, f"Bloc CSS [data-theme=\"{tema}\"] negasit"
block = blk.group(1)
for var in ("--card2", "--line2"):
assert var in block, f"{var} lipseste din blocul CSS [data-theme=\"{tema}\"]"
def test_anti_fouc_7_stari(client):
"""Anti-FOUC din <head> cunoaste TOATE cele 7+1 stari valide:
light/dark/petrol/grafit/cobalt/cupru/hartie + auto.
Valoare necunoscuta -> auto, fara blink.
Fostul test test_anti_fouc_4_stari acoperea doar light/dark/petrol/auto;
acum verifica toate 8 starile."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
head_match = re.search(r'<head>(.*?)</head>', html, re.DOTALL | re.IGNORECASE)
assert head_match, "<head> negasit"
head = head_match.group(1)
style_pos = head.find('<style>')
assert style_pos >= 0, "<style> negasit in <head>"
head_before_style = head[:style_pos]
for tema in ("light", "dark", "petrol", "grafit", "cobalt", "cupru", "hartie", "auto"):
assert tema in head_before_style, (
f"Tema '{tema}' lipseste din scriptul anti-FOUC (section inainte de <style>). "
f"Utilizatorul cu localStorage.theme='{tema}' va vedea blink la prima incarcare."
)
def test_migrare_localStorage_legacy(client):
"""Valorile vechi (light/dark/petrol) din localStorage raman VALIDE dupa adaugarea
temelor noi. Fara migrare fortata; preferinta setata inainte de update e pastrata.
Valoare lipsa/necunoscuta -> auto (fallback sigur, fara blink)."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
head_match = re.search(r'<head>(.*?)</head>', html, re.DOTALL | re.IGNORECASE)
assert head_match, "<head> negasit"
head = head_match.group(1)
style_pos = head.find('<style>')
head_before_style = head[:style_pos]
# Valorile vechi trebuie sa fie recunoscute ca valide in anti-FOUC
for tema_veche in ("light", "dark", "petrol"):
assert tema_veche in head_before_style, (
f"Tema legacy '{tema_veche}' a disparut din scriptul anti-FOUC. "
f"Userii cu localStorage.theme='{tema_veche}' vor vedea blink (tratati ca necunoscut)."
)
# Fallback la 'auto' trebuie sa fie prezent
assert "auto" in head_before_style, (
"'auto' (fallback pentru valori necunoscute) lipseste din anti-FOUC"
)
def test_themes_dry_single_source(client):
"""DRY (E2): config temelor traieste intr-o singura structura sursa-de-adevar
(var THEMES). ICONS/LABELS NU sunt literali separati (ar putea diverge de THEMES).
Un test prinde o intrare ICONS/LABELS lipsa, nu doar token CSS lipsa.
Adaugarea unei teme noi = O singura intrare in THEMES."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
# Structura THEMES trebuie sa existe
assert "THEMES" in html, (
"var THEMES (sursa de adevar unica pentru config teme) negasit in HTML. "
"E2: config trebuie consolidat intr-o singura structura."
)
themes_match = re.search(r'var THEMES\s*=\s*\[(.*?)\];', html, re.DOTALL)
assert themes_match, "var THEMES = [...]; nu a fost gasit (forma asteptata: var THEMES = [...])"
themes_body = themes_match.group(1)
# Fiecare tema (inclusiv cele 4 noi) trebuie sa fie in THEMES
for tema in ("light", "dark", "petrol", "grafit", "cobalt", "cupru", "hartie", "auto"):
assert (f"'{tema}'" in themes_body or f'"{tema}"' in themes_body), (
f"Tema '{tema}' lipseste din var THEMES. "
f"DRY (E2): adaugarea temei = O singura intrare in THEMES."
)
# ICONS si LABELS NU trebuie sa fie literali separati cu cheile hardcodate
# (daca sunt literali, o tema noua in THEMES nu apare automat in ICONS/LABELS)
icons_literal = re.search(r'var ICONS\s*=\s*\{', html)
labels_literal = re.search(r'var LABELS\s*=\s*\{', html)
assert not icons_literal, (
"var ICONS = {...} e inca un literal separat (nu derivat din THEMES). "
"O tema noua in THEMES nu va aparea automat in ICONS — rupe DRY (E2)."
)
assert not labels_literal, (
"var LABELS = {...} e inca un literal separat (nu derivat din THEMES). "
"O tema noua in THEMES nu va aparea automat in LABELS — rupe DRY (E2)."
)
# ── US-008: Test parametrizat robust — token critic in fiecare tema ─────────────
@pytest.mark.parametrize("token", ["--card2", "--line2", "--accent", "--ok", "--err"])
@pytest.mark.parametrize("tema", ["light", "dark", "petrol", "grafit", "cobalt", "cupru", "hartie"])
def test_token_critic_in_tema_parametrizat(client, tema, token):
"""US-008: test parametrizat robust — fiecare token critic e definit in fiecare tema.
Ancorare pe selectorul CSS [data-theme="X"] {...} (sau :root pentru dark),
NU pe felii fixe [idx:idx+N]. Evita false-green-ul din regresia 5.13:
testele care feliau cu [idx:idx+N] nu prindeau un token lipsa dintr-o tema specifica
(offset-ul era mascat de continut din alte teme).
La esec, pytest raporteaza EXACT combinatia (tema, token) care lipseste —
debugging rapid fara cautare manuala in CSS.
Auto (tema 8): rezolvat la dark/light de anti-FOUC, fara bloc CSS propriu;
acoperit de test_anti_fouc_7_stari. Verificam cele 7 teme concrete (cu bloc CSS).
"""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
if tema == "dark":
# Dark e tema implicita — traieste in :root {}
blk = re.search(r':root\s*\{([^}]+)\}', html, re.DOTALL)
assert blk, ":root CSS block negasit — dark tema nu are paleta definita"
block = blk.group(1)
else:
blk = re.search(
r'\[data-theme=["\']' + re.escape(tema) + r'["\']\]\s*\{([^}]+)\}',
html, re.DOTALL,
)
assert blk, (
f'Bloc CSS [data-theme="{tema}"] negasit in HTML. '
f'Tema "{tema}" nu are paleta definita — adauga blocul CSS.'
)
block = blk.group(1)
assert token in block, (
f"Token '{token}' lipseste din tema '{tema}' "
f"({'\":root\"' if tema == 'dark' else f'\"[data-theme={tema}]\"'}). "
f"Componentele cu var({token}) vor arata gresit pe aceasta tema. "
f"Adauga '{token}:<valoare>;' in blocul CSS al temei '{tema}'."
)

248
tests/test_web_bulk_fix.py Normal file
View File

@@ -0,0 +1,248 @@
"""Teste US-010 (PRD 5.15): Bulk-fix din lista — selectie multipla -> actiune unica.
Acceptance criteria:
- test_bulk_remapeaza_selectie: N randuri needs_mapping + aplica cod -> toate -> queued
- test_bulk_doar_blocate: randuri sent/sending nu sunt eligibile (sarite silentios)
- test_bulk_scoped_cont: 404-before-409 — un cont nu atinge randurile altui cont
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# ---------------------------------------------------------------------------
# Helpere comune (aceeasi conventie ca test_web_submissions.py)
# ---------------------------------------------------------------------------
def _create_account_user(email: str, name: str = "Service", 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, name, 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 not found on /login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _insert_submission(acct: int, status: str = "needs_mapping",
*, payload: dict | None = None) -> int:
"""Insereaza o trimitere cu payload standard (needs_mapping cu cod_op_service)."""
from app.db import get_connection
conn = get_connection()
try:
p = payload if payload is not None else {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B123TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": "INTERN1", "denumire": "Schimb ulei"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{status}-{os.urandom(6).hex()}", acct, status, json.dumps(p)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _get_status(sid: int) -> str | None:
"""Citeste status-ul curent al unui rand din DB (sursa de adevar)."""
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()
return row["status"] if row else None
finally:
conn.close()
def _csrf_from_fragment(client) -> str:
"""Extrage CSRF token din /_fragments/submissions sau din dashboard (fallback).
Submissions fragment include CSRF doar cand exista randuri (form bulk).
Dashboard-ul (/) include mereu CSRF in formularul de upload.
"""
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
if m:
return m.group(1)
# Fallback: dashboard principal (contine intotdeauna un form cu CSRF dupa login)
resp2 = client.get("/")
m2 = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp2.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp2.text)
assert m2, "CSRF token not found in submissions fragment or dashboard"
return m2.group(1)
# ---------------------------------------------------------------------------
# Fixture client
# ---------------------------------------------------------------------------
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "bulk_fix.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()
# ---------------------------------------------------------------------------
# Teste US-010
# ---------------------------------------------------------------------------
def test_bulk_remapeaza_selectie(client):
"""US-010 AC principal: N randuri needs_mapping + aplica cod valid -> toate -> queued.
OE-1 face parte din nomenclatorul seed (nomenclator_seed.FALLBACK_NOMENCLATOR),
incarcat de init_db la startup; nu e nevoie de insert separat.
Payload-uri diferite (VIN diferit) ca sa nu colizioneze la recalculul idempotentei.
"""
acct = _create_account_user("bulk_fix1@test.com")
sid1 = _insert_submission(acct, "needs_mapping", payload={
"vin": "WVWZZZ1KZAW000111",
"nr_inmatriculare": "B111TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": "INTERN1", "denumire": "Schimb ulei"}],
})
sid2 = _insert_submission(acct, "needs_mapping", payload={
"vin": "WVWZZZ1KZAW000222",
"nr_inmatriculare": "B222TST",
"data_prestatie": "2026-06-16",
"odometru_final": "60000",
"prestatii": [{"cod_op_service": "INTERN2", "denumire": "Verificare franare"}],
})
_login(client, "bulk_fix1@test.com")
csrf = _csrf_from_fragment(client)
resp = client.post(
"/trimiteri/bulk-fix",
data={
"csrf_token": csrf,
"submission_id": [str(sid1), str(sid2)],
"cod_prestatie": "OE-1",
},
)
assert resp.status_code == 200, f"Asteptam 200, primit {resp.status_code}"
# Ambele randuri trebuie sa fie acum queued
s1 = _get_status(sid1)
s2 = _get_status(sid2)
assert s1 == "queued", f"sid1 status={s1!r}, asteptam 'queued'"
assert s2 == "queued", f"sid2 status={s2!r}, asteptam 'queued'"
# Sumar vizibil in raspuns HTML (cel putin unul din: "reusit", "2", "queued")
html = resp.text
assert "reusit" in html.lower() or "2 " in html or "queued" in html.lower(), \
"Sumar bulk-fix lipseste din raspuns"
def test_bulk_doar_blocate(client):
"""US-010 AC eligibilitate: randuri sent/sending sarite silentios; doar blocate procesate."""
acct = _create_account_user("bulk_fix2@test.com")
# Rand sent (read-only, nu trebuie atins)
sid_sent = _insert_submission(acct, "sent", payload={
"vin": "WVWZZZ1KZAW000222",
"nr_inmatriculare": "B222TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-2", "denumire": "Intretinere"}],
})
# Rand needs_mapping (gestionabil, trebuie procesat)
sid_blocked = _insert_submission(acct, "needs_mapping")
_login(client, "bulk_fix2@test.com")
csrf = _csrf_from_fragment(client)
# Trimitem ambele id-uri; doar cel blocat trebuie procesat
resp = client.post(
"/trimiteri/bulk-fix",
data={
"csrf_token": csrf,
"submission_id": [str(sid_sent), str(sid_blocked)],
"cod_prestatie": "OE-1",
},
)
assert resp.status_code == 200
# Randul sent ramane sent (read-only — INTERZIS sa fie modificat)
assert _get_status(sid_sent) == "sent", \
"Randul sent a fost modificat de bulk-fix — INTERZIS"
# Randul blocat a trecut la queued
assert _get_status(sid_blocked) == "queued", \
f"Randul needs_mapping nu a trecut la queued: {_get_status(sid_blocked)!r}"
def test_bulk_scoped_cont(client):
"""US-010 AC scope: contul A nu poate modifica randurile contului B.
Pattern 404-before-409: randurile cross-account sunt sarite silentios
(nu confirmam existenta), raspuns HTTP 200 cu sumar care reflecta 0 reusite.
"""
acct_a = _create_account_user("bulk_fix_a@test.com", name="Cont A")
acct_b = _create_account_user("bulk_fix_b@test.com", name="Cont B")
# Randul lui B (alt cont)
sid_b = _insert_submission(acct_b, "needs_mapping", payload={
"vin": "WVWZZZ1KZAW000333",
"nr_inmatriculare": "B333TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": "INTERN3", "denumire": "Test extern"}],
})
# Logat ca A — incearca sa aplice cod pe randul lui B
_login(client, "bulk_fix_a@test.com")
csrf = _csrf_from_fragment(client)
resp = client.post(
"/trimiteri/bulk-fix",
data={
"csrf_token": csrf,
"submission_id": [str(sid_b)],
"cod_prestatie": "OE-1",
},
)
# Raspuns 200 (nu 404 expus HTTP — cross-account e sarit silentios ca la bulk-delete)
assert resp.status_code == 200
# Randul lui B NEATINS
assert _get_status(sid_b) == "needs_mapping", \
"Randul contului B a fost modificat de contul A — INCALCARE SCOPE!"

View File

@@ -231,8 +231,9 @@ def test_camp_apare_o_singura_data(client):
def test_nr_si_vin_pe_randuri_separate(client): def test_nr_si_vin_pe_randuri_separate(client):
"""Nr. inmatriculare pe rand propriu, VIN dedesubt ambele inputuri latime plina, """VIN si Nr. inmatriculare sunt ambele prezente ca inputuri separate in formular.
nr. inaintea VIN-ului in markup.""" US-007 (PRD 5.15): VIN apare PRIMUL in markup (formular slim), nr. inmatriculare
in grila 2-col dupa VIN."""
acct = _create_account_user("u2@test.com") acct = _create_account_user("u2@test.com")
sid = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0U2001", odo="")) sid = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0U2001", odo=""))
_login(client, "u2@test.com") _login(client, "u2@test.com")
@@ -241,7 +242,7 @@ def test_nr_si_vin_pe_randuri_separate(client):
poz_nr = html.find('name="nr_inmatriculare"') poz_nr = html.find('name="nr_inmatriculare"')
poz_vin = html.find('name="vin"') poz_vin = html.find('name="vin"')
assert poz_nr != -1 and poz_vin != -1 assert poz_nr != -1 and poz_vin != -1
assert poz_nr < poz_vin # nr. apare inaintea VIN-ului (rand propriu, VIN dedesubt) assert poz_vin < poz_nr # US-007: VIN apare primul (slim form), nr. dupa in grila 2-col
def test_un_singur_buton_primar_per_stare(client): def test_un_singur_buton_primar_per_stare(client):

View File

@@ -0,0 +1,472 @@
"""Teste US-005 (PRD 5.15): obs editabil + concat operatie la import.
AC-uri:
- obs adaugat in bucla de campuri din post_corecteaza (routes.py) si in EDIT_FIELDS
(import_router.py); corecteaza si editeaza preview accepta si persista obs.
- obs optional (text liber, fara validare de continut, doar .strip()).
- obs apare in prezentare_din_payload (payload_view.py).
- obs EXCLUS din cheia de idempotenta (D8): editarea obs NU schimba cheia.
- La import fara coloana obs: denumirea operatiei se COPIAZA in obs (D7).
- Derive-on-empty idempotent: re-preview NU dubleaza obs (E3).
TDD: toate testele se scriu INAINTE de implementare (RED -> GREEN).
"""
from __future__ import annotations
import io
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
"""Client web cu autentificare activa (pentru corecteaza)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "obs.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()
@pytest.fixture()
def api_client(monkeypatch):
"""Client API fara autentificare web (pentru import preview + editeaza)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "obs_api.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()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
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 a fost gasit in pagina de login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303, f"Login esuat: {resp.status_code}"
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 a fost gasit in dashboard"
return m.group(1)
def _insert(acct: int, *, status: str, payload: dict, key: str | None = None) -> int:
from app.db import get_connection
conn = get_connection()
try:
k = key or f"k-{os.urandom(6).hex()}"
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(k, acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _row_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 _row_status(sid: int) -> str:
from app.db import get_connection
conn = get_connection()
try:
r = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()
return r["status"]
finally:
conn.close()
def _seed_nomenclator(cod: str = "OE-1", op_service: str = "Schimb ulei") -> None:
"""Insereaza cod in nomenclator si mapare op_service -> cod pentru contul 1."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, "Schimb ulei motor"),
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (1, ?, ?, 1)",
(op_service, cod),
)
conn.commit()
finally:
conn.close()
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
import csv as _csv
buf = io.StringIO()
writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
def _upload(client, data: bytes, filename: str = "test.csv") -> int:
r = client.post(
"/v1/import",
files={"file": (filename, io.BytesIO(data), "text/csv")},
)
assert r.status_code == 200, r.text
return int(r.json()["import_id"])
def _save_mapping(client, import_id: int, json_mapare: dict) -> None:
r = client.post(
f"/v1/import/{import_id}/column-mapping",
json={"json_mapare": json_mapare, "format_data": "YYYY-MM-DD"},
)
assert r.status_code == 200, r.text
def _preview(client, import_id: int) -> list[dict]:
r = client.get(f"/v1/import/{import_id}/preview")
assert r.status_code == 200, r.text
return r.json()["rows"]
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_obs_editabil_persistat_corecteaza(client):
"""AC: obs adaugat in bucla post_corecteaza -> persists in payload_json.
RED: 'obs' nu e inca in bucla de campuri din post_corecteaza (routes.py:1177).
"""
acct = _create_account_user("obs.corecteaza@test.com")
_login(client, "obs.corecteaza@test.com")
# Submission needs_data cu odometru gol (trigger pentru blocaj)
sid = _insert(acct, status="needs_data", payload={
"vin": "WVWZZZ1JZXW0AB001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "", # trigger needs_data
"prestatii": [{"cod_prestatie": "OE-1"}],
})
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"odometru_final": "50000", # fix odo
"obs": "Schimb ulei verificat", # obs editabil
},
)
assert resp.status_code == 200, (
f"Status neasteptat: {resp.status_code}\n{resp.text[:500]}"
)
payload = _row_payload(sid)
assert payload.get("obs") == "Schimb ulei verificat", (
f"obs nu e persistat in payload_json; payload={payload}"
)
assert _row_status(sid) == "queued", (
f"status neasteptat: {_row_status(sid)}"
)
def test_obs_persistat_preview_editeaza(api_client):
"""AC: obs in EDIT_FIELDS + RandEditIn -> editeaza preview salveaza obs -> apare in resolved.
RED: 'obs' nu e in RandEditIn (import_router.py:1188) sau in EDIT_FIELDS (:261).
"""
_seed_nomenclator()
data = _csv_bytes([{
"VIN": "WVWZZZ1JZXW0AB002",
"Nr": "B200BBB",
"Data": "2026-06-10",
"KM": "50000",
"Operatie": "Schimb ulei",
# Fara coloana Observatii: obs vine din derive
}])
iid = _upload(api_client, data)
_save_mapping(api_client, iid, {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
})
rows = _preview(api_client, iid)
assert rows[0]["resolved_status"] == "ok", f"Stare neasteptata inainte de edit: {rows[0]}"
# Editeaza obs explicit pe randul 0
r = api_client.post(
f"/v1/import/{iid}/rand/0/editeaza",
json={"obs": "Observatie test manuala"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body.get("override", {}).get("obs") == "Observatie test manuala", (
f"obs nu e in override returnat: {body}"
)
# Preview dupa editare: obs din override trebuie sa apara in resolved
rows2 = _preview(api_client, iid)
resolved_obs = rows2[0]["resolved"].get("obs")
assert resolved_obs == "Observatie test manuala", (
f"obs nu apare in resolved dupa editeaza; resolved={rows2[0]['resolved']}"
)
def test_obs_optional_gol_ok(client):
"""AC: obs optional; o trimitere fara obs trece validarea si devine queued.
RED: implicit nu esueaza, dar ne asiguram ca lipsa obs nu introduce o eroare.
"""
acct = _create_account_user("obs.gol@test.com")
_login(client, "obs.gol@test.com")
sid = _insert(acct, status="needs_data", payload={
"vin": "WVWZZZ1JZXW0AB006",
"nr_inmatriculare": "B600FFF",
"data_prestatie": "2026-06-10",
"odometru_final": "", # trigger needs_data
"prestatii": [{"cod_prestatie": "OE-1"}],
})
csrf = _csrf(client)
# Corecteaza FARA obs in form (obs absent)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"odometru_final": "50000",
# obs absent din form
},
)
assert resp.status_code == 200, resp.text
assert _row_status(sid) == "queued", (
f"Status neasteptat dupa corectie fara obs: {_row_status(sid)}"
)
def test_import_concateneaza_operatie_in_obs(api_client):
"""AC (D7): import fara coloana obs -> obs = denumire operatie in preview.
RED: obs nu e derivat din operatie la import (inca nu e implementat in
_resolve_row_for_preview).
"""
_seed_nomenclator(cod="OE-1", op_service="Schimb ulei")
data = _csv_bytes([{
"VIN": "WVWZZZ1JZXW0AB003",
"Nr": "B300CCC",
"Data": "2026-06-10",
"KM": "50000",
"Operatie": "Schimb ulei",
# Fara coloana Observatii in fisier
}])
iid = _upload(api_client, data)
_save_mapping(api_client, iid, {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
# "Observatii" nu e in mapare -> obs vine din derive
})
rows = _preview(api_client, iid)
resolved = rows[0]["resolved"]
obs = resolved.get("obs", "")
assert obs == "Schimb ulei", (
f"obs trebuie sa fie 'Schimb ulei' (copiat din operatie); got={obs!r}"
)
def test_anti_dublu_concat(api_client):
"""AC (E3): DERIVE-ON-EMPTY idempotent; re-preview si override explicit NU dubleaza obs.
RED: fara DERIVE-ON-EMPTY, un al doilea preview sau o editare cu obs setat ar putea
produce 'Schimb ulei; Schimb ulei'.
"""
_seed_nomenclator(cod="OE-1", op_service="Schimb ulei")
data = _csv_bytes([{
"VIN": "WVWZZZ1JZXW0AB004",
"Nr": "B400DDD",
"Data": "2026-06-10",
"KM": "50000",
"Operatie": "Schimb ulei",
}])
iid = _upload(api_client, data)
_save_mapping(api_client, iid, {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
})
# Primul preview: obs derivat din operatie
rows1 = _preview(api_client, iid)
obs1 = rows1[0]["resolved"].get("obs", "")
assert obs1 == "Schimb ulei", f"Primul preview: obs neasteptat: {obs1!r}"
# Simulam utilizatorul care seteaza explicit obs = valoarea deja derivata
r = api_client.post(
f"/v1/import/{iid}/rand/0/editeaza",
json={"obs": "Schimb ulei"},
)
assert r.status_code == 200, r.text
# Al doilea preview: obs NU trebuie dublat
rows2 = _preview(api_client, iid)
obs2 = rows2[0]["resolved"].get("obs", "")
assert obs2 == "Schimb ulei", (
f"Al doilea preview a produs obs gresit: {obs2!r} (asteptat: 'Schimb ulei')"
)
assert "Schimb ulei; Schimb ulei" not in obs2, (
f"obs a fost dublat: {obs2!r}"
)
# Al treilea preview (fara nicio alta editare): inca nu se dubleaza
rows3 = _preview(api_client, iid)
obs3 = rows3[0]["resolved"].get("obs", "")
assert obs3 == "Schimb ulei", (
f"Al treilea preview a produs obs gresit: {obs3!r}"
)
def test_obs_sters_explicit_nu_se_re_deriveaza(api_client):
"""Bug fix (code-review 5.15): obs='' (sters explicit de user) NU se re-deriveaza.
obs e camp derivat (copiaza denumirea operatiei cand e gol). Cand userul sterge
obs in preview (obs=''), _merge_override pastreaza acum obs='' in override (nu il
mai face pop) -> override aplicat ultimul suprascrie derive-on-empty -> obs ramane
gol. Inainte: pop -> obs gol -> re-derivat din denumire -> stergerea ignorata.
RED inainte de fix: al doilea preview re-deriveaza obs = 'Schimb ulei'.
"""
_seed_nomenclator(cod="OE-1", op_service="Schimb ulei")
data = _csv_bytes([{
"VIN": "WVWZZZ1JZXW0AB009",
"Nr": "B900GGG",
"Data": "2026-06-10",
"KM": "50000",
"Operatie": "Schimb ulei",
}])
iid = _upload(api_client, data)
_save_mapping(api_client, iid, {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
})
# Primul preview: obs derivat din operatie.
rows1 = _preview(api_client, iid)
assert rows1[0]["resolved"].get("obs") == "Schimb ulei"
# Userul STERGE obs (string gol).
r = api_client.post(
f"/v1/import/{iid}/rand/0/editeaza",
json={"obs": ""},
)
assert r.status_code == 200, r.text
# Preview dupa stergere: obs trebuie sa RAMANA gol (NU re-derivat).
rows2 = _preview(api_client, iid)
obs2 = rows2[0]["resolved"].get("obs", "")
assert obs2 == "", (
f"obs sters explicit a fost re-derivat: {obs2!r} (asteptat gol)"
)
# Idempotent: al treilea preview tot gol.
rows3 = _preview(api_client, iid)
assert rows3[0]["resolved"].get("obs", "") == "", (
f"obs sters re-derivat la al treilea preview: {rows3[0]['resolved'].get('obs')!r}"
)
def test_obs_nu_schimba_cheia_idempotenta():
"""AC (D8): editarea obs NU schimba cheia de idempotenta.
Fara import circular DB; testeaza direct functiile din idempotency.py.
RED: daca obs ar fi in build_key, doua versiuni (cu/fara obs) ar produce chei diferite.
"""
from app.idempotency import build_key, canonicalize_row
payload_fara_obs = {
"vin": "WVWZZZ1JZXW0AB005",
"nr_inmatriculare": "B500EEE",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
payload_cu_obs = {
**payload_fara_obs,
"obs": "Schimb ulei motor 5W30 adaugat dupa",
}
canon1 = canonicalize_row(payload_fara_obs)
canon2 = canonicalize_row(payload_cu_obs)
key1 = build_key(1, canon1)
key2 = build_key(1, canon2)
assert key1 == key2, (
f"obs a schimbat neasteptat cheia de idempotenta!\n"
f" fara obs: {key1}\n"
f" cu obs: {key2}"
)

View File

@@ -0,0 +1,546 @@
"""Teste US-006 (PRD 5.15): prestatii multi-cod (lista) la editare/corectie.
AC-uri verificate:
- Handler-ele accepta LISTA de cod_prestatie (form.getlist) -> prestatii cu mai multe coduri.
- cod_op_service/denumire RAMAN pe item (invariant D7, E1 IRON RULE).
- Cod invalid -> respins cu mesaj; cod necunoscut NU ajunge la RAR (ORA-12899).
- Lista goala -> ramane needs_mapping.
- Dedup per-item: (op_service, cod) unic, NU cod unic (doua ops diferite cu acelasi cod ok).
- Recalcul idempotenta dupa editare.
- odometruInitial obligatoriu cand cod_prestatie contine R-ODO/I-ODO.
- REGRESIE E1 (IRON RULE): op_service supravietuieste /repune cu cod.
TDD: toate testele sunt scrise INAINTE de implementare (RED -> GREEN).
"""
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, "prestatii.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()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
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 _insert(acct: int, *, status: str, payload: dict, key: str | None = None) -> int:
from app.db import get_connection
conn = get_connection()
try:
k = key or f"k-{os.urandom(6).hex()}"
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(k, acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _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 _payload_json(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 _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
"""Insereaza un cod in nomenclator_rar (fara operatii_mapping)."""
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 _payload_cu_ops(vin: str, ops: list[tuple[str, str]]) -> dict:
"""Payload cu prestatii avand cod_op_service/denumire (needs_mapping state)."""
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
],
}
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_mai_multe_coduri_acceptate(client):
"""US-006 AC1: LISTA de cod_prestatie -> prestatii cu N itemi, fiecare cu cod setat.
RED: form.get("cod_prestatie") intoarce doar primul cod; form.getlist necesar.
"""
acct = _create_account_user("multi.cod@test.com")
_login(client, "multi.cod@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("IG-1", "Inlocuire garnitura")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0MC001",
[("Op-A", "Schimb ulei motor"), ("Op-B", "Inlocuire garnitura chiulasa")],
))
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", "IG-1"], # 2 coduri pentru 2 operatii
},
)
assert resp.status_code == 200, resp.text[:500]
r = _row(sid)
assert r["status"] == "queued", f"status asteptat queued, got {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 2, f"asteptat 2 prestatii, got {len(prestatii)}: {prestatii}"
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "OE-1" in coduri, f"OE-1 lipsa din prestatii: {prestatii}"
assert "IG-1" in coduri, f"IG-1 lipsa din prestatii: {prestatii}"
def test_cod_op_service_pastrat_dupa_corecteaza(client):
"""E1/D7: cod_op_service si denumire RAMAN pe item dupa /corecteaza cu cod direct.
RED: implementarea veche injecta in prestatii[0] fara sa afecteze op_service
(intr-adevar in /corecteaza nu se facea pop), dar testul confirma explicit invariantul.
"""
acct = _create_account_user("op.pastrat@test.com")
_login(client, "op.pastrat@test.com")
_seed_cod("OE-1")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0OP001",
[("Schimb ulei", "Schimb ulei motor 5W30")],
))
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 1
item = prestatii[0]
assert item.get("cod_prestatie") == "OE-1", f"cod_prestatie lipsa: {item}"
assert item.get("cod_op_service") == "Schimb ulei", f"cod_op_service pierdut: {item}"
assert item.get("denumire") == "Schimb ulei motor 5W30", f"denumire pierduta: {item}"
def test_cod_invalid_respins(client):
"""US-006 AC3: cod necunoscut in nomenclator -> respins cu mesaj, status neschimbat.
RED: validarea fata de nomenclator nu e aplicata per-cod la multi-select.
"""
acct = _create_account_user("cod.invalid@test.com")
_login(client, "cod.invalid@test.com")
# NU seed-uim "XX-99" -> cod necunoscut
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0CI001",
[("Op-Test", "Operatie test")],
))
old_status = _row(sid)["status"]
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "XX-99"},
)
assert resp.status_code == 200
# Cod invalid -> mesaj de eroare vizibil
assert "XX-99" in resp.text or "necunoscut" in resp.text.lower(), (
f"Mesaj de eroare lipsa pentru cod invalid; text={resp.text[:500]}"
)
# Status neschimbat
assert _row(sid)["status"] == old_status, (
f"Status s-a schimbat desi codul e invalid: {_row(sid)['status']}"
)
def test_lista_goala_needs_mapping(client):
"""US-006 AC4: nicio cod_prestatie trimis -> submission ramane needs_mapping.
RED: cu multi-select, lista goala nu injecteaza nimic; resolve_prestatii
gaseste inca operatii nemapate -> trebuie sa ramana needs_mapping.
"""
acct = _create_account_user("goala.nemap@test.com")
_login(client, "goala.nemap@test.com")
# NU seed-uim nicio mapare -> operatia ramane nemapata
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0GN001",
[("Op-Nemap", "Operatie nemapata")],
))
csrf = _csrf(client)
# Trimit form FARA cod_prestatie (lista goala)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf},
)
assert resp.status_code == 200
assert _row(sid)["status"] == "needs_mapping", (
f"Status trebuia sa ramana needs_mapping, got {_row(sid)['status']}"
)
def test_idempotency_recalculat(client):
"""US-006 AC6: dupa setarea de coduri noi, cheia de idempotenta e recalculata.
RED: single-cod injecta in prestatii[0] si recalcula cheia; cu multi-cod
acelasi mecanism se aplica tuturor itemilor.
"""
acct = _create_account_user("ido.recalc@test.com")
_login(client, "ido.recalc@test.com")
_seed_cod("OE-1")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0ND001",
[("Op-Ido", "Operatie ido")],
))
old_key = _row(sid)["idempotency_key"]
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200
assert _row(sid)["status"] == "queued"
new_key = _row(sid)["idempotency_key"]
assert new_key != old_key, (
f"Cheia de idempotenta NU s-a schimbat dupa setarea codului: {new_key}"
)
def test_odometru_initial_conditionat_R_ODO(client):
"""US-006 AC7: cod_prestatie=R-ODO fara odometruInitial -> validate_prezentare
intoarce eroare -> submission ramane needs_data (NU queued).
RED: validarea R-ODO e deja in validate_prezentare; testul confirma ca
multi-cod nu bypass-eaza aceasta regula.
"""
acct = _create_account_user("odo.rodo@test.com")
_login(client, "odo.rodo@test.com")
_seed_cod("R-ODO", "Revizie odometru")
# Payload: needs_mapping (op fara cod), FARA odometru_initial
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0RO001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
# odometru_initial ABSENT
"prestatii": [{"cod_op_service": "Revizie", "denumire": "Revizie odometru"}],
})
csrf = _csrf(client)
# Trimit R-ODO ca cod (valid in nomenclator), dar fara odometru_initial
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "R-ODO"},
)
assert resp.status_code == 200
status = _row(sid)["status"]
# R-ODO fara odometruInitial -> validare esuata -> needs_data (nu queued)
assert status in ("needs_data", "needs_mapping"), (
f"Status neasteptat: {status}; trebuia needs_data/needs_mapping (R-ODO fara odo initial)"
)
assert status != "queued", (
"R-ODO fara odometruInitial NU trebuie sa treaca in queued!"
)
def test_dedup_per_item_nu_dupa_cod(client):
"""US-006 AC5 (E4): doua operatii DIFERITE cu ACELASI cod RAR ambele supravietuiesc.
Dedup = (op_service, cod) identice, NU cod singur. Doua ops distincte pot
mapa legitim la acelasi cod RAR fara sa fie sterse de dedup.
RED: dedupare naiva dupa cod ar sterge a doua operatie (op-B cu acelasi OE-1).
"""
acct = _create_account_user("dedup.ops@test.com")
_login(client, "dedup.ops@test.com")
_seed_cod("OE-1", "Schimb ulei")
# Doua operatii distincte, ambele vor primi OE-1
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0DD001",
[("Op-A", "Prima operatie"), ("Op-B", "A doua operatie")],
))
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", "OE-1"], # acelasi cod pentru ambele ops
},
)
assert resp.status_code == 200
prestatii = _payload_json(sid)["prestatii"]
# Ambele TREBUIE sa supravietuiasca: (Op-A, OE-1) != (Op-B, OE-1)
assert len(prestatii) == 2, (
f"Dedup a sters o operatie distincta! prestatii={prestatii} "
"(doua ops cu acelasi cod trebuie pastrate)"
)
ops = [p.get("cod_op_service") for p in prestatii]
assert "Op-A" in ops and "Op-B" in ops, f"ops_service pierdute: {ops}"
# --------------------------------------------------------------------------- #
# Test de regresie E1 (IRON RULE): op_service supravietuieste /repune cu cod #
# --------------------------------------------------------------------------- #
def test_op_service_supravietuieste_repune_cu_cod(client):
"""E1 IRON RULE: dupa /repune cu cod_prestatie, cod_op_service/denumire RAMAN pe item.
RED: routes.py:1371 face `p0.pop("cod_op_service", None)` — sterge operatia
cand se seteaza un cod direct prin /repune. US-006 ELIMINA acel pop.
Aceasta regresie e CRITICA: sterge contextul op->cod necesar pentru US-009
(salvare mapare din chip) si rupe invariantul D7.
"""
acct = _create_account_user("e1.repune@test.com")
_login(client, "e1.repune@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
# Starea error: payload cu op_service (operatia venita de la import/API)
sid = _insert(acct, status="error", payload={
"vin": "WVWZZZ1JZXW0E1001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{
"cod_op_service": "Schimb ulei",
"denumire": "Schimb ulei motor 5W30",
# fara cod_prestatie initial
}],
})
csrf = _csrf(client)
# /repune cu cod direct
resp = client.post(
f"/trimitere/{sid}/repune",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200, resp.text[:500]
r = _row(sid)
assert r["status"] == "queued", f"status neasteptat: {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 1
item = prestatii[0]
# IRON RULE E1: op_service si denumire TREBUIE sa fie prezente
assert item.get("cod_op_service") == "Schimb ulei", (
f"E1 VIOLATED: cod_op_service a fost sters de /repune! item={item}"
)
assert item.get("denumire") == "Schimb ulei motor 5W30", (
f"E1 VIOLATED: denumire a fost stearsa de /repune! item={item}"
)
# Codul trebuie setat
assert item.get("cod_prestatie") == "OE-1", (
f"cod_prestatie nu a fost setat corect: item={item}"
)
def test_repune_nu_trunchiaza_prestatii_multiple(client):
"""Bug fix (code-review 5.15): /repune NU pierde prestatii[1:].
Formularul /repune trimite UN SINGUR select cod_prestatie. Implementarea veche
itera `enumerate(codes)` -> pastra doar len(codes) itemi, deci un rand error cu
2+ prestatii pierdea toate prestatiile dupa prima -> declaratie INCOMPLETA la RAR
(FINALIZATA ireversibil). Fix: iteram peste `existing`, aplicam codes pozitional,
pastram toate prestatiile.
RED inainte de fix: len(prestatii) == 1 (a doua prestatie pierduta).
"""
acct = _create_account_user("repune.multi@test.com")
_login(client, "repune.multi@test.com")
_seed_cod("AAA", "Prestatie A")
_seed_cod("BBB", "Prestatie B")
_seed_cod("CCC", "Prestatie C")
# Rand error cu DOUA prestatii (ambele cu cod valid).
sid = _insert(acct, status="error", payload={
"vin": "WVWZZZ1JZXW0RM001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [
{"cod_prestatie": "AAA"},
{"cod_prestatie": "BBB"},
],
})
csrf = _csrf(client)
# /repune cu UN SINGUR cod nou (schimba prima prestatie).
resp = client.post(
f"/trimitere/{sid}/repune",
data={"csrf_token": csrf, "cod_prestatie": "CCC"},
)
assert resp.status_code == 200, resp.text[:500]
r = _row(sid)
assert r["status"] == "queued", f"status neasteptat: {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 2, (
f"AMBELE prestatii trebuie pastrate de /repune, nu doar prima! got={prestatii}"
)
coduri = [p.get("cod_prestatie") for p in prestatii]
assert coduri == ["CCC", "BBB"], (
f"Codul nou se aplica POZITIONAL primei prestatii, a doua ramane intacta: {coduri}"
)
def test_corectie_eroare_validare_pastreaza_picker(client):
"""Bug fix (code-review 5.15): re-render-ul de eroare validare pastreaza optiunile pickerului.
post_corectie_trimitere re-randa _trimitere_detaliu pe ramura erori-validare FARA
`conn`/`account_id` -> `nomenclator_rar=[]` -> picker-ul chips randa ZERO optiuni ->
userul nu mai poate alege cod RAR fara sa inchida+redeschida modalul. Fix: pasam
`conn`+`account_id` la _detaliu_ctx pe TOATE ramurile de re-render.
RED inainte de fix: codul de picker "PK-1" lipseste din re-render.
"""
acct = _create_account_user("corectie.picker@test.com")
_login(client, "corectie.picker@test.com")
_seed_cod("ZZ-9", "Operatie existenta") # codul curent al randului (valid -> fara unmapped)
_seed_cod("PK-1", "Optiune picker") # cod doar in nomenclator (detector de picker)
# needs_data editabil, prestatie cu cod direct valid (resolve OK, fara unmapped).
sid = _insert(acct, status="needs_data", payload={
"vin": "WVWZZZ1JZXW0PK001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "ZZ-9"}],
})
csrf = _csrf(client)
# Corectie cu VIN invalid -> validare esueaza -> ramura de re-render 1432.
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "vin": "BAD"},
)
assert resp.status_code == 200
assert _row(sid)["status"] == "needs_data"
# Picker-ul trebuie sa contina optiunile din nomenclator (conn/account_id pasate).
assert "PK-1" in resp.text, (
"Picker-ul chips e GOL dupa eroare de validare — _detaliu_ctx fara conn/account_id"
)
def test_repune_select_afiseaza_denumirea(client):
"""Bug fix (code-review 5.15): selectul /repune afiseaza denumirea operatiei.
Template-ul folosea cheia gresita `item.nome_prestatie` (typo) -> optiunile
apareau ca "AAA — " fara denumire. Cheia corecta e `nume_prestatie`.
"""
acct = _create_account_user("repune.denumire@test.com")
_login(client, "repune.denumire@test.com")
_seed_cod("AAA", "Schimb ulei motor")
sid = _insert(acct, status="error", payload={
"vin": "WVWZZZ1JZXW0RD001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "AAA"}],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Optiunea trebuie sa afiseze denumirea, nu doar codul gol.
assert "Schimb ulei motor" in html, (
"Selectul /repune nu afiseaza denumirea operatiei (typo nome_prestatie)"
)
assert "AAA — Schimb ulei motor" in html, (
f"Optiunea select nu randeaza 'cod — denumire': {html[html.find('AAA'):html.find('AAA')+60]}"
)

View File

@@ -0,0 +1,496 @@
"""Teste TDD pentru US-007 (PRD 5.15): formular editare slim.
RED -> implementare -> GREEN.
AC-uri verificate:
- Un singur camp VIN (fara "Confirma VIN").
- Textarea obs (Observatii) prezent in formular.
- Chips multi-select prestatii cu hidden inputs name="cod_prestatie".
- Endpoint /form-chips re-randeaza sectiunea chips (add/remove).
- Acelasi _form_editare.html in ambele modale (trimitere detaliu + editare preview).
- Reveal dinamic odometru initial cand chips contin R-ODO/I-ODO.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
from pathlib import Path
import pytest
from starlette.testclient import TestClient
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "app" / "web" / "templates"
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "slim_form.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()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
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 Test", 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 _insert(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 _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 _payload_needs_data_cu_cod(vin: str = "WVWZZZ1JZXW0US007A") -> dict:
"""Payload needs_data: cod RAR setat, dar odometru_final gol."""
return {
"vin": vin,
"nr_inmatriculare": "B200AA",
"data_prestatie": "2026-06-20",
"odometru_final": "", # gol -> needs_data
"prestatii": [{"cod_prestatie": "OE-1", "cod_op_service": "Op-A", "denumire": "Schimb ulei"}],
}
def _payload_cu_ops(vin: str, ops: list[tuple]) -> dict:
"""Payload cu prestatii avand cod_op_service (needs_mapping)."""
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
],
}
def _payload_cu_r_odo(vin: str = "WVWZZZ1JZXW0RODO1") -> dict:
"""Payload needs_data cu R-ODO in chips — declanseaza reveal odometru initial."""
return {
"vin": vin,
"nr_inmatriculare": "B300RO",
"data_prestatie": "2026-06-20",
"odometru_final": "39000",
# odometru_initial ABSENT -> needs_data cand R-ODO
"prestatii": [{"cod_prestatie": "R-ODO", "cod_op_service": "", "denumire": "Revizie odometru"}],
}
# --------------------------------------------------------------------------- #
# Test 1: UN SINGUR camp VIN (fara "Confirma VIN") #
# --------------------------------------------------------------------------- #
def test_un_singur_vin(client):
"""US-007 AC1: formularul slim are UN SINGUR input name='vin'.
Fara camp 'Confirma VIN' — PRD si contractul RAR cer un singur VIN.
RED: daca ar exista doua campuri VIN sau un camp 'confirma_vin', testul pica.
"""
acct = _create_account_user("vin.unic@test.com")
_login(client, "vin.unic@test.com")
_seed_cod("OE-1")
sid = _insert(acct, status="needs_data", payload=_payload_needs_data_cu_cod())
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200, resp.text[:300]
html = resp.text
# Exact un singur input cu name="vin"
vin_inputs = re.findall(r'<input[^>]+name="vin"[^>]*>', html)
assert len(vin_inputs) == 1, (
f"Trebuie exact UN input name='vin', gasit {len(vin_inputs)}: {vin_inputs}"
)
# Fara camp "Confirma VIN" sau "confirma_vin"
assert "confirma_vin" not in html.lower(), (
"Formular NU trebuie sa aiba camp 'confirma_vin' (VIN unic per contract RAR)"
)
assert "confirma vin" not in html.lower(), (
"Formular NU trebuie sa afiseze eticheta 'confirma vin'"
)
# --------------------------------------------------------------------------- #
# Test 2: Camp Observatii (textarea name="obs") #
# --------------------------------------------------------------------------- #
def test_camp_observatii_prezent(client):
"""US-007 AC2: formularul are textarea name='obs' pentru Observatii (US-005).
RED: obs nu e inca in _form_editare.html (US-005 adauga backend-ul, US-007 adauga UI-ul).
"""
acct = _create_account_user("obs.forma@test.com")
_login(client, "obs.forma@test.com")
_seed_cod("OE-1")
sid = _insert(acct, status="needs_data", payload=_payload_needs_data_cu_cod())
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# textarea cu name="obs" trebuie sa existe
has_textarea_obs = bool(
re.search(r'<textarea[^>]+name="obs"', html) or
re.search(r'<textarea[^>]*name=["\']obs["\']', html)
)
assert has_textarea_obs, (
"Formularul trebuie sa contina <textarea name='obs'> pentru Observatii. "
"US-007 adauga campul obs (textarea) in _form_editare.html."
)
# Eticheta "Observatii" sau "obs" vizibila
assert re.search(r'[Oo]bservat', html), (
"Formularul trebuie sa afiseze eticheta 'Observatii'"
)
# --------------------------------------------------------------------------- #
# Test 3: Chips multi-select prestatii (hidden inputs name="cod_prestatie") #
# --------------------------------------------------------------------------- #
def test_chips_multi_select_prestatii(client):
"""US-007 AC3: submission cu cod_prestatie setat afiseaza chip cu hidden input.
RED: _form_editare.html nu are inca sectiunea de chips.
"""
acct = _create_account_user("chips.test@test.com")
_login(client, "chips.test@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
sid = _insert(acct, status="needs_data", payload=_payload_needs_data_cu_cod())
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Trebuie sa existe un input (de obicei hidden) cu name="cod_prestatie" si valoarea "OE-1"
has_cod_prestatie_chip = bool(
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html)
)
assert has_cod_prestatie_chip, (
"Formularul trebuie sa contina un hidden input cu name='cod_prestatie' value='OE-1' "
"reprezentand chip-ul de prestatie. "
"US-007 adauga sectiunea de chips in _form_editare.html."
)
# --------------------------------------------------------------------------- #
# Test 4: Endpoint /form-chips re-randeaza sectiunea chips #
# --------------------------------------------------------------------------- #
def test_adauga_sterge_chip(client):
"""US-007 AC (E6): POST /form-chips cu action=add re-randeaza chips cu noul cod.
RED: endpoint-ul /form-chips nu exista inca.
"""
acct = _create_account_user("form.chips@test.com")
_login(client, "form.chips@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
# POST /form-chips: adauga OE-1 la prima operatie (index 0)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
# Starea curenta: o operatie fara cod
"chip_op_service": ["Op-A"],
"chip_denumire": ["Schimb ulei motor"],
"cod_prestatie": [""], # nemaapat initial
# Actiunea
"chips_action": "add",
"chips_add_op_index": "0",
"chips_add_cod_0": "OE-1",
},
)
assert resp.status_code == 200, f"/form-chips a returnat {resp.status_code}: {resp.text[:400]}"
html = resp.text
# Dupa add, chip-ul cu OE-1 trebuie sa fie in HTML
assert "OE-1" in html, (
f"Dupa add, OE-1 trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}"
)
# Si hidden input cu valoarea OE-1
has_hidden = bool(
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html)
)
assert has_hidden, (
"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):
"""US-007: POST /form-chips cu action=remove sterge chip-ul la indexul dat.
RED: endpoint-ul /form-chips nu exista inca.
"""
acct = _create_account_user("form.chips.del@test.com")
_login(client, "form.chips.del@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
# POST /form-chips: sterge chip-ul de la index 0 (OE-1 existent)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
# Starea curenta: OE-1 mapat pe Op-A
"chip_op_service": ["Op-A"],
"chip_denumire": ["Schimb ulei motor"],
"cod_prestatie": ["OE-1"],
# Actiunea: sterge indexul 0
"chips_action": "remove",
"chips_remove_index": "0",
},
)
assert resp.status_code == 200, f"/form-chips remove a returnat {resp.status_code}: {resp.text[:400]}"
html = resp.text
# Dupa remove, OE-1 nu mai apare ca chip (input hidden cu acea valoare)
has_oe1_chip = bool(
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html)
)
assert not has_oe1_chip, (
"Dupa remove, OE-1 NU mai trebuie sa apara ca chip "
f"(hidden input cu cod_prestatie=OE-1). html[:500]={html[:500]}"
)
# --------------------------------------------------------------------------- #
# Test 5: Acelasi _form_editare.html in ambele modale #
# --------------------------------------------------------------------------- #
def test_form_slim_in_ambele_modale():
"""US-007 AC4: _form_editare.html e inclus ATAT in _trimitere_detaliu.html
CAT SI in _editare_preview_modal.html (fara duplicare logica).
"""
sursa_detaliu = (TEMPLATES_DIR / "_trimitere_detaliu.html").read_text(encoding="utf-8")
sursa_preview = (TEMPLATES_DIR / "_editare_preview_modal.html").read_text(encoding="utf-8")
assert "_form_editare.html" in sursa_detaliu, (
"_trimitere_detaliu.html trebuie sa includa _form_editare.html (DRY)"
)
assert "_form_editare.html" in sursa_preview, (
"_editare_preview_modal.html trebuie sa includa _form_editare.html (DRY)"
)
# --------------------------------------------------------------------------- #
# Test 6: Reveal dinamic odometru initial la R-ODO (D10c, E6) #
# --------------------------------------------------------------------------- #
def test_reveal_odometru_la_R_ODO(client):
"""US-007 D10c: cand chips contin R-ODO, campul odometru initial e dezvaluit
si marcat ca necesar (bordura warn + label).
Cand NU contine R-ODO, sectiunea e ascunsa/discreta.
RED: _form_editare.html nu are inca logica de reveal conditionat.
"""
acct = _create_account_user("odo.reveal@test.com")
_login(client, "odo.reveal@test.com")
_seed_cod("R-ODO", "Revizie odometru")
_seed_cod("OE-1", "Schimb ulei")
# Submission cu R-ODO -> reveal activ
sid_rodo = _insert(acct, status="needs_data", payload=_payload_cu_r_odo())
resp_rodo = client.get(f"/_fragments/trimitere/{sid_rodo}")
assert resp_rodo.status_code == 200
html_rodo = resp_rodo.text
# Cand R-ODO e prezent: campul odometru_initial trebuie sa fie dezvaluit
# cu un MARKER SPECIFIC al implementarii noi:
# - clasa "odo-initial-warn" pe div-ul sectiunii
# - SAU textul "necesar pentru r-odo" in label (exact, case-insensitive)
# Aceste lucruri NU exista in implementarea curenta (care arata mereu campul fara marker).
has_r_odo_reveal = (
"odo-initial-warn" in html_rodo or
"necesar pentru r-odo" in html_rodo.lower() or
"necesar pentru i-odo" in html_rodo.lower()
)
assert has_r_odo_reveal, (
"Cand chips contin R-ODO, formularul trebuie sa dezvaluie sectiunea odometru initial "
"cu clasa 'odo-initial-warn' sau text 'necesar pentru R-ODO'. "
f"html_rodo[:800]={html_rodo[:800]}"
)
# Submission fara R-ODO -> reveal inactiv (campul discret sau ascuns)
payload_fara_rodo = {
"vin": "WVWZZZ1JZXW0NOROD1",
"nr_inmatriculare": "B100AA",
"data_prestatie": "2026-06-20",
"odometru_final": "", # needs_data din alt motiv
"prestatii": [{"cod_prestatie": "OE-1", "cod_op_service": "", "denumire": "Schimb ulei"}],
}
sid_norm = _insert(acct, status="needs_data", payload=payload_fara_rodo)
resp_norm = client.get(f"/_fragments/trimitere/{sid_norm}")
assert resp_norm.status_code == 200
html_norm = resp_norm.text
# Fara R-ODO: "necesar pentru R-ODO" nu trebuie sa apara
has_r_odo_text_in_norm = (
"necesar pentru r-odo" in html_norm.lower() or
"necesar pentru i-odo" in html_norm.lower() or
"odo-initial-warn" in html_norm
)
assert not has_r_odo_text_in_norm, (
"Fara R-ODO in chips, formularul NU trebuie sa arate 'necesar pentru R-ODO'. "
f"html_norm[:800]={html_norm[:800]}"
)
# --------------------------------------------------------------------------- #
# Test 7: /form-chips via HTMX returneaza sectiune si cu reveal R-ODO #
# --------------------------------------------------------------------------- #
def test_form_chips_reveal_r_odo(client):
"""US-007 E6: POST /form-chips cu R-ODO in chips -> raspunsul marcheaza reveal odo.
RED: endpoint-ul nu exista + logica de reveal nu e implementata.
"""
acct = _create_account_user("chips.rodo@test.com")
_login(client, "chips.rodo@test.com")
_seed_cod("R-ODO", "Revizie odometru")
csrf = _csrf(client)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
# Stare curenta: R-ODO deja in chips (flat)
"chip_op_service": [""],
"chip_denumire": ["Revizie odometru"],
"cod_prestatie": ["R-ODO"],
# Nicio actiune — justa re-randare
"chips_action": "",
},
)
assert resp.status_code == 200, f"/form-chips R-ODO a returnat {resp.status_code}"
html = resp.text
# In sectiunea chip, R-ODO trebuie sa apara (chip warn sau chip normal)
assert "R-ODO" in html, (
f"R-ODO trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}"
)
# Indicatorul has_r_odo trebuie sa fie un marker SPECIFIC al implementarii noi:
# chip-warn (clasa warn pe chip R-ODO) SAU data-has-r-odo="true"
# Aceste marcheri nu exista inainte de implementarea US-007.
has_r_odo_signal = (
"chip-warn" in html or # chip-ul R-ODO e stilat warn (CSS existent din US-002)
'data-has-r-odo="true"' in html # sau data-attr explicit
)
assert has_r_odo_signal, (
"Cand R-ODO e in chips, raspunsul /form-chips trebuie sa contina "
"class='chip-warn' pe chip-ul R-ODO sau data-has-r-odo='true'. "
f"html[:600]={html[:600]}"
)
# --------------------------------------------------------------------------- #
# Test 8: Picker per operatie (E4 binding) -- format op-row #
# --------------------------------------------------------------------------- #
def test_picker_per_operatie_in_form(client):
"""US-007 E4: operatie nemapata (needs_mapping) -> formularul afiseaza picker pe operatie.
RED: _form_editare.html nu are inca sectiunea de chips cu op-rows.
"""
acct = _create_account_user("picker.op@test.com")
_login(client, "picker.op@test.com")
# NU seed-uim nicio mapare -> operatia ramane nemapata
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0OP001",
[("REVIZIE PERIODICA", "Revizie periodica anuala")],
))
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Operatia REVIZIE PERIODICA trebuie sa apara in form (op-row cu clasa specifica US-007)
# clasa "op-row" din CSS base.html (US-002) e adaugata NUMAI de chips_prestatii.html nou
has_op_row = "op-row" in html
assert has_op_row, (
"Formularul trebuie sa contina clasa 'op-row' (din US-002 CSS) "
"pentru picker-ul per-operatie (E4 binding). "
"Aceasta clasa e adaugata de _chips_prestatii.html in US-007. "
f"html[:600]={html[:600]}"
)
# Operatia REVIZIE PERIODICA trebuie sa apara in context op-row
assert "REVIZIE PERIODICA" in html, (
"Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare (op-row). "
f"html[:500]={html[:500]}"
)

View File

@@ -0,0 +1,340 @@
"""Teste TDD US-009 (PRD 5.15): salvare mapare din chip + cleanup (B) select redundant.
RED -> implementare -> GREEN.
AC-uri verificate:
- Endpoint /trimitere/{id}/salveaza-regula-chip salveaza regula via save_mapping+reresolve_account.
- Re-rezolvarea deblocheaza si submission-uri frate cu aceeasi operatie (batch_id IS NULL).
- Editarea one-off prin /corecteaza nu forteaza salvarea regulii in operations_mapping.
- Cleanup (B): detaliu needs_data NU mai contine <select name="cod_prestatie"> simultan cu chips.
"""
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, "chip_mapare.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()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
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 Test US009", 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 _insert(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 _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 _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 _get_rule(acct: int, op: str):
"""Cauta regula op->cod in operations_mapping pentru cont + operatie."""
from app.db import get_connection
conn = get_connection()
try:
return conn.execute(
"SELECT cod_prestatie FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
(acct, op),
).fetchone()
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Test 1: Salvare regula din chip (US-009 AC1) #
# --------------------------------------------------------------------------- #
def test_salveaza_regula_din_chip(client):
"""US-009 AC1: POST /trimitere/{id}/salveaza-regula-chip -> save_mapping + reresolve.
RED: endpoint-ul nu exista inca; trebuie creat cu reuse EXACT save_mapping+reresolve_account.
"""
acct = _create_account_user("save.chip@test.com")
_login(client, "save.chip@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
op = "Schimb ulei motor"
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0SC001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": op, "denumire": "Schimb ulei 5W30"}],
})
csrf = _csrf(client)
# Salveaza regula din chip (endpoint nou US-009)
resp = client.post(
f"/trimitere/{sid}/salveaza-regula-chip",
data={
"csrf_token": csrf,
"salveaza_op": op,
"salveaza_cod": "OE-1",
},
)
assert resp.status_code == 200, (
f"Endpoint-ul salveaza-regula-chip a returnat {resp.status_code}: {resp.text[:500]}"
)
# Regula trebuie salvata in operations_mapping
rule = _get_rule(acct, op)
assert rule is not None, (
f"Regula nu a fost salvata in operations_mapping: op={op!r}"
)
assert rule["cod_prestatie"] == "OE-1", (
f"Codul salvat e gresit: {rule['cod_prestatie']!r} (asteptat OE-1)"
)
# Submission-ul propriu trebuie re-rezolvat (reresolve_account e apelat)
r = _row(sid)
assert r["status"] == "queued", (
f"Dupa salvarea regulii, submission-ul trebuia sa fie queued, got {r['status']!r}"
)
# Nota: mesajul de confirmare ("Regula salvata...") e in context dar flash-ul
# e randat in sectiunea editabila; dupa re-rezolvare la queued, detaliu e read-only.
# Verificarile esentiale (rule in DB + status queued) sunt de ajuns pentru US-009 AC1.
# --------------------------------------------------------------------------- #
# Test 2: Re-rezolvare deblocheaza submission frate (US-009 AC2) #
# --------------------------------------------------------------------------- #
def test_reresolve_deblocheaza_frate(client):
"""US-009 AC2: salvarea regulii din chip deblocheaza si alt submission needs_mapping
cu aceeasi operatie (canal API — batch_id IS NULL → reresolve_account scoped null).
RED: endpoint-ul nu exista; dupa implementare, reresolve_account re-rezolva ambele.
"""
acct = _create_account_user("frate.deblocat@test.com")
_login(client, "frate.deblocat@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
op = "Schimb ulei motor"
# Doua submission-uri cu aceeasi operatie, ambele needs_mapping, batch_id=NULL (API)
sid1 = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0RD001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": op, "denumire": "Schimb ulei 5W30"}],
})
sid2 = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0RD002",
"nr_inmatriculare": "B200BBB",
"data_prestatie": "2026-06-11",
"odometru_final": "60000",
"prestatii": [{"cod_op_service": op, "denumire": "Schimb ulei 5W30"}],
})
csrf = _csrf(client)
# Salveaza regula din chip pentru sid1 -> reresolve_account deblocheaza si sid2
resp = client.post(
f"/trimitere/{sid1}/salveaza-regula-chip",
data={
"csrf_token": csrf,
"salveaza_op": op,
"salveaza_cod": "OE-1",
},
)
assert resp.status_code == 200, resp.text[:500]
# sid1 trebuie sa fie re-rezolvat
r1 = _row(sid1)
assert r1["status"] == "queued", (
f"sid1 trebuia sa fie queued dupa salvarea regulii, got {r1['status']!r}"
)
# sid2 (fratele) trebuie sa fie de asemenea re-rezolvat de reresolve_account
r2 = _row(sid2)
assert r2["status"] == "queued", (
f"sid2 (fratele) trebuia sa fie deblocat (queued) de reresolve_account, "
f"got {r2['status']!r}. reresolve_account trebuie sa re-rezolve TOATE "
f"submission-urile cu aceeasi operatie pe canalul API (batch_id IS NULL)."
)
# --------------------------------------------------------------------------- #
# Test 3: Editare one-off nu forteaza salvarea regulii (US-009 AC3) #
# --------------------------------------------------------------------------- #
def test_optional_nu_forteaza(client):
"""US-009 AC3: editarea ramane one-off daca userul nu apeleaza salveaza-regula-chip.
POST-ul la /corecteaza (fara /salveaza-regula-chip) trebuie sa functioneze normal;
nicio regula nu se salveaza automat in operations_mapping.
RED: daca /corecteaza ar salva automat regula (nedorit), testul pica.
"""
acct = _create_account_user("optional.oneoff@test.com")
_login(client, "optional.oneoff@test.com")
_seed_cod("OE-1", "Schimb ulei")
op = "Schimb ulei special 0W20"
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0AP009",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": op, "denumire": "Ulei sintetic 0W20"}],
})
csrf = _csrf(client)
# Corecteaza one-off (direct la /corecteaza, fara /salveaza-regula-chip)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200
r = _row(sid)
assert r["status"] == "queued", (
f"Status trebuia sa fie queued dupa corectie one-off, got {r['status']!r}"
)
# Regula NU trebuie salvata automat in operations_mapping
rule = _get_rule(acct, op)
assert rule is None, (
f"Regula a fost salvata AUTOMAT in operations_mapping (nu ar trebui!): "
f"op={op!r}, rule={dict(rule) if rule else None}. "
"Salvarea regulii trebuie sa fie OPTIONALA (US-009 AC3)."
)
# --------------------------------------------------------------------------- #
# Test 4: Cleanup (B) — <select name="cod_prestatie"> redundant eliminat #
# --------------------------------------------------------------------------- #
def test_fara_select_vechi_redundant(client):
"""Cleanup (B): detaliu needs_data NU mai contine <select name='cod_prestatie'>.
Dupa cleanup, formularul editabil foloseste NUMAI chips (hidden inputs cod_prestatie),
fara vechiul select dublu. Chips functioneaza ca singura sursa de cod_prestatie.
Nota: <select name="cod_prestatie"> RAMANE in formularul /repune pentru starea error
(neschimbat — nu e subiectul cleanup-ului B).
RED: inainte de cleanup, atat selectul cat si chips emit cod_prestatie → dublu.
"""
acct = _create_account_user("no.select.vechi@test.com")
_login(client, "no.select.vechi@test.com")
_seed_cod("OE-1", "Schimb ulei")
# needs_data: starea editabila (editabil=True); chip cu cod setat
sid = _insert(acct, status="needs_data", payload={
"vin": "WVWZZZ1JZXW0NS001",
"nr_inmatriculare": "B200AA",
"data_prestatie": "2026-06-20",
"odometru_final": "", # gol -> needs_data
"prestatii": [{"cod_prestatie": "OE-1", "cod_op_service": "Op-A", "denumire": "Schimb ulei"}],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Chipurile trebuie sa fie prezente (hidden input cu name="cod_prestatie")
has_chip_hidden = (
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, (
"Chips: trebuie sa existe input hidden cu name='cod_prestatie' (din _chips_prestatii.html). "
f"html[:600]={html[:600]}"
)
# 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)
select_cod_prestatie = (
re.search(r'<select[^>]+name=["\']cod_prestatie["\']', html) or
re.search(r'<select[^>]*name="cod_prestatie"', html)
)
assert not select_cod_prestatie, (
"Sectiunea editabila needs_data NU trebuie sa mai contina "
"<select name='cod_prestatie'>. "
"Chips-urile (hidden inputs) il inlocuiesc (cleanup B, US-009). "
f"Gasit: {select_cod_prestatie.group(0) if select_cod_prestatie else 'N/A'}. "
f"html[:800]={html[:800]}"
)

View File

@@ -0,0 +1,99 @@
"""Bug fix (code-review 5.15): modalul de detaliu trebuie sa se deschida la
click/Enter pe randul SLIM.
US-004 a redenumit randul listei in `class="trimitere-slim"` (ID-ul ramane
`trimitere-row-{id}`). Handler-ele JS din base.html verificau doar
`classList.contains('trimitere-row')` -> nu se mai potriveau pe randul slim,
deci modalul nu se mai deschidea. Testul asserteaza ca JS-ul randat (pagina
completa /) trateaza explicit clasa `trimitere-slim` in AMBELE handler:
htmx:beforeRequest (click) si keydown (Enter/Space).
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", 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, name, 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
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "modal_slim_test.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 test_handler_click_recunoaste_rand_slim(client):
"""htmx:beforeRequest deschide modalul si pentru clasa trimitere-slim."""
_create_account_user("modal_click@test.com")
_login(client, "modal_click@test.com")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Handler-ul de click (htmx:beforeRequest) trebuie sa trateze trimitere-slim.
open_line = re.search(r"contains\('trimitere-slim'\)[^\n]*open\(elt\)", html)
assert open_line, (
"Handler-ul de click (htmx:beforeRequest -> open(elt)) nu recunoaste "
"clasa 'trimitere-slim' -> modalul nu se deschide la click pe randul slim"
)
def test_handler_keyboard_recunoaste_rand_slim(client):
"""keydown (Enter/Space) declanseaza click si pentru clasa trimitere-slim."""
_create_account_user("modal_kbd@test.com")
_login(client, "modal_kbd@test.com")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Handler-ul keydown verifica clasa inainte de a chema t.click().
# Trebuie sa includa 'trimitere-slim' in conditia de garda.
kbd = re.search(
r"keydown[^\n]*\n(?:.*\n){0,6}?.*contains\('trimitere-slim'\)",
html,
)
assert kbd, (
"Handler-ul keydown (Enter/Space) nu recunoaste clasa 'trimitere-slim' "
"-> tastatura nu deschide modalul pe randul slim"
)

View File

@@ -500,3 +500,241 @@ def test_liste_actionabile_o_coloana_pana_1024(client):
# Blocul tableta cardifica listele (thead ascuns = card per rand, o coloana). # Blocul tableta cardifica listele (thead ascuns = card per rand, o coloana).
assert ".tabel-trimiteri thead, .tabel-card thead { display:none; }" in html, \ assert ".tabel-trimiteri thead, .tabel-card thead { display:none; }" in html, \
"Blocul tableta nu ascunde thead-ul pentru cardificare (o coloana)" "Blocul tableta nu ascunde thead-ul pentru cardificare (o coloana)"
# ============================================================
# PRD 5.15 US-002: componente de design slim (CSS, fara consumatori)
# ============================================================
def _bloc_componente_slim(html: str) -> str:
"""Extrage blocul CSS dintre sentinelii SENTINEL-COMPONENTE-SLIM (inceput si sfarsit).
Testeaza existenta ambilor sentineli si returneaza continutul dintre ei.
"""
sentinel = "SENTINEL-COMPONENTE-SLIM"
i = html.find(sentinel)
assert i != -1, "Lipseste SENTINEL-COMPONENTE-SLIM in base.html (US-002 PRD 5.15)"
i2 = html.find(sentinel, i + len(sentinel))
assert i2 != -1, "Lipseste al doilea SENTINEL-COMPONENTE-SLIM (sfarsit bloc US-002)"
return html[i : i2 + len(sentinel)]
def test_clasa_contor_card(client):
""".contor-card: fundal --card2, bordura --line, radius 8px, padding.
Sub-elemente: .contor-cifra (cifra mare bold), .contor-label (muted), .contor-sub (mono).
"""
_create_account_user("cc2@test.com")
_login(client, "cc2@test.com")
html = client.get("/?tab=acasa").text
assert ".contor-card" in html, ".contor-card lipseste din CSS (base.html)"
bloc = _bloc_componente_slim(html)
assert "var(--card2)" in bloc, ".contor-card nu foloseste var(--card2) ca fundal"
assert "var(--line)" in bloc, ".contor-card nu are bordura var(--line)"
assert "border-radius:8px" in bloc, ".contor-card lipseste border-radius:8px"
assert ".contor-cifra" in bloc, "sub-elementul .contor-cifra lipseste din bloc"
assert ".contor-label" in bloc, "sub-elementul .contor-label lipseste din bloc"
assert "var(--muted)" in bloc, ".contor-label nu foloseste var(--muted)"
def test_clasa_lista_slim(client):
""".lista-trimiteri-slim + .trimitere-slim: separator --line2, padding, tinta min-height:44px.
Sub-elemente: .slim-vin (mono) si .slim-meta (muted 11px).
"""
_create_account_user("ls2@test.com")
_login(client, "ls2@test.com")
html = client.get("/?tab=acasa").text
assert ".lista-trimiteri-slim" in html, ".lista-trimiteri-slim lipseste din CSS (base.html)"
assert ".trimitere-slim" in html, ".trimitere-slim lipseste din CSS (base.html)"
bloc = _bloc_componente_slim(html)
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 ".slim-vin" in bloc, ".slim-vin lipseste din bloc"
assert ".slim-meta" in bloc, ".slim-meta lipseste din bloc"
assert "var(--muted)" in bloc, ".slim-meta nu foloseste var(--muted)"
def test_clasa_camp_slim(client):
""".camp-slim CSS exista cu fundal --card2.
Macro-ul camp() din _macros.html suporta parametrul slim=False ca default.
Default slim=False garanteaza ca randarea actuala ramane neschimbata.
"""
_create_account_user("cslim2@test.com")
_login(client, "cslim2@test.com")
html = client.get("/?tab=acasa").text
assert ".camp-slim" in html, ".camp-slim lipseste din CSS (base.html)"
bloc = _bloc_componente_slim(html)
assert "var(--card2)" in bloc, ".camp-slim nu foloseste var(--card2) ca fundal"
# Macro-ul camp() din _macros.html trebuie sa aiba parametrul slim
macros_path = os.path.join(
os.path.dirname(__file__), "..", "app", "web", "templates", "_macros.html"
)
with open(macros_path, encoding="utf-8") as f:
macros = f.read()
assert "slim" in macros, "macro-ul camp() nu are parametrul slim in _macros.html"
assert "slim=False" in macros, (
"macro-ul camp() nu are slim=False ca default — randarea actuala poate fi rupta"
)
def test_clasa_chips(client):
""".chips (container) + .chip (item): accent 18%, font 10-11px.
.chip-del: buton de stergere accesibil (element separat in CSS).
"""
_create_account_user("chp2@test.com")
_login(client, "chp2@test.com")
html = client.get("/?tab=acasa").text
assert ".chips" in html, ".chips lipseste din CSS (base.html)"
assert ".chip" in html, ".chip lipseste din CSS (base.html)"
bloc = _bloc_componente_slim(html)
assert "var(--accent)" in bloc, ".chip nu foloseste var(--accent)"
assert "18%" in bloc, ".chip nu are fundal accent 18% (color-mix accent 18%)"
assert "11px" in bloc or "10px" in bloc, ".chip nu are font 10-11px"
assert ".chip-del" in bloc, ".chip-del (buton de stergere) lipseste din bloc"
def test_fara_hex_in_componente_noi(client):
"""Zero hex literal in blocul CSS nou (SENTINEL-COMPONENTE-SLIM).
Toate culorile folosesc EXCLUSIV var(--token), zero #rrggbb hardcodat.
Ancorat pe SENTINEL ca sa nu scaneze blocul CSS vechi (unde exista #fff).
"""
_create_account_user("hexfree2@test.com")
_login(client, "hexfree2@test.com")
html = client.get("/?tab=acasa").text
bloc = _bloc_componente_slim(html)
# Cauta hex literals in proprietati CSS de culoare
hex_in_props = re.findall(
r"(?:color|background|border(?:-color)?|fill|stroke)\s*:[^;{}]*?"
r"(#[0-9a-fA-F]{3,8})",
bloc,
)
assert not hex_in_props, (
f"Hex literal gasit in componente noi US-002 — folositi var(--token): {hex_in_props}"
)
# ============================================================
# PRD 5.15 US-004: lista slim trimiteri — layout consistent desktop + <=1024px
# ============================================================
def test_lista_slim_randeaza_si_are_tinta_touch(client):
"""US-004: lista slim randeaza cu .trimitere-slim; tinta touch >=44px
e garantata de CSS (min-height:44px din blocul SENTINEL-COMPONENTE-SLIM).
Cardurile .tabel-trimiteri din 5.8 nu regreseaza: regula tabel-trimiteri
thead display:none (card pe mobil) exista in continuare in base.html.
"""
acct = _create_account_user("slim_resp@test.com")
_insert_submission(acct, status="sent")
_login(client, "slim_resp@test.com")
html = client.get("/?tab=acasa").text
# Lista slim randeaza (elementele sunt injectate via hx-get="/_fragments/submissions"
# -> testam ca clasele CSS sunt prezente in base.html, gata sa fie consumate)
bloc = _bloc_componente_slim(html)
assert "lista-trimiteri-slim" in bloc, \
".lista-trimiteri-slim lipseste din blocul CSS slim (US-002 prerequisite)"
assert "trimitere-slim" in bloc, \
".trimitere-slim lipseste din blocul CSS slim"
assert "min-height:44px" in bloc, \
".trimitere-slim nu are min-height:44px — tinta touch mobil garantata"
# Regresie guard: regula card per rand 5.8 supravietuieste (o coloana pe mobil)
mobil = _bloc_mobil_principal(html)
assert ".tabel-trimiteri thead { display:none; }" in mobil, \
"Regula card 5.8 (.tabel-trimiteri thead display:none) a disparut din CSS"
def test_lista_slim_layout_tableta_1024(client):
"""US-004: blocul tableta (768-1024px) nu rupe lista slim.
.trimitere-slim e o lista stivuita (o coloana), fara grila 2/rand.
Regula tableta cardifica listele existente (thead display:none) fara a elimina slim.
"""
_create_account_user("slim_tab@test.com")
_login(client, "slim_tab@test.com")
html = client.get("/?tab=acasa").text
# Blocul tableta exista (PRD 5.12/5.13 — pastrat)
assert "@media (min-width:768px) and (max-width:1024px)" in html, \
"Lipseste blocul @media tableta — regresia 5.12"
idx_t = html.find("@media (min-width:768px) and (max-width:1024px)")
tableta = html[idx_t:idx_t + 800]
# Tableta ascunde thead (card per rand, o coloana) — lista slim e deja o coloana
assert "thead" in tableta, \
"Blocul tableta nu contine reguli pentru thead"
# Lista slim (ul.lista-trimiteri-slim) e o coloana prin constructie (flex-direction:column
# implicit pe ul); nu trebuie repeat(2) in CSS.
assert "repeat(2" not in html, \
"CSS contine repeat(2 — listele NU trebuie sa fie 2/rand pana la 1024px"
# ============================================================
# PRD 5.15 US-008: regresie componente slim + fara overflow orizontal
# ============================================================
def test_slim_list_fara_overflow_orizontal_css(client):
"""US-008: lista slim (.trimitere-slim) nu forteaza overflow orizontal pe 390px / 1280px.
Verifica la nivel CSS / markup (nu browser): display:flex garanteaza adaptarea
naturala la latimea containerului; niciun min-width fix mai mare de 390px pe elementele
din blocul SENTINEL-COMPONENTE-SLIM (ar depasi viewport-ul mobil de 390px).
Ancorare pe SENTINEL-COMPONENTE-SLIM — nu pe felii fixe din CSS global.
"""
_create_account_user("noovf_slim@test.com")
_login(client, "noovf_slim@test.com")
html = client.get("/?tab=acasa").text
bloc = _bloc_componente_slim(html)
# display:flex pe .trimitere-slim asigura adaptarea la latimea oricarui viewport
assert "display:flex" in bloc, (
".trimitere-slim nu are display:flex in SENTINEL-COMPONENTE-SLIM — "
"layout nu se adapteaza la viewport; poate cauza overflow orizontal."
)
# nicio valoare min-width > 390 in blocul slim (ar depasi viewport-ul mobil 390px)
min_widths = re.findall(r'min-width:(\d+)px', bloc)
for w in min_widths:
assert int(w) <= 390, (
f"min-width:{w}px in SENTINEL-COMPONENTE-SLIM poate cauza overflow orizontal "
f"pe viewport de 390px (mobil). Verificati daca e pe .trimitere-slim."
)
def test_strip_sanatate_fara_hex_hardcodat():
"""US-008: _status.html (strip sanatate D6 + contoare-contor) nu contine hex literal de culoare.
Garanteaza ca strip-ul adapteaza la temele luminoase (hartie/light) si intunecate (grafit/dark)
exclusiv prin var(--token), NU prin valori hex hardcodate care ar ramane aceleasi
indiferent de tema selectata.
Complement la test_fara_hex_in_componente_noi (care verifica SENTINEL din base.html).
Strip sanatate traieste in _status.html, deci e verificat separat.
"""
from pathlib import Path
templates_dir = Path(__file__).parent.parent / "app" / "web" / "templates"
content = (templates_dir / "_status.html").read_text(encoding="utf-8")
# Hex literals in context de proprietate CSS de culoare (color/background/border + inline style)
hex_in_culori = re.findall(
r'(?:color|background|border)\s*[:=][^;{}\n"\']*?(#[0-9a-fA-F]{6,8})\b',
content,
)
assert not hex_in_culori, (
f"Hex literal de culoare in _status.html — strip sanatate va arata gresit pe "
f"tema hartie (luminoasa) / light. Folositi var(--token). Gasite: {hex_in_culori}"
)

231
tests/test_web_scope.py Normal file
View File

@@ -0,0 +1,231 @@
"""US-011 (PRD 5.15): account-scope pe GET-urile de listare web (securitate).
Verifica:
- /_fragments/submissions: un cont nu vede randurile altui cont
- /_fragments/trimitere/{id}: 404-before-leak pe id strain
- /_fragments/nomenclator: necesita autentificare (fragment dashboard, nu endpoint public)
- /_fragments/trimiteri-versiune: necesita autentificare
- Unauthenticated access -> redirect 303 pe ORICE fragment cu date
Legatura cu implementare: mecanismul de scope existent (require_login +
account_scope_clause) se reutilizeaza fara logica noua. Nomenclatorul primeste
require_login din US-011 (fragment dashboard, nu endpoint public).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", 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, name, 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 negasit in pagina de login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _insert_submission(
acct: int,
status: str = "sent",
vin: str = "WVWZZZ1JZXW000777",
nr: str = "B777TST",
) -> int:
from app.db import get_connection
conn = get_connection()
try:
p = {
"vin": vin,
"nr_inmatriculare": nr,
"data_prestatie": "2026-06-18",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"scope-{acct}-{os.urandom(4).hex()}", acct, status, json.dumps(p)),
)
conn.commit()
rid = cur.lastrowid
assert rid is not None
return int(rid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "scope.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()
# ---------------------------------------------------------------------------
# Test 1: cross-account isolation pe listare submissions
# ---------------------------------------------------------------------------
def test_get_listare_scoped_cont(client):
"""Un cont NU vede randurile (VIN/PII) ale altui cont in /_fragments/submissions.
Contul A are un submission cu nr. inmatriculare unic NR_A; contul B nu trebuie
sa vada NR_A in listarea sa. Verificam izolarea atat prin nr. cat si prin VIN
(ultimele 6 caractere afisate ca vin_scurt in template).
"""
NR_A = "BV01SCO"
NR_B = "BV02SCO"
VIN_A = "WVWZZZ1JZXW111AAA" # vin_scurt va fi '...111AAA'
VIN_B = "WVWZZZ1JZXW222BBB" # vin_scurt va fi '...222BBB'
acct_a = _create_account_user("scope-a@test.com", name="ContA")
acct_b = _create_account_user("scope-b@test.com", name="ContB")
_insert_submission(acct_a, vin=VIN_A, nr=NR_A)
_insert_submission(acct_b, vin=VIN_B, nr=NR_B)
# Login ca cont B
_login(client, "scope-b@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Contul B vede propriul nr inmatriculare
assert NR_B in html, "Contul B ar trebui sa vada propriul nr inmatriculare"
# Contul B NU vede nr inmatriculare si VIN (vin_scurt) ale contului A
assert NR_A not in html, "Scurgere cross-account: nr_inmatriculare contului A vizibil contului B"
assert "111AAA" not in html, "Scurgere cross-account: VIN (vin_scurt) contului A vizibil contului B"
# ---------------------------------------------------------------------------
# Test 2: unauthenticated -> redirect pe listare submissions
# ---------------------------------------------------------------------------
def test_get_listare_neautentificat_redirect_submissions(client):
"""Fara sesiune activa, /_fragments/submissions returneaza 303 (redirect /login)."""
resp = client.get("/_fragments/submissions")
assert resp.status_code == 303, (
f"Asteptat 303 redirect, primit {resp.status_code}. "
"/_fragments/submissions trebuie sa necesite autentificare."
)
# ---------------------------------------------------------------------------
# Test 3: 404-before-leak pe detaliu id strain
# ---------------------------------------------------------------------------
def test_get_detaliu_scoped_404(client):
"""Detaliul unui submission apartinand altui cont returneaza 404 (fara leak).
Acelasi 404 pentru id inexistent = nu confirmam existenta.
"""
acct_a = _create_account_user("detscope-a@test.com", name="DetA")
_create_account_user("detscope-b@test.com", name="DetB")
sid_a = _insert_submission(acct_a, vin="WVWZZZ1JZXWAAA111")
# Login ca cont B
_login(client, "detscope-b@test.com")
# Cerere detaliu pe submission-ul contului A
resp = client.get(f"/_fragments/trimitere/{sid_a}")
assert resp.status_code == 404, (
f"Asteptat 404, primit {resp.status_code}. "
"Nu trebuie confirmata existenta unui submission al altui cont."
)
# Id inexistent -> acelasi 404 (nu confirmam existenta)
resp2 = client.get("/_fragments/trimitere/999999")
assert resp2.status_code == 404
# ---------------------------------------------------------------------------
# Test 4: nomenclator necesita autentificare (RED inainte de fix)
# ---------------------------------------------------------------------------
def test_get_nomenclator_neautentificat_redirect(client):
"""/_fragments/nomenclator este un fragment al dashboard-ului autentificat.
Fara sesiune, trebuie sa returneze 303 redirect la /login.
RED inainte de fix: in prezent fragmentul nu apeleaza require_login
si returneaza 200 chiar fara autentificare.
"""
resp = client.get("/_fragments/nomenclator")
assert resp.status_code == 303, (
f"Asteptat 303 redirect pentru fragment dashboard neautentificat, "
f"primit {resp.status_code}. "
"/_fragments/nomenclator trebuie sa necesite autentificare."
)
def test_get_nomenclator_autentificat_ok(client):
"""/_fragments/nomenclator accesibil dupa autentificare."""
_create_account_user("nom-auth@test.com", name="NomAuth")
_login(client, "nom-auth@test.com")
resp = client.get("/_fragments/nomenclator")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Test 5: trimiteri-versiune necesita autentificare
# ---------------------------------------------------------------------------
def test_get_trimiteri_versiune_neautentificat_redirect(client):
"""/_fragments/trimiteri-versiune necesita autentificare (redirect 303 fara sesiune)."""
resp = client.get("/_fragments/trimiteri-versiune")
assert resp.status_code == 303
# ---------------------------------------------------------------------------
# Test 6: izolare paginare - filtru nu poate scoate randuri strain
# ---------------------------------------------------------------------------
def test_get_listare_filtru_nu_sparge_scope(client):
"""Filtrele (status, vehicul) nu pot scoate randuri din alt cont.
Un cont B cu filtru vehicul=VIN_A nu trebuie sa vada niciodata VIN_A.
"""
VIN_A = "WVWZZZ1JZXW333CCC"
NR_A = "BV03FLT"
acct_a = _create_account_user("filtru-a@test.com", name="FiltrA")
_create_account_user("filtru-b@test.com", name="FiltrB")
_insert_submission(acct_a, vin=VIN_A, nr=NR_A)
_login(client, "filtru-b@test.com")
# Cont B incearca sa filtreze dupa nr inmatriculare al contului A
resp = client.get(f"/_fragments/submissions?vehicul={NR_A}")
assert resp.status_code == 200
assert NR_A not in resp.text, (
"Filtrul vehicul a scos date din alt cont (scurgere cross-account prin filtru)."
)

View File

@@ -1,15 +1,21 @@
"""Teste US-001 (PRD 3.5): bara de status compacta cu bife accesibile + data formatata. """Teste US-003 (PRD 5.15): strip sanatate mereu-vizibil + carduri-contor pe dashboard.
Bifa = glifa distincta (✓ / ✗) + text, NU doar culoare (daltonism, design review). D6 (strip sanatate): linie colorata DEASUPRA contoarelor — verde "declaratiile curg" /
Verde/✓ cand worker viu + RAR ok; rosu/✗ cand oprit/indisponibil. rosu "Blocat: worker oprit / RAR inaccesibil", cu glifa accesibila (✓/✗).
D4 (contoare): In coada / Trimise (all-time + luna/azi) / De corectat.
E7 (timezone): azi/luna bucketate in timp local RO (UTC+3), nu UTC.
Actualizat in US-003 (PRD 5.15): bara veche cu bife individuale worker/RAR
inlocuita de strip unificat de sanatate + carduri-contor.
""" """
from __future__ import annotations from __future__ import annotations
import json
import os import os
import re import re
import tempfile import tempfile
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
import pytest import pytest
from starlette.testclient import TestClient from starlette.testclient import TestClient
@@ -70,8 +76,12 @@ def client(monkeypatch):
get_settings.cache_clear() get_settings.cache_clear()
# ---------------------------------------------------------------------------
# Teste existente — actualizate pentru US-003 D6 (strip unificat in loc de bife individuale)
# ---------------------------------------------------------------------------
def test_status_are_bife_verzi_cand_totul_ok(client): def test_status_are_bife_verzi_cand_totul_ok(client):
"""Worker viu + RAR login recent -> bifa verde ✓ pentru ambele stari binare.""" """Worker viu + RAR login recent -> glifa verde ✓ + text 'declaratiile curg normal'."""
_create_account_user("bifeok@test.com") _create_account_user("bifeok@test.com")
_login(client, "bifeok@test.com", "parolasecreta10") _login(client, "bifeok@test.com", "parolasecreta10")
@@ -81,15 +91,16 @@ def test_status_are_bife_verzi_cand_totul_ok(client):
resp = client.get("/_fragments/status") resp = client.get("/_fragments/status")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Glifa de OK prezenta (accesibilitate: nu doar culoare) # Glifa accesibila ✓ (nu doar culoare)
assert "&#10003;" in html, f"Lipseste glifa ✓ cand totul e ok. HTML: {html[:600]}" assert "&#10003;" in html, f"Lipseste glifa ✓ cand totul e ok. HTML: {html[:600]}"
# Texte umane de OK # US-003 D6: strip unificat (nu bife individuale worker/RAR)
assert "activa" in html.lower() assert "curg normal" in html.lower(), (
assert "functionala" in html.lower() f"Textul 'curg normal' din strip sanatate lipseste. HTML: {html[:600]}"
)
def test_status_are_bife_rosii_cand_worker_oprit(client): def test_status_are_bife_rosii_cand_worker_oprit(client):
"""Fara heartbeat -> worker oprit -> bifa rosie ✗ + text 'oprita'.""" """Fara heartbeat -> worker oprit -> glifa rosie ✗ + text explicit 'blocat' / 'nu pleaca'."""
_create_account_user("biferosu@test.com") _create_account_user("biferosu@test.com")
_login(client, "biferosu@test.com", "parolasecreta10") _login(client, "biferosu@test.com", "parolasecreta10")
@@ -99,7 +110,9 @@ def test_status_are_bife_rosii_cand_worker_oprit(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert "&#10007;" in html, f"Lipseste glifa ✗ cand worker oprit. HTML: {html[:600]}" assert "&#10007;" in html, f"Lipseste glifa ✗ cand worker oprit. HTML: {html[:600]}"
assert "oprita" in html.lower() # US-003 D6: mesaj explicit (nu text vag "oprita")
assert "blocat" in html.lower(), f"Cuvantul 'blocat' lipseste la worker oprit. HTML: {html[:600]}"
assert "nu pleaca" in html.lower(), f"Avertismentul 'nu pleaca' lipseste. HTML: {html[:600]}"
def test_status_data_formatata_romaneste(client): def test_status_data_formatata_romaneste(client):
@@ -118,12 +131,231 @@ def test_status_data_formatata_romaneste(client):
def test_status_fara_fonturi_minuscule(client): def test_status_fara_fonturi_minuscule(client):
"""Niciun text din bara nu mai foloseste font-size sub 13px (US-001 AC).""" """Niciun text din bara nu mai foloseste font-size literal sub 13px (US-001 AC)."""
_create_account_user("bifefont@test.com") _create_account_user("bifefont@test.com")
_login(client, "bifefont@test.com", "parolasecreta10") _login(client, "bifefont@test.com", "parolasecreta10")
resp = client.get("/_fragments/status") resp = client.get("/_fragments/status")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Culorile prin clase CSS (nu inline font-size); shorthand font:N Xpx nu e acoperit de aceste litere
for bad in ("font-size:11px", "font-size:12px", "font-size: 11px", "font-size: 12px"): for bad in ("font-size:11px", "font-size:12px", "font-size: 11px", "font-size: 12px"):
assert bad not in html, f"Bara de status foloseste {bad} (sub 13px)." assert bad not in html, f"Bara de status foloseste {bad} (sub 13px) inline."
# ---------------------------------------------------------------------------
# Teste NOI pentru US-003 (RED inainte de implementare)
# ---------------------------------------------------------------------------
def test_strip_sanatate_mereu_vizibil(client):
"""D6: strip de sanatate e prezent in fragment, indiferent de starea worker/RAR."""
_create_account_user("stripviz@test.com")
_login(client, "stripviz@test.com", "parolasecreta10")
# Stare worker viu
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert 'id="strip-sanatate"' in html, (
f"Strip sanatate (id='strip-sanatate') lipseste din fragment. HTML: {html[:600]}"
)
def test_strip_rosu_worker_oprit(client):
"""D6: worker oprit → strip rosu cu glifа ✗ + text 'Blocat: worker oprit — declaratiile NU pleaca'."""
_create_account_user("stroprosu@test.com")
_login(client, "stroprosu@test.com", "parolasecreta10")
_set_heartbeat(last_beat=None, last_rar_login_ok=None)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert 'id="strip-sanatate"' in html, "Strip sanatate lipseste."
assert "&#10007;" in html, "Glifa ✗ lipseste in strip rosu."
assert "blocat" in html.lower(), "Cuvantul 'Blocat' trebuie sa apara cand worker e oprit."
assert "worker oprit" in html.lower(), "Textul 'worker oprit' trebuie sa fie explicit in strip."
assert "nu pleaca" in html.lower(), "Avertismentul 'NU pleaca' trebuie sa fie in strip."
def test_trei_contoare_card(client):
"""US-003: fragment status contine exact 3 carduri .contor-card (In coada / Trimise / De corectat)."""
_create_account_user("treicont@test.com")
_login(client, "treicont@test.com", "parolasecreta10")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
count = html.count("contor-card")
assert count >= 3, (
f"Trebuie minim 3 elemente contor-card in fragment, gasit: {count}. HTML: {html[:800]}"
)
# Etichete asteptate
assert "In coada" in html, "Eticheta 'In coada' lipseste din contoare."
assert "Trimise" in html, "Eticheta 'Trimise' lipseste din contoare."
assert "De corectat" in html, "Eticheta 'De corectat' lipseste din contoare."
def test_trimise_all_time_luna_azi(client):
"""D4: cardul Trimise afiseaza all-time ca cifra principala + sub-linie 'luna N · azi N'."""
acct_id, _ = _create_account_user("trimisetime@test.com")
_login(client, "trimisetime@test.com", "parolasecreta10")
# Insereaza o trimitere sent cu updated_at = acum
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key, updated_at) "
"VALUES (?, 'sent', ?, 'key-luna-azi', datetime('now'))",
(acct_id, json.dumps({"vin": "VINTEST00000000001"})),
)
conn.commit()
finally:
conn.close()
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
# Sub-linia trebuie sa contina "luna" si "azi" (format: "luna N · azi N")
assert "luna" in html.lower(), (
f"Sub-linia 'luna N · azi N' lipseste din cardul Trimise. HTML: {html[:800]}"
)
assert "azi" in html.lower(), (
f"Sub-linia 'luna N · azi N' nu contine 'azi'. HTML: {html[:800]}"
)
def test_fara_bara_veche(client):
"""US-003: contoarele vechi inline ('In asteptare:' / 'Declarate la RAR:') nu mai apar."""
_create_account_user("faraveche@test.com")
_login(client, "faraveche@test.com", "parolasecreta10")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
# Stilul vechi: etichete inline cu colon (bara de la PRD 3.5)
assert "In asteptare:" not in html, (
f"Contorul vechi 'In asteptare:' inca prezent. HTML: {html[:600]}"
)
assert "Declarate la RAR:" not in html, (
f"Contorul vechi 'Declarate la RAR:' inca prezent. HTML: {html[:600]}"
)
def _set_tz_bucuresti(monkeypatch, request):
"""Forteaza TZ=Europe/Bucharest pentru ca modificatorul SQLite 'localtime' sa
rezolve la fusul RO indiferent de TZ-ul runner-ului (CI ruleaza de regula in UTC).
Restaureaza tzset-ul la teardown (monkeypatch reface env-ul TZ; tzset reciteste)."""
import time
monkeypatch.setenv("TZ", "Europe/Bucharest")
if hasattr(time, "tzset"):
time.tzset()
request.addfinalizer(time.tzset) # dupa ce monkeypatch reface TZ -> reciteste
def test_granita_miez_noapte_local_ro(monkeypatch, request):
"""E7: trimitere cu updated_at = ieri UTC 22:00 = azi Romania (UTC+2/+3) se numara 'azi'.
Cu date(updated_at) simplu (UTC) ar aparea pe ziua precedenta — GRESIT.
Cu date(updated_at, 'localtime') + TZ=Europe/Bucharest apare pe ziua de azi RO — CORECT
(DST-aware: +2h iarna, +3h vara).
"""
_set_tz_bucuresti(monkeypatch, request)
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "granita.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
from app.accounts import create_account
from app.web.routes import _status_counts
# Initializeaza schema (init_db o face idempotent)
init_db()
# Ieri la 22:00 UTC = azi 00:00 (iarna) / 01:00 (vara) Romania -> 'azi' in ambele.
today_utc = datetime.now(timezone.utc).date()
yesterday_utc = today_utc - timedelta(days=1)
boundary_updated_at = f"{yesterday_utc} 22:00:00"
conn = get_connection()
try:
acct_id = create_account(conn, "Service Granita", active=True)
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key, updated_at) "
"VALUES (?, 'sent', ?, 'key-granita-1', ?)",
(acct_id, json.dumps({"vin": "VIN00000000000001"}), boundary_updated_at),
)
conn.commit()
counts = _status_counts(conn, acct_id)
# 22:00 UTC -> azi in RO (localtime) => sent_today=1
assert counts["sent_today"] == 1, (
f"E7 FAIL: trimitere la {boundary_updated_at} UTC = azi in Romania "
f"trebuie sa fie 'azi', dar sent_today={counts.get('sent_today')}. "
"SQL trebuie sa foloseasca date(updated_at, 'localtime') = date('now', 'localtime')."
)
assert counts["sent_month"] >= 1, (
f"E7: sent_month trebuie sa fie >= 1, got {counts.get('sent_month')}"
)
finally:
conn.close()
get_settings.cache_clear()
def test_iarna_nu_bleed_in_ziua_urmatoare(monkeypatch, request):
"""Bug fix (code-review 5.15): iarna (UTC+2), o trimitere la 21:30 UTC = 23:30 RO AZI
NU trebuie sa cada pe ziua de MAINE.
Vechiul offset fix '+3 hours' o impingea la 00:30 maine -> sent_today gresit.
'localtime' (DST-aware) o pastreaza corect pe azi. Testul fixeaza o data de iarna
explicita (15 ianuarie) ca sa fie determinist indiferent de cand ruleaza.
"""
_set_tz_bucuresti(monkeypatch, request)
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "iarna.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
from app.accounts import create_account
init_db()
# Data de iarna fixa: 2026-01-15. 21:30 UTC = 23:30 RO (EET, UTC+2) -> ziua 15, nu 16.
conn = get_connection()
try:
acct_id = create_account(conn, "Service Iarna", active=True)
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key, updated_at) "
"VALUES (?, 'sent', ?, 'key-iarna-1', ?)",
(acct_id, json.dumps({"vin": "VIN00000000000002"}), "2026-01-15 21:30:00"),
)
conn.commit()
# Verifica direct ce zi RO atribuie SQLite (localtime vs vechiul +3h).
row = conn.execute(
"SELECT date(updated_at, 'localtime') AS zi_local, "
" date(updated_at, '+3 hours') AS zi_plus3 "
"FROM submissions WHERE idempotency_key='key-iarna-1'"
).fetchone()
assert row["zi_local"] == "2026-01-15", (
f"localtime (EET, UTC+2) trebuie sa pastreze 21:30 UTC pe 15 ian RO, "
f"got {row['zi_local']}"
)
# Demonstreaza bug-ul vechi: +3h impingea pe 16 ian (ziua gresita iarna).
assert row["zi_plus3"] == "2026-01-16", (
f"Confirmare bug vechi: '+3 hours' iarna pune 21:30 UTC pe 16 ian, got {row['zi_plus3']}"
)
finally:
conn.close()
get_settings.cache_clear()

View File

@@ -94,7 +94,11 @@ def client(monkeypatch):
# ============================================================ # ============================================================
def test_status_fragment_text_uman(client): def test_status_fragment_text_uman(client):
"""GET /_fragments/status (autentificat) -> contine 'Trimitere automata', NU 'worker viu'.""" """GET /_fragments/status (autentificat) -> contine text uman de sanatate, NU stari tehnice brute.
Actualizat US-003 (PRD 5.15): strip sanatate unificat in loc de bife individuale worker/RAR.
Textul 'Trimitere automata' a fost inlocuit cu 'declaratiile curg normal' / 'Blocat: ...'.
"""
_create_account_user("status@test.com", "parolasecreta10") _create_account_user("status@test.com", "parolasecreta10")
_login(client, "status@test.com", "parolasecreta10") _login(client, "status@test.com", "parolasecreta10")
@@ -102,17 +106,14 @@ def test_status_fragment_text_uman(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Trebuie sa contina textul uman din eticheta_worker (labels.py) # US-003 D6: strip sanatate cu text uman compus (nu bife individuale)
assert "Trimitere automata" in html, ( assert "declaratiile" in html.lower(), (
f"Fragmentul nu contine 'Trimitere automata'. HTML (primele 500 ch): {html[:500]}" f"Fragmentul nu contine textul de sanatate ('declaratiile'). HTML (primele 500 ch): {html[:500]}"
) )
# NU trebuie sa contina textul brut tehnic # NU trebuie sa contina text tehnic brut
assert "worker viu" not in html.lower(), ( assert "worker viu" not in html.lower(), (
f"Fragmentul contine 'worker viu' (text tehnic brut). HTML (primele 500 ch): {html[:500]}" f"Fragmentul contine 'worker viu' (text tehnic brut). HTML (primele 500 ch): {html[:500]}"
) )
# NU trebuie sa contina "mort" (stare tehnica bruta)
# (poate aparea in 'oprita' -> acceptam; 'mort' singur -> nu)
# Verificam ca nu apare 'mort' ca eticheta standalone
assert "viu</div>" not in html, ( assert "viu</div>" not in html, (
"Fragmentul contine eticheta bruta 'viu'" "Fragmentul contine eticheta bruta 'viu'"
) )

View File

@@ -78,7 +78,11 @@ def client(monkeypatch):
def test_submissions_coloane_umane(client): def test_submissions_coloane_umane(client):
"""Antete RO; stare umana (nu 'sent'); vehicul/operatie din payload; fara 'HTTP RAR' ca antet.""" """US-004 (PRD 5.15): layout slim — informatia umana vizibila, cod brut ascuns.
Anterior (pana la US-004): tabel cu antete <th>; dupa US-004: lista slim.
Informatia ramane accesibila: vehicul/operatie din payload, stare umana,
nr. prezentare RAR pe linia meta discreta.
"""
acct = _create_account_user("col@test.com") acct = _create_account_user("col@test.com")
_insert_submission(acct, "sent", id_prezentare=68516) _insert_submission(acct, "sent", id_prezentare=68516)
_login(client, "col@test.com") _login(client, "col@test.com")
@@ -86,17 +90,25 @@ def test_submissions_coloane_umane(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
# Antete romanesti (Motiv a iesit din tabel in PRD 5.8 US-007 -> traieste in detaliu)
for antet in ("Stare", "Vehicul", "Operatie", "Data prestatie", "Nr. prezentare RAR"): # Layout slim prezent (US-004 PRD 5.15)
assert antet in html, f"Lipseste antetul '{antet}'" assert "lista-trimiteri-slim" in html, "lista slim lipseste"
# "HTTP RAR" NU mai e antet principal de coloana assert "trimitere-slim" in html, "rand slim lipseste"
# "HTTP RAR" NU e antet / eticheta vizibila
assert "<th>HTTP RAR</th>" not in html assert "<th>HTTP RAR</th>" not in html
# Starea afisata e text uman, nu 'sent' brut intr-un pill assert "HTTP RAR" not in html
assert ">sent<" not in html, "Starea bruta 'sent' nu ar trebui afisata"
assert "Declarate la RAR" in html, "Starea umana lipseste" # Starea afisata e text uman, nu 'sent' brut
# Vehicul + operatie din payload, nu doar idPrezentare assert ">sent<" not in html, "Starea bruta 'sent' nu ar trebui afisata direct"
assert "B777ZZZ" in html assert "Declarate la RAR" in html, "Starea umana (titlu pill) lipseste"
assert "Reparatie frane" in html
# Vehicul + operatie din payload vizibile pe rand slim
assert "B777ZZZ" in html, "Nr inmatriculare din payload lipseste"
assert "Reparatie frane" in html, "Operatia din payload lipseste"
# Nr. prezentare RAR accesibil pe linia meta discreta
assert "68516" in html, "Nr. prezentare RAR lipseste din linia meta"
def test_tab_eticheta_trimiteri(client): def test_tab_eticheta_trimiteri(client):
@@ -405,3 +417,128 @@ def test_detaliu_trimitere_404_cross_account(client):
# acelasi 404 pentru un id inexistent # acelasi 404 pentru un id inexistent
resp2 = client.get("/_fragments/trimitere/999999") resp2 = client.get("/_fragments/trimitere/999999")
assert resp2.status_code == 404 assert resp2.status_code == 404
# ---------------------------------------------------------------------------
# US-004 (PRD 5.15): lista trimiteri rand slim
# RED (inainte de implementare): testele de mai jos ESUEAZA pe tabelul vechi.
# GREEN (dupa implementare): lista slim cu .trimitere-slim / .lista-trimiteri-slim.
# ---------------------------------------------------------------------------
def test_rand_slim_vin_operatie_pill(client):
"""US-004: fiecare rand slim afiseaza VIN scurt in .slim-vin, operatie+ora in
.slim-meta si un pill de stare cu clasa stare_css si eticheta stare_scurt.
Lista e inconjurata de .lista-trimiteri-slim.
"""
acct = _create_account_user("slim1@test.com")
_insert_submission(acct, "sent", id_prezentare=80001)
_login(client, "slim1@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Structura slim prezenta
assert "lista-trimiteri-slim" in html, "lista-trimiteri-slim lipseste din raspuns"
assert "trimitere-slim" in html, "trimitere-slim lipseste din raspuns"
# VIN scurt in clasa slim-vin (mono, linia 1)
assert "slim-vin" in html, "slim-vin lipseste — linia 1 VIN mono"
# Linia 2 muted (operatie + ora/data)
assert "slim-meta" in html, "slim-meta lipseste — linia 2 muted"
# VIN scurt randat (WVWZZZ1JZXW000777 -> …000777)
assert "000777" in html, "VIN scurt (ultimele 6 cifre) lipseste"
# Pill de stare: clasa CSS + eticheta scurta
assert "s-sent" in html, "clasa pill s-sent lipseste"
assert "Finalizat" in html, "eticheta scurta stare_scurt lipseste"
def test_filtre_paginare_pastrate(client):
"""US-004 lock: filtrele si paginarea raman functionale dupa redesign slim.
Lista slim afiseaza rezultatele filtrate corect.
"""
acct = _create_account_user("filtrepag@test.com")
_insert_submission(acct, "sent")
_insert_submission(acct, "needs_data")
_login(client, "filtrepag@test.com")
# Fara filtru: ambele randuri, layout slim
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
assert "lista-trimiteri-slim" in html, "lista slim lipseste din randare nefiltered"
# OOB f-page exista (mecanismul de paginare intact)
assert 'id="f-page"' in html, "OOB input f-page lipseste — paginarea e rupta"
# Filtrare dupa status=sent: contul trebuie sa arate DOAR trimis
resp_sent = client.get("/_fragments/submissions?status=sent")
assert resp_sent.status_code == 200
html_sent = resp_sent.text
assert "lista-trimiteri-slim" in html_sent, "lista slim lipseste din randare filtrata"
# Randul needs_data dispare la filtru=sent
assert "s-needs_data" not in html_sent, "randul needs_data apare la filtru status=sent"
# Randul sent apare
assert "s-sent" in html_sent, "randul sent lipseste la filtru status=sent"
def test_bulk_doar_blocate(client):
"""US-004 lock: form bulk-trimiteri exista; checkbox DOAR pe randuri gestionabile
(needs_data/needs_mapping/error); randurile read-only (sent) nu au checkbox.
"""
acct = _create_account_user("bulk5@test.com")
sid_blocked = _insert_submission(acct, "needs_data") # gestionabil -> checkbox
_insert_submission(acct, "sent") # read-only -> fara checkbox
_login(client, "bulk5@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Layout slim prezent
assert "lista-trimiteri-slim" in html, "lista slim lipseste — test nu acopera US-004"
# Form bulk exista cu actiunea corecta
assert 'id="bulk-trimiteri"' in html, "form#bulk-trimiteri lipseste"
assert 'hx-post="/trimiteri/sterge-bulk"' in html, "actiunea bulk-delete lipseste"
# Checkbox prezent pe randul blocat
assert f'name="submission_id" value="{sid_blocked}"' in html, \
f"Checkbox lipseste pe randul blocat #{sid_blocked}"
# Exact 1 checkbox (randul sent nu are)
checkboxes = re.findall(r'name="submission_id"', html)
assert len(checkboxes) == 1, \
f"Trebuie exact 1 checkbox (doar randul blocat), gasit {len(checkboxes)}"
def test_click_deschide_detaliu(client):
"""US-004: click pe randul slim deschide /_fragments/trimitere/{id} in modalul global.
Randul are atributele HTMX si a11y necesare (role=button, aria-haspopup=dialog).
"""
acct = _create_account_user("clickdet@test.com")
sid = _insert_submission(acct, "sent")
_login(client, "clickdet@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Layout slim ca premisa (confirma ca testul acopera noul design)
assert "lista-trimiteri-slim" in html, "lista slim lipseste — test nu acopera US-004"
# Randul are hx-get catre fragmentul de detaliu
assert f'hx-get="/_fragments/trimitere/{sid}"' in html, \
f"hx-get spre /_fragments/trimitere/{sid} lipseste"
# Target = corpul modalului global (neschimbat fata de implementarea anterioara)
assert 'hx-target="#detaliu-modal-body"' in html, \
"hx-target #detaliu-modal-body lipseste"
# Atribute a11y: role=button, tabindex=0, aria-haspopup=dialog
assert 'role="button"' in html, "role=button lipseste pe randul slim"
assert 'tabindex="0"' in html, "tabindex=0 lipseste pe randul slim"
assert 'aria-haspopup="dialog"' in html, "aria-haspopup=dialog lipseste pe randul slim"

View File

@@ -82,14 +82,14 @@ def client(monkeypatch):
def test_vin_pe_rand_separat_sub_nr(client): def test_vin_pe_rand_separat_sub_nr(client):
"""VIN-ul apare intr-un element block-level (div/p/small cu display:block) sub nr. """VIN-ul apare intr-un element block-level cu clasa slim-vin (PRD 5.15 US-004).
Inainte: <span class="muted">...VIN...</span> inline. PRD 5.10 (US-005): VIN era <div class="muted"> sub nr in coloana Vehicul.
Dupa: <div class="muted">...VIN...</div> (block, rand separat). PRD 5.15 (US-004): VIN e acum identificatorul PRINCIPAL, linia 1 a randului slim,
Testul asserteaza prezenta unui element block, nu doar textul. in <div class="slim-vin"> (mono, prominent, block-level). NU mai e muted.
""" """
acct = _create_account_user("vin_layout@test.com") acct = _create_account_user("vin_layout@test.com")
sid = _ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ") _ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ")
_login(client, "vin_layout@test.com") _login(client, "vin_layout@test.com")
resp = client.get("/_fragments/submissions") resp = client.get("/_fragments/submissions")
@@ -97,44 +97,29 @@ def test_vin_pe_rand_separat_sub_nr(client):
html = resp.text html = resp.text
# VIN trunchiat trebuie sa apara in HTML # VIN trunchiat trebuie sa apara in HTML
assert "000001" in html, "VIN-ul trunchiat trebuie sa apara in tabel" assert "000001" in html, "VIN-ul trunchiat trebuie sa apara in lista slim"
# Elementul ce contine VIN-ul trebuie sa fie block-level (div, p, small etc.) # VIN e intr-un element block-level (div cu clasa slim-vin)
# NU un simplu <span> inline. # Pattern: <div class="slim-vin">...000001...</div>
# Pattern: <div ... >...000001...</div> sau <p ... >...000001...</p>
# Acceptam orice block-level tag (div/p/small) care contine fragmentul VIN.
block_tags = ["div", "p", "small"]
vin_fragment = "000001" vin_fragment = "000001"
found_block = any( found_slim_vin = re.search(
re.search( rf'<div[^>]*class="slim-vin[^"]*"[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</div>',
rf"<{tag}[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>", html,
html,
)
for tag in block_tags
) )
assert found_block, ( assert found_slim_vin, (
f"VIN '{vin_fragment}' trebuie sa fie intr-un element block-level " f"VIN '{vin_fragment}' trebuie sa fie in <div class=\"slim-vin\"> (block-level, "
f"(div/p/small), nu intr-un <span> inline. HTML gasit: " f"mono, linia 1 a randului slim). HTML gasit: "
+ html[max(0, html.find(vin_fragment) - 80):html.find(vin_fragment) + 80] + html[max(0, html.find(vin_fragment) - 80):html.find(vin_fragment) + 80]
) )
# Elementul block trebuie sa aiba clasa 'muted' (stil discret)
muted_block = any(
re.search(
rf'<{tag}[^>]*class="[^"]*muted[^"]*"[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>',
html,
)
for tag in block_tags
)
assert muted_block, (
f"Elementul block cu VIN trebuie sa aiba clasa 'muted'"
)
def test_vin_lipsa_nu_genereaza_rand_gol(client): def test_vin_lipsa_nu_genereaza_rand_gol(client):
"""Cand VIN-ul lipseste (sau e EMPTY=''), nu apare un element gol in celula Vehicul.""" """Cand VIN-ul lipseste (sau e EMPTY=''), slim-vin nu afiseaza '' izolat.
Fallback: slim-vin afiseaza vehicul_nr (nr. inmatriculare) cu clasa muted.
(PRD 5.15 US-004: slim-vin are garda vin != '')
"""
acct = _create_account_user("vin_gol@test.com") acct = _create_account_user("vin_gol@test.com")
sid = _ins(acct, vin="", nr="B999TST") # VIN gol -> EMPTY="—" sid = _ins(acct, vin="", nr="B999TST") # VIN gol -> vin_scurt='—'
_login(client, "vin_gol@test.com") _login(client, "vin_gol@test.com")
resp = client.get("/_fragments/submissions") resp = client.get("/_fragments/submissions")
@@ -144,8 +129,13 @@ def test_vin_lipsa_nu_genereaza_rand_gol(client):
# Randul trebuie sa existe # Randul trebuie sa existe
assert f'id="trimitere-row-{sid}"' in html assert f'id="trimitere-row-{sid}"' in html
# In coloana vehicul nu trebuie sa apara un element block gol cu "—" # slim-vin NU trebuie sa contina '—' izolat (VIN lipsa -> fallback vehicul_nr)
# (garda != '—' exista deja, verifica ca e respectata) slim_vin_match = re.search(r'<div[^>]*class="slim-vin[^"]*"[^>]*>([^<]*)</div>', html)
assert 'class="muted"' not in html.split('col-vehicul')[1].split('col-operatie')[0] or \ assert slim_vin_match, "slim-vin lipseste din randul cu VIN gol"
'' not in (html.split('col-vehicul')[1].split('col-operatie')[0]), \ slim_vin_content = slim_vin_match.group(1).strip()
"Elementul muted din coloana Vehicul nu trebuie sa contina '' (rand gol VIN)" assert slim_vin_content != "", (
"slim-vin afiseaza '' izolat cand VIN lipseste — "
"trebuie sa afiseze vehicul_nr ca fallback"
)
# Fallback: nr inmatriculare vizibil
assert "B999TST" in html, "Nr inmatriculare (fallback) lipseste cand VIN e gol"

View File

@@ -0,0 +1,567 @@
"""Harness de evaluare held-out pentru sistemul de mapare operatii->coduri RAR.
Scop (L14-S5, Decision #19 PRD 5.14):
Masurarea ACURATETEI REALE a clasificatorului inainte de a permite orice tier
auto-send peste GOLD propriu.
Rationale:
Masuratorile existente (100% acord vs Groq, 87% unanim NVIDIA) sunt masuri de
ACORD (cross-model), nu de ACURATETE vs ground-truth. Same-family NVIDIA =
eroare corelata: daca ambele modele gresesc la fel, acordul e 100% dar
acuratete = 0. Un set etichetat de OM (esantion aleator stratificat) e singurul
mod de a masura acuratete reala.
Continut:
1. sample_stratified() — esantionare stratificata aleatorie (cap/mijloc/coada
Zipf), determinista cu seed. FARA apel LLM.
2. export_for_labeling() — export CSV gol pt etichetare umana (ground-truth).
Coloana cod_gold RAMANE GOALA: etichetarea umana e
exclusiv responsabilitatea operatorului.
3. eval_predictions() — date (predictii, gold) -> precizie globala + per-cod
+ matrice confuzie + rata cod-gresit.
4. kill_criterion() — evalueaza daca sistemul indeplineste pragul de acceptanta
(F-E, PRD 5.14).
Ce NU face:
NU eticheteaza ground-truth-ul. Etichetarea de cod ar fi exact "antrenare pe test"
si ar invalida precizia raportata (Decision #19). Fisierul exportat se completeaza
MANUAL de operatorul uman.
CLI:
python3 tools/mapare-llm/heldout_eval.py --n 250 --out esantion-heldout.csv
Genereaza esantionul de 250 denumiri pt etichetare umana.
python3 tools/mapare-llm/heldout_eval.py --eval predictii.csv gold.csv
Evalueaza predictii vs ground-truth (ambele CSV cu camp 'denumire').
"""
from __future__ import annotations
import csv
import os
import random
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.abspath(os.path.join(_HERE, '..', '..'))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
# ---------------------------------------------------------------------------
# Constante
# ---------------------------------------------------------------------------
# Coduri RAR valide (din or_common.py / nomenclator, 18 coduri + NUL)
# NUL = supresie (non-operatie); NU este cod RAR valid transmis la RAR.
VALID_RAR: frozenset[str] = frozenset([
"OE-1", "OE-2", "OE-3", "OE-4", "OE-5", "OE-6", "OE-7", "OE-8",
"OE-D", "OE-F", "OE-C", "OE-S", "OE-R", "OE-A", "OE-I",
"AITLV", "R-ODO", "I-ODO",
])
NUL = "NUL" # eticheta speciala: supresie (nu e cod RAR)
ALL_LABELS = VALID_RAR | {NUL} # toate etichetele valide ale clasificatorului
UNRESOLVED = "?" # clasificatorul nu a dat raspuns -> needs_mapping
# Seed implicit pentru reproductibilitate esantionare
DEFAULT_SEED = 42
# Strate Zipf (proportii din numarul total de denumiri DISTINCTE):
# cap = top 20% dupa frecventa (cateva denumiri, volum ridicat)
# mijloc = urmatoarele 30%
# coada = restul 50% (multe denumiri, volum scazut individual)
_STRAT_HEAD_END_PCT = 0.20
_STRAT_MID_END_PCT = 0.50 # head+mid = 50%, deci mid = 30%
# Kill-criterion (F-E, PRD 5.14):
#
# DEFAULT_WRONG_CODE_THRESHOLD = 0.005 (0.5%)
# Justificare: un cod gresit = FINALIZATA ireversibila la RAR (Premisa 3).
# La 200 operatii/zi auto-rezolvate cu 0.5% rata gresita = 1 FINALIZATA
# gresita/zi, ceea ce depaseste toleranta operationala acceptabila.
# Pragul poate fi RELAXAT empiric pe baza de date reale; NU inasprit post-hoc.
# Recomandat: strangeti cel putin 200 esantioane inainte de a calibra.
#
# DEFAULT_COVERAGE_THRESHOLD = 0.50 (50%)
# Justificare: sub 50% acoperire, sistemul nu aduce economie reala vs
# needs_mapping uman (ar trebui sa lasi totul pe operatorul uman).
DEFAULT_WRONG_CODE_THRESHOLD = 0.005
DEFAULT_COVERAGE_THRESHOLD = 0.50
# ---------------------------------------------------------------------------
# Esantionare stratificata (FARA LLM)
# ---------------------------------------------------------------------------
def sample_stratified(
rows: list[tuple[str, int]],
n_sample: int = 250,
seed: int = DEFAULT_SEED,
) -> list[dict]:
"""Esantionare stratificata aleatorie pe trei strate Zipf: cap/mijloc/coada.
Determinista cu seed; NU apeleaza LLM (PRD L14-S5).
rows: lista de (denumire, nr) — frecventele absolute.
Nu trebuie sortata in prealabil.
n_sample: marimea totala a esantionului (aproximativa, +/-3 datorita rotunjirii).
Default 250 = practic pt etichetare umana in 2-3 ore.
seed: seed pentru random.Random — acelasi seed produce acelasi esantion.
Returneaza:
list de dict: {denumire: str, nr: int, strat: str}
strat in {"cap", "mijloc", "coada"}
Stratificare (pe count, nu pe volum):
cap = top 20% din denumirile distincte (cele cu frecventa mare)
mijloc = urmatoarele 30%
coada = restul 50%
Alocare per strat: proportionala cu marimea stratului (egal per denumire),
cu minim 1 per strat non-gol.
"""
if not rows:
return []
# Sorteaza descrescator dupa frecventa (ca sa definim stratele corect)
sorted_rows = sorted(rows, key=lambda x: -x[1])
n = len(sorted_rows)
# Limite strate (pe indici)
head_end = max(1, round(n * _STRAT_HEAD_END_PCT))
mid_end = max(head_end + 1, round(n * _STRAT_MID_END_PCT))
mid_end = min(mid_end, n)
strata: dict[str, list[tuple[str, int]]] = {
"cap": sorted_rows[:head_end],
"mijloc": sorted_rows[head_end:mid_end],
"coada": sorted_rows[mid_end:],
}
# Alocare proportionala cu marimea stratului
names = ["cap", "mijloc", "coada"]
sizes = {name: len(strata[name]) for name in names}
total_size = sum(sizes.values()) # == n
rng = random.Random(seed)
# Calculeaza alocarea cu regula: max(1, round(n_sample * frac)) per strat ne-gol
alloc: dict[str, int] = {}
for name in names[:-1]:
if sizes[name] == 0:
alloc[name] = 0
else:
a = max(1, round(n_sample * sizes[name] / total_size))
a = min(a, sizes[name]) # nu mai mult decat avem
alloc[name] = a
# Ultima strata primeste restul (pentru a ne apropia de n_sample)
used = sum(alloc.get(name, 0) for name in names[:-1])
remaining = max(0, n_sample - used)
alloc["coada"] = min(remaining, sizes["coada"])
if alloc["coada"] == 0 and sizes["coada"] > 0:
alloc["coada"] = 1 # garantam minim 1 din coada daca exista
# Esantionare per strat
result: list[dict] = []
for name in names:
items = strata[name]
k = alloc.get(name, 0)
if k > 0 and items:
sampled = rng.sample(items, k)
for (den, nr) in sampled:
result.append({"denumire": den, "nr": nr, "strat": name})
return result
# ---------------------------------------------------------------------------
# Export CSV pentru etichetare umana
# ---------------------------------------------------------------------------
def export_for_labeling(sample: list[dict], path: str) -> None:
"""Exporta esantionul ca CSV pentru etichetare UMANA (ground-truth).
Coloana `cod_gold` ramane GOALA in fisierul exportat.
NU o completa cu etichete LLM sau automate: ar fi "antrenare pe test"
si ar invalida precizia raportata (Decision #19, PRD 5.14).
sample: lista de {denumire, nr, strat} returnata de sample_stratified()
path: fisierul CSV de scris (suprascrie daca exista)
Format CSV: UTF-8-BOM, separator ';', coloane:
denumire;nr;strat;cod_gold
"""
with open(path, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f, delimiter=';')
writer.writerow(["denumire", "nr", "strat", "cod_gold"])
for item in sample:
writer.writerow([
item["denumire"],
item["nr"],
item["strat"],
"", # cod_gold GOLA — de completat de operator uman
])
# ---------------------------------------------------------------------------
# Evaluare predictii vs ground-truth
# ---------------------------------------------------------------------------
def eval_predictions(
predictions: list[dict],
ground_truth: list[dict],
) -> dict:
"""Evalueaza predictiile clasificatorului fata de ground-truth uman.
Matching pe 'denumire'. Denumirile din ground_truth fara predictie corespunzatoare
sunt tratate ca UNRESOLVED (pred='?').
predictions: list de {denumire: str, cod_pred: str}
cod_pred: cod RAR ("OE-1"…) | "NUL" | "?" (nerezolvat)
ground_truth: list de {denumire: str, cod_gold: str}
cod_gold: cod RAR | "NUL" (completat de operator uman)
Returneaza dict cu:
total — numarul total de intrari din ground_truth
correct — predictii corecte (pred == gold)
global_precision — correct / total
wrong_code_count — cazuri cod-gresit (critic: FINALIZATA ireversibila)
def: pred in VALID_RAR AND gold in VALID_RAR AND pred != gold
wrong_code_rate — wrong_code_count / total
coverage_count — predictii cu cod_pred != '?' (clasificatorul a raspuns)
coverage_rate — coverage_count / total
per_cod — dict {cod -> {tp, fp, fn, precision, recall}}
confusion_matrix — dict {"gold->pred" -> count}
Nota 'cod gresit' vs 'NUL gresit':
pred=NUL si gold=OE-X -> item merge la needs_mapping, nu la FINALIZATA.
Rau (operatie pierduta), dar REPARABIL.
pred=OE-X si gold=NUL -> trimitem non-operatia la RAR cu un cod.
Rau (inselatoare), dar RAR nu o accepta ca operatie.
pred=OE-X si gold=OE-Y (X!=Y) -> FINALIZATA cu cod GRESIT. IREVERSIBIL.
Doar ultimul caz e 'wrong_code' (blocant pentru auto-send dincolo de GOLD).
"""
if not ground_truth:
return {
"total": 0,
"correct": 0,
"global_precision": 0.0,
"wrong_code_count": 0,
"wrong_code_rate": 0.0,
"coverage_count": 0,
"coverage_rate": 0.0,
"per_cod": {},
"confusion_matrix": {},
}
gt_map: dict[str, str] = {item["denumire"]: item["cod_gold"] for item in ground_truth}
pred_map: dict[str, str] = {item["denumire"]: item["cod_pred"] for item in predictions}
total = len(gt_map)
correct = 0
wrong_code_count = 0
coverage_count = 0
per_cod_tp: dict[str, int] = {}
per_cod_fp: dict[str, int] = {}
per_cod_fn: dict[str, int] = {}
confusion: dict[str, int] = {}
for den, gold in gt_map.items():
pred = pred_map.get(den, UNRESOLVED)
# Matrice confuzie
key = f"{gold}->{pred}"
confusion[key] = confusion.get(key, 0) + 1
# Coverage: classificatorul a dat un raspuns (nu '?')
if pred != UNRESOLVED:
coverage_count += 1
if pred == gold:
# Predictie corecta
correct += 1
per_cod_tp[gold] = per_cod_tp.get(gold, 0) + 1
else:
# Eroare: FN pentru gold, FP pentru pred (daca nu '?')
per_cod_fn[gold] = per_cod_fn.get(gold, 0) + 1
if pred != UNRESOLVED:
per_cod_fp[pred] = per_cod_fp.get(pred, 0) + 1
# COD GRESIT: ambii (pred si gold) sunt coduri RAR valide (diferite)
# -> ar produce FINALIZATA cu cod eronat (ireversibil)
if pred in VALID_RAR and gold in VALID_RAR:
wrong_code_count += 1
# Calculeaza per_cod (union a tuturor codurilor vazute)
all_codes = set(per_cod_tp) | set(per_cod_fp) | set(per_cod_fn)
per_cod: dict[str, dict] = {}
for code in sorted(all_codes):
tp = per_cod_tp.get(code, 0)
fp = per_cod_fp.get(code, 0)
fn = per_cod_fn.get(code, 0)
precision = tp / (tp + fp) if (tp + fp) > 0 else None
recall = tp / (tp + fn) if (tp + fn) > 0 else None
per_cod[code] = {
"tp": tp,
"fp": fp,
"fn": fn,
"precision": precision,
"recall": recall,
}
return {
"total": total,
"correct": correct,
"global_precision": correct / total,
"wrong_code_count": wrong_code_count,
"wrong_code_rate": wrong_code_count / total,
"coverage_count": coverage_count,
"coverage_rate": coverage_count / total,
"per_cod": per_cod,
"confusion_matrix": confusion,
}
# ---------------------------------------------------------------------------
# Kill-criterion (F-E, PRD 5.14)
# ---------------------------------------------------------------------------
def kill_criterion(
metrics: dict,
wrong_code_threshold: float = DEFAULT_WRONG_CODE_THRESHOLD,
coverage_threshold: float = DEFAULT_COVERAGE_THRESHOLD,
) -> dict:
"""Evalueaza daca sistemul de clasificare indeplineste pragul de acceptanta (F-E).
Sistemul TRECE daca:
wrong_code_rate < wrong_code_threshold (implicit 0.5%)
SI
coverage_rate > coverage_threshold (implicit 50%)
Un sistem care nu trece kill-criterion NU trebuie folosit pentru auto-send
dincolo de GOLD propriu (Decision #19, #17, PRD 5.14).
metrics: dict returnat de eval_predictions() sau compatibil
(must have keys: wrong_code_rate, coverage_rate).
wrong_code_threshold: pragul maxim admis pentru rata cod-gresit.
coverage_threshold: pragul minim admis pentru acoperire.
Returneaza dict cu:
passes — True daca ambele conditii sunt indeplinite
reason — explicatie in limba romana
wrong_code_rate — valoarea actuala
coverage_rate — valoarea actuala
thresholds — {"wrong_code": ..., "coverage": ...}
"""
wcr = metrics.get("wrong_code_rate", 1.0)
cvr = metrics.get("coverage_rate", 0.0)
cond_wrong_code = wcr < wrong_code_threshold
cond_coverage = cvr > coverage_threshold
passes = cond_wrong_code and cond_coverage
if passes:
reason = (
f"TRECE: rata cod-gresit {wcr:.2%} < {wrong_code_threshold:.2%} "
f"si acoperire {cvr:.1%} > {coverage_threshold:.1%}."
)
elif not cond_wrong_code and not cond_coverage:
reason = (
f"ESUEAZA: rata cod-gresit {wcr:.2%} >= {wrong_code_threshold:.2%} "
f"(FINALIZATA ireversibila) SI acoperire {cvr:.1%} <= {coverage_threshold:.1%} "
f"(sistem neutilizabil). Auto-send dincolo de GOLD dezactivat."
)
elif not cond_wrong_code:
reason = (
f"ESUEAZA: rata cod-gresit {wcr:.2%} >= {wrong_code_threshold:.2%}. "
f"Un cod gresit = FINALIZATA ireversibila la RAR (Premisa 3, PRD 5.14). "
f"Auto-send dincolo de GOLD dezactivat pana la recalibrat."
)
else:
reason = (
f"ESUEAZA: acoperire {cvr:.1%} <= {coverage_threshold:.1%}. "
f"Sub pragul minim de utilitate practica. "
f"Sistemul ar lasa prea multe intrari in needs_mapping vs efort uman direct."
)
return {
"passes": passes,
"reason": reason,
"wrong_code_rate": wcr,
"coverage_rate": cvr,
"thresholds": {
"wrong_code": wrong_code_threshold,
"coverage": coverage_threshold,
},
}
# ---------------------------------------------------------------------------
# I/O corpus real (refoloseste holdout.load_csv)
# ---------------------------------------------------------------------------
def _load_corpus_from_csvs(data_dir: str) -> list[tuple[str, int]]:
"""Incarca corpus din CSV-urile docs/operatii-service/*.csv.
Refoloseste logica din holdout.load_csv + agregare cross-client.
"""
import glob
from app.mapping import normalize_for_match
agg: dict[str, list] = {}
for path in sorted(glob.glob(os.path.join(data_dir, "*.csv"))):
try:
with open(path, encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter=';')
for row in reader:
denop = (row.get('DENOP') or '').strip().strip('"')
nr_raw = (row.get('NR') or '').strip().strip('"')
if not denop or not nr_raw:
continue
try:
nr = int(nr_raw)
except ValueError:
continue
if nr <= 0:
continue
key = normalize_for_match(denop)
if key not in agg:
agg[key] = [denop, 0]
agg[key][1] += nr
except OSError:
continue
return [(v[0], v[1]) for v in agg.values()]
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _print_report(metrics: dict) -> None:
sep = "=" * 70
print(sep)
print("RAPORT EVALUARE HELD-OUT (L14-S5, PRD 5.14)")
print(sep)
print(f" Total intrari evaluate: {metrics['total']}")
print(f" Corecte: {metrics['correct']}")
print(f" Precizie globala: {metrics['global_precision']:.2%}")
print(f" Acoperire (pred != '?'): {metrics['coverage_rate']:.2%}")
print(f" Rata cod-gresit: {metrics['wrong_code_rate']:.2%} "
f"({metrics['wrong_code_count']} cazuri)")
print()
print("KILL-CRITERION (F-E):")
kc = kill_criterion(metrics)
print(f" {kc['reason']}")
print()
if metrics['per_cod']:
print("PRECIZIE PER COD (TP/FP/FN/prec/recall):")
for cod, s in sorted(metrics['per_cod'].items()):
prec = f"{s['precision']:.0%}" if s['precision'] is not None else "N/A"
rec = f"{s['recall']:.0%}" if s['recall'] is not None else "N/A"
print(f" {cod:<10} TP={s['tp']:3d} FP={s['fp']:3d} FN={s['fn']:3d}"
f" prec={prec:>5} recall={rec:>5}")
print()
if metrics['confusion_matrix']:
print("MATRICE CONFUZIE (gold->pred, >0):")
for key, cnt in sorted(metrics['confusion_matrix'].items()):
if cnt > 0 and not key.endswith(f"->{key.split('->')[0]}"):
# Afiseaza doar erorile (gold != pred)
gold, pred_lbl = key.split("->", 1)
if gold != pred_lbl:
print(f" {key:<25} {cnt}")
print(sep)
def main() -> None:
import argparse
p = argparse.ArgumentParser(
description="Harness eval held-out L14-S5 (PRD 5.14).",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Moduri de utilizare:
Generare esantion pt etichetare umana (FARA LLM):
python3 tools/mapare-llm/heldout_eval.py --n 250 --out esantion.csv
Evaluare predictii vs ground-truth (dupa etichetare umana):
python3 tools/mapare-llm/heldout_eval.py \\
--eval predictii.csv gold.csv
Format CSV predictii: denumire;cod_pred (separator ';')
Format CSV gold: denumire;cod_gold (separator ';')
""",
)
p.add_argument("--n", type=int, default=250,
help="Marimea esantionului de etichetat (default 250)")
p.add_argument("--seed", type=int, default=DEFAULT_SEED,
help=f"Seed reproductibilitate (default {DEFAULT_SEED})")
p.add_argument("--out", default=None,
help="Fisier output CSV pt esantion (mod generare)")
p.add_argument("--eval", nargs=2, metavar=("PRED_CSV", "GOLD_CSV"),
help="Fisiere predictii si ground-truth (mod evaluare)")
p.add_argument("--data", default=None,
help="Director CSV date (default: docs/operatii-service/)")
args = p.parse_args()
data_dir = args.data or os.path.join(_ROOT, "docs", "operatii-service")
if args.eval:
# Mod evaluare
pred_path, gold_path = args.eval
def read_csv_map(path, cod_col):
result = []
with open(path, encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter=';')
for row in reader:
den = (row.get('denumire') or '').strip()
cod = (row.get(cod_col) or '').strip()
if den:
result.append({"denumire": den, cod_col: cod})
return result
preds = read_csv_map(pred_path, "cod_pred")
gold = read_csv_map(gold_path, "cod_gold")
metrics = eval_predictions(preds, gold)
_print_report(metrics)
return
# Mod generare esantion
print(f"Incarcare corpus din {data_dir} ...")
rows = _load_corpus_from_csvs(data_dir)
print(f"Corpus: {len(rows)} denumiri distincte, "
f"volum total {sum(nr for _, nr in rows):,}")
sample = sample_stratified(rows, n_sample=args.n, seed=args.seed)
# Statistici strate
from collections import Counter
strat_cnt = Counter(item["strat"] for item in sample)
print(f"Esantion ({len(sample)} iteme, seed={args.seed}):")
for strat in ("cap", "mijloc", "coada"):
print(f" {strat:<8}: {strat_cnt.get(strat, 0):4d} iteme")
out_path = args.out or os.path.join(_HERE, "heldout-esantion.csv")
export_for_labeling(sample, out_path)
print(f"Esantion exportat: {out_path}")
print()
print("INSTRUCTIUNI ETICHETARE:")
print(" Deschide fisierul exportat si completeaza coloana 'cod_gold'")
print(" cu codul RAR corect pentru fiecare denumire.")
print(" Coduri RAR valide:", ", ".join(sorted(VALID_RAR)), ", NUL")
print(" NUL = denumire care NU este operatie de service (discount, ITP, etc.)")
print(" '?' = incert (clasificatorul nu poate decide)")
print()
print(" ATENTIE: NU folosi etichete LLM drept cod_gold!")
print(" Asta ar fi 'antrenare pe test' (Decision #19, PRD 5.14) si ar")
print(" invalida orice masurare de acuratete.")
if __name__ == "__main__":
main()

347
tools/mapare-llm/holdout.py Normal file
View File

@@ -0,0 +1,347 @@
"""
Validare empirica Premisa 1 — "90%+ din traficul viitor sunt repetari ale acelorasi denumiri".
LIMITARE CRITICA (documentata explicit):
CSV-urile din docs/operatii-service/ contin frecvente AGREGATE (DENOP + NR),
fara coloana de data/timestamp. Validarea temporala stricta (corpus = lunile 1-N,
test = lunile N+) NU este posibila cu datele curente.
PROXY FOLOSIT (onest, nu pretinde ca = validare temporala):
1. COVERAGE PROXY (Zipf):
hit_rate_at_K = sum(NR pt top-K denumiri dupa frecventa) / total_NR
Masoara: daca etichetam top-K denumiri si traficul viitor urmeaza aceeasi
distributie Zipf (ipoteza stationaritate), ce % din trafic va fi acoperit.
NU masoara drift vocabular in timp.
2. LEAVE-FIRST-OUT PROXY:
leave_one_out_hit_rate = (total_volume - total_distinct) / total_volume
Masoara: daca corpus = "toate denumirile vazute cel putin o data", ce % din
aparitii sunt "repetari" (aparitia 2,3,...n a fiecarei denumiri)?
Singletonii (NR=1) contribuie 0 hit-uri (prima aparitie = miss inevitable).
Aceasta e limita superioara a hit-rate-ului sub stationaritate.
VERDICT Premisa 1 (bazat pe coverage proxy):
SUSTINUTA — <= 10% din denumirile distincte acopera >= 90% din volum
SLABA — intre 10% si 30% din distincte necesare pentru >= 90% volum
NEVALIDABILA — > 30% din distincte necesare (distributie Zipf slaba/plata)
Refoloseste normalize_for_match din app/mapping.py pentru cheia de potrivire.
"""
from __future__ import annotations
import csv
import os
import sys
# Calea la root-ul proiectului (doua nivele deasupra tools/mapare-llm/)
_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.abspath(os.path.join(_HERE, '..', '..'))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
from app.mapping import normalize_for_match
# Re-expunem normalize_for_match sub un alias mai scurt pentru uz intern + teste.
def normalize_key(text: object) -> str:
"""Alias pentru normalize_for_match din app/mapping.py.
Upper + fara diacritice + spatii colapsate.
Exemplu: 'Reparație motor' -> 'REPARATIE MOTOR'.
"""
return normalize_for_match(text)
# ---------------------------------------------------------------------------
# I/O
# ---------------------------------------------------------------------------
def load_csv(path: str) -> list[tuple[str, int]]:
"""Incarca CSV cu coloanele DENOP (denumire) + NR (frecventa).
Returneaza lista de (denumire_originala, nr_total) dupa agregare pe
cheia normalize_key (unifica variante ortografice: diacritice, majuscule).
Randurile cu DENOP gol sau NR non-pozitiv sunt ignorate.
"""
agg: dict[str, list] = {} # normalized_key -> [first_seen_denumire, total_nr]
with open(path, encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter=';')
for row in reader:
denop = (row.get('DENOP') or '').strip().strip('"')
nr_raw = (row.get('NR') or '').strip().strip('"')
if not denop or not nr_raw:
continue
try:
nr = int(nr_raw)
except ValueError:
continue
if nr <= 0:
continue
key = normalize_key(denop)
if key not in agg:
agg[key] = [denop, 0]
agg[key][1] += nr
return [(v[0], v[1]) for v in agg.values()]
# ---------------------------------------------------------------------------
# Functii pure (testabile fara I/O)
# ---------------------------------------------------------------------------
def compute_volume_coverage(rows: list[tuple[str, int]]) -> list[dict]:
"""Sorteaza dupa NR descrescator si calculeaza acoperirea cumulativa de volum.
Returneaza:
[{denumire, nr, cumulative_volume_frac, cumulative_count}, ...]
unde cumulative_volume_frac e fractia din total_NR acoperita de primele
`cumulative_count` denumiri (dupa sortare descrescatoare).
"""
sorted_rows = sorted(rows, key=lambda x: -x[1])
total_volume = sum(nr for _, nr in sorted_rows)
if total_volume == 0:
return []
cumul = 0
result = []
for i, (denumire, nr) in enumerate(sorted_rows, 1):
cumul += nr
result.append({
'denumire': denumire,
'nr': nr,
'cumulative_volume_frac': cumul / total_volume,
'cumulative_count': i,
})
return result
def corpus_size_for_threshold(rows: list[tuple[str, int]], threshold: float = 0.90) -> int:
"""Numarul minim de etichete (top-frecventa) pentru >= threshold acoperire de volum.
Sorteaza descrescator si numara cate denumiri sunt necesare pana la prag.
Returneaza len(rows) daca pragul nu e atins (distributie prea plata).
"""
coverage = compute_volume_coverage(rows)
for entry in coverage:
if entry['cumulative_volume_frac'] >= threshold:
return entry['cumulative_count']
return len(rows)
def compute_hit_rate_at_k(rows: list[tuple[str, int]], k: int) -> float:
"""Fractia de volum total acoperita de top-K denumiri (coverage proxy).
Interpretare: daca etichetam cele mai frecvente K denumiri, si traficul viitor
urmeaza aceeasi distributie, hit_rate_at_K = probabilitatea ca o tranzactie
viitoare sa fie acoperita de corpus.
"""
if not rows:
return 0.0
sorted_rows = sorted(rows, key=lambda x: -x[1])
total_volume = sum(nr for _, nr in sorted_rows)
if total_volume == 0:
return 0.0
top_k_volume = sum(nr for _, nr in sorted_rows[:k])
return top_k_volume / total_volume
def leave_one_out_hit_rate(rows: list[tuple[str, int]]) -> float:
"""Proxy leave-first-out: (total_volume - total_distinct) / total_volume.
Interpretare: daca corpus = toate denumirile vazute cel putin o data,
fractia de aparitii care sunt "repetari" (nu prima aparitie) = hit-uri.
Singletonii (NR=1) contribuie 0 hit-uri (prima si unica aparitie = miss).
Aceasta e LIMITA SUPERIOARA a hit-rate-ului real sub ipoteza de stationaritate.
NU e validare temporala (nu masoara cand apar denumirile noi in timp).
"""
if not rows:
return 0.0
total_volume = sum(nr for _, nr in rows)
total_distinct = len(rows)
if total_volume == 0:
return 0.0
return (total_volume - total_distinct) / total_volume
def singleton_stats(rows: list[tuple[str, int]]) -> dict:
"""Statistici pentru denumirile cu NR=1 (vazute o singura data).
Singletonii sunt importanti: ei sunt INTOTDEAUNA miss-uri la prima aparitie
si, daca nu mai apar, raman miss-uri permanent.
"""
singletons = [(d, n) for d, n in rows if n == 1]
total_distinct = len(rows)
total_volume = sum(nr for _, nr in rows)
singleton_volume = len(singletons) # fiecare singleton contribuie NR=1
return {
'singleton_count': len(singletons),
'total_distinct': total_distinct,
'singleton_volume_frac': singleton_volume / total_volume if total_volume else 0.0,
'singleton_distinct_frac': len(singletons) / total_distinct if total_distinct else 0.0,
}
def run_holdout(rows: list[tuple[str, int]], client_name: str = 'unknown') -> dict:
"""Analiza holdout proxy completa pentru un set de (denumire, nr).
Combina coverage proxy (Zipf) si leave-first-out proxy.
Returneaza un dict cu statistici si verdict privind Premisa 1.
"""
total_distinct = len(rows)
total_volume = sum(nr for _, nr in rows)
coverage_at_100 = compute_hit_rate_at_k(rows, k=100)
coverage_at_500 = compute_hit_rate_at_k(rows, k=500)
coverage_at_1000 = compute_hit_rate_at_k(rows, k=1000)
labels_for_90pct = corpus_size_for_threshold(rows, threshold=0.90)
frac_for_90pct = labels_for_90pct / total_distinct if total_distinct else 1.0
loh = leave_one_out_hit_rate(rows)
s = singleton_stats(rows)
# Verdict bazat pe coverage proxy (Zipf): ce procent din distincte necesare pt 90% vol
if frac_for_90pct <= 0.10:
verdict = 'SUSTINUTA'
elif frac_for_90pct <= 0.30:
verdict = 'SLABA'
else:
verdict = 'NEVALIDABILA'
return {
'client': client_name,
'total_distinct': total_distinct,
'total_volume': total_volume,
'coverage_at_100': round(coverage_at_100 * 100, 2),
'coverage_at_500': round(coverage_at_500 * 100, 2),
'coverage_at_1000': round(coverage_at_1000 * 100, 2),
'labels_for_90pct': labels_for_90pct,
'frac_for_90pct': round(frac_for_90pct * 100, 2),
'leave_one_out_hit_rate': round(loh * 100, 2),
'singleton_count': s['singleton_count'],
'singleton_distinct_frac': round(s['singleton_distinct_frac'] * 100, 2),
'singleton_volume_frac': round(s['singleton_volume_frac'] * 100, 2),
'verdict': verdict,
'nota': (
'PROXY FRECVENTA (fara timestamp temporal): validare temporala stricta '
'imposibila cu datele curente. hit_rate_at_K = % volum acoperit de top-K '
'etichete; valida NUMAI sub ipoteza distributie stabila in timp.'
),
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _format_row(label: str, value: str, width: int = 45) -> str:
return f" {label:<{width}}{value}"
def main() -> None:
"""Ruleaza holdout pe toate CSV-urile din docs/operatii-service/."""
root = os.path.join(_ROOT, 'docs', 'operatii-service')
clients = ['clever', 'sigma', 'automotive', 'south']
sep = "=" * 72
print(sep)
print("HOLDOUT PREMISA 1 — PROXY FRECVENTA (fara date temporale)")
print(sep)
print("LIMITARE: CSV-urile contin frecvente AGREGATE (DENOP + NR), fara")
print("coloana de data/timestamp. Validarea temporala stricta NU e posibila.")
print()
print("PROXY 1 (Coverage Zipf): hit_rate_at_K = % volum acoperit de top-K")
print(" -> valida sub ipoteza distributie stabila (nemasurabila cu date curente)")
print("PROXY 2 (Leave-first-out): (total_vol - total_distinct) / total_vol")
print(" -> limita superioara a hit-rate-ului daca am eticheta tot ce vedem odata")
print(sep)
print()
all_rows_combined: list[tuple[str, int]] = []
results = []
for client in clients:
path = os.path.join(root, f'operatii-service-{client}.csv')
rows = load_csv(path)
all_rows_combined.extend(rows)
r = run_holdout(rows, client_name=client)
results.append(r)
print(f"CLIENT: {client.upper()}")
print(_format_row("Denumiri distincte:", f"{r['total_distinct']:,}"))
print(_format_row("Volum total operatii:", f"{r['total_volume']:,}"))
print(_format_row("Coverage top-100:", f"{r['coverage_at_100']:.1f}%"))
print(_format_row("Coverage top-500:", f"{r['coverage_at_500']:.1f}%"))
print(_format_row("Coverage top-1000:", f"{r['coverage_at_1000']:.1f}%"))
print(_format_row(
"Etichete pt 90% vol:",
f"{r['labels_for_90pct']} ({r['frac_for_90pct']:.1f}% din distinct)"
))
print(_format_row(
"Leave-first-out hit-rate:",
f"{r['leave_one_out_hit_rate']:.1f}%"
))
print(_format_row(
"Singletons (NR=1):",
f"{r['singleton_count']} ({r['singleton_distinct_frac']:.1f}% din distinct,"
f" {r['singleton_volume_frac']:.1f}% din vol)"
))
print(f" VERDICT PREMISA 1: {r['verdict']}")
print()
# Agregat: re-agreg pe cheia normalized (pentru ca clientii pot avea aceleasi denumiri)
agg_dict: dict[str, list] = {}
for client in clients:
path = os.path.join(root, f'operatii-service-{client}.csv')
rows_c = load_csv(path)
for (d, n) in rows_c:
k = normalize_key(d)
if k not in agg_dict:
agg_dict[k] = [d, 0]
agg_dict[k][1] += n
all_rows_agg = [(v[0], v[1]) for v in agg_dict.values()]
agg = run_holdout(all_rows_agg, client_name='AGREGAT_4_CLIENTI')
print(f"CLIENT: AGREGAT (4 clienti, distinct cross-client)")
print(_format_row("Denumiri distincte:", f"{agg['total_distinct']:,}"))
print(_format_row("Volum total operatii:", f"{agg['total_volume']:,}"))
print(_format_row("Coverage top-100:", f"{agg['coverage_at_100']:.1f}%"))
print(_format_row("Coverage top-500:", f"{agg['coverage_at_500']:.1f}%"))
print(_format_row("Coverage top-1000:", f"{agg['coverage_at_1000']:.1f}%"))
print(_format_row(
"Etichete pt 90% vol:",
f"{agg['labels_for_90pct']} ({agg['frac_for_90pct']:.1f}% din distinct)"
))
print(_format_row("Leave-first-out hit-rate:", f"{agg['leave_one_out_hit_rate']:.1f}%"))
print(_format_row(
"Singletons (NR=1):",
f"{agg['singleton_count']} ({agg['singleton_distinct_frac']:.1f}% din distinct,"
f" {agg['singleton_volume_frac']:.1f}% din vol)"
))
print(f" VERDICT PREMISA 1: {agg['verdict']}")
print()
print(sep)
print("CONCLUZIE PREMISA 1:")
verdicts = [r['verdict'] for r in results]
if all(v == 'SUSTINUTA' for v in verdicts):
print(" SUSTINUTA la toti clientii individual.")
elif any(v == 'SUSTINUTA' for v in verdicts):
print(" PARTIALA: sustinuta la unii clienti, slaba/nevalidabila la altii.")
else:
print(" SLABA sau NEVALIDABILA la toti clientii.")
print(f" Agregat: {agg['verdict']}")
print()
print("NOTA METODOLOGICA:")
print(" Concluzia e valida NUMAI sub ipoteza ca distributia de frecvente e stabila")
print(" in timp (vocabularul service-ului nu se schimba semnificativ de la luna la luna).")
print(" Pentru validare temporala stricta, sunt necesare date cu coloana de data.")
print(sep)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,300 @@
"""Etichetator batch offline OpenRouter (Layer 1) — L14-S1.
Clasifica denumirile de operatii service in cele 18 coduri RAR + NUL.
Cerinte implementate (PRD 5.14 / Decision Audit Trail):
1. Prioritizare pe FRECVENTA (desc): corpus_by_freq() din or_common
2. Grupare pe similaritate (rapidfuzz token_sort_ratio, threshold conservator
Eng-F7): LLM eticheteaza doar reprezentantul, codul se propaga la grup
3. Ensemble NVIDIA (super-120b + nano-9b, PRD #9): acord unanim -> high;
dezacord (orice divergenta) -> needs_mapping. Vot pe coduri, nu pe
self-confidence. ultra-550b EXCLUS (4-5x mai lent, zero castig)
4. Scrub PII (F3): integrat in or_common.call() (regex nr inmatriculare/VIN)
5. Resumabil: scrie *-partial.json incremental, reia de unde a ramas;
retry/backoff pe 429 gestionat de or_common.call()
6. Output: {denumire, cod, sursa, confidence, grup_rep}
NUL = ancore negativa + supresie, NU promovat la cod RAR (#4)
CLI: python3 tools/mapare-llm/or_label.py [N] [--out path] [--partial path]
[--threshold 85] [--batch 20] [--pace 4.0]
"""
import sys
import os
import json
import time
from collections import Counter
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import or_common as oc
from rapidfuzz import fuzz
# Modele NVIDIA (decizie PRD #9: pastram super-120b + nano-9b; aruncam ultra-550b)
MODELS = [
"nvidia/nemotron-3-super-120b-a12b:free",
"nvidia/nemotron-nano-9b-v2:free",
]
DEFAULT_THRESHOLD = 85 # raza conservatoare pt grupare (Eng-F7)
DEFAULT_BATCH = 20 # denumiri per apel LLM (cap free tier ~50 cereri/zi)
DEFAULT_N = 500 # top N dupa frecventa de procesat
DEFAULT_PACE = 4.0 # sec intre batch-uri (free tier OpenRouter ~20 req/min)
HERE = os.path.dirname(os.path.abspath(__file__))
PARTIAL_PATH = os.path.join(HERE, "or-labels-partial.json")
FINAL_PATH = os.path.join(HERE, "or-labels-final.json")
def group_by_similarity(corpus, threshold=DEFAULT_THRESHOLD):
"""Grupeaza denumirile pe similaritate fuzz.token_sort_ratio.
corpus: lista de (denumire, freq) sortata DESCRESCATOR dupa frecventa.
Elementul cu frecventa maxima = reprezentantul grupului.
threshold: scor minim de similaritate (0-100). Valoare conservatoare = 85.
Algoritm greedy: primul item nemapat devine reprezentant; urmatoarele
iteme cu scor >= threshold fata de reprezentant intra in grupul sau.
Conservator: nu grupeaza tranzitiv (doar fata de reprezentant).
Intoarce: lista de dict {rep: str, freq: int, members: [(den, freq), ...]}
"""
assigned = set()
groups = []
for i, (den_i, freq_i) in enumerate(corpus):
if den_i in assigned:
continue
members = []
for j, (den_j, freq_j) in enumerate(corpus):
if j <= i or den_j in assigned:
continue
if fuzz.token_sort_ratio(den_i, den_j) >= threshold:
members.append((den_j, freq_j))
assigned.add(den_j)
assigned.add(den_i)
groups.append({"rep": den_i, "freq": freq_i, "members": members})
return groups
def ensemble_vote(votes):
"""Calculeaza verdictul ensemble din voturile modelelor.
votes: dict {model_id: cod} - "?" inseamna parse-fail (se exclude).
Reguli (2 modele NVIDIA, aceeasi familie):
- Toate N modele cu acelasi cod valid -> (cod, "high", "ensemble-unanim")
- Toate N modele cu "NUL" -> ("NUL", "high", "ensemble-unanim-nul")
- Orice divergenta / parse-fail partial -> ("?", "needs_mapping", "ensemble-dezacord")
Vot pe coduri, NU pe self-confidence (PRD #10, Eng-F7).
NUL tratat SEPARAT: ancore negativa, nu e cod RAR (#4).
Intoarce: (cod_final, confidence, sursa)
cod_final: cod RAR valid | "NUL" | "?" (needs human review)
confidence: "high" | "needs_mapping"
sursa: "ensemble-unanim" | "ensemble-unanim-nul" | "ensemble-dezacord"
"""
n_models = len(votes)
valid_votes = [v for v in votes.values() if v != "?"]
if not valid_votes:
return "?", "needs_mapping", "ensemble-dezacord"
c = Counter(valid_votes)
top_cod, top_cnt = c.most_common(1)[0]
if top_cnt == n_models:
# Unanimitate: toate cele N modele au raspuns cu acelasi cod
if top_cod == "NUL":
return "NUL", "high", "ensemble-unanim-nul"
if top_cod in oc.VALID:
return top_cod, "high", "ensemble-unanim"
# Cod returnat de LLM nu e in nomenclatorul RAR -> dezacord
return "?", "needs_mapping", "ensemble-dezacord"
# Dezacord (inclusiv parse-fail partial: top_cnt < n_models)
return "?", "needs_mapping", "ensemble-dezacord"
def load_partial(path):
"""Incarca rezultate partiale daca fisierul exista.
Intoarce dict {rep -> {cod, confidence, sursa, votes}} sau {} daca
fisierul lipseste sau e corupt.
"""
if os.path.exists(path):
try:
return json.load(open(path, encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return {}
def save_partial(path, results):
"""Salveaza rezultate partiale incrementabil (suprascrie fisierul).
results: dict {rep -> {cod, confidence, sursa, votes}}
"""
json.dump(results, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
def label_groups(groups, partial, batch_size=DEFAULT_BATCH, pace=DEFAULT_PACE):
"""Eticheteaza reprezentantii grupurilor cu ensemble NVIDIA.
Sare reprezentantii deja in partial (resumabil).
Colecteaza voturi per model in batch-uri, calculeaza ensemble,
actualizeaza partial la final.
groups: lista de {rep, freq, members} din group_by_similarity()
partial: dict {rep -> label} - stare anterioara (modificat in-place)
batch_size: denumiri per apel LLM
pace: sec intre batch-uri (0 = fara pauza, util in teste)
Intoarce partial actualizat.
"""
todo = [g["rep"] for g in groups if g["rep"] not in partial]
if not todo:
print("toti reprezentantii sunt deja in partial, nimic de facut", flush=True)
return partial
print(f"de etichetat: {len(todo)} reprezentanti "
f"(skip {len(groups) - len(todo)} din partial)", flush=True)
# Colectam voturile per model, pentru toti reprezentantii nerezolvati
votes_per_rep = {rep: {} for rep in todo}
nb = (len(todo) + batch_size - 1) // batch_size
for mi, m in enumerate(MODELS):
print(f" model: {m}", flush=True)
for bi, k in enumerate(range(0, len(todo), batch_size)):
batch = todo[k:k + batch_size]
codes, meta = oc.call(m, batch)
for rep, cod in zip(batch, codes):
votes_per_rep[rep][m] = cod
print(f" batch {bi+1}/{nb} {meta['ms']}ms err={meta['err']}", flush=True)
if bi < nb - 1 and pace > 0:
time.sleep(pace)
if pace > 0 and mi < len(MODELS) - 1:
time.sleep(pace) # pauza intre modele diferite
# Ensemble + scriere in partial
for rep in todo:
cod, confidence, sursa = ensemble_vote(votes_per_rep[rep])
partial[rep] = {
"cod": cod,
"confidence": confidence,
"sursa": sursa,
"votes": votes_per_rep[rep],
}
return partial
def expand_to_all(groups, partial):
"""Propaga etichetele reprezentantilor la membrii grupului.
Reprezentantul primeste sursa din ensemble ("ensemble-*").
Membrii primesc sursa="propagat" si codul/confidence al reprezentantului.
NUL este pastrat ca NUL la propagare, nu e convertit la cod RAR (#4).
Intoarce: lista de dict {denumire, cod, sursa, confidence, grup_rep}
"""
results = []
for g in groups:
rep = g["rep"]
label = partial.get(rep, {})
cod = label.get("cod", "?")
confidence = label.get("confidence", "needs_mapping")
sursa_rep = label.get("sursa", "ensemble-dezacord")
# Reprezentantul
results.append({
"denumire": rep,
"cod": cod,
"sursa": sursa_rep,
"confidence": confidence,
"grup_rep": rep,
})
# Membrii grupului: propaga codul reprezentantului
for (mem, _freq) in g["members"]:
results.append({
"denumire": mem,
"cod": cod,
"sursa": "propagat",
"confidence": confidence,
"grup_rep": rep,
})
return results
def run(n=DEFAULT_N, output_path=FINAL_PATH, partial_path=PARTIAL_PATH,
threshold=DEFAULT_THRESHOLD, batch_size=DEFAULT_BATCH, pace=DEFAULT_PACE):
"""Punctul principal: citeste corpus, grupeaza, eticheteaza, salveaza.
Resumabil: daca partial_path exista, sare reprezentantii deja etichetati.
n: top N denumiri dupa frecventa de procesat
output_path: fisier JSON cu toate etichetele (final)
partial_path: fisier JSON resumabil (stare intermediara per reprezentant)
threshold: raza similaritate pt grupare (0-100, default 85 = conservator)
batch_size: denumiri per apel LLM
pace: sec intre batch-uri
Intoarce: lista de rezultate (identica cu fisierul output_path).
"""
corpus = oc.corpus_by_freq()
top = corpus[:n]
vol_total = sum(nr for _, nr in corpus) or 1
vol_top = sum(nr for _, nr in top)
print(f"corpus: {len(corpus)} denumiri distincte, volum total {vol_total}")
print(f"top {n} dupa frecventa: volum {vol_top} ({100*vol_top/vol_total:.1f}%)")
groups = group_by_similarity(top, threshold)
n_reps = len(groups)
n_mems = sum(len(g["members"]) for g in groups)
print(f"dupa grupare: {n_reps} reprezentanti, {n_mems} membri propagati din {n}")
partial = load_partial(partial_path)
print(f"partial incarcat: {len(partial)} reprezentanti deja etichetati")
partial = label_groups(groups, partial, batch_size, pace)
save_partial(partial_path, partial)
print(f"partial salvat: {partial_path}")
results = expand_to_all(groups, partial)
json.dump(results, open(output_path, "w", encoding="utf-8"),
ensure_ascii=False, indent=1)
# Raport sumar
nul_cnt = sum(1 for r in results if r["cod"] == "NUL")
high_cnt = sum(1 for r in results if r["confidence"] == "high")
needs_cnt = sum(1 for r in results if r["confidence"] == "needs_mapping")
prop_cnt = sum(1 for r in results if r["sursa"] == "propagat")
print(f"\nREZULTAT: {len(results)} denumiri in output")
print(f" NUL (gunoi, ancore negative): {nul_cnt}")
print(f" confidence high (unanim): {high_cnt}")
print(f" needs_mapping (dezacord): {needs_cnt}")
print(f" propagate din grup: {prop_cnt}")
print(f"salvat: {output_path}")
return results
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser(description="Etichetator batch offline OpenRouter (L14-S1)")
p.add_argument("n", nargs="?", type=int, default=DEFAULT_N,
help=f"top N denumiri dupa frecventa (default {DEFAULT_N})")
p.add_argument("--out", default=FINAL_PATH, metavar="PATH",
help="fisier output final JSON (default: or-labels-final.json)")
p.add_argument("--partial", default=PARTIAL_PATH, metavar="PATH",
help="fisier partial resumabil JSON (default: or-labels-partial.json)")
p.add_argument("--threshold", type=int, default=DEFAULT_THRESHOLD,
help=f"raza similaritate grupare 0-100 (default {DEFAULT_THRESHOLD})")
p.add_argument("--batch", type=int, default=DEFAULT_BATCH,
help=f"denumiri per apel LLM (default {DEFAULT_BATCH})")
p.add_argument("--pace", type=float, default=DEFAULT_PACE,
help=f"sec intre batch-uri (default {DEFAULT_PACE})")
a = p.parse_args()
run(a.n, a.out, a.partial, a.threshold, a.batch, a.pace)