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:
@@ -19,3 +19,9 @@ AUTOPASS_WORKER_USE_TEST_CREDS=false
|
||||
# --- RAR ---
|
||||
# test | prod
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- **`FINALIZATA` e terminal la RAR** — fara anulare/corectie prin API. De aceea reconcilierea anti-duplicat: pe eroare **ambigua** (timeout / TransportError / 502/503/504 / 429 / 408) sau rand `sending` orfan, worker-ul cauta in finalizate (match pe vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara a re-trimite (`reconcile.py`). **EXCEPTIE: un RAR 500 cu mesaj** (`RarError.rar_message`, ex. `ORA-12899`) e un esec DEFINITIV (RAR a raspuns „am esuat", nu o pierdere de raspuns) → worker-ul NU reconciliaza si NU reincearca, marcheaza `error` cu mesajul RAR (`RAR_EROARE_SERVER`). Altfel ar marca fals `sent` pe un record PARTIAL pe care RAR (ne-tranzactional) il lasa la esec.
|
||||
- **Creds RAR per cont**: durabile in `accounts.rar_creds_enc` (canal web, fallback re-login) SAU efemere in `submissions.rar_creds_enc` (canal API, sterse dupa primul login reusit). Worker incearca submission-ul intai, apoi fallback la cont. Purjarea sterge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_enc`.
|
||||
- **Auth API-key** (`auth.py`): identifica CONTUL ROAAUTO, separat de credentialele RAR. Stocam doar SHA-256 al cheii. Enforcement prin `AUTOPASS_REQUIRE_API_KEY`: `false` (dev) → fara cheie merge pe cont id=1, cheie invalida → 401; `true` (prod) → cheie obligatorie pe `/v1/*` protejat. POST-urile + rutele de import sunt account-scoped; GET-urile de listare sunt 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 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`.
|
||||
|
||||
209
DESIGN.md
209
DESIGN.md
@@ -29,9 +29,11 @@ sistemul sa ramana discret.
|
||||
```
|
||||
--bg: #0f1218 fundal aplicatie
|
||||
--card: #181c24 suprafete (carduri, modal, inputuri pe fundal)
|
||||
--card2: #0f1218 fundal input slim / carduri-contor (= --bg, nivelul cel mai adanc)
|
||||
--ink: #e6e9ef text principal
|
||||
--muted: #8b93a7 text secundar (label-uri, coduri, „by")
|
||||
--line: #262b36 borduri, separatoare
|
||||
--line2: #1f2530 separator subtire lista slim (mai subtil decat --line)
|
||||
--accent:#2E74D6 azur ROMFAST — butoane primare, pill activ, linkuri, focus
|
||||
--ok: #2FBF8F sent / succes
|
||||
--warn: #E0A93B sending / atentie / Lipsa cod
|
||||
@@ -43,10 +45,12 @@ sistemul sa ramana discret.
|
||||
```
|
||||
--bg: #f5f7fa fundal (alb-rece ca romfast.ro)
|
||||
--card: #ffffff suprafete
|
||||
--card2: #f5f7fa fundal input slim / carduri-contor (= --bg)
|
||||
--ink: #1a1d24 text principal
|
||||
--muted: #5c6473 text secundar
|
||||
--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
|
||||
--warn: #b45309 chihlimbar 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
|
||||
--card: #161e20 suprafete
|
||||
--card2: #0e1416 fundal input/contor (= --bg)
|
||||
--ink: #e6e9ef text principal
|
||||
--muted: #8b93a7 text secundar
|
||||
--line: #232c2e borduri
|
||||
--line2: #1c2426 separator subtire (intre --bg si --line)
|
||||
--accent:#0E7C7B teal petrol — butoane, pill activ, linkuri, focus
|
||||
--ok: #2FBF8F sent
|
||||
--warn: #E0A93B atentie
|
||||
--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)
|
||||
|
||||
```
|
||||
@@ -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`
|
||||
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)
|
||||
- `Dark` → `data-theme="dark"` (azur pe inchis, comportamentul implicit actual)
|
||||
- `Petrol` → `data-theme="petrol"` (teal pe petrol-inchis)
|
||||
- `Auto` → urmeaza `prefers-color-scheme`; rezolva la Light azur sau Dark azur in functie de OS
|
||||
(nu seteaza `data-theme` fix, ci il deriva la paint).
|
||||
- `Light` → `data-theme="light"` (azur pe alb) — ☀
|
||||
- `Dark` → `data-theme="dark"` (azur pe inchis, comportamentul implicit actual) — ☾
|
||||
- `Petrol` → `data-theme="petrol"` (teal pe petrol-inchis) — ◐
|
||||
- `Grafit` → `data-theme="grafit"` (azur deschis pe negru-grafit, similar dark) — ◑
|
||||
- `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
|
||||
anti-FOUC din `<head>` trebuie sa rezolve „Auto"→light/dark inainte de primul paint (fara blink).
|
||||
Iconite: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto. Default la prima vizita = Auto (OS-aware), ca azi.
|
||||
anti-FOUC din `<head>` cunoaste toate cele 7+1 stari; valori vechi (light/dark/petrol) raman
|
||||
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
|
||||
|
||||
@@ -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`,
|
||||
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">×</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">×</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" ...>×</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
|
||||
|
||||
- Mecanismul light/dark existent (anti-FOUC, persistenta `localStorage`, comutator) — il pastram,
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
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
|
||||
|
||||
# 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 .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
@@ -186,6 +186,14 @@ def _resolve_row_for_preview(
|
||||
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||
|
||||
# obs derive-on-empty (D7/E3 PRD 5.15): daca obs e gol si avem operatie,
|
||||
# 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
|
||||
canon = canonicalize_row(mapped)
|
||||
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
|
||||
# aici (raman in panoul de mapare).
|
||||
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final")
|
||||
# aici (raman in panoul de mapare). obs = text liber, se trateaza ca non-canonic
|
||||
# (doar .strip(), fara canonicalize_row) — urmeaza ramura `else` din _merge_override.
|
||||
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs")
|
||||
|
||||
|
||||
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
|
||||
s = str(val).strip()
|
||||
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:
|
||||
raw[camp] = s
|
||||
if raw:
|
||||
@@ -1078,6 +1095,13 @@ def commit_import(
|
||||
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||
|
||||
# obs derive-on-empty (D7/E3 PRD 5.15): copiere denumire in obs daca obs e gol.
|
||||
# Identic cu logica din _resolve_row_for_preview (override aplicat tot ultimul).
|
||||
obs_curent = str(mapped.get("obs") or "").strip()
|
||||
if not obs_curent and operatie_val:
|
||||
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["obs"] = obs_denumire
|
||||
|
||||
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
|
||||
prestatii = mapped.get("prestatii") or []
|
||||
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
|
||||
@@ -1180,12 +1204,14 @@ def commit_import(
|
||||
|
||||
class RandEditIn(BaseModel):
|
||||
"""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
|
||||
nr_inmatriculare: str | None = None
|
||||
data_prestatie: str | None = None
|
||||
odometru_initial: str | None = None
|
||||
odometru_final: str | None = None
|
||||
obs: str | None = None
|
||||
|
||||
|
||||
@router.post("/{import_id}/rand/{row_index}/editeaza")
|
||||
|
||||
@@ -104,6 +104,13 @@ class Settings(BaseSettings):
|
||||
worker_retry_max_s: int = 300
|
||||
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
|
||||
def rar_base_url(self) -> str:
|
||||
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test
|
||||
|
||||
246
app/embeddings.py
Normal file
246
app/embeddings.py
Normal 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)
|
||||
151
app/mapping.py
151
app/mapping.py
@@ -14,6 +14,7 @@ unit-testabile direct. Cele cu `conn` sunt helpere de persistenta.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
@@ -483,10 +484,18 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
|
||||
entry["denumire"] = item.get("denumire")
|
||||
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] = []
|
||||
for entry in agg.values():
|
||||
entry["blocked"] = len(entry.pop("_ids"))
|
||||
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.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
|
||||
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:
|
||||
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
|
||||
|
||||
|
||||
@@ -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.
|
||||
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 {
|
||||
"vehicul_nr": nr 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
|
||||
"op_service_cod": op_service_cod,
|
||||
"op_service_denumire": op_service_denumire,
|
||||
"obs": obs,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
-- 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.
|
||||
CREATE TABLE IF NOT EXISTS worker_heartbeat (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
|
||||
139
app/shared_store.py
Normal file
139
app/shared_store.py
Normal 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
172
app/web/templates/_chips_prestatii.html
Normal file
172
app/web/templates/_chips_prestatii.html
Normal 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 }}">
|
||||
×
|
||||
</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 }}">×</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>
|
||||
@@ -1,17 +1,22 @@
|
||||
{# _form_editare.html — partial partajat: campurile vehicul/data/odometru.
|
||||
US-005 (PRD 5.12): extras DRY din _trimitere_detaliu.html; refolosit si de
|
||||
_preview_rand.html (US-006) pentru editarea randurilor de import in modal.
|
||||
{# _form_editare.html — partial partajat slim: campurile vehicul/data/odo + obs + chips prestatii.
|
||||
US-007 (PRD 5.15): redesign slim cu VIN unic, Observatii textarea, chips prestatii (E4),
|
||||
si reveal dinamic odometru initial cand chips contin R-ODO/I-ODO (D10c, E6 server-driven).
|
||||
|
||||
Inclus cu {% include "_form_editare.html" %} INSIDE un <form> element al
|
||||
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_vin — valoare curenta vin
|
||||
form_data — valoare curenta data_prestatie (YYYY-MM-DD sau brut)
|
||||
form_odo_final — valoare curenta odometru_final
|
||||
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 {})
|
||||
fix_map — dict {field_name: hint_fix} (poate fi {})
|
||||
vin_context — string VIN pentru aria-label (poate fi '')
|
||||
@@ -19,23 +24,78 @@
|
||||
#}
|
||||
{% from "_macros.html" import camp, icon %}
|
||||
|
||||
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
|
||||
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
{{ camp('vin', 'VIN (serie sasiu)', form_vin,
|
||||
{# === 1. VIN — camp unic (fara "Confirma VIN"; contractul RAR cere un singur VIN) === #}
|
||||
{{ camp('vin', 'VIN (serie sasiu)', form_vin, slim=True, mono=True,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
|
||||
{# Restul campurilor in grila responsiva existenta. #}
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:0 16px;">
|
||||
{{ camp('data_prestatie', 'Data prestatie', form_data, tip='date',
|
||||
{# === 2. Data prestatie + Nr. inmatriculare — grila 2 coloane === #}
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0 12px;">
|
||||
{{ camp('data_prestatie', 'Data prestatiei', form_data, tip='date', slim=True,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
{{ camp('odometru_final', 'Odometru final', form_odo_final,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial,
|
||||
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr, slim=True, mono=True,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
</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,
|
||||
sistemul .act (desktop = text alaturat; mobil = doua iconite Lucide 44px alaturate).
|
||||
Implicit (ex. _trimitere_detaliu): un singur buton text, neschimbat. #}
|
||||
|
||||
@@ -18,9 +18,13 @@
|
||||
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')
|
||||
#}
|
||||
{% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c') %}
|
||||
<div style="margin-bottom:10px;">
|
||||
<label for="{{ id_prefix }}-{{ nome }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
|
||||
{% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c', slim=False, mono=False) %}
|
||||
{# slim=False: randare clasica (neschimbata). slim=True: varianta compacta (.camp-slim) din US-002 PRD 5.15:
|
||||
label 11px muted deasupra, input ~30px, fundal --card2.
|
||||
mono=True (valid numai cu slim=True): adauga clasa 'camp-mono' pe input pentru campuri
|
||||
VIN/odometru/nr (IBM Plex Mono, prin .camp-slim .camp-mono din base.html). #}
|
||||
<div {% if slim %}class="camp-slim"{% else %}style="margin-bottom:10px;"{% endif %}>
|
||||
<label for="{{ id_prefix }}-{{ nome }}"{% if not slim %} class="muted" style="font-size:12px; display:block;"{% endif %}>{{ eticheta }}</label>
|
||||
{% if tip == 'date' %}
|
||||
{# D#10/R3: degradare grijulie pentru valori ne-YYYY-MM-DD.
|
||||
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] == '-') -%}
|
||||
<input id="{{ id_prefix }}-{{ nome }}" type="date" name="{{ nome }}"
|
||||
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 %}"
|
||||
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
|
||||
{% if not _dp_ok and valoare %}
|
||||
@@ -38,7 +43,8 @@
|
||||
{% else %}
|
||||
<input id="{{ id_prefix }}-{{ nome }}" type="{{ tip }}" name="{{ nome }}"
|
||||
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 err_map.get(nome) %}aria-invalid="true"{% endif %}>
|
||||
{% endif %}
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
<tbody>
|
||||
{% for e in pending %}
|
||||
{% 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). #}
|
||||
<tr data-dt-row="{{ e.cod_op_service }} {{ e.denumire or '' }}
|
||||
{%- 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">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<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>
|
||||
<div><strong>{{ e.cod_op_service }}</strong>
|
||||
<span class="pill" title="submission-uri blocate">{{ e.blocked }} blocate</span></div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
hx-swap="outerHTML"
|
||||
{% 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 %}
|
||||
<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;">
|
||||
@@ -14,50 +14,68 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rand 1: doua bife binare + ultima autentificare -->
|
||||
<div style="display:flex; gap:28px; flex-wrap:wrap; align-items:center; font-size:14px;">
|
||||
|
||||
{# Bifa: glifa (✓/✗) + culoare + text — accesibil (nu doar culoare, design review) #}
|
||||
{% macro bifa(ok, text, tip) %}
|
||||
<span title="{{ tip }}" style="display:inline-flex; align-items:center; gap:7px;">
|
||||
{% if ok %}
|
||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||
<span class="s-sent">{{ text }}</span>
|
||||
{# === D6: Strip sanatate mereu-vizibil DEASUPRA contoarelor ===
|
||||
Verde: worker viu + RAR ok → "Declaratiile curg normal"
|
||||
Rosu: worker oprit SAU RAR inaccesibil → "Blocat: ... — declaratiile NU pleaca"
|
||||
Glife accesibile ✓/✗ (nu doar culoare). Layout: glifa+text stanga, ultima auth dreapta.
|
||||
#}
|
||||
<div id="strip-sanatate"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;
|
||||
padding:10px 14px; border-radius:8px; margin-bottom:14px;
|
||||
{% if sanatate_ok %}background:color-mix(in srgb, var(--ok) 13%, transparent); border:1px solid color-mix(in srgb, var(--ok) 30%, transparent);
|
||||
{% else %}background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);
|
||||
{% endif %}">
|
||||
<div style="display:flex; align-items:center; gap:9px;">
|
||||
{% if sanatate_ok %}
|
||||
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--ok);">✓</span>
|
||||
{% else %}
|
||||
<span class="s-error" aria-hidden="true" style="font-weight:bold;">✗</span>
|
||||
<span class="s-error">{{ text }}</span>
|
||||
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--err);">✗</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endmacro %}
|
||||
|
||||
{{ bifa(worker_ok, worker_lbl[0], worker_lbl[1]) }}
|
||||
{{ bifa(rar_ok, rar_lbl[0], rar_lbl[1]) }}
|
||||
|
||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||
<span class="muted">{{ eticheta_ultima_auth }}:</span>
|
||||
<span>{{ last_login }}</span>
|
||||
<span style="font-weight:700; font-size:13px;">{{ sanatate_text }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Rand 2: contoare coada -->
|
||||
<div style="margin-top:10px; display:flex; gap:20px; flex-wrap:wrap; font-size:14px;">
|
||||
<span><span class="muted">In asteptare:</span> <span class="s-queued">{{ counts_queued }}</span></span>
|
||||
<span><span class="muted">Declarate la RAR:</span> <span class="s-sent">{{ counts_sent }}</span></span>
|
||||
<span><span class="muted">Blocate:</span>
|
||||
<span class="{{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</span>
|
||||
</span>
|
||||
{# === D4: 3 carduri-contor (mockup exact: Trimise / In coada / De corectat) ===
|
||||
Responsive: flex-wrap => 3 pe rand desktop, 2/stivuite pe mobil (min-width:120px).
|
||||
Trimise: all-time (cifra mare) + sub-linie "luna N · azi N" (D4 + E7).
|
||||
De corectat: rosu cand >0 (s-error), muted cand 0.
|
||||
#}
|
||||
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:14px;">
|
||||
|
||||
{# 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 }} · azi {{ sent_today }}</div>
|
||||
</div>
|
||||
|
||||
{# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #}
|
||||
{# 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>
|
||||
|
||||
{# === 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).
|
||||
{# 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>
|
||||
|
||||
{# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping ===
|
||||
Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ.
|
||||
#}
|
||||
{% set _tab = tab_activ | default('acasa') %}
|
||||
<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="/"
|
||||
{% if _tab == 'acasa' or _tab == '' %}aria-current="page"{% endif %}
|
||||
class="status-nav-link{% if _tab == 'acasa' or _tab == '' %} status-nav-activ{% endif %}">Trimiteri</a>
|
||||
|
||||
@@ -12,9 +12,22 @@
|
||||
{# Versiunea datelor cu care s-a randat tabelul; pollerul "Date noi" o compara. #}
|
||||
<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 %}
|
||||
{# Form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
|
||||
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
|
||||
{# Form bulk cu DOUA actiuni: (1) aplica cod RAR la selectate (bulk-fix, US-010),
|
||||
(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"
|
||||
hx-post="/trimiteri/sterge-bulk"
|
||||
hx-target="#submissions-wrap"
|
||||
@@ -23,30 +36,47 @@
|
||||
hx-disinherit="hx-confirm"
|
||||
style="margin:0;">
|
||||
<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"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
<div class="tablewrap tabel-trimiteri">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th class="col-chk"><span class="muted" title="Selecteaza randuri blocate">✓</span></th>
|
||||
<th class="col-id">#</th>
|
||||
<th class="col-stare">Stare</th>
|
||||
<th class="col-vehicul">Vehicul</th>
|
||||
<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>
|
||||
|
||||
{# Lista slim trimiteri (US-004, PRD 5.15).
|
||||
Inlocuieste tabelul cu randuri compacte: VIN mono + operatie·ora + pill.
|
||||
Nr. inmatriculare, data prestatie si nr. prezentare RAR raman accesibile
|
||||
pe linia meta discreta (linia 3) si in modalul de detaliu. #}
|
||||
<ul class="lista-trimiteri-slim" role="list"
|
||||
aria-label="Lista trimiteri">
|
||||
{% 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). #}
|
||||
<tr id="trimitere-row-{{ r.id }}"
|
||||
class="trimitere-row"
|
||||
<li id="trimitere-row-{{ r.id }}"
|
||||
class="trimitere-slim"
|
||||
data-detaliu-id="{{ r.id }}"
|
||||
hx-get="/_fragments/trimitere/{{ r.id }}"
|
||||
hx-target="#detaliu-modal-body"
|
||||
@@ -55,47 +85,61 @@
|
||||
aria-haspopup="dialog"
|
||||
style="cursor:pointer;"
|
||||
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 %}
|
||||
<input type="checkbox" name="submission_id" value="{{ r.id }}"
|
||||
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-id muted" data-eticheta="#">{{ r.id }}</td>
|
||||
<td class="col-stare" data-eticheta="Stare">
|
||||
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
|
||||
{# Eticheta umana scurta sub pill — text mic, `s-error` pe error/needs_*
|
||||
(singurele stari pe care `eticheta_problema` e ne-goala).
|
||||
Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #}
|
||||
{% 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 }}
|
||||
</div>
|
||||
|
||||
{# Bloc text principal — stanga, ocupa spatiul ramas #}
|
||||
<div style="flex:1 1 auto; min-width:0;">
|
||||
|
||||
{# Linia 1: VIN mono scurt (slim-vin).
|
||||
Guard: vin_scurt='—' inseamna VIN lipsa; fallback la vehicul_nr. #}
|
||||
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
||||
{# VIN pe rand separat sub nr (element block, nu span inline) #}
|
||||
<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>
|
||||
<div class="slim-vin">{{ r.prez.vin_scurt }}</div>
|
||||
{% else %}
|
||||
<div class="muted cod-rar-sub">nemapat</div>
|
||||
<div class="slim-vin muted">{{ r.prez.vehicul_nr }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-data" data-eticheta="Data prestatie">{{ r.prez.data_prestatie }}</td>
|
||||
<td class="col-rar" data-eticheta="Nr. prezentare RAR">{{ r.id_prezentare or '—' }}</td>
|
||||
<td class="col-actualizat muted" data-eticheta="Actualizat">{{ r.updated_at }}</td>
|
||||
</tr>
|
||||
|
||||
{# Linia 2: Operatie · ora/data (slim-meta muted) #}
|
||||
<div class="slim-meta">{{ r.prez.operatie }} · {{ r.updated_at }}</div>
|
||||
|
||||
{# 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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
{#
|
||||
|
||||
@@ -106,32 +106,10 @@
|
||||
hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
{# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
|
||||
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else).
|
||||
RAMANE in _trimitere_detaliu.html (D#5 — logica specifica acestui modal). #}
|
||||
{% if nomenclator_rar %}
|
||||
<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 }} · {{ cod_afis }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Cleanup B (US-009 PRD 5.15): vechiul <select name="cod_prestatie"> eliminat.
|
||||
Chips din _form_editare.html (via _chips_prestatii.html) il inlocuiesc complet:
|
||||
emit hidden inputs name="cod_prestatie" + picker per-operatie (E4, US-007).
|
||||
post_corectie_trimitere foloseste form.getlist("cod_prestatie") → compatibil. #}
|
||||
|
||||
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
|
||||
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent.
|
||||
@@ -190,7 +168,7 @@
|
||||
{% for item in nomenclator_rar %}
|
||||
<option value="{{ item.cod_prestatie }}"
|
||||
{% if item.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
|
||||
{{ item.cod_prestatie }} — {{ item.nome_prestatie }}
|
||||
{{ item.cod_prestatie }} — {{ item.nume_prestatie }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
@@ -16,13 +16,16 @@
|
||||
<script>
|
||||
// Anti-FOUC: citeste preferinta tema din localStorage inainte de primul
|
||||
// paint; seteaza data-theme pe <html> sincron, fara blink.
|
||||
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto.
|
||||
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink).
|
||||
// Cunoaste TOATE cele 7+1 teme: light/dark/petrol/grafit/cobalt/cupru/hartie + auto.
|
||||
// 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() {
|
||||
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 {
|
||||
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') {
|
||||
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
@@ -100,16 +103,32 @@
|
||||
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;
|
||||
}
|
||||
/* Paleta dark (default) — accent azur ROMFAST */
|
||||
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
|
||||
/* Paleta dark (default) — accent azur ROMFAST.
|
||||
--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; }
|
||||
/* 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; }
|
||||
/* Paleta Petrol — tema intunecata alternativa, accent teal #0E7C7B.
|
||||
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; }
|
||||
/* 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; }
|
||||
/* CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
|
||||
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 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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -749,18 +824,36 @@
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Comutator tema ciclic: click cicleaza Light->Dark->Petrol->Auto.
|
||||
// Separare init (sincronizare iconita/label) de persistenta (doar la click explicit).
|
||||
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
|
||||
// Comutator tema ciclic (DRY E2 — PRD 5.15): config traieste intr-o singura structura
|
||||
// sursa-de-adevar THEMES din care se DERIVA CYCLE/VALID/ICONS/LABELS/NEXT.
|
||||
// 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() {
|
||||
var btn = document.getElementById('tema-toggle');
|
||||
if (!btn) return;
|
||||
var CYCLE = ['light', 'dark', 'petrol', 'auto'];
|
||||
var VALID = {light:1, dark:1, petrol:1, auto:1};
|
||||
// Iconite per tema: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto
|
||||
var ICONS = {light:'☀', dark:'☾', petrol:'◐', auto:'◙'};
|
||||
var LABELS = {light:'Light', dark:'Dark', petrol:'Petrol', auto:'Auto'};
|
||||
var NEXT = {light:'Dark', dark:'Petrol', petrol:'Auto', auto:'Light'};
|
||||
// SURSA DE ADEVAR UNICA: adaugarea unei teme = o singura intrare aici.
|
||||
// Iconite: ☀ Light | ☾ Dark | ◐ Petrol | ◑ Grafit | ◆ Cobalt | ◇ Cupru | ○ Hartie | ◉ Auto
|
||||
var THEMES = [
|
||||
{id:'light', label:'Light', icon:'☀'},
|
||||
{id:'dark', label:'Dark', icon:'☾'},
|
||||
{id:'petrol', label:'Petrol', icon:'◐'},
|
||||
{id:'grafit', label:'Grafit', icon:'◑'},
|
||||
{id:'cobalt', label:'Cobalt', icon:'◆'},
|
||||
{id:'cupru', label:'Cupru', icon:'◇'},
|
||||
{id:'hartie', label:'Hartie', icon:'○'},
|
||||
{id:'auto', label:'Auto', icon:'◙'},
|
||||
];
|
||||
// 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() {
|
||||
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) {
|
||||
var elt = evt.detail && evt.detail.elt;
|
||||
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.
|
||||
body.addEventListener('htmx:afterSettle', function() {
|
||||
@@ -1083,7 +1176,7 @@
|
||||
// Tastatura pe rand (role=button): Enter/Space deschid modalul.
|
||||
document.body.addEventListener('keydown', function(evt) {
|
||||
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') {
|
||||
evt.preventDefault();
|
||||
t.click();
|
||||
|
||||
@@ -14,8 +14,13 @@ services:
|
||||
environment:
|
||||
AUTOPASS_DB_PATH: /data/autopass.db
|
||||
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_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
|
||||
healthcheck:
|
||||
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
@@ -1,6 +1,67 @@
|
||||
<!-- plan sub /autoplan -->
|
||||
# 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
|
||||
|
||||
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`,
|
||||
`or_modeltest.py`; de adaugat `or_label.py` cu grupare + propagare).
|
||||
- Stratul 2: `suggest_from_corpus` + similaritate embeddings in `app/mapping.py`,
|
||||
apelata in `pending_unmapped` pentru sugestia din editor. Model embedding incarcat
|
||||
la pornire / serviciu, vectori pre-calculati pe baza.
|
||||
- Stratul 2: similaritate embeddings in `app/mapping.py` (`enrich_suggestions` ->
|
||||
`suggest_nearest`), apelata in `pending_unmapped` / `_nemapate_pentru_submission`
|
||||
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
|
||||
`operations_mapping`), seed la confirmare umana; override per-cont.
|
||||
- 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) |
|
||||
| 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 |
|
||||
| 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)
|
||||
|
||||
@@ -221,4 +297,92 @@ e goala momentan si se completeaza la urmatoarea rulare.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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`.
|
||||
> 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)
|
||||
- **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**:
|
||||
- [ ] 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`)
|
||||
+ 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
|
||||
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).
|
||||
- [ ] **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`
|
||||
(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
|
||||
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).
|
||||
- [ ] 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).
|
||||
- [ ] Contrast AA pentru text principal in toate temele (light + hartie sunt cele luminoase).
|
||||
- [ ] `DESIGN.md` actualizat: sectiunea cromatica + selector tema reflecta toate temele.
|
||||
- [x] Contrast AA pentru text principal in toate temele (light + hartie sunt cele luminoase).
|
||||
- [x] `DESIGN.md` actualizat: sectiunea cromatica + selector tema reflecta toate temele.
|
||||
- **Verificare E2E**: browser pe `/` (dashboard logat) — ciclare prin toate temele, persistenta la
|
||||
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)
|
||||
- **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**:
|
||||
- [ ] `.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
|
||||
`.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.
|
||||
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`
|
||||
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).
|
||||
- [ ] **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
|
||||
(`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
|
||||
(`var(--errt)`), nu `#E05D5D` hardcodat, ca sa ramana AA pe temele luminoase (hartie/light).
|
||||
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.
|
||||
|
||||
### 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)
|
||||
- **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**:
|
||||
- [ ] **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
|
||||
oprit SAU RAR inaccesibil ("Blocat: worker oprit" / "Blocat: RAR inaccesibil"), cu ultima
|
||||
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
|
||||
— declaratiile NU pleaca" (sau "... RAR inaccesibil"), verde "Declaratiile curg normal".
|
||||
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).
|
||||
- [ ] **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
|
||||
(`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
|
||||
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`.
|
||||
**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
|
||||
@@ -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
|
||||
marcheaza `sent` cu `updated_at` = momentul reconcilierii, nu al inserarii RAR — pentru
|
||||
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),
|
||||
nu intr-o pagina noua.
|
||||
- [ ] 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] Scoped pe cont; poll-ul existent (`/_fragments/status`) randeaza noul antet fara a pierde tab-ul.
|
||||
- [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,
|
||||
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)
|
||||
- **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**:
|
||||
- [ ] 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.
|
||||
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.
|
||||
- [ ] Click pe rand deschide `/_fragments/trimitere/{id}` in modal (neschimbat).
|
||||
- [ ] 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] Click pe rand deschide `/_fragments/trimitere/{id}` in modal (neschimbat).
|
||||
- [x] Slim layout consistent desktop si <=1024px (cardurile responsive existente nu regreseaza).
|
||||
- [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").
|
||||
- **Verificare E2E**: browser — filtrare + paginare + click detaliu + bulk pe blocate, pe 4 teme,
|
||||
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)
|
||||
- **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**:
|
||||
- [ ] `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`.
|
||||
- [ ] `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` traieste in `payload_json` (camp `obs` din contractul RAR); fara coloana noua / migrare (D5).
|
||||
- [x] `obs` adaugat in `EDIT_FIELDS`; `corecteaza` si `editeaza` (preview) accepta si persista `obs`.
|
||||
- [x] `obs` optional (text liber, fara validare de continut, doar trim); apare in `payload_view`.
|
||||
- [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
|
||||
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
|
||||
`obs`; daca are coloana Observatii, se pastreaza textul ei. Format de concatenare definit
|
||||
(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
|
||||
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).
|
||||
- **Verificare E2E**: `POST /trimitere/{id}/corecteaza` cu `obs` -> persistat -> vizibil in detaliu;
|
||||
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").
|
||||
- **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**:
|
||||
- [ ] 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]`
|
||||
(`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.
|
||||
@@ -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
|
||||
(altfel rupe D7 si US-009). **Test de regresie obligatoriu** (IRON RULE): op_service
|
||||
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);
|
||||
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).
|
||||
- [ ] 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).
|
||||
- [ ] 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] Lista goala de coduri -> ramane `needs_mapping` (nu se trimite fara cod).
|
||||
- [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
|
||||
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.
|
||||
- [ ] Se pastreaza regula `odometruInitial` obligatoriu cand lista contine `R-ODO`/`I-ODO`
|
||||
- [x] Recalcul idempotenta dupa editare (mecanism existent), cu prinderea coliziunii ca azi.
|
||||
- [x] Se pastreaza regula `odometruInitial` obligatoriu cand lista contine `R-ODO`/`I-ODO`
|
||||
(contract §payload) — validare existenta, doar verificata pe lista.
|
||||
- **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.
|
||||
@@ -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)
|
||||
- **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**:
|
||||
- [ ] 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 liber, legat de `obs` (US-005).
|
||||
- [ ] Prestatii = chips multi-select. **Binding op<->cod (E4)**: cand exista operatii
|
||||
- [x] Observatii = textarea liber, legat de `obs` (US-005).
|
||||
- [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
|
||||
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).
|
||||
- [ ] 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).
|
||||
- [ ] **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
|
||||
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.
|
||||
- [ ] 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).
|
||||
- [ ] **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
|
||||
server (server computeaza din lista de chips, fara ramura JS); navigare tastatura =
|
||||
`<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.
|
||||
- [ ] **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
|
||||
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
|
||||
@@ -361,8 +361,8 @@ fisiere fierbinti (base.html) si nu vreau regresii pe teme/liste/formular.
|
||||
(~3 fisiere)
|
||||
- **Test intai (RED)**: completare scenarii lipsa (componente noi pe TOATE temele; slim list desktop+mobil)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `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] `pytest -q -m "not live"` verde (fara regresii fata de baseline).
|
||||
- [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
|
||||
(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.
|
||||
@@ -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)
|
||||
- **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**:
|
||||
- [ ] 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`
|
||||
(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`).
|
||||
- [ ] Optional: daca userul nu vrea sa salveze, editarea ramane one-off (fara regula). Se compune
|
||||
- [x] Re-rezolvarea deblocheaza si alte submission-uri `needs_mapping` cu aceeasi operatie (pe `batch_id`).
|
||||
- [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).
|
||||
- **Verificare E2E**: adaug cod la operatie nemapata + salveaza regula -> al doilea rand cu aceeasi
|
||||
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)
|
||||
- **Test intai (RED)**: `tests/test_web_bulk_fix.py` — `test_bulk_remapeaza_selectie`, `test_bulk_doar_blocate`, `test_bulk_scoped_cont`
|
||||
- **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`).
|
||||
- [ ] 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] Scoped pe cont (404-before-409 ca la bulk-delete); doar randuri blocate eligibile.
|
||||
- [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).
|
||||
- **Verificare E2E**: selectez 3 randuri needs_mapping + aplic un cod -> toate 3 -> `queued`.
|
||||
- **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_get_listare_neautentificat_401`; `test_get_detaliu_scoped` (404-before-leak pe id strain).
|
||||
- **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).
|
||||
- [ ] 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] Un cont nu poate enumera/citi VIN/PII al altui cont prin listare sau detaliu.
|
||||
- [x] Enforcement aliniat cu `AUTOPASS_REQUIRE_API_KEY` (dev vs prod), fara a rupe contul id=1
|
||||
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
|
||||
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)
|
||||
- **Test intai (RED)**: `test_device_mix_inregistrat`, `test_device_mix_fara_pii`.
|
||||
- **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
|
||||
daca `app_events` poarta semnalul.
|
||||
- [ ] Un mod simplu de citire a raportului (query/admin), suficient pentru a decide investitia mobil.
|
||||
- [ ] Zero PII nou; aliniat retentiei `app_events` existente.
|
||||
- [x] Un mod simplu de citire a raportului (query/admin), suficient pentru a decide investitia mobil.
|
||||
- [x] Zero PII nou; aliniat retentiei `app_events` existente.
|
||||
- **Verificare E2E**: acces dashboard de pe doua viewport-uri -> doua evenimente clasificate corect.
|
||||
|
||||
## 4. Riscuri
|
||||
@@ -504,8 +504,36 @@ Val 6: [US-008] regresie + E2E final (dupa toate)
|
||||
|
||||
## Raport VERIFY
|
||||
|
||||
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
||||
> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E pe RAR test). Lipseste pana la VERIFY.
|
||||
Verificator independent (context curat, subagent Sonnet) — 2026-06-28. **VERDICT: PASS** (12/12 stories),
|
||||
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
4
pytest.ini
Normal 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")
|
||||
@@ -20,3 +20,8 @@ openpyxl==3.1.5
|
||||
|
||||
# Migrare DBF (tools/import_dbf.py). Necesar doar pentru import optional, nu pentru runtime.
|
||||
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
|
||||
|
||||
@@ -14,10 +14,34 @@ variabila exportata explicit in shell. Testele care chiar verifica enforcement-u
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "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:
|
||||
"""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
226
tests/test_api_scope.py
Normal 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)."
|
||||
)
|
||||
@@ -49,11 +49,13 @@ def test_dashboard_renders_with_rar_state(client):
|
||||
assert r.status_code == 200
|
||||
# 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"
|
||||
# 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")
|
||||
assert rs.status_code == 200
|
||||
# eticheta_worker(False) => "Trimitere automata: oprita" → fragmentul afiseaza "oprita"
|
||||
assert "oprita" in rs.text or "Trimitere automata" in rs.text
|
||||
# US-003 D6: strip sanatate unificat — "declaratiile" apare in orice stare (curg/blocat)
|
||||
assert "declaratiile" in rs.text.lower(), (
|
||||
f"Strip sanatate lipseste din fragment. HTML: {rs.text[:500]}"
|
||||
)
|
||||
# Tab-ul Nomenclator e accesat via /_fragments/nomenclator
|
||||
rn = client.get("/_fragments/nomenclator")
|
||||
assert rn.status_code == 200
|
||||
|
||||
152
tests/test_device_mix.py
Normal file
152
tests/test_device_mix.py
Normal 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
233
tests/test_embeddings.py
Normal 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
527
tests/test_heldout_eval.py
Normal 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
286
tests/test_holdout.py
Normal 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")
|
||||
578
tests/test_mapare_integrare_l14.py
Normal file
578
tests/test_mapare_integrare_l14.py
Normal 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
491
tests/test_or_label.py
Normal 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
290
tests/test_shared_store.py
Normal 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
|
||||
@@ -186,3 +186,187 @@ def test_fragmente_fara_fundal_hardcodat():
|
||||
"Fragmente cu fundal hardcodat dark (nu adapteaza la tema light):\n"
|
||||
+ "\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
248
tests/test_web_bulk_fix.py
Normal 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!"
|
||||
@@ -231,8 +231,9 @@ def test_camp_apare_o_singura_data(client):
|
||||
|
||||
|
||||
def test_nr_si_vin_pe_randuri_separate(client):
|
||||
"""Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele inputuri latime plina,
|
||||
nr. inaintea VIN-ului in markup."""
|
||||
"""VIN si Nr. inmatriculare sunt ambele prezente ca inputuri separate in formular.
|
||||
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")
|
||||
sid = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0U2001", odo=""))
|
||||
_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_vin = html.find('name="vin"')
|
||||
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):
|
||||
|
||||
472
tests/test_web_corectie_obs.py
Normal file
472
tests/test_web_corectie_obs.py
Normal 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}"
|
||||
)
|
||||
546
tests/test_web_corectie_prestatii.py
Normal file
546
tests/test_web_corectie_prestatii.py
Normal 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]}"
|
||||
)
|
||||
496
tests/test_web_form_editare_slim.py
Normal file
496
tests/test_web_form_editare_slim.py
Normal 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]}"
|
||||
)
|
||||
340
tests/test_web_mapare_din_chip.py
Normal file
340
tests/test_web_mapare_din_chip.py
Normal 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]}"
|
||||
)
|
||||
99
tests/test_web_modal_slim.py
Normal file
99
tests/test_web_modal_slim.py
Normal 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"
|
||||
)
|
||||
@@ -500,3 +500,241 @@ def test_liste_actionabile_o_coloana_pana_1024(client):
|
||||
# Blocul tableta cardifica listele (thead ascuns = card per rand, o coloana).
|
||||
assert ".tabel-trimiteri thead, .tabel-card thead { display:none; }" in html, \
|
||||
"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
231
tests/test_web_scope.py
Normal 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)."
|
||||
)
|
||||
@@ -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).
|
||||
Verde/✓ cand worker viu + RAR ok; rosu/✗ cand oprit/indisponibil.
|
||||
D6 (strip sanatate): linie colorata DEASUPRA contoarelor — verde "declaratiile curg" /
|
||||
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
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
@@ -70,8 +76,12 @@ def client(monkeypatch):
|
||||
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):
|
||||
"""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")
|
||||
_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")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# Glifa de OK prezenta (accesibilitate: nu doar culoare)
|
||||
# Glifa accesibila ✓ (nu doar culoare)
|
||||
assert "✓" in html, f"Lipseste glifa ✓ cand totul e ok. HTML: {html[:600]}"
|
||||
# Texte umane de OK
|
||||
assert "activa" in html.lower()
|
||||
assert "functionala" in html.lower()
|
||||
# US-003 D6: strip unificat (nu bife individuale worker/RAR)
|
||||
assert "curg normal" in html.lower(), (
|
||||
f"Textul 'curg normal' din strip sanatate lipseste. HTML: {html[:600]}"
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
_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
|
||||
html = resp.text
|
||||
assert "✗" 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):
|
||||
@@ -118,12 +131,231 @@ def test_status_data_formatata_romaneste(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")
|
||||
_login(client, "bifefont@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
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"):
|
||||
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 "✗" 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()
|
||||
|
||||
@@ -94,7 +94,11 @@ def client(monkeypatch):
|
||||
# ============================================================
|
||||
|
||||
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")
|
||||
_login(client, "status@test.com", "parolasecreta10")
|
||||
|
||||
@@ -102,17 +106,14 @@ def test_status_fragment_text_uman(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Trebuie sa contina textul uman din eticheta_worker (labels.py)
|
||||
assert "Trimitere automata" in html, (
|
||||
f"Fragmentul nu contine 'Trimitere automata'. HTML (primele 500 ch): {html[:500]}"
|
||||
# US-003 D6: strip sanatate cu text uman compus (nu bife individuale)
|
||||
assert "declaratiile" in html.lower(), (
|
||||
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(), (
|
||||
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, (
|
||||
"Fragmentul contine eticheta bruta 'viu'"
|
||||
)
|
||||
|
||||
@@ -78,7 +78,11 @@ def client(monkeypatch):
|
||||
|
||||
|
||||
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")
|
||||
_insert_submission(acct, "sent", id_prezentare=68516)
|
||||
_login(client, "col@test.com")
|
||||
@@ -86,17 +90,25 @@ def test_submissions_coloane_umane(client):
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
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"):
|
||||
assert antet in html, f"Lipseste antetul '{antet}'"
|
||||
# "HTTP RAR" NU mai e antet principal de coloana
|
||||
|
||||
# Layout slim prezent (US-004 PRD 5.15)
|
||||
assert "lista-trimiteri-slim" in html, "lista slim lipseste"
|
||||
assert "trimitere-slim" in html, "rand slim lipseste"
|
||||
|
||||
# "HTTP RAR" NU e antet / eticheta vizibila
|
||||
assert "<th>HTTP RAR</th>" not in html
|
||||
# Starea afisata e text uman, nu 'sent' brut intr-un pill
|
||||
assert ">sent<" not in html, "Starea bruta 'sent' nu ar trebui afisata"
|
||||
assert "Declarate la RAR" in html, "Starea umana lipseste"
|
||||
# Vehicul + operatie din payload, nu doar idPrezentare
|
||||
assert "B777ZZZ" in html
|
||||
assert "Reparatie frane" in html
|
||||
assert "HTTP RAR" not in html
|
||||
|
||||
# Starea afisata e text uman, nu 'sent' brut
|
||||
assert ">sent<" not in html, "Starea bruta 'sent' nu ar trebui afisata direct"
|
||||
assert "Declarate la RAR" in html, "Starea umana (titlu pill) lipseste"
|
||||
|
||||
# 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):
|
||||
@@ -405,3 +417,128 @@ def test_detaliu_trimitere_404_cross_account(client):
|
||||
# acelasi 404 pentru un id inexistent
|
||||
resp2 = client.get("/_fragments/trimitere/999999")
|
||||
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"
|
||||
|
||||
@@ -82,14 +82,14 @@ def client(monkeypatch):
|
||||
|
||||
|
||||
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.
|
||||
Dupa: <div class="muted">...VIN...</div> (block, rand separat).
|
||||
Testul asserteaza prezenta unui element block, nu doar textul.
|
||||
PRD 5.10 (US-005): VIN era <div class="muted"> sub nr in coloana Vehicul.
|
||||
PRD 5.15 (US-004): VIN e acum identificatorul PRINCIPAL, linia 1 a randului slim,
|
||||
in <div class="slim-vin"> (mono, prominent, block-level). NU mai e muted.
|
||||
"""
|
||||
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")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
@@ -97,44 +97,29 @@ def test_vin_pe_rand_separat_sub_nr(client):
|
||||
html = resp.text
|
||||
|
||||
# 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.)
|
||||
# NU un simplu <span> inline.
|
||||
# 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 e intr-un element block-level (div cu clasa slim-vin)
|
||||
# Pattern: <div class="slim-vin">...000001...</div>
|
||||
vin_fragment = "000001"
|
||||
found_block = any(
|
||||
re.search(
|
||||
rf"<{tag}[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>",
|
||||
found_slim_vin = re.search(
|
||||
rf'<div[^>]*class="slim-vin[^"]*"[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</div>',
|
||||
html,
|
||||
)
|
||||
for tag in block_tags
|
||||
)
|
||||
assert found_block, (
|
||||
f"VIN '{vin_fragment}' trebuie sa fie intr-un element block-level "
|
||||
f"(div/p/small), nu intr-un <span> inline. HTML gasit: "
|
||||
assert found_slim_vin, (
|
||||
f"VIN '{vin_fragment}' trebuie sa fie in <div class=\"slim-vin\"> (block-level, "
|
||||
f"mono, linia 1 a randului slim). HTML gasit: "
|
||||
+ 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):
|
||||
"""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")
|
||||
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")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
@@ -144,8 +129,13 @@ def test_vin_lipsa_nu_genereaza_rand_gol(client):
|
||||
# Randul trebuie sa existe
|
||||
assert f'id="trimitere-row-{sid}"' in html
|
||||
|
||||
# In coloana vehicul nu trebuie sa apara un element block gol cu "—"
|
||||
# (garda != '—' exista deja, verifica ca e respectata)
|
||||
assert 'class="muted"' not in html.split('col-vehicul')[1].split('col-operatie')[0] or \
|
||||
'—' not in (html.split('col-vehicul')[1].split('col-operatie')[0]), \
|
||||
"Elementul muted din coloana Vehicul nu trebuie sa contina '—' (rand gol VIN)"
|
||||
# slim-vin NU trebuie sa contina '—' izolat (VIN lipsa -> fallback vehicul_nr)
|
||||
slim_vin_match = re.search(r'<div[^>]*class="slim-vin[^"]*"[^>]*>([^<]*)</div>', html)
|
||||
assert slim_vin_match, "slim-vin lipseste din randul cu VIN gol"
|
||||
slim_vin_content = slim_vin_match.group(1).strip()
|
||||
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"
|
||||
|
||||
567
tools/mapare-llm/heldout_eval.py
Normal file
567
tools/mapare-llm/heldout_eval.py
Normal 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
347
tools/mapare-llm/holdout.py
Normal 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()
|
||||
300
tools/mapare-llm/or_label.py
Normal file
300
tools/mapare-llm/or_label.py
Normal 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)
|
||||
Reference in New Issue
Block a user