Compare commits
30 Commits
b26dbb79e1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5ce0e2e2b | ||
|
|
deb6afff3e | ||
|
|
b4818349be | ||
|
|
ff9d0f41d1 | ||
|
|
7371c3703d | ||
|
|
851f76ca16 | ||
|
|
a29896a790 | ||
|
|
3f513f6c12 | ||
|
|
8f39dfbc1e | ||
|
|
e1243f603e | ||
|
|
80d90f317d | ||
|
|
12021eb269 | ||
|
|
308fee6c27 | ||
|
|
756f77730f | ||
|
|
c05fa00007 | ||
|
|
ce90dac833 | ||
|
|
c9f9a1ca0e | ||
|
|
9eccb9f6fa | ||
|
|
8dd0e1678c | ||
|
|
3fc53534e2 | ||
|
|
9e42e7ed6f | ||
|
|
19f89ecd70 | ||
|
|
9031f81908 | ||
|
|
4caf055c53 | ||
|
|
822185e138 | ||
|
|
41aa385644 | ||
|
|
865c208821 | ||
|
|
670019361c | ||
|
|
8d4ff3400e | ||
|
|
bafaf05e83 |
@@ -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
|
||||
|
||||
|
||||
38
TODOS.md
38
TODOS.md
@@ -38,3 +38,41 @@ Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand de
|
||||
- [ ] **Validare premisa "utilizare mobil reala"** — inainte de orice extindere responsive viitoare,
|
||||
confirma device-mix-ul (analytics/cerere user). Daca ~95% desktop, nu mai investi in cardificare
|
||||
mobil. (CEO F1, high — premisa nedovedita acum.)
|
||||
|
||||
## Din /plan-ceo-review PRD 5.15 (2026-06-28)
|
||||
|
||||
- [ ] **Validare premisa "utilizare mobil reala" (reluare F1 din 5.13)** — partea slim/compact a lui
|
||||
5.15 presupune utilizare reala pe mobil. Inainte de orice rafinare responsive viitoare, confirma
|
||||
device-mix-ul (analytics / cerere user). Daca ~95% desktop, nu mai investi in cardificare mobil.
|
||||
(CEO, high — premisa nedovedita.)
|
||||
|
||||
- [ ] **Deduparea/etichetarea temelor grafit~dark si hartie~light** — 5.15 adauga 4 teme peste cele 3
|
||||
existente (7 + Auto). grafit e ~ identic cu dark, hartie ~ identic cu light. Daca selectorul devine
|
||||
confuz sau matricea de test apasa, dedupica sau eticheteaza-le clar. (CEO, low — simplificare optionala.)
|
||||
|
||||
- [ ] **US-009/US-010 ca PRD separat daca propagarea design e urgenta** — salvarea mapare-din-chip si
|
||||
bulk-fix sunt adiacente FUNCTIONALE (acceptate via SELECTIVE EXPANSION), dincolo de obiectivul pur de
|
||||
propagare design. Daca vrei sa livrezi designul rapid, pot fi scoase intr-un PRD propriu. (CEO, low.)
|
||||
|
||||
## Din raport comparatie mockup 5.16 (2026-06-29)
|
||||
|
||||
> Restul task-urilor din `docs/raport-comparatie-mockup-5.16.md` au fost livrate (T-1..T-9).
|
||||
> Cele de mai jos raman explicit in coada la cererea userului.
|
||||
|
||||
- [ ] **Stare de eroare HTMX la incarcarea listei (D-4)** — cand `/_fragments/submissions`
|
||||
da 500 sau pica reteaua, `#submissions-wrap` ramane blocat pe spinner ("se incarca…") fara
|
||||
mesaj. De adaugat un partial de eroare / `hx-on::response-error` cu "nu s-a putut incarca,
|
||||
reincearca". Robustete pre-existenta (nu introdusa de 5.16), impact functional real —
|
||||
**candidatul cu cea mai mare valoare** din lista. (Design D-4, medium.)
|
||||
|
||||
- [ ] **Retokenizare px completa in template-uri** — `_submissions.html` / `_preview_*` folosesc
|
||||
literali `font-size:13px/12px/11px` in loc de token-urile `--fs-*`. 5.16 a corectat doar
|
||||
instanta sub-12px (incalca pragul PRD). Restul ramane debt: schimbarea in masa (13px→`--fs-sm`
|
||||
=13.5px) misca layout-ul, deci necesita o baza de regresie vizuala inainte. (Eng, bounded —
|
||||
amanat ca scope creep fara baza AC.)
|
||||
|
||||
- [ ] **Diacritice in textul vizibil pentru user** — mockup-urile folosesc diacritice complete
|
||||
("Observații", "Salvează", "Adaugă"); aplicatia le omite in majoritatea label-urilor. Fontul
|
||||
le randeaza corect (US-001 confirmat). De aplicat pe label-uri/butoane/titluri, pastrand
|
||||
cod/comentariile fara diacritice. Decizie initiala (poarta de gust T3): nu se aplica acum —
|
||||
reintrodus in coada la cererea userului (2026-06-29) ca finisaj viitor. (Transversal, low.)
|
||||
|
||||
103
app/accounts.py
103
app/accounts.py
@@ -15,6 +15,7 @@ inca fluxul de trimitere. (Addendum A2.)
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
def _norm_cui(cui: str | None) -> str | None:
|
||||
@@ -43,6 +44,8 @@ def create_account(
|
||||
cui: str | None = None,
|
||||
email: str | None = None,
|
||||
active: bool = True,
|
||||
requested_plan: str | None = None,
|
||||
consent_at: str | None = None,
|
||||
) -> int:
|
||||
"""Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1).
|
||||
|
||||
@@ -50,17 +53,31 @@ def create_account(
|
||||
`email` se normalizeaza (trim+lower); sir gol -> ValueError.
|
||||
Un CUI deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial
|
||||
`ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta.
|
||||
|
||||
`requested_plan`: planul CERUT la signup (separat de `tier`). NU acorda drepturi — `tier`
|
||||
ramane mereu 'free' la creare; planul cerut e doar o intentie pentru integrarea platilor.
|
||||
Valoare invalida (nu e in VALID_TIERS) -> ignorata (stocata NULL), nu arunca.
|
||||
`consent_at`: marca temporala consimtamant Termeni+GDPR (proba); None = fara flux consimtamant.
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("name gol (un cont are nevoie de nume)")
|
||||
cui = _norm_cui(cui)
|
||||
email = _norm_email(email)
|
||||
# Planul cerut: pastram doar valori valide; orice altceva -> NULL (defensiv).
|
||||
req_plan = requested_plan if requested_plan in VALID_TIERS else None
|
||||
try:
|
||||
# Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z.
|
||||
trial_until = (
|
||||
(datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
||||
cur = conn.execute(
|
||||
"INSERT INTO accounts (name, cui, email, active, status) VALUES (?, ?, ?, ?, ?)",
|
||||
(name, cui, email, 1 if active else 0, "active" if active else "pending"),
|
||||
"INSERT INTO accounts (name, cui, email, active, status, tier, trial_until, "
|
||||
"requested_plan, consent_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(name, cui, email, 1 if active else 0, "active" if active else "pending",
|
||||
"free", trial_until, req_plan, consent_at),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
||||
@@ -107,6 +124,8 @@ def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None:
|
||||
# Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de
|
||||
# retentie); restul sunt reversibile.
|
||||
VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted")
|
||||
# Tieruri de cont valide (5.17). Sursa de adevar: app/plans.py#PLANS (nu duplica valorile).
|
||||
VALID_TIERS = ("free", "standard", "pro", "premium")
|
||||
# Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b).
|
||||
_PROTECTED_ACCOUNT_ID = 1
|
||||
|
||||
@@ -131,6 +150,83 @@ def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def set_tier(
|
||||
conn: sqlite3.Connection,
|
||||
account_id: int,
|
||||
tier: str,
|
||||
trial_until: str | None = None,
|
||||
) -> None:
|
||||
"""Seteaza planul unui cont (tier + trial_until).
|
||||
|
||||
tier invalid -> ValueError cu mesaj clar.
|
||||
Contul de sistem id=1 e protejat (ca set_status).
|
||||
Cont inexistent -> ValueError.
|
||||
Logheaza schimbarea in app_events (reuse observ.log_event, fara PII nou).
|
||||
|
||||
trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul).
|
||||
"""
|
||||
if tier not in VALID_TIERS:
|
||||
raise ValueError(
|
||||
f"tier invalid: {tier!r} (valid: {', '.join(VALID_TIERS)})"
|
||||
)
|
||||
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
if account_id == _PROTECTED_ACCOUNT_ID:
|
||||
raise ValueError(
|
||||
"Contul default (id=1) nu poate fi mutat pe alt plan via CLI "
|
||||
"(cont de sistem, tratat coerent)."
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE accounts SET tier=?, trial_until=? WHERE id=?",
|
||||
(tier, trial_until, account_id),
|
||||
)
|
||||
# Audit in app_events (decizie PRD 5.17 US-008, fara PII nou)
|
||||
try:
|
||||
from .observ import log_event
|
||||
log_event(
|
||||
"plan_schimbare_tier",
|
||||
account_id=account_id,
|
||||
mesaj=f"tier -> {tier}",
|
||||
context={"tier": tier, "trial_until": trial_until},
|
||||
conn=conn,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — jurnal best-effort (ca observ.log_event)
|
||||
pass
|
||||
|
||||
|
||||
def set_trial(conn: sqlite3.Connection, account_id: int, trial_until: str | None) -> None:
|
||||
"""Seteaza DOAR `trial_until` (acorda/prelungeste/sterge trial Pro), fara a atinge `tier`.
|
||||
|
||||
Trial Pro activ (trial_until in viitor) ridica planul efectiv la 'pro' (vezi
|
||||
plans.effective_tier), indiferent de tier-ul de baza. Folosit din panoul admin ca sa
|
||||
acorzi un trial fara a schimba tier-ul de baza (post-trial).
|
||||
|
||||
Contul de sistem id=1 e protejat. Cont inexistent -> ValueError.
|
||||
trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul).
|
||||
"""
|
||||
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
if account_id == _PROTECTED_ACCOUNT_ID:
|
||||
raise ValueError("Contul default (id=1) nu poate primi trial (cont de sistem).")
|
||||
conn.execute(
|
||||
"UPDATE accounts SET trial_until=? WHERE id=?", (trial_until, account_id)
|
||||
)
|
||||
# Audit in app_events (best-effort, fara PII nou — ca set_tier).
|
||||
try:
|
||||
from .observ import log_event
|
||||
log_event(
|
||||
"plan_trial_setat",
|
||||
account_id=account_id,
|
||||
mesaj=f"trial_until -> {trial_until or 'NULL'}",
|
||||
context={"trial_until": trial_until},
|
||||
conn=conn,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — jurnal best-effort (ca observ.log_event)
|
||||
pass
|
||||
|
||||
|
||||
def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
|
||||
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele
|
||||
sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API
|
||||
@@ -154,7 +250,8 @@ def list_accounts(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
|
||||
(stergere soft -> invizibile in panou)."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, cui, email, active, status, created_at FROM accounts "
|
||||
"SELECT id, name, cui, email, active, status, tier, trial_until, "
|
||||
"requested_plan, consent_at, created_at FROM accounts "
|
||||
"WHERE status != 'deleted' ORDER BY id"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@@ -29,8 +29,10 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ... import errors
|
||||
from ...auth import resolve_account_id
|
||||
from ...auth import require_api_access, resolve_account_id
|
||||
from ...crypto import decrypt_creds, encrypt_creds
|
||||
from ...db import get_connection
|
||||
from ...idempotency import build_key, canonicalize_row
|
||||
@@ -186,6 +188,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 +267,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 +290,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:
|
||||
@@ -396,7 +415,7 @@ def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, di
|
||||
async def upload_import(
|
||||
file: UploadFile,
|
||||
sheet_name: str | None = None,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
account_id: int = Depends(require_api_access),
|
||||
) -> dict:
|
||||
"""Upload fisier xlsx/csv -> staging in import_batches/import_rows.
|
||||
|
||||
@@ -917,7 +936,7 @@ class CommitIn(BaseModel):
|
||||
def commit_import(
|
||||
import_id: int,
|
||||
req: CommitIn,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
account_id: int = Depends(require_api_access),
|
||||
) -> dict:
|
||||
"""Gate HARD confirmare + enqueue randuri ok + log atestare.
|
||||
|
||||
@@ -1005,6 +1024,48 @@ def commit_import(
|
||||
if n_total_ok == 0:
|
||||
raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.")
|
||||
|
||||
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
|
||||
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
|
||||
from ...config import get_settings as _get_settings
|
||||
from ...plans import PLANS, effective_tier, monthly_usage
|
||||
from ...observ import log_event as _log_event_plan
|
||||
_settings = _get_settings()
|
||||
if _settings.enforce_plans:
|
||||
_acct_row = conn.execute(
|
||||
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
|
||||
).fetchone()
|
||||
_now = datetime.now(timezone.utc)
|
||||
_et = effective_tier(_acct_row, _now)
|
||||
_plan_limit = PLANS[_et].get("monthly_limit")
|
||||
if _plan_limit is not None:
|
||||
_usage = monthly_usage(conn, acct, _now)
|
||||
if _usage + n_total_ok > _plan_limit:
|
||||
_remaining = max(0, _plan_limit - _usage)
|
||||
_log_event_plan(
|
||||
"plan_limita_lunara_atinsa",
|
||||
account_id=acct,
|
||||
nivel="WARNING",
|
||||
mesaj=f"Import de {n_total_ok} respins (usage={_usage}, limita={_plan_limit})",
|
||||
context={
|
||||
"n_to_enqueue": n_total_ok, "usage": _usage,
|
||||
"plan_limit": _plan_limit, "tier": _et,
|
||||
},
|
||||
conn=conn,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"error": "plan_limita_lunara",
|
||||
**errors.eroare(
|
||||
"PLAN_LIMITA_LUNARA",
|
||||
cauza=(
|
||||
f"Ai trimis {_usage}/{_plan_limit} prezentari luna aceasta;"
|
||||
f" mai poti trimite {_remaining}."
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Incarca maparea de coloane pentru a construi payload-ul
|
||||
first_row_db = conn.execute(
|
||||
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
|
||||
@@ -1078,6 +1139,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 +1248,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")
|
||||
|
||||
@@ -13,11 +13,13 @@ import csv
|
||||
import io
|
||||
import json
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...auth import resolve_account_id
|
||||
from ...auth import require_api_access, resolve_account_id
|
||||
from ...crypto import encrypt_creds
|
||||
from ...db import get_connection
|
||||
from ...errors import eroare as err_eroare
|
||||
@@ -135,7 +137,7 @@ def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult:
|
||||
@router.post("/prezentari", response_model=PrezentariResponse)
|
||||
def create_prezentari(
|
||||
req: PrezentareRequest,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
account_id: int = Depends(require_api_access),
|
||||
) -> PrezentariResponse:
|
||||
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
|
||||
|
||||
@@ -165,6 +167,46 @@ def create_prezentari(
|
||||
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||
|
||||
# T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
|
||||
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
|
||||
from ...config import get_settings as _get_settings
|
||||
from ...plans import PLANS, effective_tier, monthly_usage
|
||||
_settings = _get_settings()
|
||||
if _settings.enforce_plans:
|
||||
_acct_row = conn.execute(
|
||||
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
|
||||
).fetchone()
|
||||
_now = datetime.now(timezone.utc)
|
||||
_et = effective_tier(_acct_row, _now)
|
||||
_plan_limit = PLANS[_et].get("monthly_limit")
|
||||
if _plan_limit is not None:
|
||||
_usage = monthly_usage(conn, acct, _now)
|
||||
_nr_cerut = len(req.prezentari)
|
||||
if _usage + _nr_cerut > _plan_limit:
|
||||
_remaining = max(0, _plan_limit - _usage)
|
||||
log_event(
|
||||
"plan_limita_lunara_atinsa",
|
||||
account_id=acct,
|
||||
nivel="WARNING",
|
||||
mesaj=f"Lot de {_nr_cerut} respins (usage={_usage}, limita={_plan_limit})",
|
||||
context={
|
||||
"nr_cerut": _nr_cerut, "usage": _usage,
|
||||
"plan_limit": _plan_limit, "tier": _et,
|
||||
},
|
||||
conn=conn,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=err_eroare(
|
||||
"PLAN_LIMITA_LUNARA",
|
||||
cauza=(
|
||||
f"Ai trimis {_usage}/{_plan_limit} prezentari luna aceasta;"
|
||||
f" mai poti trimite {_remaining}."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
for prez in req.prezentari:
|
||||
content = prez.model_dump()
|
||||
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
||||
|
||||
59
app/auth.py
59
app/auth.py
@@ -18,8 +18,9 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
|
||||
from .config import get_settings
|
||||
from .db import get_connection
|
||||
@@ -162,3 +163,59 @@ def resolve_account_id(
|
||||
_log_auth_esuat(request, plaintext, "cheie API invalida sau revocata")
|
||||
raise HTTPException(status_code=401, detail="cheie API invalida sau revocata")
|
||||
return account_id
|
||||
|
||||
|
||||
def require_api_access(
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> int:
|
||||
"""Dependency FastAPI (T4, PRD 5.17): verifica ca tier-ul efectiv permite accesul la API.
|
||||
|
||||
Reguli:
|
||||
- enforce_plans=False (kill-switch): sare verificarea.
|
||||
- dev id=1 cu require_api_key=False: bypass (dogfooding, testele existente nu pica).
|
||||
- Pro/Premium sau trial Pro activ: permit.
|
||||
- Free/Standard fara trial: 403 PLAN_FARA_API cu eroare 3 niveluri.
|
||||
|
||||
Refoloseste resolve_account_id (account_id deja rezolvat din cheie API).
|
||||
Se ataseaza ca Depends() pe rutele de ingestie API (POST /v1/prezentari,
|
||||
POST /v1/import, POST /v1/import/{id}/commit). valideaza + nomenclator raman libere.
|
||||
"""
|
||||
from .plans import PLANS, effective_tier
|
||||
from .errors import eroare as _eroare
|
||||
|
||||
settings = get_settings()
|
||||
# Kill-switch operare: sare toate gate-urile de plan.
|
||||
if not settings.enforce_plans:
|
||||
return account_id
|
||||
# Bypass pentru contul implicit dev (id=1) in modul fara cheie API obligatorie.
|
||||
# In prod (require_api_key=True), id=1 nu are bypass implicit (cheie = obligatorie).
|
||||
if not settings.require_api_key and account_id == DEFAULT_ACCOUNT_ID:
|
||||
return account_id
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT tier, trial_until FROM accounts WHERE id=?", (account_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
et = effective_tier(row, now)
|
||||
if not PLANS[et].get("api_access"):
|
||||
from .observ import log_event
|
||||
log_event(
|
||||
"plan_api_refuzat",
|
||||
account_id=account_id,
|
||||
nivel="WARNING",
|
||||
mesaj=f"Acces API refuzat: tier efectiv={et}",
|
||||
context={"tier_efectiv": et},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=_eroare(
|
||||
"PLAN_FARA_API",
|
||||
cauza=f"Tier efectiv: {et}. API disponibil pe Pro/Premium.",
|
||||
),
|
||||
)
|
||||
return account_id
|
||||
|
||||
@@ -99,11 +99,40 @@ class Settings(BaseSettings):
|
||||
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie
|
||||
# creds vin per-cerere de la ROAAUTO — lasa False.
|
||||
worker_use_test_creds: bool = False
|
||||
# Keepalive RAR: cand coada e goala, worker-ul face un login de proba la fiecare
|
||||
# atata timp ca sa pastreze last_rar_login_ok proaspat (sub pragul de 30h al
|
||||
# dashboard-ului) — altfel banner-ul "RAR inaccesibil" apare fals doar din lipsa
|
||||
# de trafic. 0 = dezactivat. Implicit o data pe zi (24h < 30h, margine de 6h).
|
||||
worker_rar_keepalive_interval_s: int = 86400
|
||||
worker_sending_lease_s: int = 120 # rand 'sending' mai vechi de atat = orfan (worker mort mid-POST)
|
||||
worker_retry_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
|
||||
worker_retry_max_s: int = 300
|
||||
worker_max_retries: int = 8 # peste atat -> error + banner
|
||||
|
||||
# --- Planuri de cont (PRD 5.17) ---
|
||||
# Enforcement DUR al limitelor de plan (volum + acces API). True (implicit) = activ.
|
||||
# False = kill-switch de operare: sare toate gate-urile de plan (util pentru debugging
|
||||
# sau rollback rapid fara revert de cod). Enforcement DUR e activ implicit de la deploy
|
||||
# (decizie user 2026-06-28, decizia #22 autoplan): nu exista conturi legacy, produs in TESTE.
|
||||
enforce_plans: bool = True
|
||||
|
||||
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
||||
# ACTIVAT implicit: editorul de mapari ofera sugestii semantice (model fastembed/ONNX).
|
||||
# Cost: prima folosire lazy-load-eaza modelul (~230MB pe disc) sincron in thread-ul de
|
||||
# cerere -> prima cerere /mapari poate dura 30-120s pana modelul intra in memorie; cererile
|
||||
# urmatoare sunt instant. SUGGESTION-ONLY: nu intra in resolve_prestatii (nu auto-trimite).
|
||||
# Pune-l pe False (start.sh/Docker/.env: AUTOPASS_EMBEDDINGS_ENABLED=false) cand vrei
|
||||
# /mapari instant la prima cerere sau suita de teste rapida (cade pe GOLD/SILVER+fuzzy).
|
||||
embeddings_enabled: bool = True
|
||||
|
||||
# --- Seed corpus operatii etichetate (SILVER, PRD 5.18 US-004) ---
|
||||
# ACTIVAT implicit: la init_db, populeaza mapping_suggestions din artefactul comis
|
||||
# `app/data/operatii-etichetate.json` (INSERT OR IGNORE). Asa SILVER nu mai e gol in
|
||||
# productie -> sugestii exact-match + corpus k-NN reale. SUGGESTION-ONLY.
|
||||
# Pune-l pe False (AUTOPASS_SEED_OPERATII_ENABLED=false) cand vrei SILVER gol —
|
||||
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
|
||||
seed_operatii_enabled: bool = True
|
||||
|
||||
@property
|
||||
def rar_base_url(self) -> str:
|
||||
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test
|
||||
|
||||
137450
app/data/operatii-etichetate.json
Normal file
137450
app/data/operatii-etichetate.json
Normal file
File diff suppressed because it is too large
Load Diff
143
app/db.py
143
app/db.py
@@ -37,6 +37,22 @@ def init_db() -> None:
|
||||
from .mapping import seed_nomenclator_if_empty
|
||||
|
||||
seed_nomenclator_if_empty(conn)
|
||||
# Seed corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
|
||||
# Gated: OFF in teste (conftest), ON in productie. INSERT OR IGNORE -> idempotent.
|
||||
# DOAR daca mapping_suggestions e gol: seedul are ~17k randuri; re-rularea lui pe
|
||||
# FIECARE boot (API + worker concurent) tinea write-lock-ul indelung -> al doilea
|
||||
# proces primea "database is locked" la pornire. Guard "_if_empty" (ca nomenclatorul)
|
||||
# -> boot rapid cand e deja seeded. Re-seed dupa actualizarea fisierului = manual
|
||||
# (goleste tabela), consistent cu semantica v1 ignore-not-upsert a seederului.
|
||||
if get_settings().seed_operatii_enabled:
|
||||
already = conn.execute(
|
||||
"SELECT 1 FROM mapping_suggestions LIMIT 1"
|
||||
).fetchone()
|
||||
if not already:
|
||||
from .operatii_seed import seed_operatii_etichetate
|
||||
|
||||
seed_operatii_etichetate(conn)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -55,11 +71,26 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("ALTER TABLE submissions ADD COLUMN batch_id INTEGER")
|
||||
if "row_index" not in sub_cols:
|
||||
conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER")
|
||||
if "rar_env" not in sub_cols:
|
||||
# PRD 5.20 US-001. Mediul RAR tinta pe submission. Pe DB existent NU lasam
|
||||
# randurile pe DEFAULT 'test': un rand prod pre-migrare etichetat 'test' ar fi
|
||||
# reconciliat de worker (US-006) contra endpoint TEST -> no-match -> re-send prod
|
||||
# = DUPLICAT REAL IREVERSIBIL. Backfill din AUTOPASS_RAR_ENV global (ancora de
|
||||
# migrare) + recompute idempotency_key env-aware. Ruleaza O SINGURA DATA (in
|
||||
# blocul de adaugare a coloanei); pe DB fresh coloana vine din schema.sql (fara rows).
|
||||
conn.execute(
|
||||
"ALTER TABLE submissions ADD COLUMN rar_env TEXT NOT NULL DEFAULT 'test' "
|
||||
"CHECK (rar_env IN ('test', 'prod'))"
|
||||
)
|
||||
_backfill_submissions_rar_env(conn)
|
||||
|
||||
# Coloane accounts
|
||||
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||
if "rar_creds_enc" not in acc_cols:
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
|
||||
acc_cols.add("rar_creds_enc")
|
||||
# Medii RAR per cont (PRD 5.20 US-001): activare + slot creds + default, per mediu.
|
||||
_migrate_accounts_medii(conn, acc_cols)
|
||||
if "active" not in acc_cols:
|
||||
# Conturi existente raman active (default 1).
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
||||
@@ -84,6 +115,23 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
if "email" not in acc_cols:
|
||||
# Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy.
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT")
|
||||
if "tier" not in acc_cols:
|
||||
# Plan de cont (US-001, PRD 5.17). Legacy -> 'free' fara trial (enforcement DUR la deploy).
|
||||
conn.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT 'free' "
|
||||
"CHECK (tier IN ('free','standard','pro','premium'))"
|
||||
)
|
||||
if "trial_until" not in acc_cols:
|
||||
# Trial Pro activ daca != NULL si > now. Nullable (NULL = fara trial).
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN trial_until TEXT")
|
||||
if "requested_plan" not in acc_cols:
|
||||
# Planul cerut la signup (integrare plati). NU acorda drepturi; `tier` ramane sursa
|
||||
# de adevar pt API/volum. Nullable. ALTER nu poate adauga CHECK pe coloana noua in
|
||||
# SQLite -> validarea valorilor se face in cod (signup, fata de VALID_TIERS).
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN requested_plan TEXT")
|
||||
if "consent_at" not in acc_cols:
|
||||
# Marca temporala consimtamant Termeni+GDPR (proba). Nullable (NULL = CLI/legacy).
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN consent_at TEXT")
|
||||
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
||||
@@ -131,6 +179,101 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
|
||||
"""PRD 5.20 US-001: coloane medii RAR per cont + backfill din ancora globala.
|
||||
|
||||
Adauga (idempotent): rar_test_enabled/rar_prod_enabled (bife activare),
|
||||
rar_creds_test_enc/rar_creds_prod_enc (sloturi creds), rar_env_default.
|
||||
|
||||
Backfill (O SINGURA DATA, cand coloanele tocmai au fost adaugate pe DB existent):
|
||||
creds-ul legacy `rar_creds_enc` apartine mediului `AUTOPASS_RAR_ENV` global de la
|
||||
momentul migrarii (ancora) — il copiem in slotul acelui mediu, activam DOAR acel
|
||||
mediu (celalalt dezactivat) si fixam default-ul pe el. Conturile fara creds raman
|
||||
pe default-urile coloanei (prod on / test off). Migrarea NU presupune env-ul; se
|
||||
bazeaza pe ancora globala, exact cum opera contul inainte de 5.20.
|
||||
"""
|
||||
newly_added = "rar_env_default" not in acc_cols
|
||||
if "rar_test_enabled" not in acc_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN rar_test_enabled INTEGER NOT NULL DEFAULT 0 "
|
||||
"CHECK (rar_test_enabled IN (0, 1))"
|
||||
)
|
||||
if "rar_prod_enabled" not in acc_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN rar_prod_enabled INTEGER NOT NULL DEFAULT 1 "
|
||||
"CHECK (rar_prod_enabled IN (0, 1))"
|
||||
)
|
||||
if "rar_creds_test_enc" not in acc_cols:
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_test_enc TEXT")
|
||||
if "rar_creds_prod_enc" not in acc_cols:
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_prod_enc TEXT")
|
||||
if "rar_env_default" not in acc_cols:
|
||||
# ALTER nu poate adauga CHECK pe coloana noua in SQLite -> validarea ('test'/'prod')
|
||||
# se face in cod (rar_env.py / rutele de cont). DEFAULT 'prod' (cont client nou).
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_env_default TEXT NOT NULL DEFAULT 'prod'")
|
||||
|
||||
if not newly_added:
|
||||
return # coloanele existau deja -> backfill-ul a rulat la o pornire anterioara
|
||||
|
||||
# Are coloana legacy rar_creds_enc randuri de migrat? (Pe DB foarte nou, e absenta.)
|
||||
if "rar_creds_enc" not in acc_cols:
|
||||
return
|
||||
env = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test"
|
||||
other = "prod" if env == "test" else "test"
|
||||
slot = f"rar_creds_{env}_enc"
|
||||
conn.execute(
|
||||
f"UPDATE accounts SET {slot} = rar_creds_enc, "
|
||||
f"rar_{env}_enabled = 1, rar_{other}_enabled = 0, rar_env_default = ? "
|
||||
f"WHERE rar_creds_enc IS NOT NULL AND TRIM(rar_creds_enc) <> '' AND {slot} IS NULL",
|
||||
(env,),
|
||||
)
|
||||
|
||||
|
||||
def _backfill_submissions_rar_env(conn: sqlite3.Connection) -> None:
|
||||
"""PRD 5.20 US-001 (AUTO-FIX G + E4/3): backfill rar_env + recompute idempotency_key.
|
||||
|
||||
Ruleaza O SINGURA DATA, imediat dupa ce coloana `submissions.rar_env` a fost adaugata
|
||||
pe un DB existent. Toate randurile pre-migrare au fost trimise (sau urmeaza) catre
|
||||
mediul `AUTOPASS_RAR_ENV` global — le etichetam cu acel env (NU DEFAULT 'test'), altfel
|
||||
reconcilierea worker-ului ar lovi endpoint-ul gresit -> duplicat ireversibil.
|
||||
|
||||
Recompute `idempotency_key` la forma env-aware (`build_key(account_id, canon, rar_env)`):
|
||||
altfel un re-POST al unui rand legacy (cheie env-less) ar rata randul existent ->
|
||||
duplicat. Recompute-ul e consistent (acelasi env pe toate randurile pre-migrare) deci
|
||||
nu poate crea coliziuni intre randuri care erau deja distincte.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from .idempotency import build_key, canonicalize_row
|
||||
|
||||
env = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test"
|
||||
conn.execute("UPDATE submissions SET rar_env = ?", (env,))
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, account_id, idempotency_key, payload_json FROM submissions"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
try:
|
||||
content = _json.loads(r["payload_json"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
canon = canonicalize_row(content)
|
||||
# Pastreaza prestatiile rezolvate (cod_prestatie/cod_op_service) pentru _op_identity.
|
||||
canon["prestatii"] = content.get("prestatii") or []
|
||||
new_key = build_key(r["account_id"], canon, env)
|
||||
if new_key == r["idempotency_key"]:
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET idempotency_key = ? WHERE id = ?",
|
||||
(new_key, r["id"]),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# Coliziune improbabila pe UNIQUE(idempotency_key): lasa cheia veche (no-op),
|
||||
# randul ramane gasibil prin dual-lookup legacy.
|
||||
continue
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
249
app/embeddings.py
Normal file
249
app/embeddings.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""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, is_nul, 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, is_nul, similaritate}].
|
||||
|
||||
`is_nul` (PRD 5.18 US-005): cand corpusul include exemple NUL (non-operatii),
|
||||
un vecin NUL = semnal de SUPRESIE, nu cod. Default False pe corpusuri vechi
|
||||
fara `is_nul` in itemi. Returneaza [] daca backend-ul lipseste, corpus-ul e gol
|
||||
sau apare orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
|
||||
"""
|
||||
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"],
|
||||
"is_nul": bool(item.get("is_nul", False)),
|
||||
"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, is_nul, similaritate}] sau [] la eroare.
|
||||
|
||||
Sigur de apelat indiferent de starea backend-ului.
|
||||
"""
|
||||
return _get_engine().suggest_nearest(denumire, top_k=top_k)
|
||||
@@ -178,6 +178,22 @@ CATALOG: dict[str, dict[str, str]] = {
|
||||
" cererii (request_id) afisat."
|
||||
),
|
||||
},
|
||||
# Coduri de plan (PRD 5.17)
|
||||
"PLAN_LIMITA_LUNARA": {
|
||||
"problema": "Ai atins limita planului Gratuit (60 prestatii/luna)",
|
||||
"fix": (
|
||||
"Treci pe planul Standard sau Pro, sau asteapta inceperea lunii urmatoare."
|
||||
" Numarul de prestatii ramase in luna curenta e in campul cauza."
|
||||
),
|
||||
},
|
||||
"PLAN_FARA_API": {
|
||||
"problema": "Importul prin API e disponibil pe planul Pro",
|
||||
"fix": (
|
||||
"Planul tau curent nu include accesul la API."
|
||||
" Endpoint-ul /v1/prezentari/valideaza ramane disponibil pentru testare fara upgrade."
|
||||
" Contacteaza-ne pentru a face upgrade la planul Pro."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -70,17 +70,23 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
|
||||
"""SHA-256 partajat canal-API + canal-import.
|
||||
def build_key(account_id: int | None, canon: dict[str, Any], rar_env: str = "test") -> str:
|
||||
"""SHA-256 partajat canal-API + canal-import, env-aware (PRD 5.20 US-003).
|
||||
|
||||
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
||||
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
||||
|
||||
`rar_env` ('test'|'prod') intra in cheie: aceeasi prezentare la test si apoi la
|
||||
prod sunt DOUA trimiteri reale distincte (sisteme RAR separate), nu un duplicat.
|
||||
Default 'test' = back-compat cu apelantii care nu paseaza inca env-ul; toate
|
||||
rutele de ingestie paseaza env-ul rezolvat explicit.
|
||||
"""
|
||||
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
|
||||
from .mapping import account_or_default
|
||||
acct = account_or_default(account_id)
|
||||
canonic = {
|
||||
"account_id": acct,
|
||||
"rar_env": rar_env,
|
||||
"vin": canon.get("vin", ""),
|
||||
"nr_inmatriculare": canon.get("nr_inmatriculare", ""),
|
||||
"data_prestatie": canon.get("data_prestatie"),
|
||||
@@ -91,8 +97,8 @@ def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
"""SHA-256 peste (account_id + campurile semnificative ale prezentarii).
|
||||
def idempotency_key(account_id: int | None, prezentare: dict[str, Any], rar_env: str = "test") -> str:
|
||||
"""SHA-256 peste (account_id + rar_env + campurile semnificative ale prezentarii).
|
||||
|
||||
Wrapper backward-compat peste canonicalize_row + build_key.
|
||||
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
||||
@@ -102,7 +108,7 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
|
||||
"""
|
||||
canon = canonicalize_row(prezentare)
|
||||
return build_key(account_id, canon)
|
||||
return build_key(account_id, canon, rar_env)
|
||||
|
||||
|
||||
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
|
||||
232
app/mapping.py
232
app/mapping.py
@@ -14,7 +14,9 @@ unit-testabile direct. Cele cu `conn` sunt helpere de persistenta.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
@@ -48,6 +50,60 @@ def normalize_for_match(value: object) -> str:
|
||||
return " ".join(s.upper().split())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pre-filtru determinist non-operatii (NUL) — US-001 PRD 5.18 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
#
|
||||
# Masuratoarea k-NN (memorie test-precizie-knn-embeddings) arata recall NUL doar
|
||||
# 64%: gunoiul evident (ITP, plata, discount, nr. inmatriculare, tractare) scapa
|
||||
# semantic ca OE-1. Un pre-filtru text/regex il marcheaza NUL INAINTE de k-NN.
|
||||
#
|
||||
# Garantie: ZERO fals-pozitiv pe operatii reale. Regulile au fost calibrate pe
|
||||
# `docs/operatii-service/*.csv` (toate aparitiile distincte). Triggerele NEambigue
|
||||
# (ITP, ACHITAT/PLATA, DISCOUNT/REDUCERE, TAXA) sunt neconditionate (0 FP masurat).
|
||||
# Triggerele AMBIGUE (TRACTARE, NR INMATRICULARE + pattern placuta) apar si in
|
||||
# operatii reale ("D/R CARLIG TRACTARE", "D/R ELECTROMOTOR CT 44 MKY") -> sunt
|
||||
# ECRANATE de un context de piesa/operatie (`_NUL_CTX_PIESA`).
|
||||
|
||||
# Trigger-uri neambigue (substring/regex pe text normalizat).
|
||||
_NUL_ITP = re.compile(r"(?:\bITP\b|\d\s*X\s*ITP|X\s*ITP\b|\bITP[.,])")
|
||||
_NUL_PLATA = re.compile(r"\b(ACHITAT|ACHITARE|PLATA|PLATIT|PLATIRE)\b")
|
||||
_NUL_DISCOUNT = re.compile(r"\b(DISCOUNT|REDUCERE)\b")
|
||||
_NUL_TAXA = re.compile(r"\bTAXA\b")
|
||||
|
||||
# Trigger-uri ambigue — valide ca NUL DOAR in absenta unui context de piesa.
|
||||
_NUL_TRACTARE = re.compile(r"\b(TRACTARE|TRACTARI)\b")
|
||||
_NUL_NR_PLACUTA = re.compile(
|
||||
r"(\bNR\s+INMATRICULARE\b|\bNUMAR\s+INMATRICULARE\b|\b[A-Z]{1,2}\s?\d{2,3}\s?[A-Z]{3}\b)"
|
||||
)
|
||||
# Daca apare oricare cuvant de aici, TRACTARE/placuta e nume de piesa sau operatie
|
||||
# reala (carlig/capac de tractare, suport placuta, placuta lipita la o reparatie).
|
||||
_NUL_CTX_PIESA = re.compile(
|
||||
r"\b(D/R|D-R|CARLIG|CAPAC|BARA|PROTECTIE|MONTAT|MONTAJ|DEMONTAT|INLOCUIT|"
|
||||
r"INLOCUIRE|REPARAT|REPARATIE|VOPSIT|SCHIMBAT|SUPORT)\b"
|
||||
)
|
||||
|
||||
|
||||
def prefiltru_nul(denumire: object) -> bool:
|
||||
"""True daca operatia e gunoi evident (non-operatie de service) -> NUL determinist.
|
||||
|
||||
Ruleaza INAINTE de k-NN/embeddings in `enrich_suggestions` (US-006). Pur, fara DB.
|
||||
Zero fals-pozitiv pe operatii reale (vezi comentariul de mai sus + tests).
|
||||
"""
|
||||
text = normalize_for_match(denumire)
|
||||
if not text:
|
||||
return False
|
||||
# Neambigue: 0 FP masurat, fara ecranare.
|
||||
if _NUL_ITP.search(text) or _NUL_PLATA.search(text) or _NUL_DISCOUNT.search(text) or _NUL_TAXA.search(text):
|
||||
return True
|
||||
# Ambigue: doar daca NU e context de piesa.
|
||||
if _NUL_CTX_PIESA.search(text):
|
||||
return False
|
||||
if _NUL_TRACTARE.search(text) or _NUL_NR_PLACUTA.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def suggest_codes(
|
||||
denumire: object,
|
||||
nomenclator: list[dict],
|
||||
@@ -483,10 +539,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 +625,174 @@ 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_silver(rows: list) -> str:
|
||||
"""Semnatura stabila a corpusului SILVER (mapping_suggestions) pentru cache.
|
||||
|
||||
Hash pe (denumire_normalizata, cod, is_nul) sortat -> se schimba la orice
|
||||
add/remove/redenumire/relabel, ramane stabila altfel (evita re-embed inutil).
|
||||
"""
|
||||
triples = sorted(
|
||||
(str(r["denumire_normalizata"] or ""), str(r["cod_prestatie"] or ""), int(r["is_nul"] or 0))
|
||||
for r in rows
|
||||
)
|
||||
blob = "".join(f"{d}|{c}|{n}" for d, c, n in triples)
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
|
||||
"""Construieste/actualizeaza corpusul embeddings din corpusul ETICHETAT (PRD 5.18 US-005).
|
||||
|
||||
Sursa corpusului = `mapping_suggestions` (SILVER): exemple reale etichetate
|
||||
{denumire_normalizata -> cod, is_nul}, NU cele 18 categorii generice din
|
||||
`nomenclator_rar`. k-NN peste exemple reale e net mai precis (94.3% acord LLM).
|
||||
Parametrul `nomenclator` e pastrat pentru compatibilitatea apelantilor, dar nu mai
|
||||
e folosit ca sursa.
|
||||
|
||||
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default ON; OFF in teste): cand e
|
||||
dezactivat, e un no-op total -> /mapari instant + suita de teste rapida.
|
||||
|
||||
Cand e activat: indexeaza corpusul o singura data (lazy-load modelul ~230MB la
|
||||
prima chemare), re-indexeaza doar cand semnatura corpusului SILVER s-a schimbat.
|
||||
Itemii NUL (is_nul=1, cod NULL) raman in corpus: un vecin NUL e semnal de supresie
|
||||
(US-006). Degradare gratioasa: orice eroare lasa corpusul gol -> enrich cade pe restul.
|
||||
"""
|
||||
from .config import get_settings
|
||||
if not get_settings().embeddings_enabled:
|
||||
return
|
||||
try:
|
||||
from . import embeddings as _emb
|
||||
rows = conn.execute(
|
||||
"SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return
|
||||
sig = _corpus_signature_silver(rows)
|
||||
if _emb.corpus_signature() == sig and _emb.has_corpus():
|
||||
return # deja indexat pe acelasi corpus SILVER -> nimic de facut
|
||||
items = [
|
||||
{
|
||||
"denumire": str(r["denumire_normalizata"]),
|
||||
"cod": (str(r["cod_prestatie"]) if r["cod_prestatie"] is not None else None),
|
||||
"is_nul": bool(r["is_nul"]),
|
||||
}
|
||||
for r in rows
|
||||
if r["denumire_normalizata"]
|
||||
]
|
||||
_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.)
|
||||
|
||||
Ordine completa (PRD 5.18 US-006):
|
||||
pre-filtru NUL determinist -> (daca NUL: fara cod, `surse['nul']=True`)
|
||||
altfel GOLD partajat > exact (SILVER) > k-NN embeddings.
|
||||
|
||||
Returneaza:
|
||||
{
|
||||
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
|
||||
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None, 'nul': bool}
|
||||
}
|
||||
|
||||
INVARIANTE:
|
||||
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13).
|
||||
- Pre-filtru NUL (US-001) ruleaza PRIMUL: gunoiul evident (ITP/plata/discount...) e
|
||||
marcat non-operatie INAINTE de k-NN, fara sugestie de cod.
|
||||
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie (#4); vecin k-NN NUL idem.
|
||||
- Degradare gratioasa pe embeddings (#16b): daca motorul nu e disponibil sau arunca,
|
||||
returneaza sugestia disponibila din celelalte surse, fara exceptie.
|
||||
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store
|
||||
importa normalize_for_match din mapping).
|
||||
"""
|
||||
sugestie_principala: dict | None = None
|
||||
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
|
||||
|
||||
if not denumire:
|
||||
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
||||
|
||||
# 0. Pre-filtru NUL determinist (US-001) INAINTE de orice k-NN/lookup: non-operatie
|
||||
# evidenta -> fara cod, scurtcircuit (nu interogheaza embeddings/SILVER pe gunoi).
|
||||
if prefiltru_nul(denumire):
|
||||
surse["nul"] = True
|
||||
return {"sugestie_principala": None, "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():
|
||||
# F1 (US-005): corpusul k-NN e text NORMALIZAT (denumire_normalizata),
|
||||
# deci query-ul TREBUIE normalizat la fel — altfel cosine degradeaza si
|
||||
# nu mai e configul sub care s-a masurat 94.3%.
|
||||
nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=1)
|
||||
# Prag minim: similaritate prea mica = sugestie inutila.
|
||||
# Evita recomandari irelevante cand corpus-ul e mic/partial.
|
||||
if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
|
||||
if nn[0].get("is_nul"):
|
||||
# Vecin NUL (non-operatie) = semnal de SUPRESIE, nu cod (US-006).
|
||||
surse["nul"] = True
|
||||
elif nn[0].get("cod"):
|
||||
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.
|
||||
|
||||
|
||||
59
app/operatii_seed.py
Normal file
59
app/operatii_seed.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Seeder corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
|
||||
|
||||
Artefactul `app/data/operatii-etichetate.json` e produs offline de
|
||||
`tools/mapare-llm/genereaza_seed.py` (etichetare LM Studio, o singura data) si comis
|
||||
in repo. La `init_db` il incarcam in `mapping_suggestions` cu INSERT OR IGNORE, ca
|
||||
SILVER sa nu mai fie gol in productie (sugestii exact-match + corpus k-NN reale).
|
||||
|
||||
Format seed: [{denumire, denumire_normalizata, cod, is_nul, source, confidence}].
|
||||
Reutilizeaza `shared_store.seed_suggestions` (normalizeaza cheia + impune NUL->cod NULL,
|
||||
INSERT OR IGNORE). NB (F10): confirmarile UMANE stau in `shared_mappings`, NU aici —
|
||||
deci INSERT OR IGNORE pastreaza codul LLM existent la re-seed (v1 = ignore, nu upsert).
|
||||
|
||||
SUGGESTION-ONLY (invariant #13): nimic din SILVER nu intra in resolve_prestatii/load_mapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from .shared_store import seed_suggestions
|
||||
|
||||
SEED_PATH = os.path.join(os.path.dirname(__file__), "data", "operatii-etichetate.json")
|
||||
|
||||
|
||||
def load_seed_file(path: str = SEED_PATH) -> list[dict]:
|
||||
"""Citeste artefactul seed. Lipsa / invalid -> [] (degradare gratioasa)."""
|
||||
if not path or not os.path.exists(path):
|
||||
return []
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (ValueError, OSError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def seed_operatii_etichetate(conn: sqlite3.Connection, path: str = SEED_PATH) -> int:
|
||||
"""Incarca seedul in mapping_suggestions (INSERT OR IGNORE). Intoarce nr. randuri inserate.
|
||||
|
||||
Mapeaza cheia seedului `cod` -> `cod_prestatie` (forma asteptata de seed_suggestions);
|
||||
`is_nul=True` forteaza cod NULL acolo. Idempotent: re-rularea nu dubleaza randuri.
|
||||
"""
|
||||
raw = load_seed_file(path)
|
||||
if not raw:
|
||||
return 0
|
||||
items = [
|
||||
{
|
||||
"denumire": e.get("denumire") or e.get("denumire_normalizata") or "",
|
||||
"cod_prestatie": e.get("cod"),
|
||||
"is_nul": bool(e.get("is_nul")),
|
||||
"source": e.get("source") or "llm_seed",
|
||||
"confidence": e.get("confidence") or 0.0,
|
||||
}
|
||||
for e in raw
|
||||
if isinstance(e, dict)
|
||||
]
|
||||
return seed_suggestions(conn, items)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
130
app/plans.py
Normal file
130
app/plans.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Definitia planurilor de cont (sursa unica de adevar). Modul PUR, fara import DB/HTTP.
|
||||
|
||||
Pattern ca app/errors.py: catalog + helperi. Consumat de rutele de ingestie si dashboard.
|
||||
Nu importa DB, HTTP, sau orice alt modul intern cu efecte secundare.
|
||||
|
||||
Decizii implementare (PRD 5.17 / autoplan 2026-06-28):
|
||||
- FREE_MONTHLY_LIMIT: constanta unica (T-CEO-2), tunabila fara arqueologie de cod.
|
||||
- CONSUMED_STATUSES: decizie #20 — prestatie consumata = acceptata in coada.
|
||||
- effective_tier: `now` injectabil (decizie #2) pentru teste deterministe.
|
||||
- monthly_usage: pattern E7/5.15 (strftime localtime), `now` injectabil.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Limita lunara pentru planul Gratuit.
|
||||
# Decizie user T-CEO-2 (2026-06-28): o singura constanta, referita din PLANS.
|
||||
# Tunabila fara a modifica logica de enforcement.
|
||||
FREE_MONTHLY_LIMIT: int = 60
|
||||
|
||||
# Statusurile care consuma din cota lunara (decizie #20, 2026-06-28).
|
||||
# Prestatie consumata = acceptata in coada (queued/sending/sent), nu cele respinse/blocate.
|
||||
# Rationale: limita e pe ce trimitem la RAR, nu pe incercari esuate sau blocate.
|
||||
CONSUMED_STATUSES: tuple[str, ...] = ("queued", "sending", "sent")
|
||||
|
||||
# Sursa unica de adevar pentru planuri. Fiecare plan are:
|
||||
# label -- eticheta afisata in RO (UI, mesaje)
|
||||
# monthly_limit -- None = nelimitat; int = limita prestatii/luna
|
||||
# api_access -- True = acces import prin API (/v1/*); False = doar web dashboard
|
||||
#
|
||||
# Aliniat landing-ului comercial (PRD 5.17 US-001):
|
||||
# Gratuit: 60/luna, fara API
|
||||
# Standard: nelimitat, fara API
|
||||
# Pro: nelimitat, cu API
|
||||
# Premium: nelimitat, cu API (suport dedicat)
|
||||
PLANS: dict[str, dict] = {
|
||||
"free": {
|
||||
"label": "Gratuit",
|
||||
"monthly_limit": FREE_MONTHLY_LIMIT,
|
||||
"api_access": False,
|
||||
},
|
||||
"standard": {
|
||||
"label": "Standard",
|
||||
"monthly_limit": None,
|
||||
"api_access": False,
|
||||
},
|
||||
"pro": {
|
||||
"label": "Pro",
|
||||
"monthly_limit": None,
|
||||
"api_access": True,
|
||||
},
|
||||
"premium": {
|
||||
"label": "Premium",
|
||||
"monthly_limit": None,
|
||||
"api_access": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def effective_tier(account_row, now: datetime) -> str:
|
||||
"""Returneaza tier-ul efectiv al contului la momentul `now` (injectabil pentru determinism).
|
||||
|
||||
Daca `trial_until` e in viitor -> 'pro' (trial Pro activ).
|
||||
Altfel -> `tier`-ul de baza al contului.
|
||||
trial_until malformat/NULL -> fallback defensiv la tier de baza (nu arunca niciodata).
|
||||
|
||||
`now` TREBUIE injectat explicit (nu datetime.now() intern) — decizie #2 din autoplan.
|
||||
Suporta sqlite3.Row si dict.
|
||||
"""
|
||||
# Citire robusta: suporta sqlite3.Row (IndexError pe key absent) si dict (KeyError)
|
||||
try:
|
||||
tier = account_row["tier"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
tier = "free"
|
||||
try:
|
||||
trial_until_str = account_row["trial_until"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
trial_until_str = None
|
||||
|
||||
# Fallback defensiv la 'free' daca tier e None/gol
|
||||
if not tier:
|
||||
tier = "free"
|
||||
|
||||
if not trial_until_str:
|
||||
return tier
|
||||
|
||||
try:
|
||||
# Parseaza trial_until; stocam ca "YYYY-MM-DD HH:MM:SS" (UTC implicit) sau ISO
|
||||
tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T"))
|
||||
# Daca fara timezone -> assume UTC (cum stocam in DB)
|
||||
if tu.tzinfo is None:
|
||||
tu = tu.replace(tzinfo=timezone.utc)
|
||||
# Normalizeaza `now` la aware daca e naive
|
||||
now_cmp = now
|
||||
if now_cmp.tzinfo is None:
|
||||
now_cmp = now_cmp.replace(tzinfo=timezone.utc)
|
||||
if tu > now_cmp:
|
||||
return "pro"
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
pass # malformat -> fallback defensiv la tier de baza
|
||||
|
||||
return tier
|
||||
|
||||
|
||||
def monthly_usage(conn: sqlite3.Connection, account_id: int, now: datetime) -> int:
|
||||
"""Numara prestatiile contului acceptate in coada in luna calendaristica curenta.
|
||||
|
||||
Definitia 'luna curenta': strftime('%Y-%m', created_at, 'localtime') corespunde
|
||||
lunii lui `now` (acelasi pattern ca E7/5.15 din routes.py — consistent cu 'localtime').
|
||||
`now` injectabil pentru teste deterministe. Scoped strict pe account_id.
|
||||
created_at NULL/malformat -> exclus defensiv (nu arunca niciodata).
|
||||
|
||||
NOTA: containerul are /etc/localtime=UTC, deci 'localtime' = UTC in mediul de test.
|
||||
Testele de granita construiesc timestamp-uri relative la luna curenta calculata cu
|
||||
acelasi 'localtime', nu valori absolute care presupun +2/+3h.
|
||||
"""
|
||||
# Formatam `now` ca string SQLite si folosim acelasi modificator 'localtime' ca routes.py
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
placeholders = ",".join("?" * len(CONSUMED_STATUSES))
|
||||
row = conn.execute(
|
||||
f"SELECT COUNT(*) AS n FROM submissions "
|
||||
f"WHERE account_id = ? "
|
||||
f" AND status IN ({placeholders}) "
|
||||
f" AND created_at IS NOT NULL "
|
||||
f" AND strftime('%Y-%m', created_at, 'localtime') = strftime('%Y-%m', ?, 'localtime')",
|
||||
(account_id, *CONSUMED_STATUSES, now_str),
|
||||
).fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
91
app/rar_env.py
Normal file
91
app/rar_env.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Medii RAR per cont (PRD 5.20): disponibilitate + default efectiv.
|
||||
|
||||
Sursa UNICA de adevar pentru REQ-DISP / REQ-DEFAULT: vizibilitatea selector/toggle
|
||||
in UI, validarea tintei in API si decizia worker-ului citesc TOATE de aici, ca sa
|
||||
decida identic.
|
||||
|
||||
Un mediu ('test'|'prod') e *disponibil* pentru un cont daca e activat (bifa) SI are
|
||||
credentiale (slot per-mediu non-gol). Din disponibilitate decurge tot UX-ul:
|
||||
- 0 medii -> nicio tinta; trimiterea web e blocata, API cade pe ancora globala.
|
||||
- 1 mediu -> tinta implicita (acel mediu), fara selector.
|
||||
- 2 medii -> selector la import + toggle in statusbar + alegere in API.
|
||||
|
||||
Functii PURE (fara DB) peste un rand de cont (sqlite3.Row sau dict). Helperele cu
|
||||
`conn` incarca randul si deleaga.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
VALID_ENVS: tuple[str, str] = ("test", "prod")
|
||||
|
||||
|
||||
def _field(account: Any, key: str, default: Any = None) -> Any:
|
||||
"""Citire toleranta a unui camp de cont (dict sau sqlite3.Row, camp posibil absent)."""
|
||||
if account is None:
|
||||
return default
|
||||
if isinstance(account, dict):
|
||||
return account.get(key, default)
|
||||
try:
|
||||
return account[key] # sqlite3.Row
|
||||
except (IndexError, KeyError):
|
||||
return default
|
||||
|
||||
|
||||
def _are_creds(account: Any, env: str) -> bool:
|
||||
creds = _field(account, f"rar_creds_{env}_enc", None)
|
||||
return bool(creds and str(creds).strip())
|
||||
|
||||
|
||||
def _enabled(account: Any, env: str) -> bool:
|
||||
return int(_field(account, f"rar_{env}_enabled", 0) or 0) == 1
|
||||
|
||||
|
||||
def medii_disponibile(account: Any) -> list[str]:
|
||||
"""Subset din ('test','prod') = activat AND creds prezente. Ordine stabila test<prod."""
|
||||
return [env for env in VALID_ENVS if _enabled(account, env) and _are_creds(account, env)]
|
||||
|
||||
|
||||
def rar_env_efectiv(account: Any) -> str | None:
|
||||
"""Mediul tinta implicit al contului (REQ-DEFAULT).
|
||||
|
||||
Mereu unul din mediile disponibile: default-ul contului daca inca e disponibil,
|
||||
altfel singurul disponibil; daca 0 disponibile -> None (nicio tinta).
|
||||
"""
|
||||
disp = medii_disponibile(account)
|
||||
if not disp:
|
||||
return None
|
||||
default = _field(account, "rar_env_default", "prod")
|
||||
if default in disp:
|
||||
return default
|
||||
return disp[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpere cu conexiune #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_ACCOUNT_ENV_COLS = (
|
||||
"id, rar_test_enabled, rar_prod_enabled, "
|
||||
"rar_creds_test_enc, rar_creds_prod_enc, rar_env_default"
|
||||
)
|
||||
|
||||
|
||||
def load_account_env(conn: sqlite3.Connection, account_id: int) -> sqlite3.Row | None:
|
||||
"""Randul de cont cu exact coloanele de mediu (pentru medii_disponibile/rar_env_efectiv)."""
|
||||
from .mapping import account_or_default
|
||||
|
||||
return conn.execute(
|
||||
f"SELECT {_ACCOUNT_ENV_COLS} FROM accounts WHERE id=?",
|
||||
(account_or_default(account_id),),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def medii_disponibile_cont(conn: sqlite3.Connection, account_id: int) -> list[str]:
|
||||
return medii_disponibile(load_account_env(conn, account_id))
|
||||
|
||||
|
||||
def rar_env_efectiv_cont(conn: sqlite3.Connection, account_id: int) -> str | None:
|
||||
return rar_env_efectiv(load_account_env(conn, account_id))
|
||||
@@ -19,12 +19,36 @@ CREATE TABLE IF NOT EXISTS accounts (
|
||||
-- vezi accounts.delete_account — randul ramane doar pentru audit).
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('pending','active','blocked','archived','deleted')),
|
||||
rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1)
|
||||
rar_creds_enc TEXT, -- LEGACY (PRD 5.20 US-013 dropeaza coloana): creds RAR durabile env-less
|
||||
-- Medii RAR per cont (PRD 5.20 US-001). Fiecare mediu = bifa de activare + slot creds.
|
||||
-- medii_disponibile = enabled AND creds prezente (app/rar_env.py). Cont client nou =
|
||||
-- Productie on / Testare off (clientii declara real); contul operator se pune manual pe Testare.
|
||||
rar_test_enabled INTEGER NOT NULL DEFAULT 0 CHECK (rar_test_enabled IN (0, 1)),
|
||||
rar_prod_enabled INTEGER NOT NULL DEFAULT 1 CHECK (rar_prod_enabled IN (0, 1)),
|
||||
rar_creds_test_enc TEXT, -- creds RAR criptate (Fernet) pentru mediul Testare
|
||||
rar_creds_prod_enc TEXT, -- creds RAR criptate (Fernet) pentru mediul Productie
|
||||
rar_env_default TEXT NOT NULL DEFAULT 'prod' CHECK (rar_env_default IN ('test', 'prod')),
|
||||
-- Comportament implicit la cod prestatie necunoscut/nemapat pe canalul API:
|
||||
-- 0 (default, non-distructiv: submission 'needs_mapping', intra in editorul de mapare) sau
|
||||
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
|
||||
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (on_unmapped_error_default IN (0, 1)),
|
||||
-- Plan de cont (5.17). Tier de baza al contului (admin aloca manual via CLI set-tier).
|
||||
-- trial_until: daca != NULL si > now -> effective_tier() intoarce 'pro' (trial Pro activ).
|
||||
-- Cont nou primeste tier='free' + trial_until=now+30z via create_account.
|
||||
-- Contul implicit id=1 (dev) primeste DEFAULT 'free' + trial_until=NULL (fara trial).
|
||||
tier TEXT NOT NULL DEFAULT 'free'
|
||||
CHECK (tier IN ('free','standard','pro','premium')),
|
||||
trial_until TEXT, -- ISO datetime UTC sau NULL; nullable
|
||||
-- Planul CERUT de client la signup (separat de `tier`). NU acorda drepturi:
|
||||
-- `tier` ramane sursa unica de adevar pentru gate-ul API (require_api_access) si volum.
|
||||
-- Folosit la integrarea platilor: client cere plan -> plateste -> admin/webhook urca `tier`
|
||||
-- -> API se deblocheaza. NULL = necunoscut (cont creat via CLI / inainte de coloana).
|
||||
requested_plan TEXT
|
||||
CHECK (requested_plan IS NULL OR requested_plan IN ('free','standard','pro','premium')),
|
||||
-- Marca temporala a acceptarii Termenilor + politicii de confidentialitate (GDPR, L.142).
|
||||
-- Setata la signup (proba de consimtamant). NULL = cont fara flux de consimtamant (CLI/legacy).
|
||||
consent_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
-- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi
|
||||
@@ -72,6 +96,10 @@ CREATE TABLE IF NOT EXISTS submissions (
|
||||
status TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
|
||||
payload_json TEXT NOT NULL,
|
||||
-- Mediul RAR tinta al acestei trimiteri (PRD 5.20 US-001). DEFAULT 'test' e doar plasa
|
||||
-- pentru randuri net-noi care nu seteaza explicit; fiecare INSERT (API/import/web) seteaza
|
||||
-- rar_env explicit. Backfill din AUTOPASS_RAR_ENV global la migrare (NU lasa pe DEFAULT).
|
||||
rar_env TEXT NOT NULL DEFAULT 'test' CHECK (rar_env IN ('test', 'prod')),
|
||||
rar_creds_enc TEXT, -- creds RAR criptate (Fernet), sterse dupa primul login reusit
|
||||
rar_status_code INTEGER,
|
||||
rar_error TEXT,
|
||||
@@ -200,6 +228,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),
|
||||
|
||||
142
app/shared_store.py
Normal file
142
app/shared_store.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""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)
|
||||
# Normalizeaza INAINTE de truthiness: un cod whitespace-only (" ") sau
|
||||
# ne-string trebuie sa devina NULL, nu '' (altfel rand non-NUL cu cod gol).
|
||||
cod = None
|
||||
if not is_nul:
|
||||
raw_cod = str(item.get("cod_prestatie") or "").strip().upper()
|
||||
cod = raw_cod or None
|
||||
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),
|
||||
)
|
||||
@@ -8,6 +8,7 @@ Rute:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
@@ -15,12 +16,42 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..accounts import account_is_complete, list_accounts, set_active, set_status, delete_account
|
||||
from ..accounts import account_is_complete, list_accounts, set_active, set_status, set_tier, set_trial, delete_account
|
||||
from ..config import get_settings
|
||||
from ..db import get_connection
|
||||
from ..plans import PLANS, effective_tier
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from ..web.session import require_admin
|
||||
|
||||
|
||||
def _plan_label(code: str | None) -> str:
|
||||
"""Eticheta RO a unui cod de plan (din PLANS). None/necunoscut -> '—'."""
|
||||
if not code:
|
||||
return "—"
|
||||
plan = PLANS.get(code)
|
||||
return plan["label"] if plan else code
|
||||
|
||||
|
||||
def _trial_zile_ramase(trial_until_str: str | None, now: datetime) -> int | None:
|
||||
"""Zile ramase din trial (rotunjit in sus), sau None daca nu e trial activ/malformat.
|
||||
|
||||
Acelasi parsing tolerant ca plans.effective_tier (UTC implicit pe valori naive).
|
||||
"""
|
||||
if not trial_until_str:
|
||||
return None
|
||||
try:
|
||||
tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T"))
|
||||
if tu.tzinfo is None:
|
||||
tu = tu.replace(tzinfo=timezone.utc)
|
||||
now_cmp = now if now.tzinfo else now.replace(tzinfo=timezone.utc)
|
||||
secunde = (tu - now_cmp).total_seconds()
|
||||
if secunde <= 0:
|
||||
return None
|
||||
# Rotunjire in sus la zile (o fractie de zi ramasa = inca 1 zi afisata).
|
||||
return int(secunde // 86400) + (1 if secunde % 86400 else 0)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
router = APIRouter()
|
||||
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||
|
||||
@@ -47,10 +78,19 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
|
||||
"""Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare."""
|
||||
accounts = list_accounts(conn)
|
||||
emails = _emails_by_account(conn)
|
||||
now = datetime.now(timezone.utc)
|
||||
for acct in accounts:
|
||||
# Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului
|
||||
acct["is_complete"] = account_is_complete(acct)
|
||||
acct["email"] = emails.get(acct["id"])
|
||||
# Plan EFECTIV (ce are contul acum): trial Pro activ ridica `free` la `pro`.
|
||||
# `tier` ramane sursa de adevar pentru drepturi; `requested_plan` e doar intentia de la signup.
|
||||
eff = effective_tier(acct, now)
|
||||
acct["tier_label"] = _plan_label(acct.get("tier")) # tier de baza (post-trial)
|
||||
acct["tier_efectiv_label"] = _plan_label(eff) # plan efectiv ACUM
|
||||
acct["trial_activ"] = eff != (acct.get("tier") or "free")
|
||||
acct["trial_zile"] = _trial_zile_ramase(acct.get("trial_until"), now)
|
||||
acct["requested_plan_label"] = _plan_label(acct.get("requested_plan"))
|
||||
# Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
||||
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
|
||||
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
|
||||
@@ -146,6 +186,73 @@ async def admin_delete(request: Request, account_id: list[int] = Form(...),
|
||||
return _lifecycle_route(request, account_id, csrf_token, "delete")
|
||||
|
||||
|
||||
@router.post("/admin/set-tier", response_class=HTMLResponse)
|
||||
async def admin_set_tier(
|
||||
request: Request,
|
||||
account_id: int = Form(...),
|
||||
tier: str = Form(...),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
"""Schimba planul (tier) unui cont din panou. require_admin + CSRF, PRG 303.
|
||||
|
||||
Reuseaza accounts.set_tier (valideaza tier-ul, protejeaza id=1, logheaza schimbarea).
|
||||
INCHEIE trial-ul (trial_until=NULL): alocarea manuala = plan real de-acum, cu efect
|
||||
imediat — altfel trial-ul Pro universal (30z la signup) ar masca alegerea pana la
|
||||
expirare (decizie user 2026-06-29). Tier invalid / cont protejat -> re-randare cu eroare.
|
||||
"""
|
||||
require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
try:
|
||||
# trial_until=None: alocarea manuala incheie trial-ul si aplica tier-ul ales acum.
|
||||
set_tier(conn, account_id, tier, trial_until=None)
|
||||
conn.commit()
|
||||
except ValueError as exc:
|
||||
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@router.post("/admin/set-trial", response_class=HTMLResponse)
|
||||
async def admin_set_trial(
|
||||
request: Request,
|
||||
account_id: int = Form(...),
|
||||
trial_days: int = Form(...),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
"""Acorda/prelungeste un trial Pro de N zile (de la acum), fara a schimba tier-ul de baza.
|
||||
|
||||
require_admin + CSRF, PRG 303. Reuseaza accounts.set_trial (protejeaza id=1, logheaza).
|
||||
trial_days <= 0 sau peste plafon -> re-randare panou cu eroare (422). Plafon defensiv 3650z.
|
||||
"""
|
||||
require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
if trial_days <= 0 or trial_days > 3650:
|
||||
return _render_admin(
|
||||
request, conn,
|
||||
error="Numarul de zile pentru trial trebuie sa fie intre 1 si 3650.",
|
||||
status_code=422,
|
||||
)
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
trial_until = (now + timedelta(days=trial_days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
set_trial(conn, account_id, trial_until)
|
||||
conn.commit()
|
||||
except ValueError as exc:
|
||||
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@router.post("/admin/deactivate", response_class=HTMLResponse)
|
||||
async def admin_deactivate(
|
||||
request: Request,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
@@ -9,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..accounts import create_account
|
||||
from ..accounts import VALID_TIERS, create_account
|
||||
from ..auth import create_api_key
|
||||
from ..config import get_settings
|
||||
from ..db import get_connection
|
||||
@@ -47,10 +48,18 @@ async def signup_post(
|
||||
cui: str = Form(default=""),
|
||||
email: str = Form(default=""),
|
||||
parola: str = Form(default=""),
|
||||
plan: str = Form(default=""),
|
||||
consent: str = Form(default=""),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
# Planul CERUT (intentie, nu drept): pastram doar valori valide; orice altceva -> 'free'.
|
||||
# `tier`-ul real ramane 'free' la creare; planul ales se onoreaza dupa plata (admin/webhook).
|
||||
requested_plan = plan.strip().lower() if plan else ""
|
||||
if requested_plan not in VALID_TIERS:
|
||||
requested_plan = "free"
|
||||
|
||||
settings = get_settings()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s):
|
||||
@@ -58,7 +67,7 @@ async def signup_post(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=_RATE_MSG,
|
||||
name=name, cui=cui, email=email,
|
||||
name=name, cui=cui, email=email, plan=requested_plan,
|
||||
), status_code=429)
|
||||
|
||||
if len(parola) < _PASSWORD_MIN:
|
||||
@@ -66,7 +75,7 @@ async def signup_post(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.",
|
||||
name=name, cui=cui, email=email,
|
||||
name=name, cui=cui, email=email, plan=requested_plan,
|
||||
), status_code=422)
|
||||
|
||||
# CUI obligatoriu la signup (US-001, PRD 5.12)
|
||||
@@ -76,9 +85,19 @@ async def signup_post(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error="CUI-ul firmei este obligatoriu.",
|
||||
name=name, cui=cui, email=email,
|
||||
name=name, cui=cui, email=email, plan=requested_plan,
|
||||
), status_code=422)
|
||||
|
||||
# Consimtamant Termeni + GDPR obligatoriu (proba). Checkbox bifat -> valoare ne-goala.
|
||||
if not (consent and consent.strip()):
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error="Trebuie sa accepti Termenii si prelucrarea datelor (GDPR) pentru a crea cont.",
|
||||
name=name, cui=cui, email=email, plan=requested_plan,
|
||||
), status_code=422)
|
||||
consent_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
|
||||
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
|
||||
conn = get_connection()
|
||||
@@ -86,7 +105,10 @@ async def signup_post(
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
is_first = count_admins(conn) == 0
|
||||
account_id = create_account(conn, name, cui=cui_norm, email=email, active=False)
|
||||
account_id = create_account(
|
||||
conn, name, cui=cui_norm, email=email, active=False,
|
||||
requested_plan=requested_plan, consent_at=consent_at,
|
||||
)
|
||||
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
|
||||
api_key = create_api_key(conn, account_id)
|
||||
conn.execute("COMMIT")
|
||||
@@ -121,7 +143,7 @@ async def signup_post(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=error_msg,
|
||||
name=name, cui=cui, email=email,
|
||||
name=name, cui=cui, email=email, plan=requested_plan,
|
||||
), status_code=422)
|
||||
except Exception as exc:
|
||||
conn.execute("ROLLBACK")
|
||||
@@ -129,7 +151,7 @@ async def signup_post(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=str(exc),
|
||||
name=name, cui=cui, email=email,
|
||||
name=name, cui=cui, email=email, plan=requested_plan,
|
||||
), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
1101
app/web/routes.py
1101
app/web/routes.py
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@
|
||||
In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul
|
||||
intern) → <details> ramane neatins → containerul ramane deschis intre pasi. === #}
|
||||
<details id="import-details"{% if not are_trimiteri %} open{% endif %}>
|
||||
<summary>Importa un fisier</summary>
|
||||
<summary>+ Importa fisier (XLSX / CSV)</summary>
|
||||
{% include '_upload.html' %}
|
||||
</details>
|
||||
|
||||
|
||||
234
app/web/templates/_chips_prestatii.html
Normal file
234
app/web/templates/_chips_prestatii.html
Normal file
@@ -0,0 +1,234 @@
|
||||
{#
|
||||
_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 %}
|
||||
|
||||
{# ===== US-005 (5.16): Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #}
|
||||
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat).
|
||||
T-7 (5.16): containerul .chips se randeaza DOAR cand exista chips extra — altfel ramanea
|
||||
un chenar gol nefinisat sub randurile de operatie. #}
|
||||
{% set _extra_chips = _chips | rejectattr('cod_op_service') | selectattr('cod_prestatie') | list %}
|
||||
{% if _extra_chips %}
|
||||
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;">
|
||||
{% for chip in _extra_chips %}
|
||||
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
||||
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %}"
|
||||
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}">
|
||||
{{ chip.cod_prestatie }}
|
||||
<button type="button" class="chip-del"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
|
||||
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">×</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if nomenclator_rar %}
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;">
|
||||
<select name="chips_add_cod_flat"
|
||||
aria-label="Adauga cod RAR suplimentar"
|
||||
style="min-width:160px;font-size:11px;height:26px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
|
||||
<option value="">+ Adauga alta operatie / cod RAR</option>
|
||||
{% for n in nomenclator_rar %}
|
||||
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button"
|
||||
class="add-code"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"add_extra"}'
|
||||
aria-label="Adauga cod RAR suplimentar la trimitere">
|
||||
+
|
||||
</button>
|
||||
</span>
|
||||
{% else %}
|
||||
{# T-D1/T-E5 (5.16): empty state in mod operatii cand nomenclatorul lipseste #}
|
||||
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;margin-top:4px;">
|
||||
Nomenclator indisponibil — adaugarea de coduri suplimentare nu e posibila.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if chips_extra_error %}
|
||||
{# T-C1/T-E4 (5.16): semnal vizibil cand add_extra are select gol sau cod invalid #}
|
||||
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
|
||||
Selecteaza un cod RAR din lista inainte de a adauga.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
{# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #}
|
||||
<div class="chips" role="group" aria-label="Coduri RAR selectate">
|
||||
{% for chip in _chips %}
|
||||
{% if chip.cod_prestatie %}
|
||||
{% set _is_warn_flat = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
||||
<span class="chip {% if _is_warn_flat %}chip-warn{% endif %}"
|
||||
aria-label="Prestatie {{ chip.cod_prestatie }}">
|
||||
{{ chip.cod_prestatie }}
|
||||
<button type="button" class="chip-del"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
|
||||
aria-label="Sterge codul {{ chip.cod_prestatie }}">×</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{# Picker adaugare cod nou in mod plat #}
|
||||
{% if nomenclator_rar %}
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;">
|
||||
<select name="chips_add_cod_flat"
|
||||
aria-label="Adauga cod RAR nou"
|
||||
style="font-size:11px;height:22px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
|
||||
<option value="">+ cod</option>
|
||||
{% for n in nomenclator_rar %}
|
||||
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button"
|
||||
class="add-code"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"add_flat"}'
|
||||
aria-label="Adauga cod RAR selectat in lista">
|
||||
+
|
||||
</button>
|
||||
</span>
|
||||
{% else %}
|
||||
{# T-D1/T-E5 (5.16): empty state in mod plat cand nomenclatorul lipseste #}
|
||||
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;">
|
||||
Nomenclator indisponibil — nu se pot adauga coduri RAR momentan.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Hint discret fara chips (debut) #}
|
||||
{% if not _chips %}
|
||||
<div style="font-size:10px;color:var(--muted);padding:4px 0;">
|
||||
Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,19 +6,26 @@
|
||||
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"
|
||||
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||
<h2 id="trimiteri-heading" style="font-size:15px; margin:0;">
|
||||
Trimiterile tale
|
||||
{% if blocate_total %}
|
||||
<span class="tab-badge" title="{{ blocate_total }} necesita atentie"
|
||||
style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ blocate_total }}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{# US-002 (5.16): titlul de sectiune vizibil ("Trimiterile tale") a fost eliminat —
|
||||
lista incepe direct sub filtre. Heading pastrat sr-only pentru a11y (section
|
||||
aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
|
||||
<h2 id="trimiteri-heading" class="sr-only">Trimiterile tale</h2>
|
||||
{% if blocate_total %}
|
||||
<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin:0 0 10px;">
|
||||
<span class="tab-badge" title="{{ blocate_total }} necesita atentie"
|
||||
style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ blocate_total }}</span>
|
||||
<span class="muted" style="font-size:var(--fs-sm);">de rezolvat</span>
|
||||
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
|
||||
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; flex-wrap:wrap; margin:0 0 10px;">
|
||||
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
|
||||
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bara de filtre: [quick-pills data STANGA] [cautare vehicul MIJLOC] [pills stare DREAPTA].
|
||||
Pill-urile de stare scriu campul hidden status si re-trimit form-ul (filtreazaStare).
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
<div class="card" id="card-cont">
|
||||
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
|
||||
|
||||
<!-- Sectiunea: Plan curent (US-006 PRD 5.17) -->
|
||||
{% if plan_linie is defined %}
|
||||
<div id="sectiune-plan" style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
|
||||
<h3 style="font-size:var(--fs-sm); color:var(--muted); font-weight:500; margin:0 0 10px;
|
||||
text-transform:uppercase; letter-spacing:.04em;">Plan curent</h3>
|
||||
|
||||
<div style="font-size:var(--fs-md); font-weight:600; margin-bottom:6px;
|
||||
color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--ink){% endif %};">
|
||||
{{ plan_linie }}
|
||||
</div>
|
||||
|
||||
{% if monthly_limit_val is defined and monthly_limit_val is not none and effective_tier_name|default('') == 'free' %}
|
||||
<div style="font-size:var(--fs-sm); color:var(--muted); margin-bottom:8px;">
|
||||
Planul Gratuit include {{ monthly_limit_val }} prestatii/luna prin dashboard-ul web.
|
||||
{% if plan_limita_atinsa|default(false) %}
|
||||
Limita lunara a fost atinsa — trimiterile noi sunt blocate pana la inceputul lunii urmatoare.
|
||||
{% elif plan_warn|default(false) %}
|
||||
Te apropii de limita lunara.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="font-size:var(--fs-sm); color:var(--muted); padding:8px 10px;
|
||||
border:1px solid var(--line); border-radius:6px; margin-top:4px;">
|
||||
Vrei sa treci pe Standard, Pro sau Premium?
|
||||
Contacteaza-ne pentru alocare manuala — nu exista inca plata self-service.
|
||||
<strong>Pro</strong> adauga import prin API; <strong>Standard</strong> si
|
||||
<strong>Premium</strong> ridica limita de volum.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sectiunea: Date firma (US-002) -->
|
||||
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
|
||||
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Date firma</h3>
|
||||
|
||||
@@ -45,14 +45,10 @@
|
||||
Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
|
||||
</div>
|
||||
|
||||
{# with_cancel=True: _form_editare.html randeaza Salveaza + Anuleaza pe acelasi
|
||||
rand (sistemul .act: desktop text, mobil iconite Lucide 44px alaturate). #}
|
||||
{% set with_cancel = true %}
|
||||
{% include "_form_editare.html" %}
|
||||
|
||||
<div style="margin-top:10px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||
<button type="button"
|
||||
style="min-height:44px; padding:8px 18px;
|
||||
background:var(--card); color:var(--muted); border-color:var(--line);"
|
||||
data-modal-close>Anuleaza</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if is_needs_review %}
|
||||
|
||||
@@ -1,41 +1,113 @@
|
||||
{# _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 '')
|
||||
btn_label — eticheta butonului primar (ex. 'Salveaza si retrimite')
|
||||
#}
|
||||
{% from "_macros.html" import camp %}
|
||||
{% 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. #}
|
||||
{% if with_cancel %}
|
||||
<div class="act-group" style="margin-top:14px;">
|
||||
<button type="submit" class="act act-primary" aria-label="{{ btn_label or 'Salveaza' }}">
|
||||
<span class="act-tx">{{ btn_label or 'Salveaza' }}</span>{{ icon('save') }}</button>
|
||||
<button type="button" class="act" aria-label="{{ cancel_label or 'Renunta' }}" data-modal-close>
|
||||
<span class="act-tx">{{ cancel_label or 'Renunta' }}</span>{{ icon('x') }}</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="margin-top:14px;">
|
||||
<button type="submit">{{ btn_label or 'Salveaza' }}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -50,3 +56,25 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# PRD 5.13 — sistem butoane de actiune responsive.
|
||||
CSS-ul aferent (.act, .act-tx, .act-ic, .act-save, .act-del, .act-group)
|
||||
este definit in base.html.
|
||||
Desktop: se afiseaza textul (.act-tx); mobil: se afiseaza iconita (.act-ic). #}
|
||||
|
||||
{% macro icon(name) -%}
|
||||
<svg class="act-ic" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
{%- if name == 'save' -%}<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>
|
||||
{%- elif name == 'trash' -%}<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>
|
||||
{%- elif name == 'edit' -%}<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
{%- elif name == 'plus' -%}<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
{%- elif name == 'x' -%}<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
{%- endif -%}
|
||||
</svg>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro act_btn(label, ic, kind='', attrs='') -%}
|
||||
<button class="act{% if kind %} act-{{ kind }}{% endif %}" aria-label="{{ label }}" {{ attrs | safe }}>
|
||||
<span class="act-tx">{{ label }}</span>{{ icon(ic) }}</button>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -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,17 +46,30 @@
|
||||
<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>
|
||||
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
|
||||
</td>
|
||||
<td class="muted" style="font-size:12px;" data-eticheta="Sugestii">
|
||||
{# 5.18 US-007: badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
|
||||
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
|
||||
{% if e.sugestie_principala %}
|
||||
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
|
||||
<span class="sugg-sursa sugg-sursa--confirmat" title="cod confirmat de un operator">confirmat</span>
|
||||
{% else %}
|
||||
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
|
||||
{% endif %}
|
||||
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
|
||||
<span class="sugg-sursa sugg-sursa--nul" title="pare non-operatie (ITP/plata/discount...)">non-operatie</span>
|
||||
{% endif %}
|
||||
{% if e.suggestions %}
|
||||
{% for s in e.suggestions[:3] %}
|
||||
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}—{% endif %}
|
||||
{% elif not e.sugestie_principala and not (e.surse_sugestie and e.surse_sugestie.nul) %}—{% endif %}
|
||||
</td>
|
||||
<td data-eticheta="Cod RAR">
|
||||
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
|
||||
@@ -120,7 +134,7 @@
|
||||
<input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
|
||||
</form>
|
||||
<div><strong>{{ m.cod_op_service }}</strong></div>
|
||||
<div class="muted" style="font-size:12px;">
|
||||
<div class="muted map-acum" style="font-size:12px;">
|
||||
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
@@ -135,24 +149,11 @@
|
||||
</select>
|
||||
</td>
|
||||
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
|
||||
{# Butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton.
|
||||
{# Butoane act_btn (desktop: text; mobil: iconita 44px).
|
||||
data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
|
||||
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
|
||||
<button type="submit" form="map-salv-{{ loop.index }}"
|
||||
class="icon-btn"
|
||||
data-dirty-form="map-salv-{{ loop.index }}"
|
||||
aria-label="Salveaza maparea pentru {{ m.cod_op_service }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
<path d="M2 2a1 1 0 011-1h7.5L13 3.5V14a1 1 0 01-1 1H3a1 1 0 01-1-1V2zm5 10a2 2 0 100-4 2 2 0 000 4zM3 3v3h6V3H3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="submit" form="map-del-{{ loop.index }}"
|
||||
class="icon-btn danger"
|
||||
aria-label="Sterge maparea pentru {{ m.cod_op_service }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6zM14 3a1 1 0 01-1 1H3a1 1 0 110-2h3.5l1-1h2l1 1H13a1 1 0 011 1zm-1 1H3v9a1 1 0 001 1h8a1 1 0 001-1V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{{ ui.act_btn('Salveaza', 'save', 'save', 'type="submit" form="map-salv-' ~ loop.index ~ '" data-dirty-form="map-salv-' ~ loop.index ~ '"') }}
|
||||
{{ ui.act_btn('Sterge', 'trash', 'del', 'type="submit" form="map-del-' ~ loop.index ~ '"') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -206,10 +207,7 @@
|
||||
{{ r.cod_prestatie }}
|
||||
</td>
|
||||
<td style="text-align:right; white-space:nowrap;">
|
||||
<button type="submit" form="rt-del-{{ loop.index }}"
|
||||
style="background:var(--card); color:var(--err); border-color:var(--err);">
|
||||
Sterge
|
||||
</button>
|
||||
{{ ui.act_btn('Sterge', 'trash', 'del', 'type="submit" form="rt-del-' ~ loop.index ~ '"') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -308,9 +306,7 @@
|
||||
hx-confirm="Stergi acest format de coloane?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="format_id" value="{{ f.id }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
|
||||
Sterge
|
||||
</button>
|
||||
{{ ui.act_btn('Sterge', 'trash', 'del', 'type="submit"') }}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{# prima_inregistrare poate veni din context (web_upload_import) sau derivat din sample_rows #}
|
||||
{%- set prima_inreg = prima_inregistrare if prima_inregistrare is defined else (sample_rows[0] if sample_rows else none) -%}
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">
|
||||
<h2 style="font-size:var(--fs-md); margin:0 0 12px;">
|
||||
Mapare coloane —
|
||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||
</h2>
|
||||
@@ -20,19 +20,19 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
<p class="muted" style="margin:0 0 12px; font-size:var(--fs-sm);">
|
||||
Asociaza fiecare coloana din fisier cu campul canonic corespunzator.
|
||||
Maparea se retine automat pentru fisiere cu acelasi antet.
|
||||
</p>
|
||||
|
||||
{# Tabel orizontal preview: antet + prima inregistrare (US-003) #}
|
||||
{# Tabel orizontal preview: antet + prima inregistrare (compatibilitate teste) #}
|
||||
<div class="tablewrap" style="margin-bottom:16px;">
|
||||
<table class="preview-antet" style="border-collapse:collapse; font-size:12px; width:100%; min-width:max-content;">
|
||||
<table class="preview-antet" style="border-collapse:collapse; font-size:var(--fs-xs); width:100%; min-width:max-content;">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for col in columns %}
|
||||
<th style="padding:4px 10px; text-align:left; background:var(--card); border:1px solid var(--line);
|
||||
white-space:nowrap; font-weight:600; font-size:12px; color:var(--ink);">
|
||||
white-space:nowrap; font-weight:600; font-size:var(--fs-xs); color:var(--ink);">
|
||||
{{ col }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
@@ -44,7 +44,7 @@
|
||||
{% for col in columns %}
|
||||
{%- set val = prima_inreg.get(col, '') | string -%}
|
||||
<td style="padding:4px 10px; border:1px solid var(--line); white-space:nowrap;
|
||||
font-size:11px; color:var(--muted); max-width:160px; overflow:hidden; text-overflow:ellipsis;"
|
||||
font-size:var(--fs-xs); color:var(--muted); max-width:160px; overflow:hidden; text-overflow:ellipsis;"
|
||||
title="{{ val }}">
|
||||
{{ val[:40] }}{% if val | length > 40 %}…{% endif %}
|
||||
</td>
|
||||
@@ -53,7 +53,7 @@
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{{ columns | length }}"
|
||||
style="padding:6px 10px; border:1px solid var(--line); font-size:12px;
|
||||
style="padding:6px 10px; border:1px solid var(--line); font-size:var(--fs-xs);
|
||||
color:var(--muted); font-style:italic; text-align:center;">
|
||||
Antet fara randuri de date
|
||||
</td>
|
||||
@@ -69,7 +69,7 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
|
||||
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||
<label for="format-data" style="font-size:13px; color:var(--muted);">
|
||||
<label for="format-data" style="font-size:var(--fs-sm); color:var(--muted);">
|
||||
Format data
|
||||
</label>
|
||||
<input type="text" id="format-data" name="format_data"
|
||||
@@ -77,66 +77,97 @@
|
||||
placeholder="ex: DD.MM.YYYY"
|
||||
style="max-width:160px;"
|
||||
aria-describedby="format-data-hint">
|
||||
<span id="format-data-hint" class="muted" style="font-size:12px;">
|
||||
<span id="format-data-hint" class="muted" style="font-size:var(--fs-xs);">
|
||||
sau YYYY-MM-DD, MM/DD/YYYY etc.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% for col in columns %}
|
||||
{%- set sugg = fuzzy_suggestions.get(col, []) -%}
|
||||
{%- set best = sugg[0].camp_canonic if sugg else '' -%}
|
||||
<input type="hidden" name="colname" value="{{ col }}">
|
||||
<div class="maprow">
|
||||
<div class="mapcol grow">
|
||||
<div><strong>{{ col }}</strong></div>
|
||||
{% if sugg %}
|
||||
<div class="muted" style="font-size:12px; margin-top:2px;">
|
||||
sugestie: <span class="sugg">{{ sugg[0].camp_canonic }}
|
||||
({{ sugg[0].score | round | int }}%)</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{%- set ns = namespace(samples=[]) -%}
|
||||
{%- for row in sample_rows -%}
|
||||
{%- if row.get(col) is not none and row.get(col) != '' -%}
|
||||
{%- set ns.samples = ns.samples + [row[col] | string] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{% if ns.samples %}
|
||||
<div class="muted" style="font-size:11px; margin-top:2px;">
|
||||
ex: {{ ns.samples[:2] | join(", ") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mapcol" style="min-width:200px;">
|
||||
<label for="canon-{{ loop.index }}"
|
||||
style="display:block; font-size:12px; color:var(--muted); margin-bottom:2px;">
|
||||
Camp canonic
|
||||
</label>
|
||||
<select id="canon-{{ loop.index }}" name="canon">
|
||||
<option value="">— ignorat —</option>
|
||||
{% for field_key, field_label in canonical_fields %}
|
||||
<option value="{{ field_key }}"
|
||||
{% if field_key == best %}selected{% endif %}>
|
||||
{{ field_key }} — {{ field_label }}
|
||||
</option>
|
||||
{# Tabel mapare: coloana din fisier | exemplu | camp RAR (mockup 5.16 / US-013) #}
|
||||
<div class="tablewrap" style="margin-bottom:16px;">
|
||||
<table style="border-collapse:collapse; width:100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="font-size:var(--fs-xs); width:34%; padding:6px 10px; text-align:left;
|
||||
background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
|
||||
font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
|
||||
Coloana din fisier
|
||||
</th>
|
||||
<th style="font-size:var(--fs-xs); width:28%; padding:6px 10px; text-align:left;
|
||||
background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
|
||||
font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
|
||||
Exemplu
|
||||
</th>
|
||||
<th style="font-size:var(--fs-xs); padding:6px 10px; text-align:left;
|
||||
background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
|
||||
font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
|
||||
Camp RAR
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for col in columns %}
|
||||
{%- set sugg = fuzzy_suggestions.get(col, []) -%}
|
||||
{%- set best = sugg[0].camp_canonic if sugg else '' -%}
|
||||
{%- set ns = namespace(samples=[]) -%}
|
||||
{%- for row in sample_rows -%}
|
||||
{%- if row.get(col) is not none and row.get(col) != '' -%}
|
||||
{%- set ns.samples = ns.samples + [row[col] | string] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
<tr style="border-bottom:1px solid var(--line);">
|
||||
<td style="padding:9px 10px; vertical-align:top;">
|
||||
<input type="hidden" name="colname" value="{{ col }}">
|
||||
<strong style="font-family:var(--font-mono); font-size:var(--fs-sm);">{{ col }}</strong>
|
||||
{% if sugg %}
|
||||
<div class="muted" style="font-size:var(--fs-xs); margin-top:3px;">
|
||||
sugestie: <span class="sugg">{{ sugg[0].camp_canonic }}
|
||||
({{ sugg[0].score | round | int }}%)</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding:9px 10px; vertical-align:top;">
|
||||
{% if ns.samples %}
|
||||
<span style="font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted);">
|
||||
{{ ns.samples[:2] | join(", ") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="muted" style="font-size:var(--fs-xs);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding:9px 10px; vertical-align:top;">
|
||||
<label for="canon-{{ loop.index }}"
|
||||
style="display:block; font-size:var(--fs-xs); color:var(--muted); margin-bottom:3px;">
|
||||
Camp canonic
|
||||
</label>
|
||||
<select id="canon-{{ loop.index }}" name="canon"
|
||||
style="width:100%; font-size:var(--fs-base); min-height:38px;">
|
||||
<option value="">— ignorat —</option>
|
||||
{% for field_key, field_label in canonical_fields %}
|
||||
<option value="{{ field_key }}"
|
||||
{% if field_key == best %}selected{% endif %}>
|
||||
{{ field_key }} — {{ field_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
<button type="submit"
|
||||
{% if not prima_inreg %}disabled aria-disabled="true"{% endif %}
|
||||
style="min-height:44px; padding:10px 24px; font-size:14px;{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}">
|
||||
style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}">
|
||||
Salveaza si continua la preview
|
||||
</button>
|
||||
{% if not prima_inreg %}
|
||||
<span style="font-size:12px; color:var(--err);">
|
||||
<span style="font-size:var(--fs-xs); color:var(--err);">
|
||||
Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare.
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="muted" style="font-size:12px;">
|
||||
<span class="muted" style="font-size:var(--fs-xs);">
|
||||
maparea se retine pentru fisiere cu acelasi antet
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -144,7 +175,7 @@
|
||||
</form>
|
||||
|
||||
<div style="margin-top:12px;">
|
||||
<a href="/" class="muted" style="font-size:13px;">Incarca alt fisier</a>
|
||||
<a href="/" class="muted" style="font-size:var(--fs-sm);">Incarca alt fisier</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
{% import '_macros.html' as ui %}
|
||||
<div id="import-section">
|
||||
{# reincarcaPreview (emis de /editeaza si /confirma-review prin HX-Trigger): preview-ul
|
||||
se reincarca COMPLET (rand + contoare + colaps deja-trimise corecte) in loc de OOB swap
|
||||
pe <tr> (fragil in htmx 1.9). Evidentierea + toast-ul randului salvat: base.html. #}
|
||||
<div id="import-section"
|
||||
hx-get="/_import/{{ import_id }}/preview"
|
||||
hx-trigger="reincarcaPreview from:body"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML">
|
||||
{% set pas = 3 %}{% include '_stepper.html' %}
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
|
||||
<h2 style="font-size:15px; margin:0;">
|
||||
<h2 style="font-size:var(--fs-md); margin:0;">
|
||||
Preview —
|
||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||
</h2>
|
||||
<span class="muted" style="margin-left:auto; font-size:13px;">{{ total }} randuri</span>
|
||||
<span class="muted" style="margin-left:auto; font-size:var(--fs-sm);">{{ total }} randuri</span>
|
||||
</div>
|
||||
|
||||
{% if message %}
|
||||
@@ -30,7 +37,8 @@
|
||||
{% for status_key, label in status_labels %}
|
||||
{%- set cnt = summary.get(status_key, 0) -%}
|
||||
{% if cnt > 0 %}
|
||||
<span class="pill s-{{ status_key }}">{{ cnt }} {{ label | lower }}</span>
|
||||
<span class="pill s-{{ status_key }}" style="display:inline-flex; align-items:center; gap:5px; font-size:var(--fs-xs);">
|
||||
<span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ cnt }} {{ label | lower }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -39,14 +47,14 @@
|
||||
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;" role="group"
|
||||
aria-label="Filtrare dupa stare">
|
||||
<button type="button" class="filter-btn" data-filter="all"
|
||||
style="min-height:36px; font-size:13px; padding:4px 12px;">
|
||||
style="min-height:36px; font-size:var(--fs-sm); padding:4px 12px;">
|
||||
Toate ({{ total }})
|
||||
</button>
|
||||
{% for status_key, label in status_labels %}
|
||||
{%- set cnt = summary.get(status_key, 0) -%}
|
||||
{% if cnt > 0 %}
|
||||
<button type="button" class="filter-btn" data-filter="{{ status_key }}"
|
||||
style="min-height:36px; font-size:13px; padding:4px 12px;
|
||||
style="min-height:36px; font-size:var(--fs-sm); padding:4px 12px;
|
||||
background:transparent; border-color:var(--line); color:var(--ink);">
|
||||
{{ label }} ({{ cnt }})
|
||||
</button>
|
||||
@@ -59,7 +67,7 @@
|
||||
{% if unmapped_ops %}
|
||||
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
|
||||
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
<p class="muted" style="margin:0 0 12px; font-size:var(--fs-sm);">
|
||||
Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e
|
||||
preselectata) si salveaza — randurile blocate trec automat in
|
||||
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
|
||||
@@ -123,20 +131,31 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Toggle randuri deja-trimise / duplicate: colapsate implicit (nu ocupa loc).
|
||||
Click -> comuta clasa .preview-arata-trimise pe tabel (CSS in base.html). -->
|
||||
{% set _n_trimise = summary.get('already_sent', 0) + summary.get('duplicate_in_file', 0) %}
|
||||
{% if _n_trimise %}
|
||||
<div style="margin-bottom:8px;">
|
||||
<button type="button" class="btn-secondary btn-sm" aria-expanded="false"
|
||||
onclick="var t=document.getElementById('preview-tabel'); var on=t.classList.toggle('preview-arata-trimise'); this.setAttribute('aria-expanded', on); this.querySelector('.tgl-tx').textContent = on ? 'Ascunde {{ _n_trimise }} deja trimise / duplicate' : 'Arata {{ _n_trimise }} deja trimise / duplicate';">
|
||||
<span class="tgl-tx">Arata {{ _n_trimise }} deja trimise / duplicate</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
|
||||
US-007: 8 coloane (coloana de verificare eliminata).
|
||||
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). -->
|
||||
<div class="tablewrap tabel-trimiteri">
|
||||
5.16 (T-4): densitate redusa la coloanele esentiale — Stare / Vehicul /
|
||||
Operatie / Data + Actiuni. KM final + mesajul de validare (Note) au iesit
|
||||
din tabel: KM se editeaza in modal, motivul apare ca tooltip pe pill-ul de
|
||||
Stare. Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). -->
|
||||
<div id="preview-tabel" class="tablewrap tabel-trimiteri">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<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</th>
|
||||
<th class="col-km">KM final</th>
|
||||
<th class="col-note">Note</th>
|
||||
<th class="col-actiuni">Actiuni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -148,7 +167,7 @@
|
||||
</table>
|
||||
<!-- Mesaj "filtrat la zero": afisat de JS cand filtrul ascunde toate randurile -->
|
||||
<p id="preview-zero-message" class="muted"
|
||||
style="display:none; text-align:center; padding:24px 16px; font-size:14px;">
|
||||
style="display:none; text-align:center; padding:24px 16px; font-size:var(--fs-md);">
|
||||
Niciun rand nu corespunde filtrului selectat.
|
||||
</p>
|
||||
</div>
|
||||
@@ -169,44 +188,32 @@
|
||||
prezentari la RAR (ireversibil).
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||
<div>
|
||||
<label for="n-confirmat"
|
||||
style="font-size:13px; color:var(--muted); display:block; margin-bottom:2px;">
|
||||
Numar prezentari de confirmat
|
||||
</label>
|
||||
<input type="number" id="n-confirmat" name="n_confirmat"
|
||||
value="{{ summary.get('ok', 0) }}"
|
||||
min="0" required
|
||||
style="max-width:80px;"
|
||||
aria-describedby="n-hint">
|
||||
<span id="n-hint" class="muted" style="font-size:12px; margin-left:6px;">
|
||||
(<span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> ok)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirmed-by"
|
||||
style="font-size:13px; color:var(--muted); display:block; margin-bottom:2px;">
|
||||
Declarant (optional)
|
||||
</label>
|
||||
<input type="text" id="confirmed-by" name="confirmed_by"
|
||||
placeholder="email sau nume"
|
||||
style="max-width:200px;">
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||
<label for="n-confirmat"
|
||||
style="font-size:var(--fs-sm); color:var(--muted);">
|
||||
Confirma numarul
|
||||
</label>
|
||||
<input type="number" id="n-confirmat" name="n_confirmat"
|
||||
value="{{ summary.get('ok', 0) }}"
|
||||
min="0" required
|
||||
style="max-width:80px;"
|
||||
aria-describedby="n-hint">
|
||||
<span id="n-hint" class="muted" style="font-size:var(--fs-xs);">
|
||||
din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:6px; align-self:flex-end;">
|
||||
<button type="submit"
|
||||
id="confirm-btn"
|
||||
style="min-height:44px; padding:10px 28px; font-size:14px;"
|
||||
style="min-height:44px; padding:10px 28px; font-size:var(--fs-md);"
|
||||
{% if not summary.get('ok', 0) %}disabled title="Niciun rand ok de trimis"{% endif %}>
|
||||
Trimite la RAR
|
||||
</button>
|
||||
{% if summary.get('needs_data', 0) or summary.get('needs_mapping', 0) or summary.get('needs_review', 0) %}
|
||||
<a href="/v1/import/{{ import_id }}/export-failed" download
|
||||
style="font-size:12px; text-align:center;">
|
||||
style="font-size:var(--fs-xs); text-align:center;">
|
||||
descarca randuri cu probleme (CSV)
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -219,7 +226,7 @@
|
||||
<span id="preview-ok-count" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
|
||||
|
||||
<div style="padding:8px 0 4px;">
|
||||
<a href="#" class="muted" style="font-size:13px;"
|
||||
<a href="#" class="muted" style="font-size:var(--fs-sm);"
|
||||
hx-get="/_import/reset" hx-target="#import-section" hx-swap="outerHTML">Incarca alt fisier</a>
|
||||
</div>
|
||||
|
||||
@@ -281,5 +288,18 @@
|
||||
/* Filtru implicit "Toate" activ la incarcare */
|
||||
filterRows('all');
|
||||
updateN();
|
||||
|
||||
/* Evidentiere rand dupa reincarcarea preview-ului (window.__randSalvat setat de
|
||||
listener-ul 'randSalvat' din base.html): scroll + flash, ca userul sa vada CARE
|
||||
rand s-a schimbat si sa nu ramana cu impresia ca "nu s-a intamplat nimic". */
|
||||
if (window.__randSalvat) {
|
||||
var d = window.__randSalvat; window.__randSalvat = null;
|
||||
var r = document.getElementById('preview-row-' + d.rowIndex);
|
||||
if (r) {
|
||||
r.scrollIntoView({block:'center', behavior:'smooth'});
|
||||
void r.offsetWidth;
|
||||
r.classList.add('rand-actualizat');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -18,23 +18,33 @@
|
||||
#}
|
||||
{%- set res = row.resolved -%}
|
||||
{%- set status = row.resolved_status -%}
|
||||
{%- set disp_fix_map = {} -%}
|
||||
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') and e.get('fix') -%}{%- set _ = disp_fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endfor -%}
|
||||
{%- set _sent_dup = status in ('already_sent', 'duplicate_in_file') -%}
|
||||
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}"
|
||||
{% if _sent_dup %}class="preview-sent-row"{% endif %}
|
||||
{% if oob_tr %}hx-swap-oob="outerHTML:#preview-row-{{ row.row_index }}"{% endif %}
|
||||
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif status in ('already_sent', 'duplicate_in_file') %}opacity:.65;{% endif %}">
|
||||
<td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td>
|
||||
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif _sent_dup %}opacity:.6;{% endif %}">
|
||||
{#- Motivul (validare / deja-trimis / duplicat) — fost coloana Note, acum tooltip pe pill.
|
||||
KM final iese din tabel (se editeaza in modal). -#}
|
||||
{%- if status == 'already_sent' and row.get('already_sent_info') -%}
|
||||
{%- set ai = row.already_sent_info -%}
|
||||
{%- set _nota = 'deja trimis ' ~ ((ai.get('created_at') or '')[:10]) ~ ((' (#' ~ ai.id_prezentare ~ ')') if ai.get('id_prezentare') else '') -%}
|
||||
{%- elif status == 'duplicate_in_file' and row.get('duplicate_with') -%}
|
||||
{%- set _dwith = [] -%}
|
||||
{%- for idx in row.duplicate_with -%}{{ _dwith.append(idx + 1) or '' }}{%- endfor -%}
|
||||
{%- set _nota = 'dubla cu randul ' ~ (_dwith | join(', ')) -%}
|
||||
{%- else -%}
|
||||
{%- set _nota = row.nota_umana or '' -%}
|
||||
{%- endif -%}
|
||||
<td class="col-stare" data-eticheta="Stare">
|
||||
<span class="pill {{ row.stare_css }}">{{ row.stare_eticheta }}</span>
|
||||
<span class="pill {{ row.stare_css }}" style="display:inline-flex; align-items:center; gap:5px;"
|
||||
{% if _nota %}title="{{ _nota }}"{% endif %}>
|
||||
<span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ row.stare_eticheta }}</span>
|
||||
</td>
|
||||
<td class="col-vehicul" data-eticheta="Vehicul">
|
||||
{{ row.prez.vehicul_nr }}
|
||||
{% if row.prez.vin_scurt and row.prez.vin_scurt != '—' %}
|
||||
<div class="muted" style="font-size:12px; white-space:nowrap;">{{ row.prez.vin_scurt }}</div>
|
||||
{% endif %}
|
||||
{# Fix-uri de validare pe vehicul #}
|
||||
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
|
||||
{% if disp_fix_map.get('nr_inmatriculare') %}<span class="camp-fix">{{ disp_fix_map.get('nr_inmatriculare') }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="col-operatie" data-eticheta="Operatie">
|
||||
<div>{{ row.prez.operatie }}</div>
|
||||
@@ -44,31 +54,11 @@
|
||||
<div class="muted cod-rar-sub">nemapat</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-data" data-eticheta="Data prestatie">
|
||||
{{ row.prez.data_prestatie }}
|
||||
{% if disp_fix_map.get('data_prestatie') %}<span class="camp-fix">{{ disp_fix_map.get('data_prestatie') }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="col-km" data-eticheta="KM final">
|
||||
{{ row.prez.odometru }}
|
||||
{% if disp_fix_map.get('odometru_final') %}<span class="camp-fix">{{ disp_fix_map.get('odometru_final') }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="col-note" data-eticheta="Note"
|
||||
style="font-size:12px; white-space:normal;">
|
||||
{% if status == 'already_sent' and row.get('already_sent_info') %}
|
||||
{% set ai = row.already_sent_info %}
|
||||
deja trimis {{ (ai.get('created_at') or '')[:10] }}
|
||||
{% if ai.get('id_prezentare') %}(#{{ ai.id_prezentare }}){% endif %}
|
||||
{% elif status == 'duplicate_in_file' and row.get('duplicate_with') %}
|
||||
dubla cu randul
|
||||
{% for idx in row.duplicate_with %}{{ idx + 1 }}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
{% else %}
|
||||
{{ row.nota_umana or '' }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-data" data-eticheta="Data prestatie">{{ row.prez.data_prestatie }}</td>
|
||||
<td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
|
||||
{% if status not in ('already_sent', 'duplicate_in_file') %}
|
||||
<button type="button" class="btn-editeaza"
|
||||
style="min-height:44px; padding:6px 14px; font-size:13px;
|
||||
style="min-height:36px; padding:6px 14px; font-size:13px;
|
||||
background:transparent; border-color:var(--line); color:var(--ink);"
|
||||
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare-modal"
|
||||
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
||||
@@ -87,7 +77,7 @@
|
||||
style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
|
||||
{% for status_key, label in status_labels %}
|
||||
{%- set cnt = summary.get(status_key, 0) -%}
|
||||
{% if cnt > 0 %}<span class="pill s-{{ status_key }}">{{ cnt }} {{ label | lower }}</span>{% endif %}
|
||||
{% if cnt > 0 %}<span class="pill s-{{ status_key }}" style="display:inline-flex; align-items:center; gap:5px; font-size:var(--fs-xs);"><span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ cnt }} {{ label | lower }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span id="preview-ok-count" hx-swap-oob="true" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
|
||||
|
||||
@@ -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,121 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rand 1: doua bife binare + ultima autentificare -->
|
||||
<div style="display:flex; gap:28px; flex-wrap:wrap; align-items:center; font-size:14px;">
|
||||
{# US-006 (5.17) — Banner one-time trial->Gratuit (T-DES-1): afisat la prima incarcare
|
||||
dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage.
|
||||
Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #}
|
||||
{% if trial_expirat_recent|default(false) %}
|
||||
<div id="banner-trial-expirat"
|
||||
role="status"
|
||||
style="margin-bottom:10px; padding:7px 12px;
|
||||
border-left:3px solid var(--warn);
|
||||
background:color-mix(in srgb, var(--warn) 10%, var(--card));
|
||||
border-radius:6px; font-size:var(--fs-sm);
|
||||
display:flex; align-items:center; justify-content:space-between; gap:8px;">
|
||||
<span>Trial Pro expirat — esti pe Gratuit, 60/luna</span>
|
||||
<button onclick="sessionStorage.setItem('tfx','1'); document.getElementById('banner-trial-expirat').style.display='none';"
|
||||
style="background:transparent; border:none; color:var(--muted); cursor:pointer;
|
||||
font-size:18px; padding:0 4px; line-height:1; flex-shrink:0;"
|
||||
aria-label="Inchide bannerul">×</button>
|
||||
</div>
|
||||
<script>(function(){ if(sessionStorage.getItem('tfx')){ var el=document.getElementById('banner-trial-expirat'); if(el) el.style.display='none'; } })();</script>
|
||||
{% endif %}
|
||||
|
||||
{# 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>
|
||||
{% else %}
|
||||
<span class="s-error" aria-hidden="true" style="font-weight:bold;">✗</span>
|
||||
<span class="s-error">{{ text }}</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>
|
||||
{# === US-003 (PRD 5.16): Banda de stare RAR — NUMAI cand BLOCAT (rosu, lat de 100%).
|
||||
OK = dot verde in antet (base.html); banda nu mai apare cand totul e ok.
|
||||
Elementul id="strip-sanatate" ramane in DOM mereu, dar goleste continutul cand OK,
|
||||
astfel "hidden" + fara continut eroare in sursa = nu pica testele de prezenta id-ului.
|
||||
#}
|
||||
{% if sanatate_ok %}
|
||||
<div id="strip-sanatate" role="status" aria-live="polite" hidden></div>
|
||||
{% else %}
|
||||
<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;
|
||||
background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);">
|
||||
<div style="display:flex; align-items:center; gap:9px;">
|
||||
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--err);">✗</span>
|
||||
<span style="font-weight:700; font-size:13px;">{{ sanatate_text }}</span>
|
||||
</div>
|
||||
<span style="font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted); white-space:nowrap;">
|
||||
{{ eticheta_ultima_auth }}: {{ last_login }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === US-002 (PRD 5.16): 5 carduri-contor separate (desktop) + bara compacta (mobil <=560px).
|
||||
Total / Luna asta / Azi / In coada / De corectat.
|
||||
#}
|
||||
{# Desktop: 5 carduri side-by-side. display:flex + layout stau in CSS (.contoare-desktop in
|
||||
base.html), NU inline, ca media query-ul <=560px sa le poata ascunde pe mobil (bara compacta). #}
|
||||
<div class="contoare-desktop">
|
||||
|
||||
{# Total trimise (all-time) #}
|
||||
<div class="contor-card" style="flex:1; min-width:100px;">
|
||||
<div class="contor-cifra">{{ counts_sent }}</div>
|
||||
<div class="contor-label">Total</div>
|
||||
</div>
|
||||
|
||||
{# Luna asta #}
|
||||
<div class="contor-card" style="flex:1; min-width:100px;">
|
||||
<div class="contor-cifra s-accent">{{ sent_month }}</div>
|
||||
<div class="contor-label">Luna asta</div>
|
||||
</div>
|
||||
|
||||
{# Azi #}
|
||||
<div class="contor-card" style="flex:1; min-width:80px;">
|
||||
<div class="contor-cifra s-accent">{{ sent_today }}</div>
|
||||
<div class="contor-label">Azi</div>
|
||||
</div>
|
||||
|
||||
{# In coada #}
|
||||
<div class="contor-card" style="flex:1; min-width:80px;">
|
||||
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
|
||||
<div class="contor-label">In coada</div>
|
||||
</div>
|
||||
|
||||
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
|
||||
<a href="/" class="contor-card"
|
||||
style="flex:1; min-width:80px; 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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
{# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #}
|
||||
{# Mobil (<=560px): bara compacta — numerele + etichete scurte in-line #}
|
||||
<div class="contoare-compact">
|
||||
<div class="compact-item">
|
||||
<div class="compact-nr">{{ counts_sent }}</div>
|
||||
<div class="compact-lbl">Total</div>
|
||||
</div>
|
||||
<div class="compact-item">
|
||||
<div class="compact-nr s-accent">{{ sent_month }}</div>
|
||||
<div class="compact-lbl">Luna</div>
|
||||
</div>
|
||||
<div class="compact-item">
|
||||
<div class="compact-nr s-accent">{{ sent_today }}</div>
|
||||
<div class="compact-lbl">Azi</div>
|
||||
</div>
|
||||
<div class="compact-item">
|
||||
<div class="compact-nr s-queued">{{ counts_queued }}</div>
|
||||
<div class="compact-lbl">Coada</div>
|
||||
</div>
|
||||
<a class="compact-item" href="/" style="text-decoration:none; color:inherit;">
|
||||
<div class="compact-nr {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
|
||||
<div class="compact-lbl">Erori</div>
|
||||
</a>
|
||||
</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).
|
||||
{# === 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>
|
||||
@@ -66,4 +137,21 @@
|
||||
class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a>
|
||||
</nav>
|
||||
|
||||
{# US-006 (5.17) + T-6 (5.16): linia de plan in CORP apare DOAR in starea de avertizare
|
||||
(>=80% -> --warn; limita atinsa -> --err). Consumul normal (N/60) traieste in badge-ul
|
||||
din antet + linia din meniul burger, nu ca rand permanent in corp (densitate redusa).
|
||||
Ierarhie: nu concureaza cu stripul de sanatate (zero-silent-failures pastrat). #}
|
||||
{% if plan_linie and (plan_warn|default(false) or plan_limita_atinsa|default(false)) %}
|
||||
<div class="plan-status-line"
|
||||
style="font-size:var(--fs-sm); margin-top:6px; padding-top:6px;
|
||||
border-top:1px solid var(--line2);
|
||||
color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--muted){% endif %};
|
||||
{% if plan_warn|default(false) %}font-weight:600;{% endif %}">
|
||||
{{ plan_linie }}
|
||||
{% if plan_limita_atinsa|default(false) or plan_warn|default(false) %}
|
||||
<a href="/?tab=cont" style="font-size:var(--fs-xs); font-weight:400; color:var(--accent);">Detalii plan</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
{#
|
||||
_stepper.html — Antet wizard import (PUR vizual, fara logica de rutare).
|
||||
|
||||
Parametru: `pas` (integer 1-4) — pasul curent.
|
||||
Utilizare in template-uri care mostenesc contextul Jinja2:
|
||||
{% set pas = 1 %}{% include '_stepper.html' %}
|
||||
sau cu `with`:
|
||||
{% with pas=2 %}{% include '_stepper.html' %}{% endwith %}
|
||||
|
||||
Cei 4 pasi ficsi:
|
||||
1. Incarca fisier
|
||||
2. Potriveste coloanele
|
||||
3. Verifica
|
||||
4. Confirma trimiterea
|
||||
|
||||
Stari vizuale:
|
||||
- index < pas → "facut" (bulina plina, text bifat)
|
||||
- index == pas → "activ" (evidentiat, aria-current="step")
|
||||
- index > pas → "viitor" (estompat)
|
||||
_stepper.html — Antet wizard import COMPACT (PUR vizual). PRD 5.13.
|
||||
Parametru: `pas` (integer 1-4). Clasele .stepper-* sunt definite in base.html.
|
||||
>=1024px: bara slim orizontala (.stepper-track). <1024px: forma colapsata
|
||||
"Pasul N din 4 - Titlu" + bara de progres (.stepper-collapsed).
|
||||
Utilizare: {% set pas = 1 %}{% include '_stepper.html' %}
|
||||
#}
|
||||
{%- set _pasi_import = [
|
||||
(1, "Incarca fisier", "Trage un fisier xlsx/csv aici sau foloseste butonul de alegere."),
|
||||
@@ -24,73 +11,26 @@
|
||||
(3, "Verifica", "Verifica randurile inainte sa le trimiti la RAR."),
|
||||
(4, "Confirma trimiterea", "Confirma numarul de prezentari — actiunea e ireversibila."),
|
||||
] -%}
|
||||
<nav class="stepper-import" aria-label="Pasii importului" style="
|
||||
display:flex;
|
||||
gap:0;
|
||||
align-items:stretch;
|
||||
margin-bottom:20px;
|
||||
border:1px solid var(--line);
|
||||
border-radius:8px;
|
||||
overflow:hidden;
|
||||
background:var(--card);
|
||||
">
|
||||
{% for nr, titlu, ajutor in _pasi_import %}
|
||||
{%- if nr < pas %}
|
||||
{%- set cls = "facut" -%}
|
||||
{%- set aria = "" -%}
|
||||
{%- elif nr == pas %}
|
||||
{%- set cls = "activ" -%}
|
||||
{%- set aria = ' aria-current="step"' -%}
|
||||
{%- else %}
|
||||
{%- set cls = "viitor" -%}
|
||||
{%- set aria = "" -%}
|
||||
{%- endif %}
|
||||
<div class="stepper-pas stepper-pas--{{ cls }}"{{ aria | safe }}
|
||||
style="
|
||||
flex:1;
|
||||
padding:10px 14px;
|
||||
border-right:{% if not loop.last %}1px solid var(--line){% else %}none{% endif %};
|
||||
{% if cls == 'activ' %}
|
||||
background:rgba(91,141,239,.10);
|
||||
{% elif cls == 'facut' %}
|
||||
opacity:1;
|
||||
{% else %}
|
||||
opacity:.4;
|
||||
{% endif %}
|
||||
">
|
||||
<div style="display:flex; align-items:center; gap:6px; margin-bottom:2px;">
|
||||
<span class="stepper-nr" style="
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
width:20px;
|
||||
height:20px;
|
||||
border-radius:50%;
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
flex-shrink:0;
|
||||
{% if cls == 'activ' %}
|
||||
background:var(--accent);
|
||||
color:#fff;
|
||||
{% elif cls == 'facut' %}
|
||||
background:var(--ok);
|
||||
color:#fff;
|
||||
{% else %}
|
||||
background:var(--line);
|
||||
color:var(--muted);
|
||||
{% endif %}
|
||||
">
|
||||
{% if cls == 'facut' %}✓{% else %}{{ nr }}{% endif %}
|
||||
</span>
|
||||
<span style="
|
||||
font-size:13px;
|
||||
font-weight:{% if cls == 'activ' %}600{% else %}400{% endif %};
|
||||
color:{% if cls == 'activ' %}var(--ink){% elif cls == 'facut' %}var(--ink){% else %}var(--muted){% endif %};
|
||||
">{{ titlu }}</span>
|
||||
{%- set _activ = _pasi_import[pas - 1] -%}
|
||||
<div class="stepper">
|
||||
{# Desktop (>=1024px): bara slim orizontala. #}
|
||||
<nav class="stepper-track" aria-label="Pasii importului">
|
||||
{% for nr, titlu, ajutor in _pasi_import %}
|
||||
{%- if nr < pas %}{% set cls = "is-done" %}{% set aria = "" %}
|
||||
{%- elif nr == pas %}{% set cls = "is-active" %}{% set aria = ' aria-current="step"' %}
|
||||
{%- else %}{% set cls = "" %}{% set aria = "" %}{% endif %}
|
||||
<div class="stepper-step {{ cls }}"{{ aria | safe }}>
|
||||
<span class="stepper-nr">{% if nr < pas %}✓{% else %}{{ nr }}{% endif %}</span>
|
||||
<span class="stepper-tx">{{ titlu }}</span>
|
||||
</div>
|
||||
{% if cls == 'activ' %}
|
||||
<p class="muted" style="margin:0; font-size:12px; padding-left:26px;">{{ ajutor }}</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{# Tableta/mobil (<1024px): colapsat "Pasul N din 4 - Titlu" + progres. #}
|
||||
<div class="stepper-collapsed">
|
||||
<div class="stepper-current">Pasul {{ pas }} din 4 <span class="muted">· {{ _activ[1] }}</span></div>
|
||||
<div class="stepper-progress" role="progressbar" aria-valuenow="{{ pas }}" aria-valuemin="1" aria-valuemax="4"
|
||||
aria-label="Pasul {{ pas }} din 4"><span style="width:{{ (pas / 4 * 100) | round | int }}%;"></span></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{# Ajutorul pasului activ — o singura linie, sub bara (valabil pe ambele forme). #}
|
||||
<p class="stepper-help">{{ _activ[2] }}</p>
|
||||
</div>
|
||||
|
||||
@@ -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,63 @@
|
||||
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 %}
|
||||
</div>
|
||||
|
||||
{# Bloc text principal — stanga, ocupa spatiul ramas. Rand de 2 linii (spec 5.16):
|
||||
L1 = placuta (identificator primar); L2 = cod RAR · operatie · data prestatie. #}
|
||||
<div style="flex:1 1 auto; min-width:0;">
|
||||
|
||||
{# Linia 1: nr. inmatriculare (placuta) — identificatorul primar pe care il
|
||||
scaneaza operatorul. .slim-vin reumplut (acelasi nume de clasa, churn minim).
|
||||
Fallback cand placuta lipseste ('—'): VIN scurt, apoi mesaj neutru
|
||||
(nu randa em-dash izolat ca identificator). #}
|
||||
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
|
||||
<div class="slim-vin">{{ r.prez.vehicul_nr }}</div>
|
||||
{% elif r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
||||
<div class="slim-vin muted">{{ r.prez.vin_scurt }}</div>
|
||||
{% else %}
|
||||
<div class="slim-vin muted">fara numar</div>
|
||||
{% endif %}
|
||||
|
||||
{# Linia 2: cod RAR (sau 'nemapat') · operatie (ink, ellipsis) · data prestatie.
|
||||
Separatorul "·" e injectat prin CSS intre celule. Operatia primeste ellipsis
|
||||
ca randul sa NU treaca pe a 3-a linie nici la 390px.
|
||||
VIN integral, #id_prezentare si secundele traiesc in modalul de detaliu. #}
|
||||
<div class="slim-meta slim-rand2">
|
||||
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
|
||||
<span class="cod-rar-cod">{{ r.prez.cod_rar }}</span>
|
||||
{% else %}
|
||||
<span class="cod-rar-cod cod-rar-sub muted">nemapat</span>
|
||||
{% endif %}
|
||||
<span class="slim-op">{{ r.prez.operatie }}</span>
|
||||
{% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %}
|
||||
<span class="slim-data muted">{{ r.prez.data_prestatie }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Micro-linie umana a problemei — text mic s-error, DOAR pe stari de problema
|
||||
(loud-on-exception D6). Randul normal/finalizat ramane strict 2 linii.
|
||||
Token tipografic --fs-xs (>=12px, scala 5.16). #}
|
||||
{% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
|
||||
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-vehicul" data-eticheta="Vehicul">
|
||||
{{ r.prez.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>
|
||||
{% else %}
|
||||
<div class="muted cod-rar-sub">nemapat</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>
|
||||
</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>
|
||||
|
||||
@@ -52,15 +52,15 @@
|
||||
role="region" aria-label="Zona de incarcare fisier"
|
||||
style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;
|
||||
padding:12px 16px; text-align:left;">
|
||||
<strong style="font-size:14px;">Importa:</strong>
|
||||
<strong style="font-size:var(--fs-md);">Importa:</strong>
|
||||
<input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv"
|
||||
style="display:none;" aria-label="Selecteaza fisier xlsx sau csv">
|
||||
<button type="button" id="upload-btn"
|
||||
style="min-height:44px; padding:10px 20px; font-size:14px;">
|
||||
style="min-height:44px; padding:10px 20px; font-size:var(--fs-md);">
|
||||
Alege fisier (xlsx/csv)
|
||||
</button>
|
||||
<span class="muted" style="font-size:13px;">sau trage aici</span>
|
||||
<span class="muted" style="font-size:12px; margin-left:auto;">
|
||||
<span class="muted" style="font-size:var(--fs-sm);">sau trage aici</span>
|
||||
<span class="muted" style="font-size:var(--fs-xs); margin-left:auto;">
|
||||
NU se trimite nimic la RAR pana confirmi.
|
||||
</span>
|
||||
</div>
|
||||
@@ -69,10 +69,10 @@
|
||||
<div class="drop-zone" id="drop-zone"
|
||||
role="region" aria-label="Zona de incarcare fisier">
|
||||
{% if not sheets %}
|
||||
<p style="font-size:17px; margin:0 0 4px; font-weight:600;">Primul fisier? Trage-l aici.</p>
|
||||
<p class="muted" style="margin:0 0 16px; font-size:13px;">xlsx sau csv, max 5000 randuri</p>
|
||||
<p style="font-size:var(--fs-lg); margin:0 0 4px; font-weight:600;">Primul fisier? Trage-l aici.</p>
|
||||
<p class="muted" style="margin:0 0 16px; font-size:var(--fs-sm);">xlsx sau csv, max 5000 randuri</p>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 16px; font-size:14px;">
|
||||
<p class="muted" style="margin:0 0 16px; font-size:var(--fs-md);">
|
||||
Incarca fisierul din nou dupa ce ai ales foaia.
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -80,18 +80,18 @@
|
||||
<input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv"
|
||||
style="display:none;" aria-label="Selecteaza fisier xlsx sau csv">
|
||||
<button type="button" id="upload-btn"
|
||||
style="min-height:44px; padding:10px 24px; font-size:14px;">
|
||||
style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);">
|
||||
Alege fisier (xlsx/csv)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="muted" style="margin:8px 0 0; font-size:12px;">
|
||||
<p class="muted" style="margin:8px 0 0; font-size:var(--fs-xs);">
|
||||
NU se trimite nimic la RAR pana confirmi explicit.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<span id="upload-spinner" class="htmx-indicator muted"
|
||||
style="font-size:13px; margin-top:6px; display:inline;">
|
||||
style="font-size:var(--fs-sm); margin-top:6px; display:inline;">
|
||||
se parseaza fisierul...
|
||||
</span>
|
||||
</form>
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
'delete': ('Sterge', '/admin/delete', 'danger')
|
||||
} %}
|
||||
|
||||
{# Tier-uri selectabile in panou (cod, eticheta). Aliniat cu app/plans.py#PLANS. #}
|
||||
{% set TIERS = [('free', 'Gratuit'), ('standard', 'Standard'), ('pro', 'Pro'), ('premium', 'Premium')] %}
|
||||
|
||||
{% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
|
||||
@@ -34,7 +37,7 @@
|
||||
<thead><tr>
|
||||
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
|
||||
aria-label="Selecteaza tot"></th>
|
||||
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
|
||||
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Plan curent</th><th>Plan cerut</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for acct in rows %}
|
||||
@@ -46,6 +49,45 @@
|
||||
<td>{{ acct.name }}</td>
|
||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||
<td>{{ acct.email or "—" }}</td>
|
||||
<td style="white-space:nowrap;">
|
||||
{# Plan EFECTIV acum (prominent): trial Pro activ ridica free->pro. #}
|
||||
<div style="margin-bottom:5px;">
|
||||
<span class="pill" style="font-weight:600;">{{ acct.tier_efectiv_label }}</span>
|
||||
{% if acct.trial_activ %}
|
||||
<span class="muted" style="font-size:11px;">
|
||||
trial{% if acct.trial_zile %} · {{ acct.trial_zile }} {{ 'zi' if acct.trial_zile == 1 else 'zile' }} ramase{% endif %}
|
||||
→ apoi {{ acct.tier_label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Schimbare plan inline: select tier de baza + Aplica. Form propriu (nu imbricat in bulk-form).
|
||||
Aplica INCHEIE trial-ul si seteaza planul ales ca real, cu efect imediat. #}
|
||||
<form method="post" action="/admin/set-tier" class="tier-form"
|
||||
style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||
<select name="tier" aria-label="Plan pentru {{ acct.name }}"
|
||||
style="padding:4px 8px;min-height:32px;max-width:130px;">
|
||||
{% for code, label in TIERS %}
|
||||
<option value="{{ code }}"{% if acct.tier == code %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-sm"
|
||||
title="Aplica planul ales ca plan real (incheie trial-ul daca e activ)">Aplica</button>
|
||||
</form>
|
||||
{# Acorda/prelungeste trial Pro de N zile, fara a schimba tier-ul de baza. #}
|
||||
<form method="post" action="/admin/set-trial" class="trial-form"
|
||||
style="display:flex;align-items:center;gap:6px;margin-top:5px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||
<input type="number" name="trial_days" value="30" min="1" max="3650"
|
||||
aria-label="Zile trial Pro pentru {{ acct.name }}"
|
||||
style="padding:4px 8px;min-height:32px;width:64px;">
|
||||
<button type="submit" class="btn-sm"
|
||||
title="Acorda/prelungeste trial Pro de la acum (nu schimba tier-ul de baza)">Trial Pro</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="muted">{{ acct.requested_plan_label }}</td>
|
||||
<td><span class="pill">{{ acct.status }}</span></td>
|
||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||
<td style="white-space:nowrap;">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title>
|
||||
<title>{% block title %}ROA AUTOPASS{% endblock %}</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script>
|
||||
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
|
||||
@@ -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';
|
||||
}
|
||||
@@ -33,90 +36,50 @@
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
/* IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti).
|
||||
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex);
|
||||
reflow-ul vizibil pe VIN/coduri e acceptat explicit. */
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Regular-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-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;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Medium-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Medium-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;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Bold-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Bold-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;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexMono-Regular-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
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;
|
||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
|
||||
/* US-001 PRD 5.16: stive de font standard web. Toate regulile font-face IBM Plex sterse.
|
||||
Motiv: decizie user (risc AI-Slop #11 acceptat constient), uniformitate cross-page.
|
||||
Fisierele woff2 raman pe disc (curatare = follow-up optional, non-blocant).
|
||||
Referinte catre directorul de fonturi statice eliminate — font-ui si font-mono sunt stive sistem. */
|
||||
/* Paleta dark (default) — accent azur ROMFAST.
|
||||
--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;
|
||||
/* US-001 (PRD 5.16): stive font standard web — sursa unica de adevar */
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||
/* US-002 (PRD 5.16): scala tipografica uniforma — sursa unica de adevar */
|
||||
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px;
|
||||
--fs-lg:18px; --fs-xl:20px; --fs-2xl:28px; --fs-3xl:34px;
|
||||
--lh-tight:1.25; --lh-body:1.55; }
|
||||
/* 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
|
||||
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
|
||||
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
|
||||
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */
|
||||
body { margin:0; font:15px/1.5 "IBM Plex Sans",system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
body { margin:0; font-family:var(--font-ui); font-size:var(--fs-base); line-height:var(--lh-body);
|
||||
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
|
||||
/* Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */
|
||||
header { padding:16px 24px; border-bottom:1px solid var(--line);
|
||||
@@ -141,6 +104,18 @@
|
||||
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.empty { color:var(--muted); padding:24px; text-align:center; }
|
||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
|
||||
/* Badge sursa sugestie (5.18 US-007): de unde vine sugestia de cod in editorul de mapare.
|
||||
confirmat = GOLD validat de om (verde); similar = SILVER/embedding k-NN (azur);
|
||||
non-operatie = pre-filtru NUL / vecin NUL (gri-cald). Suggestion-only, doar indiciu vizual. */
|
||||
.sugg-sursa { display:inline-block; font-size:10px; font-weight:700; line-height:1; padding:2px 6px;
|
||||
border-radius:99px; text-transform:uppercase; letter-spacing:.03em; vertical-align:middle;
|
||||
border:1px solid transparent; }
|
||||
.sugg-sursa--confirmat { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 45%, transparent);
|
||||
background:color-mix(in srgb, var(--ok) 12%, transparent); }
|
||||
.sugg-sursa--similar { color:var(--accent); border-color:color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
background:color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
.sugg-sursa--nul { color:var(--muted); border-color:color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
background:color-mix(in srgb, var(--muted) 12%, transparent); }
|
||||
/* Pill-uri de filtrare a starii (bara de filtre Trimiteri). Inactiv = contur+text pe
|
||||
culoarea categoriei (injectata inline); activ = umplere pe acea culoare. */
|
||||
.pills-categorii { display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
||||
@@ -168,6 +143,9 @@
|
||||
.s-needs_review{color:var(--warn);}
|
||||
.s-already_sent,.s-duplicate_in_file{color:var(--muted);}
|
||||
.muted { color:var(--muted); }
|
||||
/* Heading/eticheta accesibila doar pentru cititoare de ecran (vizual ascunsa). */
|
||||
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
|
||||
clip:rect(0 0 0 0); white-space:nowrap; border:0; }
|
||||
a { color:var(--accent); }
|
||||
/* Drop zone upload fisier */
|
||||
.drop-zone { border:2px dashed var(--line); border-radius:8px; padding:32px 20px;
|
||||
@@ -177,10 +155,48 @@
|
||||
.banner.warn { border-left-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); }
|
||||
/* Bara confirmare sticky */
|
||||
.sticky-bar { position:sticky; bottom:0; background:var(--card); border-top:1px solid var(--line);
|
||||
padding:12px 16px; display:flex; align-items:flex-start; gap:16px;
|
||||
padding:10px 14px; display:flex; align-items:flex-start; gap:12px;
|
||||
flex-wrap:wrap; z-index:10; }
|
||||
/* Indicator HTMX — ascuns pana la request */
|
||||
.htmx-indicator { display:none; }
|
||||
/* US-011 (PRD 5.16): selector tema stil pill — icon + eticheta temei curente.
|
||||
Eticheta se ascunde pe <=560px (spatiu ingust), ramane iconita. */
|
||||
.tema-btn { display:inline-flex; align-items:center; gap:6px; height:36px; padding:0 12px;
|
||||
border-radius:8px; background:transparent; border:1px solid var(--line);
|
||||
color:var(--muted); font-family:var(--font-ui); font-size:var(--fs-sm);
|
||||
cursor:pointer; transition:border-color .15s, color .15s; line-height:1; }
|
||||
.tema-btn:hover { border-color:var(--accent); color:var(--ink); }
|
||||
.tema-btn:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||
@media (max-width:560px) { #tema-label { display:none; } }
|
||||
/* US-003 (PRD 5.16): dot RAR compact in antet.
|
||||
Stare OK: dot verde pulsant + "RAR online". Stare BLOCAT: dot rosu.
|
||||
Stilat ca pill; sensul NU depinde de culoare (aria-label + title). */
|
||||
.rar-chip { display:inline-flex; align-items:center; gap:7px; height:36px; padding:0 12px;
|
||||
border-radius:99px; font-size:var(--fs-sm); font-weight:600; cursor:default; white-space:nowrap; }
|
||||
.rar-chip.rar-ok { border:1px solid color-mix(in srgb,var(--ok) 35%,var(--line));
|
||||
background:color-mix(in srgb,var(--ok) 10%,transparent); color:var(--ok); }
|
||||
.rar-chip.rar-err { border:1px solid color-mix(in srgb,var(--err) 35%,var(--line));
|
||||
background:color-mix(in srgb,var(--err) 10%,transparent); color:var(--err); }
|
||||
.rar-dot { width:9px; height:9px; border-radius:99px; background:currentColor; flex-shrink:0;
|
||||
box-shadow:0 0 0 3px color-mix(in srgb,currentColor 20%,transparent); }
|
||||
.rar-dot.live { animation:rar-pulse 2s ease-in-out infinite; }
|
||||
@keyframes rar-pulse { 0%,100%{opacity:1;} 50%{opacity:.5;} }
|
||||
@media (max-width:560px) { .rar-chip .rar-tx { display:none; } }
|
||||
/* US-010 (PRD 5.16): sub-titlu cu numele service-ului (cand autentificat). */
|
||||
.h-sub { font-size:var(--fs-xs); color:var(--muted); margin-top:2px; line-height:1.2; }
|
||||
.h-sub .svc { color:var(--ink); font-weight:600; }
|
||||
/* Badge env (test/prod) si badge tier (plan cont) langa titlu. */
|
||||
.badge-env { display:inline-block; margin-left:8px; padding:1px 7px; border-radius:99px;
|
||||
font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em;
|
||||
color:var(--warn); background:color-mix(in srgb,var(--warn) 16%,transparent); vertical-align:middle; }
|
||||
.badge-tier { display:inline-block; margin-left:6px; padding:1px 8px; border-radius:99px;
|
||||
font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em;
|
||||
color:var(--accent); background:color-mix(in srgb,var(--accent) 16%,transparent); vertical-align:middle; }
|
||||
/* Menu RAR status line in burger (prima intrare) */
|
||||
.menu-rar-line { display:flex; align-items:center; gap:7px; padding:8px 10px;
|
||||
font-size:var(--fs-sm); border-radius:6px; cursor:default; }
|
||||
.menu-rar-line.ok { color:var(--ok); }
|
||||
.menu-rar-line.err { color:var(--err); background:color-mix(in srgb,var(--err) 6%,transparent); }
|
||||
.htmx-indicator.htmx-request { display:inline; }
|
||||
/* Link-uri de actiune in antetul cardurilor: zona de atins mai mare (>=36px) si
|
||||
feedback la hover; pe ecrane inguste antetul se rupe curat sub titlu. */
|
||||
@@ -199,6 +215,83 @@
|
||||
select { max-width:340px; }
|
||||
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
|
||||
button:hover { filter:brightness(1.08); }
|
||||
/* Sistem butoane unificat (design.md §5.1). Primarul = `button`/`.btn` (deja stilat). */
|
||||
.btn-secondary { background:transparent; color:var(--ink); border:1px solid var(--line); border-radius:6px;
|
||||
padding:8px 14px; font:inherit; font-weight:500; cursor:pointer; min-height:36px;
|
||||
display:inline-flex; align-items:center; justify-content:center; gap:6px; }
|
||||
.btn-secondary:hover { background:var(--line); filter:none; }
|
||||
.btn-ghost { background:transparent; color:var(--accent); border:1px solid transparent; border-radius:6px;
|
||||
padding:8px 14px; font:inherit; font-weight:500; cursor:pointer; min-height:36px;
|
||||
display:inline-flex; align-items:center; justify-content:center; gap:6px; }
|
||||
.btn-ghost:hover { background:var(--line); filter:none; }
|
||||
.btn-danger { background:transparent; color:var(--err); border:1px solid var(--err); border-radius:6px;
|
||||
padding:8px 14px; font:inherit; font-weight:500; cursor:pointer; min-height:36px;
|
||||
display:inline-flex; align-items:center; justify-content:center; gap:6px; }
|
||||
.btn-danger:hover, .btn-danger:focus-visible { background:var(--err); color:#fff; filter:none; }
|
||||
.btn-sm { padding:5px 10px; min-height:32px; font-size:13px; }
|
||||
button:focus-visible, .btn-secondary:focus-visible, .btn-ghost:focus-visible, .btn-danger:focus-visible {
|
||||
outline:2px solid var(--accent); outline-offset:2px; }
|
||||
/* Actiuni de rand (design.md §5.1): desktop = text, mobil = iconita patrata 44px.
|
||||
act_btn randeaza si .act-tx (text) si .act-ic (svg); CSS ascunde unul per breakpoint. */
|
||||
.act { display:inline-flex; align-items:center; justify-content:center; gap:6px; font:inherit; font-weight:500;
|
||||
border-radius:7px; padding:6px 12px; min-height:36px; cursor:pointer; background:transparent;
|
||||
border:1px solid var(--line); color:var(--ink); }
|
||||
.act:hover { background:var(--line); filter:none; }
|
||||
.act:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||
.act .act-ic { width:18px; height:18px; display:none; }
|
||||
.act .act-tx { display:inline; }
|
||||
.act-save.dirty { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||||
.act-save.dirty:hover { filter:brightness(.92); }
|
||||
/* Variant primar mereu-accent (ex. Salveaza in modalul de editare). */
|
||||
.act-primary { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||||
.act-primary:hover { filter:brightness(.92); background:var(--accent); }
|
||||
.act-del { color:var(--err); border-color:var(--err); }
|
||||
.act-del:hover, .act-del:focus-visible { background:var(--err); color:#fff; }
|
||||
.act-group { display:inline-flex; gap:8px; align-items:center; }
|
||||
.btn-editeaza { white-space:nowrap; }
|
||||
/* Toast global (feedback tranzitoriu post-actiune). */
|
||||
#toast { position:fixed; left:50%; bottom:24px; transform:translateX(-50%) translateY(8px);
|
||||
z-index:1300; max-width:90vw; padding:11px 18px; border-radius:10px;
|
||||
background:var(--ink); color:var(--card); font-size:14px; font-weight:500;
|
||||
box-shadow:0 8px 28px rgba(0,0,0,.28); display:flex; align-items:center; gap:9px;
|
||||
opacity:0; pointer-events:none; transition:opacity .2s, transform .2s; }
|
||||
#toast[hidden] { display:none; }
|
||||
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
||||
#toast::before { content:""; width:9px; height:9px; border-radius:50%; background:var(--ok); flex:0 0 auto; }
|
||||
#toast.t-err::before, #toast.t-s-error::before, #toast.t-s-needs_data::before,
|
||||
#toast.t-s-needs_mapping::before { background:var(--err); }
|
||||
#toast.t-warn::before, #toast.t-s-needs_review::before { background:var(--warn); }
|
||||
/* Rand de preview tocmai actualizat: flash scurt ca userul sa-l localizeze. */
|
||||
@keyframes rand-flash { 0% { background:color-mix(in srgb, var(--accent) 26%, var(--card)); }
|
||||
100% { background:transparent; } }
|
||||
.tabel-trimiteri tr.rand-actualizat { animation:rand-flash 1.6s ease-out; }
|
||||
/* Randuri deja-trimise / duplicate in preview: colapsate implicit (nu ocupa loc).
|
||||
Reafisate cand userul apasa toggle-ul -> .preview-arata-trimise pe container.
|
||||
!important fiindca regulile compacte mobil/tableta seteaza `tr{display:flex}`. */
|
||||
.tabel-trimiteri tr.preview-sent-row { display:none !important; }
|
||||
.tabel-trimiteri.preview-arata-trimise tr.preview-sent-row { display:table-row !important; }
|
||||
/* Stepper import compact (design.md §5.4). >=1024px: bara slim. <1024px: "Pasul N din 4" + progres. */
|
||||
.stepper { margin-bottom:16px; }
|
||||
.stepper-track { display:flex; align-items:stretch; border:1px solid var(--line); border-radius:8px;
|
||||
overflow:hidden; background:var(--card); }
|
||||
.stepper-step { flex:1; display:flex; align-items:center; gap:8px; padding:10px 14px; min-height:44px;
|
||||
border-right:1px solid var(--line); }
|
||||
.stepper-step:last-child { border-right:none; }
|
||||
.stepper-nr { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px;
|
||||
border-radius:50%; font-size:11px; font-weight:700; flex-shrink:0;
|
||||
background:var(--line); color:var(--muted); }
|
||||
.stepper-tx { font-size:13px; color:var(--muted); white-space:nowrap; }
|
||||
.stepper-step.is-active { background:color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||
.stepper-step.is-active .stepper-nr { background:var(--accent); color:#fff; }
|
||||
.stepper-step.is-active .stepper-tx { color:var(--ink); font-weight:600; }
|
||||
.stepper-step.is-done .stepper-nr { background:var(--ok); color:#fff; }
|
||||
.stepper-step.is-done .stepper-tx { color:var(--ink); }
|
||||
.stepper-help { margin:6px 2px 0; font-size:12px; color:var(--muted); }
|
||||
.stepper-collapsed { display:none; }
|
||||
.stepper-current { font-size:14px; font-weight:600; margin-bottom:6px; }
|
||||
.stepper-current .muted { font-weight:400; }
|
||||
.stepper-progress { height:5px; border-radius:99px; background:var(--line); overflow:hidden; }
|
||||
.stepper-progress > span { display:block; height:100%; background:var(--accent); border-radius:99px; }
|
||||
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
|
||||
/* Tab-bar */
|
||||
.tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch;
|
||||
@@ -221,7 +314,7 @@
|
||||
border-radius:0 6px 6px 0; }
|
||||
.eroare-3n-sep { margin-top:6px; }
|
||||
.eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; }
|
||||
.eroare-3n-camp { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:12px; opacity:.85; }
|
||||
.eroare-3n-camp { font-family:var(--font-mono); font-size:var(--fs-xs); opacity:.85; }
|
||||
.eroare-3n-cauza { color:var(--muted); font-size:12px; margin-top:3px; }
|
||||
.eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; }
|
||||
.eroare-3n-label { font-weight:500; }
|
||||
@@ -320,7 +413,9 @@
|
||||
.tabel-trimiteri th, .tabel-trimiteri td { white-space:normal; word-break:break-word; vertical-align:top; }
|
||||
.tabel-trimiteri .col-chk { width:30px; }
|
||||
.tabel-trimiteri .col-id { width:48px; }
|
||||
.tabel-trimiteri .col-stare { width:104px; }
|
||||
/* col-stare largita (bug 4a 5.16): cu table-layout:fixed + pill nowrap, 104px era
|
||||
prea ingusta -> pill-ul de stare se revarsa peste col-vehicul. 140px o contine. */
|
||||
.tabel-trimiteri .col-stare { width:140px; }
|
||||
.tabel-trimiteri .col-data { width:104px; }
|
||||
.tabel-trimiteri .col-rar { width:96px; }
|
||||
.tabel-trimiteri .col-actualizat { width:128px; }
|
||||
@@ -328,8 +423,8 @@
|
||||
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
|
||||
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
|
||||
/* Codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
|
||||
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:"IBM Plex Mono",ui-monospace,monospace;
|
||||
font-size:12px; padding:1px 7px; border:1px solid var(--line);
|
||||
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:var(--font-mono);
|
||||
font-size:var(--fs-xs); padding:1px 7px; border:1px solid var(--line);
|
||||
border-radius:99px; color:var(--muted); }
|
||||
/* Eticheta umana scurta sub pill — text mic; clasa `s-error` o coloreaza
|
||||
(apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
|
||||
@@ -341,9 +436,12 @@
|
||||
.tabel-trimiteri tr.trimitere-row:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||
.tabel-trimiteri tr.trimitere-row:focus,
|
||||
.tabel-trimiteri tr.trimitere-row:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||
/* 768-1024px: ascunde Actualizat (e in detaliu) -> 7 coloane, fara scroll */
|
||||
/* col-actualizat ca linie meta mica in carduri (decizie 5.13 #8). */
|
||||
.tabel-trimiteri td.col-actualizat { font-size:12px; }
|
||||
/* Stepper: sub 1024px ascunde track-ul slim, arata forma colapsata (decizie 5.13 #11). */
|
||||
@media (max-width:1024px) {
|
||||
.tabel-trimiteri .col-actualizat { display:none; }
|
||||
.stepper-track { display:none; }
|
||||
.stepper-collapsed { display:block; }
|
||||
}
|
||||
/* Tableta (768–1024px): header compact fara suprapuneri.
|
||||
Grila 3-coloane se pastreaza; logo si titlu mai mici; versiunea ascunsa
|
||||
@@ -358,6 +456,43 @@
|
||||
ascunsa pentru a elibera spatiu in celula dreapta. */
|
||||
.header-right > .muted { display:none; }
|
||||
}
|
||||
/* Tableta 768-1024px: listele actionabile raman O COLOANA, cardificate (nu tabel storcit,
|
||||
nu 2/rand). Decizie 5.13 (premisa user). Tabelele dense read-only raman .tablewrap. */
|
||||
@media (min-width:768px) and (max-width:1024px) {
|
||||
.tabel-trimiteri table, .tabel-card table { table-layout:auto; }
|
||||
.tabel-trimiteri thead, .tabel-card thead { display:none; }
|
||||
.tabel-trimiteri table, .tabel-trimiteri tbody, .tabel-trimiteri tr, .tabel-trimiteri td,
|
||||
.tabel-card table, .tabel-card tbody, .tabel-card tr, .tabel-card td { display:block; width:auto; }
|
||||
.tabel-trimiteri tr, .tabel-card tr { border:1px solid var(--line); border-radius:8px;
|
||||
padding:10px 12px; margin-bottom:10px; }
|
||||
.tabel-trimiteri td, .tabel-card td { border-bottom:none; padding:3px 0; }
|
||||
.tabel-trimiteri td::before, .tabel-card td::before { content:attr(data-eticheta); display:block;
|
||||
color:var(--muted); font-size:12px; margin-bottom:2px; }
|
||||
.tabel-trimiteri td.col-chk, .tabel-trimiteri td.col-id { display:none; }
|
||||
.tabel-trimiteri td[data-eticheta=""]::before, .tabel-card td[data-eticheta=""]::before,
|
||||
.tabel-card td:not([data-eticheta])::before { display:none; }
|
||||
.tabel-card td select, .tabel-card td input[type=text], .tabel-card td input[type=search] {
|
||||
width:100%; max-width:none; }
|
||||
/* Card compact si pe tableta (acelasi layout ca pe mobil) pentru `.tabel-trimiteri`. */
|
||||
.tabel-trimiteri tr { display:flex; flex-wrap:wrap; align-items:baseline; gap:1px 8px; }
|
||||
.tabel-trimiteri td { padding:0; }
|
||||
/* Regula desktop `tr.trimitere-row > td { padding:11px }` e mai specifica -> o anulam
|
||||
in cardul compact, altfel randurile de Trimiteri raman inalte/aerisite. */
|
||||
.tabel-trimiteri tr.trimitere-row > td { padding-top:0; padding-bottom:0; }
|
||||
.tabel-trimiteri td::before { display:none; }
|
||||
.tabel-trimiteri td.col-vehicul { order:1; flex:1 1 55%; min-width:0; font-weight:600; font-size:15px; line-height:1.25; }
|
||||
.tabel-trimiteri td.col-vehicul .muted { font-weight:400; }
|
||||
.tabel-trimiteri td.col-stare { order:2; flex:0 0 auto; margin-left:auto; align-self:flex-start; }
|
||||
.tabel-trimiteri td.col-operatie { order:3; flex:1 1 100%; font-size:13px; line-height:1.3; margin-top:1px; }
|
||||
.tabel-trimiteri td.col-data, .tabel-trimiteri td.col-km, .tabel-trimiteri td.col-rar { font-size:12px; color:var(--muted); }
|
||||
.tabel-trimiteri td.col-data { order:4; } .tabel-trimiteri td.col-km { order:5; } .tabel-trimiteri td.col-rar { order:6; }
|
||||
.tabel-trimiteri td.col-km::before { content:"· "; display:inline; color:var(--muted); }
|
||||
.tabel-trimiteri td.col-actualizat { order:7; flex:1 1 100%; font-size:12px; color:var(--muted); }
|
||||
.tabel-trimiteri td.col-note { order:8; flex:1 1 100%; font-size:12px; color:var(--accent); line-height:1.3; margin-top:1px; }
|
||||
.tabel-trimiteri td.col-actiuni { order:9; flex:0 0 auto; margin-left:auto; margin-top:4px; text-align:right; }
|
||||
.tabel-trimiteri td.col-actiuni button, .tabel-trimiteri td.col-actiuni .act { width:auto; min-height:32px; padding:5px 14px; }
|
||||
.tabel-trimiteri.preview-arata-trimise tr.preview-sent-row { display:flex !important; }
|
||||
}
|
||||
/* === Preview import: coloane extra fata de tabelul Trimiteri.
|
||||
SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata —
|
||||
nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat).
|
||||
@@ -367,7 +502,12 @@
|
||||
Restul (~680px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
|
||||
.tabel-trimiteri .col-km { width:76px; }
|
||||
.tabel-trimiteri .col-note { width:176px; }
|
||||
.tabel-trimiteri .col-actiuni { width:92px; }
|
||||
/* Nota preview = culoarea camp-fix (accent), ca sa atraga atentia (dogfood 5.13):
|
||||
campul Note e self-explanatory, asa ca hint-urile per-camp au fost scoase. */
|
||||
.tabel-trimiteri td.col-note { color:var(--accent); }
|
||||
/* Pill-ul de stare nu se rupe pe doua randuri in cardul compact. */
|
||||
.tabel-trimiteri td.col-stare .pill { white-space:nowrap; }
|
||||
.tabel-trimiteri .col-actiuni { width:104px; }
|
||||
/* Randul de editare inline iese din grila table-layout:fixed (display:block),
|
||||
astfel formularul nu e constrans de latimile coloanelor individuale.
|
||||
Salveaza/Anuleaza sunt mereu vizibile (overflow:visible, nu clip). */
|
||||
@@ -401,16 +541,51 @@
|
||||
Breakpoint unic 767px (vezi conventia de sus). Cuprinde: card per rand pe tabelul
|
||||
de trimiteri, modal full-screen, header/nav colapsat cu tinte touch
|
||||
>=44px. Desktop (>=1024px) ramane neschimbat — regulile de baza nu se modifica. */
|
||||
/* SENTINEL-TESTE-MOBIL: blocul mobil principal incepe mai jos; testele ancoreaza pe acest marker si feliaza pana la sfarsitul stilului. NU muta/sterge. */
|
||||
@media (max-width:767px) {
|
||||
/* Tabel trimiteri: card per rand (eticheta:valoare stivuit) -> fara scroll orizontal */
|
||||
.tabel-trimiteri table { table-layout:auto; }
|
||||
.tabel-trimiteri thead { display:none; }
|
||||
.tabel-trimiteri table, .tabel-trimiteri tbody, .tabel-trimiteri tr, .tabel-trimiteri td { display:block; width:auto; }
|
||||
.tabel-trimiteri tr { border:1px solid var(--line); border-radius:8px; padding:8px 12px; margin-bottom:10px; }
|
||||
.tabel-trimiteri td { border-bottom:none; padding:4px 0; display:flex; gap:10px; align-items:baseline; }
|
||||
.tabel-trimiteri td::before { content:attr(data-eticheta); color:var(--muted); font-size:12px;
|
||||
flex:0 0 auto; min-width:120px; }
|
||||
.tabel-trimiteri td.col-chk { display:none; }
|
||||
.tabel-trimiteri td { border-bottom:none; padding:3px 0; display:block; }
|
||||
.tabel-trimiteri td::before { content:attr(data-eticheta); display:block; color:var(--muted);
|
||||
font-size:12px; margin-bottom:2px; }
|
||||
.tabel-trimiteri td.col-chk, .tabel-trimiteri td.col-id { display:none; }
|
||||
|
||||
/* === Card COMPACT (PRD 5.13, corectie dogfood) ===
|
||||
Inlocuieste stiva generica eticheta+valoare (prea inalta) cu un card
|
||||
scanabil la prima vedere: vehicul = titlu, stare = pill dreapta-sus,
|
||||
operatie+cod pe rand, meta (data/km/rar) muted mic, nota mica. Fara
|
||||
etichete-zgomot. Override DUPA regulile de baza (cascada: ultimul castiga). */
|
||||
.tabel-trimiteri tr { display:flex; flex-wrap:wrap; align-items:baseline;
|
||||
gap:1px 8px; padding:9px 12px; }
|
||||
.tabel-trimiteri td { display:block; padding:0; }
|
||||
.tabel-trimiteri tr.trimitere-row > td { padding-top:0; padding-bottom:0; }
|
||||
.tabel-trimiteri td::before { display:none; } /* compact: fara etichete */
|
||||
.tabel-trimiteri td.col-vehicul { order:1; flex:1 1 55%; min-width:0;
|
||||
font-weight:600; font-size:15px; line-height:1.25; }
|
||||
.tabel-trimiteri td.col-vehicul .muted { font-weight:400; }
|
||||
.tabel-trimiteri td.col-stare { order:2; flex:0 0 auto; margin-left:auto;
|
||||
align-self:flex-start; }
|
||||
.tabel-trimiteri td.col-operatie { order:3; flex:1 1 100%; font-size:13px;
|
||||
line-height:1.3; margin-top:1px; }
|
||||
.tabel-trimiteri td.col-data,
|
||||
.tabel-trimiteri td.col-km,
|
||||
.tabel-trimiteri td.col-rar { font-size:12px; color:var(--muted); }
|
||||
.tabel-trimiteri td.col-data { order:4; }
|
||||
.tabel-trimiteri td.col-km { order:5; }
|
||||
.tabel-trimiteri td.col-km::before { content:"· "; display:inline; color:var(--muted); }
|
||||
.tabel-trimiteri td.col-rar { order:6; }
|
||||
.tabel-trimiteri td.col-actualizat { order:7; flex:1 1 100%; font-size:12px;
|
||||
color:var(--muted); }
|
||||
.tabel-trimiteri td.col-note { order:8; flex:1 1 100%; font-size:12px;
|
||||
color:var(--accent); line-height:1.3; margin-top:1px; }
|
||||
.tabel-trimiteri td.col-actiuni { order:9; flex:0 0 auto; margin-left:auto;
|
||||
margin-top:4px; text-align:right; }
|
||||
.tabel-trimiteri td.col-actiuni button,
|
||||
.tabel-trimiteri td.col-actiuni .act { width:auto; min-height:32px; padding:5px 14px; }
|
||||
.tabel-trimiteri.preview-arata-trimise tr.preview-sent-row { display:flex !important; }
|
||||
|
||||
/* Modal full-screen: ocupa tot ecranul, fara backdrop lateral (overlay fara
|
||||
padding, dialog la latime/inaltime pline, fara colturi/umbra). Scroll intern
|
||||
@@ -483,21 +658,169 @@
|
||||
#import-section #upload-btn { width:100%; min-height:44px; }
|
||||
/* Bara de status: contoarele/randurile raman aliniate la stanga, fara scroll orizontal. */
|
||||
#status-bar > div { gap:10px; }
|
||||
/* Bara de filtre trimiteri: o coloana, fiecare control full-width, buton >=44px.
|
||||
!important suprascrie latimile inline (ex. max-width:180px pe vehicul) DOAR pe mobil. */
|
||||
#filtre-trimiteri { flex-direction:column; align-items:stretch; }
|
||||
/* Bara de filtre trimiteri ADAPTATA pentru mobil (nu doar stivuita):
|
||||
- cautarea vehicul = rand propriu prioritar (input + buton pe acelasi rand);
|
||||
- grupurile de pill-uri (data + stare) = scroll orizontal, compacte (nu 8 butoane
|
||||
full-width unul sub altul). !important suprascrie latimile inline doar pe mobil. */
|
||||
#filtre-trimiteri { flex-direction:column; align-items:stretch; gap:8px; }
|
||||
#filtre-trimiteri > div { width:100%; }
|
||||
#filtre-trimiteri select, #filtre-trimiteri input[type=text],
|
||||
#filtre-trimiteri input[type=date] { width:100% !important; max-width:none !important; }
|
||||
#filtre-trimiteri button { width:100%; min-height:44px; }
|
||||
/* Cautarea vehicul: input creste, butonul Filtreaza compact langa el. */
|
||||
#filtre-trimiteri input[type=text] { flex:1 1 auto; width:auto !important; max-width:none !important; min-height:44px; }
|
||||
#filtre-trimiteri input[type=date] { width:100% !important; max-width:none !important; min-height:44px; }
|
||||
#filtre-trimiteri button[type=submit] { flex:0 0 auto; width:auto; min-height:44px; }
|
||||
/* Grupurile de pill-uri: o singura banda scrolabila orizontal, compacta. */
|
||||
#filtre-trimiteri .pills-categorii { margin-left:0 !important; flex-wrap:nowrap;
|
||||
overflow-x:auto; -webkit-overflow-scrolling:touch; padding-bottom:2px; }
|
||||
#filtre-trimiteri .pill-cat { flex:0 0 auto; }
|
||||
|
||||
/* Operatii de mapat (preview import): randul de mapare stiva pe o coloana,
|
||||
select-ul full-width (altfel max-width:340px global il scoate din viewport). */
|
||||
.maprow { gap:6px 12px; padding:10px 0; }
|
||||
.maprow .mapcol { flex:1 1 100%; min-width:0; }
|
||||
.maprow select { width:100% !important; max-width:none !important; }
|
||||
.maprow button { width:100%; min-height:44px; }
|
||||
|
||||
/* Card de autentificare (login/signup): centrat si nu depaseste viewport-ul pe mobil. */
|
||||
.auth-card { max-width:100%; margin:24px auto; }
|
||||
/* Versiunea ascunsa pe mobil (la fel ca pe tableta). */
|
||||
.header-right > .muted { display:none; }
|
||||
/* Actiuni .act pe mobil: iconita patrata 44px, textul ascuns. */
|
||||
.act { min-width:44px; min-height:44px; width:44px; padding:0; }
|
||||
.act .act-tx { display:none; }
|
||||
.act .act-ic { display:inline-block; }
|
||||
.act-group { gap:10px; }
|
||||
/* Bara confirmare compacta pe mobil. */
|
||||
.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.
|
||||
US-002 PRD 5.16: padding marit (18px), cifra pe --fs-2xl, label pe --fs-sm, sub pe --fs-xs.
|
||||
Variante de culoare a cifrei prin clasele .s-* existente (verde/accent/rosu). */
|
||||
.contor-card { background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:18px 18px; }
|
||||
.contor-cifra { font-size:var(--fs-2xl); font-weight:700; line-height:1; }
|
||||
.contor-label { font-size:var(--fs-sm); color:var(--muted); margin-top:8px; }
|
||||
.contor-sub { font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted); margin-top:4px; }
|
||||
/* Contoarele desktop = 5 carduri side-by-side. display:flex sta in CSS (NU inline pe
|
||||
element) ca media query-ul de mai jos sa-l poata ascunde pe mobil — un inline
|
||||
style="display:flex" ar bate regula @media si ar duce la contoare duplicate pe 390px. */
|
||||
.contoare-desktop { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:14px; }
|
||||
/* Bara compacta contoare pe mobil (<=560px): un singur rand, numere + etichete scurte, fara carduri mari. */
|
||||
.contoare-compact { display:none; }
|
||||
@media (max-width:560px) {
|
||||
.contoare-desktop { display:none; }
|
||||
.contoare-compact { display:flex; align-items:center; gap:0; margin-bottom:14px;
|
||||
background:var(--card2); border:1px solid var(--line); border-radius:8px;
|
||||
overflow:hidden; }
|
||||
.compact-item { flex:1; display:flex; flex-direction:column; align-items:center; padding:10px 6px;
|
||||
border-right:1px solid var(--line); min-width:0; text-align:center; }
|
||||
.compact-item:last-child { border-right:none; }
|
||||
.compact-nr { font-size:var(--fs-xl); font-weight:700; line-height:1; }
|
||||
.compact-lbl { font-size:10px; color:var(--muted); margin-top:3px; white-space:nowrap; }
|
||||
}
|
||||
/* .lista-trimiteri-slim + .trimitere-slim — lista compacta cu separator --line2.
|
||||
Randul e clickabil (rol button), tinta min-height:44px pe mobil. */
|
||||
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
|
||||
.trimitere-slim { display:flex; align-items:center; justify-content:space-between; gap:12px;
|
||||
padding:14px 16px; border-bottom:1px solid var(--line2); min-height:44px; cursor:pointer; }
|
||||
.trimitere-slim:last-child { border-bottom:none; }
|
||||
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-md); font-weight:600; color:var(--ink); }
|
||||
.slim-meta { font-size:var(--fs-sm); color:var(--muted); margin-top:3px; }
|
||||
/* Linia 2 a randului slim (5.16): cod RAR · operatie (ellipsis) · data, pe UN rand.
|
||||
Ellipsis-ul pe operatie garanteaza 2 linii MAX si la 390px. */
|
||||
.slim-rand2 { display:flex; align-items:baseline; gap:6px; min-width:0; }
|
||||
.slim-rand2 .cod-rar-cod { flex:0 0 auto; font-family:var(--font-mono); font-weight:600;
|
||||
color:var(--accent); }
|
||||
.slim-rand2 .cod-rar-cod.muted { color:var(--muted); font-weight:500; }
|
||||
.slim-rand2 .slim-op { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden;
|
||||
text-overflow:ellipsis; color:var(--ink); }
|
||||
.slim-rand2 .slim-data { flex:0 0 auto; }
|
||||
.slim-rand2 .slim-op::before, .slim-rand2 .slim-data::before {
|
||||
content:"·"; color:var(--muted); margin-right:6px; }
|
||||
.lista-trimiteri-slim .eticheta-problema { font-size:var(--fs-xs); line-height:1.3; margin-top:2px; }
|
||||
/* Pill slim (5.16): fill-tint + dot 7px + text colorat per stare (currentColor din .s-*).
|
||||
Pastrat pe FIECARE rand inclusiv Finalizat (linistit dar prezent). */
|
||||
.lista-trimiteri-slim .pill { display:inline-flex; align-items:center; gap:5px; font-weight:600;
|
||||
background:color-mix(in srgb, currentColor 14%, transparent);
|
||||
border-color:color-mix(in srgb, currentColor 35%, transparent); }
|
||||
.lista-trimiteri-slim .pill::before { content:""; width:7px; height:7px; border-radius:99px;
|
||||
background:currentColor; flex-shrink:0; }
|
||||
/* .camp-slim — varianta compacta camp formular: label --fs-sm muted deasupra, input --fs-md, fundal --card2.
|
||||
Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
|
||||
.camp-slim { margin-bottom:8px; }
|
||||
.camp-slim label { font-size:var(--fs-sm); color:var(--muted); display:block; margin-bottom:4px; }
|
||||
.camp-slim input, .camp-slim textarea, .camp-slim select { background:var(--card2); min-height:36px; width:100%;
|
||||
padding:0 10px; border:1px solid var(--line); border-radius:6px; font-family:var(--font-ui);
|
||||
font-size:var(--fs-md); color:var(--ink); }
|
||||
.camp-slim textarea { min-height:52px; height:auto; padding:8px 10px; resize:vertical; }
|
||||
.camp-slim .camp-mono { font-family:var(--font-mono); font-size:var(--fs-sm); }
|
||||
/* .chips + .chip — prestatii multi-select cu buton de stergere accesibil (.chip-del).
|
||||
Fundal accent 18%, font mono --fs-xs. */
|
||||
.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:var(--font-mono); font-size:var(--fs-xs); 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 var(--font-ui); cursor:pointer; }
|
||||
.add-code:hover, .add-code:focus-visible { border-style:solid; }
|
||||
/* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */
|
||||
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
|
||||
padding:8px 10px; border:1px solid var(--line); border-radius:6px;
|
||||
background:var(--card2); margin-bottom:8px; }
|
||||
/* Nume operatie emfatic (T-9 5.16): proeminent (bold) ca in mockup — e ancora
|
||||
vizuala a randului de mapare op<->cod. */
|
||||
.op-row-name { font-size:var(--fs-sm); font-weight:700; color:var(--ink); }
|
||||
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); }
|
||||
/* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */
|
||||
@media (max-width:767px) {
|
||||
.trimitere-slim { padding:12px 14px; }
|
||||
}
|
||||
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim US-002 === */
|
||||
/* === Fix mobil Mapari (bug live 2026-06-29) ===
|
||||
Doua probleme raportate la 390px pe pagina Mapari:
|
||||
(1) butoanele Salveaza/Sterge taiate: regula `.tabel-card td button {width:100%}`
|
||||
(specificitate 0,1,2) batea `.act {width:44px}` (0,1,0) -> cele doua butoane act
|
||||
deveneau full-width si al doilea (Sterge) iesea din card (celula are nowrap).
|
||||
(2) carduri prea inalte: etichetele data-eticheta randate ca pseudo-titluri +
|
||||
linia redundanta "acum: COD — nume" (duplica select-ul de dedesubt).
|
||||
Plasat ultimul in <style> => castiga pe cascada la specificitate egala.
|
||||
Atributele data-eticheta raman in DOM (a11y + teste); doar pseudo-eticheta se ascunde. */
|
||||
@media (max-width:767px) {
|
||||
/* Carduri Mapari compacte: fara etichete-zgomot (continutul e auto-descriptiv,
|
||||
ca la cardul de trimiteri), padding strans. */
|
||||
.tabel-card td::before { display:none; }
|
||||
.tabel-card tr { padding:9px 12px; margin-bottom:8px; }
|
||||
.tabel-card td { padding:3px 0; }
|
||||
/* "acum: COD — nume" e redundant cu select-ul de dedesubt (aceeasi valoare). */
|
||||
.map-acum { display:none; }
|
||||
/* Celula Actiuni: butoanele act pe UN rand, vizibile, cu text (nu iconita-only
|
||||
ambigua, nu full-width care impinge al doilea buton afara cardului).
|
||||
`.tabel-card td .act` (0,2,1) > `.tabel-card td button` (0,1,2). */
|
||||
.tabel-card td[data-eticheta="Actiuni"] { display:flex; gap:8px; align-items:stretch;
|
||||
margin-top:2px; }
|
||||
.tabel-card td .act { width:auto; flex:1 1 0; min-width:0; min-height:44px; padding:8px 12px; }
|
||||
.tabel-card td .act .act-tx { display:inline; }
|
||||
.tabel-card td .act .act-ic { display:inline-block; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{# Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). #}
|
||||
{# US-010 (PRD 5.16): antet branduit ROA AUTOPASS.
|
||||
Grila 3 coloane — stanga (logo) | centru (titlu+env+tier+account_name) | dreapta (RAR dot + tema + burger).
|
||||
Antet MINIMAL pe /login: nu afiseaza RAR dot, meniu burger sau account_name (nelogat). #}
|
||||
<header>
|
||||
{# Celula stanga: logo ROMFAST #}
|
||||
<div class="header-left">
|
||||
@@ -507,35 +830,86 @@
|
||||
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
|
||||
</a>
|
||||
</div>
|
||||
{# Celula centru: titlu + badge env mic.
|
||||
{# Celula centru: titlu ROA AUTOPASS + badge env + badge tier + sub-titlu account_name.
|
||||
Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
|
||||
<div class="header-center">
|
||||
<a href="/" style="text-decoration:none; color:inherit;"><h1>Gateway RAR AUTOPASS</h1></a>
|
||||
<span class="env">{{ rar_env }}</span>
|
||||
<a href="/" style="text-decoration:none; color:inherit;">
|
||||
<h1>ROA AUTOPASS<span class="badge-env">{{ rar_env }}</span>{% if is_authenticated|default(false) and tier_label|default('') %}<span class="badge-tier">{{ tier_label }}</span>{% endif %}</h1>
|
||||
</a>
|
||||
{% if is_authenticated|default(false) and account_name|default('') %}
|
||||
<div class="h-sub">Service auto: <span class="svc">{{ account_name }}</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Celula dreapta: comutator tema + versiune + meniu cont #}
|
||||
{# Celula dreapta: dot RAR (numai cand logat) + selector tema + versiune + meniu burger #}
|
||||
<div class="header-right">
|
||||
<button id="tema-toggle" class="icon-btn"
|
||||
aria-label="Comuta tema (luminos/intunecat)"
|
||||
title="Comuta tema">☀</button>
|
||||
<span class="muted" style="font-size:13px;">v{{ version }}</span>
|
||||
{# US-003 (PRD 5.16): dot RAR in antet — OK = chip verde pulsant, BLOCAT = chip rosu.
|
||||
Banda plina apare DOAR in _status.html cand BLOCAT (nu mai e mereu vizibila). #}
|
||||
{% if is_authenticated|default(false) %}
|
||||
{% if sanatate_ok|default(true) %}
|
||||
<div class="rar-chip rar-ok" role="status"
|
||||
title="RAR online{% if last_login|default('') %} — Ultima autentificare: {{ last_login }}{% endif %}"
|
||||
aria-label="RAR online">
|
||||
<span class="rar-dot live" aria-hidden="true"></span>
|
||||
<span class="rar-tx">RAR online</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rar-chip rar-err" role="status"
|
||||
title="RAR indisponibil"
|
||||
aria-label="RAR indisponibil">
|
||||
<span class="rar-dot" aria-hidden="true"></span>
|
||||
<span class="rar-tx">RAR blocat</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{# US-011 (PRD 5.16): selector tema = pill cu icon + eticheta temei curente.
|
||||
Eticheta ascunsa pe <=560px via CSS. JS actualizeaza .tema-icon si #tema-label. #}
|
||||
<button id="tema-toggle" class="tema-btn"
|
||||
aria-label="Comuta tema"
|
||||
title="Comuta tema">
|
||||
<span class="tema-icon" aria-hidden="true">☀</span>
|
||||
<span id="tema-label">Light</span>
|
||||
</button>
|
||||
<span class="muted" style="font-size:var(--fs-xs);">v{{ version }}</span>
|
||||
{% if is_authenticated|default(false) %}
|
||||
{# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout.
|
||||
US-010: structura cu <hr> separatori + RAR status (prima intrare) + Plan tier.
|
||||
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
|
||||
<div class="cont-menu-wrap">
|
||||
<button id="cont-menu-toggle" class="icon-btn"
|
||||
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
|
||||
aria-label="Meniu cont" title="Meniu cont">☰</button>
|
||||
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
|
||||
{# Prima intrare: Trimiteri (Acasa) — pagina principala cu import + lista trimiterilor. #}
|
||||
{# Prima intrare: starea RAR (US-003) #}
|
||||
{% if sanatate_ok|default(true) %}
|
||||
<div class="menu-rar-line ok" role="menuitem" aria-disabled="true">
|
||||
<span style="width:8px;height:8px;border-radius:99px;background:currentColor;display:inline-block;"></span>
|
||||
RAR online
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="menu-rar-line err" role="menuitem" aria-disabled="true">
|
||||
<span style="width:8px;height:8px;border-radius:99px;background:currentColor;display:inline-block;"></span>
|
||||
RAR indisponibil
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Plan cont curent (US-006 PRD 5.17): linie detaliata cu trial/consum/warn.
|
||||
Warn = culoare + text (accesibilitate, decizie #14). #}
|
||||
<div class="menu-rar-line" role="menuitem" aria-disabled="true"
|
||||
style="color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--muted){% endif %};
|
||||
{% if plan_warn|default(false) %}font-weight:600;{% endif %}">
|
||||
{{ plan_linie|default('Plan: ' + (tier_label|default('Gratuit'))) }}
|
||||
</div>
|
||||
<hr>
|
||||
{# Navigare principala: Trimiteri + Mapari #}
|
||||
<a role="menuitem" href="/">Trimiteri</a>
|
||||
{# Mapari, cu badge needs_mapping. #}
|
||||
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
|
||||
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
|
||||
<hr>
|
||||
{# Nomenclator: coduri RAR — public, dar in meniu arata mai logic la logat #}
|
||||
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
|
||||
<hr>
|
||||
{# Setari cont #}
|
||||
<a role="menuitem" href="/?tab=cont">Cont</a>
|
||||
<a role="menuitem" href="/?tab=integrare">Integrare</a>
|
||||
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
|
||||
<a role="menuitem" href="/?tab=jurnal">Jurnal</a>
|
||||
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %}
|
||||
<hr>
|
||||
@@ -551,6 +925,9 @@
|
||||
{# aria-live pentru anuntarea schimbarilor de tema (accesibilitate) #}
|
||||
<span id="tema-live" role="status" aria-live="polite"
|
||||
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;"></span>
|
||||
{# Toast global: feedback tranzitoriu (ex. dupa salvarea unui rand de import).
|
||||
aria-live=polite -> citit de screen-reader. window.arataToast(text, stareCss). #}
|
||||
<div id="toast" role="status" aria-live="polite" hidden></div>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
{# Modal detaliu trimitere: container global, SIBLING al <main> (nu descendent),
|
||||
ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el. Corpul
|
||||
@@ -565,18 +942,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'; }
|
||||
}
|
||||
@@ -586,7 +981,11 @@
|
||||
}
|
||||
function _syncButton(stored) {
|
||||
var s = VALID[stored] ? stored : 'auto';
|
||||
btn.innerHTML = ICONS[s];
|
||||
// US-011: actualizeaza iconita si eticheta separat (btn e pill, nu se inlocuieste innerHTML intreg)
|
||||
var icon = btn.querySelector('.tema-icon');
|
||||
if (icon) icon.innerHTML = ICONS[s];
|
||||
var label = document.getElementById('tema-label');
|
||||
if (label) label.textContent = LABELS[s];
|
||||
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
|
||||
btn.title = LABELS[s]; // doar numele temei (ex. "Petrol"), nu ciclul intreg
|
||||
}
|
||||
@@ -696,6 +1095,39 @@
|
||||
if (saveBtn) saveBtn.classList.add('dirty');
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Toast global: feedback tranzitoriu vizibil + accesibil (aria-live).
|
||||
// window.arataToast(text, stareCss) — stareCss (ex. "s-error"/"s-needs_review")
|
||||
// coloreaza punctul indicator (rosu/galben/verde). Auto-dispare dupa ~3.2s.
|
||||
(function() {
|
||||
var t = document.getElementById('toast');
|
||||
var timer = null;
|
||||
window.arataToast = function(text, stareCss) {
|
||||
if (!t) return;
|
||||
t.className = '';
|
||||
if (stareCss) t.classList.add('t-' + stareCss);
|
||||
t.textContent = text;
|
||||
t.hidden = false;
|
||||
void t.offsetWidth; // reflow -> tranzitia porneste
|
||||
t.classList.add('show');
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function() {
|
||||
t.classList.remove('show');
|
||||
setTimeout(function() { t.hidden = true; }, 220);
|
||||
}, 3200);
|
||||
};
|
||||
})();
|
||||
|
||||
// Feedback dupa salvarea/confirmarea unui rand de import (HX-Trigger 'randSalvat').
|
||||
// Toast imediat (ce rand + ce stare are acum); evidentierea randului se aplica dupa
|
||||
// ce preview-ul se reincarca (reincarcaPreview), de scriptul din _preview_import.
|
||||
document.body.addEventListener('randSalvat', function(e) {
|
||||
var d = e.detail || {};
|
||||
if (window.arataToast)
|
||||
window.arataToast('Randul ' + d.nr + ' actualizat · ' + (d.stare || ''), d.stareCss || '');
|
||||
window.__randSalvat = d;
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Cautare + paginare client-side pentru tabele mari (data-dt="<page_size>"). Filtreaza si
|
||||
// pagineaza DOM-ul deja randat (fara cereri server) — potrivit pentru maparile care pot creste
|
||||
@@ -819,7 +1251,7 @@
|
||||
|
||||
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-modal-close')) close();
|
||||
if (e.target && e.target.closest && e.target.closest('[data-modal-close]')) close();
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && isOpen()) { e.preventDefault(); close(); }
|
||||
@@ -832,7 +1264,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() {
|
||||
@@ -866,7 +1298,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();
|
||||
|
||||
464
app/web/templates/landing.html
Normal file
464
app/web/templates/landing.html
Normal file
@@ -0,0 +1,464 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ROA AUTOPASS — declari prestațiile la RAR din câteva click-uri</title>
|
||||
<meta name="description" content="Încarci fișierul tău cu operațiile service-ului, completezi o dată codurile RAR și le salvezi. ROMFAST trimite prestațiile la RAR AUTOPASS în locul tău, fără tastat manual. Conform Legii 142/2023.">
|
||||
<style>
|
||||
|
||||
/* US-001/US-008 (PRD 5.16): IBM Plex eliminat complet — stive font sistem standard web.
|
||||
Tokenurile --font-ui / --font-mono definite in :root (sursa unica de adevar). */
|
||||
:root{--font-ui:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--font-mono:ui-monospace,"SF Mono","Cascadia Code","Segoe UI Mono","Roboto Mono",Menlo,Consolas,monospace;}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;}
|
||||
body{font-family:var(--font-ui);-webkit-font-smoothing:antialiased;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);}
|
||||
body[data-theme="grafit"]{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--hbg:rgba(15,18,24,.88);--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--mut:#5c6473}
|
||||
body[data-theme="cobalt"]{--bg:#080d1c;--card:#111a33;--card2:#0b1226;--text:#e9ecfb;--sub:#8a93b8;--line:#1d2747;--line2:#161f3a;--accent:#4068FF;--hbg:rgba(8,13,28,.9);--okt:#2fd0a6;--infot:#8aa0ff;--errt:#f06a7a;--mut:#5a6390}
|
||||
body[data-theme="cupru"]{--bg:#15110b;--card:#211a12;--card2:#15110b;--text:#efe6d6;--sub:#a89a85;--line:#36291c;--line2:#281e14;--accent:#D98A3D;--hbg:rgba(21,17,11,.9);--okt:#67b98c;--infot:#dfa45c;--errt:#e2685a;--mut:#6d5f4c}
|
||||
body[data-theme="hartie"]{--bg:#f3efe6;--card:#fffdf7;--card2:#f3efe6;--text:#1e1a13;--sub:#6a6052;--line:#e2dccc;--line2:#ece6d9;--accent:#1F5FBF;--hbg:rgba(255,253,247,.92);--okt:#1c7d5d;--infot:#1F5FBF;--errt:#bd463c;--mut:#9a8f7d}
|
||||
.page{width:100%;max-width:1280px;margin:0 auto;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);overflow:hidden;}
|
||||
a{text-decoration:none;}
|
||||
input[type=range]{-webkit-appearance:none;appearance:none;background:transparent;}
|
||||
input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:99px;background:var(--line,#262b36);}
|
||||
input[type=range]::-moz-range-track{height:6px;border-radius:99px;background:var(--line,#262b36);}
|
||||
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;margin-top:-6px;border-radius:99px;background:var(--accent,#2E74D6);cursor:pointer;border:none;}
|
||||
input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:99px;background:var(--accent,#2E74D6);cursor:pointer;border:none;}
|
||||
|
||||
@media (max-width:900px){
|
||||
.lp-nav{display:none!important;}
|
||||
.lp-header{padding:0 18px!important;}
|
||||
.lp-h1{font-size:32px!important;line-height:1.1!important;}
|
||||
.page [style*="grid-template-columns"]{grid-template-columns:1fr!important;}
|
||||
.page [style*="padding:80px 40px"]{padding:48px 20px!important;}
|
||||
.page [style*="padding:0 40px 80px"]{padding:0 20px 48px!important;}
|
||||
.page [style*="padding:56px 40px 80px"]{padding:36px 20px 48px!important;}
|
||||
.page [style*="padding:44px"]{padding:28px!important;}
|
||||
.page [style*="padding:56px 40px"]{padding:40px 22px!important;}
|
||||
.page [style*="height:68px"]{height:60px!important;}
|
||||
.page [style*="gap:56px"]{gap:32px!important;}
|
||||
.page [style*="gap:48px"]{gap:28px!important;}
|
||||
}
|
||||
@media (max-width:560px){
|
||||
.lp-h1{font-size:27px!important;}
|
||||
.page [style*="padding:10px 40px"]{padding:10px 18px!important;}
|
||||
.lp-header{padding:0 12px!important;}
|
||||
#theme-label{display:none!important;}
|
||||
.lp-hactions{gap:8px!important;}
|
||||
.lp-hactions button{height:38px!important;padding:0 11px!important;font-size:13px!important;}
|
||||
}
|
||||
@media (max-width:430px){
|
||||
.lp-hactions a.auth-login-link{display:none!important;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
<script>try{var _t=localStorage.getItem('lp-theme');if(_t&&['grafit','cobalt','cupru','hartie'].indexOf(_t)>=0)document.body.setAttribute('data-theme',_t);}catch(e){}</script>
|
||||
<main class="page">
|
||||
<!-- HEADER -->
|
||||
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
|
||||
<div style="display:flex;flex-direction:column;line-height:1.05;">
|
||||
<span style="font:700 17px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);">ROA AUTOPASS</span>
|
||||
<span style="font:500 11px var(--font-ui);letter-spacing:.04em;color:var(--sub,#8b93a7);">Gateway RAR</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:28px;">
|
||||
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
|
||||
<a href="#cum-functioneaza" style="color:inherit;text-decoration:none;">Cum funcționează</a><a href="#api" style="color:inherit;text-decoration:none;">API</a><a href="#pret" style="color:inherit;text-decoration:none;">Preț</a>
|
||||
</div>
|
||||
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
|
||||
<button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px var(--font-ui);cursor:pointer;">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||
<span id="theme-label">Grafit</span>
|
||||
</button>
|
||||
<a href="/login" class="auth-login-link" style="display:inline-flex;align-items:center;height:44px;padding:0 18px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;text-decoration:none;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Autentificare</a>
|
||||
<button data-act="auth" data-tab="register" style="height:44px;padding:0 18px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HERO -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
|
||||
<div>
|
||||
<h1 class="lp-h1" style="font:700 50px/1.06 var(--font-ui);letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară prestațiile la RAR AUTOPASS, automat</h1>
|
||||
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;max-width:480px;">Încarci fișierul tău așa cum îl ai, potrivești o dată operațiile cu codurile RAR, și trimitem prestațiile la RAR AUTOPASS în locul tău. Fără tastat câmp cu câmp.</p>
|
||||
<div style="margin-bottom:32px;">
|
||||
<p style="display:flex;align-items:center;gap:8px;font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin:0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg><span><span style="color:#1F9D5C;">Gratuit</span> până la 60 de trimiteri/lună</span></p>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:22px;">
|
||||
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
||||
<button style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</button>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);flex-wrap:wrap;">
|
||||
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
|
||||
<span style="color:var(--line,#262b36);">·</span>
|
||||
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Datele tale criptate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard mockup -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);">
|
||||
<div>
|
||||
<div style="font:700 14px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri RAR AUTOPASS</div>
|
||||
<div style="font:400 12px var(--font-mono);color:var(--sub,#8b93a7);margin-top:2px;">Service Auto Vâlcea · 28 iun 2026</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<div style="display:flex;align-items:center;gap:5px;padding:4px 9px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 11px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Live</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line,#262b36);">
|
||||
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:var(--text,#e6e9ef);">847</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Trimise luna asta</div></div>
|
||||
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:var(--accent,#2E74D6);">12</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">În coadă</div></div>
|
||||
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:#E05D5D;">2</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">De corectat</div></div>
|
||||
</div>
|
||||
<div style="padding:6px 0;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
|
||||
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">WBA8E9...K7F2</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Inspecție tehnică · 09:42</div></div>
|
||||
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 12px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
|
||||
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">WVWZZZ...3M1</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Revizie periodică · 09:38</div></div>
|
||||
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,var(--accent,#2E74D6) 14%,transparent);font:500 12px var(--font-ui);color:var(--infot,#6ea2ec);"><span style="width:6px;height:6px;border-radius:99px;background:var(--accent,#2E74D6);"></span>În coadă</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
|
||||
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">VF1RFB...A88</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Sistem frânare · 09:31</div></div>
|
||||
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);font:500 12px var(--font-ui);color:var(--errt,#E05D5D);"><span style="width:6px;height:6px;border-radius:99px;background:#E05D5D;"></span>Eroare VIN</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;">
|
||||
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">ZAR937...C04</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Schimb ulei · 09:24</div></div>
|
||||
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 12px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROBLEM + CALCULATOR (combinat) -->
|
||||
<div style="padding:80px 40px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
|
||||
<div style="text-align:center;max-width:760px;margin:0 auto 40px;">
|
||||
<h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 2–3 minute pe RAR AUTOPASS.<br><span style="color:var(--errt,#E05D5D);">Minutele acelea sunt bani.</span></h2>
|
||||
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi. Mută cursorul la volumul service-ului tău și vezi cât te costă.</p>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:stretch;">
|
||||
<!-- STANGA: formularul RAR AUTOPASS -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:24px;display:flex;flex-direction:column;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">RAR AUTOPASS · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px var(--font-mono);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Confirmă VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Data prestației</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">2026-06-22</div></div>
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Număr înmatriculare</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">CT88NOE</div></div>
|
||||
</div>
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Observații</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-ui);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">REVIZIE; SCHIMBARE PLĂCUȚE FRÂNĂ</div></div>
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Prestații</div><div style="min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);"><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>REVIZIE PERIODICĂ</span><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>ÎNTREȚINERE</span></div></div>
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Valoarea citită a odometrului</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">39000</div></div>
|
||||
</div>
|
||||
<button style="margin-top:14px;height:34px;padding:0 14px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--card2,#0f1218));border:none;color:#fff;opacity:.55;font:600 12px var(--font-ui);cursor:not-allowed;align-self:flex-start;">Salvează Prezentarea</button>
|
||||
<div style="margin-top:auto;padding-top:12px;font:400 12px var(--font-ui);color:var(--sub,#8b93a7);text-align:center;">se repetă pentru fiecare comandă · zi de zi</div>
|
||||
</div>
|
||||
|
||||
<!-- DREAPTA: calculatorul (slidere + cifre) -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:34px;display:flex;flex-direction:column;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;">
|
||||
<div>
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri/lună</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);" id="out-pres">100</span></div>
|
||||
<input type="range" min="50" max="1500" step="10" value="100" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
||||
</div>
|
||||
<div>
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Manoperă</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);"><span id="out-rate">60</span><span style="font:500 12px var(--font-ui);color:var(--sub,#8b93a7);"> lei/h</span></span></div>
|
||||
<input type="range" min="30" max="200" step="5" value="60" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:color-mix(in srgb,#E05D5D 9%,var(--card2,#0f1218));border:1px solid color-mix(in srgb,#E05D5D 28%,var(--line,#262b36));border-radius:10px;padding:22px 24px;">
|
||||
<div style="font:600 11px var(--font-ui);color:var(--errt,#E05D5D);letter-spacing:.08em;text-transform:uppercase;margin-bottom:14px;">Pierdut pe raportare manuală</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;">
|
||||
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiMonth">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">lei/lună</div></div>
|
||||
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="hMonth">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">ore/lună</div></div>
|
||||
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiYear">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">lei/an</div></div>
|
||||
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="days">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">zile/an</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:20px;padding-top:18px;border-top:1px solid var(--line,#262b36);">
|
||||
<div style="display:flex;align-items:center;gap:9px;font:600 14px var(--font-ui);color:var(--okt,#2FBF8F);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Cu ROA AUTOPASS: câteva secunde pentru tot lotul</div>
|
||||
<div style="font:400 13px/1.55 var(--font-ui);color:var(--sub,#8b93a7);margin-top:6px;">Recuperezi ~<span data-calc="leiMonth">0</span> lei/lună și timpul îl pui pe mașini, nu pe formulare.</div>
|
||||
</div>
|
||||
<div style="margin-top:14px;display:flex;align-items:center;gap:8px;font:400 12px var(--font-ui);color:var(--mut,#5c6473);"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>Estimat la ~2,5 minute de tastat manual pentru fiecare trimitere.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LEGE / AMENZI -->
|
||||
<div style="padding:56px 40px 80px;">
|
||||
<div style="display:flex;gap:20px;align-items:flex-start;background:color-mix(in srgb,#E0A93B 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E0A93B 34%,var(--line,#262b36));border-radius:12px;padding:26px 28px;">
|
||||
<div style="width:44px;height:44px;flex-shrink:0;border-radius:8px;background:color-mix(in srgb,#E0A93B 16%,transparent);display:flex;align-items:center;justify-content:center;color:#E0A93B;"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9.5C7.1 20.3 4 16.4 4 12V7l8-4z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></div>
|
||||
<div>
|
||||
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Evită riscul amenzilor — transmite automat la RAR Auto-Pass</div>
|
||||
<p style="font:400 14px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Conform <strong style="color:var(--text,#e6e9ef);font-weight:600;">Legii nr. 142/2023</strong> și <strong style="color:var(--text,#e6e9ef);font-weight:600;">OMTI nr. 210/2024</strong>, service-urile auto autorizate RAR trebuie să transmită, la finalizarea fiecărei prestații, informațiile obligatorii (VIN, kilometraj și, după caz, date privind intervențiile asupra odometrului și reparațiile rezultate din avarii grave). Nerespectarea obligației se sancționează cu amendă între <span style="color:var(--errt,#E05D5D);font-weight:600;">2.000 și 5.000 lei</span>, iar transmiterea unor informații eronate cu amendă între <span style="color:#E0A93B;font-weight:600;">1.000 și 2.000 lei</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SOLVE -->
|
||||
<div id="cum-functioneaza" style="padding:80px 40px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
|
||||
<div style="max-width:780px;margin:0 auto;text-align:center;">
|
||||
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2>
|
||||
<p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA AUTOPASS îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
|
||||
</div>
|
||||
<div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 var(--font-ui);color:var(--sub,#8b93a7);">
|
||||
<span style="text-decoration:line-through;text-decoration-color:var(--errt,#E05D5D);text-decoration-thickness:2px;">2–3 minute de tastat pentru fiecare comandă</span><span style="color:var(--text,#e6e9ef);font-weight:700;"> → câteva secunde pentru tot lotul.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API INTEGRATION -->
|
||||
<div id="api" style="padding:56px 40px 80px;">
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;">
|
||||
<div>
|
||||
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu</div>
|
||||
<h2 style="font:700 30px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Ai deja un soft de service? Conectează-l automat</h2>
|
||||
<p style="font:400 15px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Softul tău se poate conecta și direct la API-ul RAR Auto-Pass. Cu ROMFAST primești în plus asistență la maparea automată a operațiilor tale (prin mai multe metode) și salvarea mapărilor pentru trimiterile viitoare — totul printr-un singur apel, cu cheie API per cont.</p>
|
||||
<button style="height:44px;padding:0 20px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi documentația API <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button>
|
||||
</div>
|
||||
<div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;gap:7px;padding:11px 14px;border-bottom:1px solid #262b36;">
|
||||
<span style="width:11px;height:11px;border-radius:99px;background:#E05D5D;"></span><span style="width:11px;height:11px;border-radius:99px;background:#E0A93B;"></span><span style="width:11px;height:11px;border-radius:99px;background:#2FBF8F;"></span>
|
||||
<span style="font:400 12px var(--font-mono);color:#8b93a7;margin-left:8px;">request.sh</span>
|
||||
</div>
|
||||
<pre style="margin:0;padding:18px;font:400 13px/1.7 var(--font-mono);color:#e6e9ef;overflow-x:auto;"><span style="color:#2FBF8F;">POST</span> /v1/prezentari
|
||||
<span style="color:#8b93a7;">Authorization:</span> <span style="color:#E0A93B;">rfak_••••••••</span>
|
||||
<span style="color:#8b93a7;">Content-Type:</span> application/json
|
||||
|
||||
{
|
||||
<span style="color:#6ea2ec;">"vin"</span>: <span style="color:#2FBF8F;">"WBA8E9C5..."</span>,
|
||||
<span style="color:#6ea2ec;">"cod_prestatie"</span>: <span style="color:#2FBF8F;">"ITP-01"</span>,
|
||||
<span style="color:#6ea2ec;">"odometru"</span>: <span style="color:#E0A93B;">142500</span>
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TRIAL BENEFIT -->
|
||||
<div style="padding:0 40px 80px;">
|
||||
<div style="display:flex;align-items:center;gap:22px;background:color-mix(in srgb,#2FBF8F 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#2FBF8F 32%,var(--line,#262b36));border-radius:14px;padding:30px 34px;flex-wrap:wrap;">
|
||||
<div style="width:48px;height:48px;flex-shrink:0;border-radius:10px;background:color-mix(in srgb,#2FBF8F 16%,transparent);display:flex;align-items:center;justify-content:center;color:var(--okt,#2FBF8F);"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/><circle cx="12" cy="12" r="4.5"/></svg></div>
|
||||
<div style="flex:1;min-width:240px;">
|
||||
<div style="font:700 19px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);margin-bottom:5px;"><span style="color:var(--okt,#2FBF8F);">30 de zile Pro gratuit</span> la fiecare cont nou</div>
|
||||
<p style="font:400 14px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Începi direct cu tot ce are planul Pro — import API, categorisire automată și suport rapid. După 30 de zile treci automat pe Gratuit, fără plată și fără întreruperi.</p>
|
||||
</div>
|
||||
<button data-act="auth" data-tab="register" data-plan="pro" style="height:48px;padding:0 24px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;white-space:nowrap;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Începe gratuit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PRICING -->
|
||||
<div id="pret" style="padding:0 40px 80px;">
|
||||
<div style="text-align:center;margin-bottom:44px;">
|
||||
<h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
|
||||
<p style="font:400 15px var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Alege planul potrivit volumului tău. Poți schimba sau anula oricând.</p>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:stretch;">
|
||||
<!-- Gratuit -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div>
|
||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
|
||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Până la 60 de trimiteri/lună</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Trimiteri nelimitate</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, în 48h</div>
|
||||
</div>
|
||||
<button data-act="auth" data-tab="register" data-plan="free" style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Creează cont gratuit</button>
|
||||
</div>
|
||||
<!-- Standard -->
|
||||
<div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;display:flex;flex-direction:column;">
|
||||
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Popular</div>
|
||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Standard</div>
|
||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">49 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
|
||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Trimiteri nelimitate</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 24h</div>
|
||||
</div>
|
||||
<button style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="standard">Creează cont gratuit</button>
|
||||
</div>
|
||||
<!-- Pro -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div>
|
||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
|
||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Nelimitat + acces API</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 8h</div>
|
||||
</div>
|
||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="pro">Creează cont gratuit</button>
|
||||
</div>
|
||||
<!-- Premium -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div>
|
||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></div>
|
||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Integrare în softul tău</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic + onboarding dedicat</div>
|
||||
</div>
|
||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="premium">Creează cont gratuit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PRIVACY -->
|
||||
<div style="padding:80px 40px;border-top:1px solid var(--line,#262b36);">
|
||||
<div style="margin:0 auto;display:grid;grid-template-columns:minmax(240px,330px) 1fr;gap:48px;align-items:center;">
|
||||
<h2 style="font:700 30px/1.2 var(--font-ui);letter-spacing:-.02em;margin:0;color:var(--text,#e6e9ef);">Datele clienților tăi nu devin marfă</h2>
|
||||
<div style="display:flex;flex-wrap:wrap;">
|
||||
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
|
||||
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Reținem doar strict necesarul</div>
|
||||
<div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Doar datele de care e nevoie ca să trimitem la RAR — nimic adunat în plus, nici la conturile gratuite.</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
|
||||
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Doar pentru scopul declarat</div>
|
||||
<div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Maparea și trimiterea la RAR. Nu le vindem și nu le dăm mai departe.</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
|
||||
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Se șterg la 3 luni</div>
|
||||
<div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Automat, fără să ceri — sau chiar acum, cu un singur click.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AUTH / REGISTER -->
|
||||
<div id="inregistrare" style="padding:80px 40px;border-top:1px solid var(--line,#262b36);background:color-mix(in srgb,var(--accent,#2E74D6) 5%,var(--bg,#0f1218));">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;margin:0 auto;align-items:center;">
|
||||
<div>
|
||||
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Creează cont</div>
|
||||
<h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2>
|
||||
<p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit. Imediat poți încărca primul fișier sau conecta softul de service.</p>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Pro gratuit 30 de zile, apoi automat pe Gratuit</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023 și OMTI 210/2024</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Datele cu caracter personal criptate (GDPR)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:32px;box-shadow:0 20px 50px -24px rgba(0,0,0,.5);">
|
||||
<div style="display:flex;gap:28px;border-bottom:1px solid var(--line,#262b36);margin-bottom:24px;">
|
||||
<button type="button" data-act="tab" data-tab="register" class="auth-tab is-active" style="position:relative;background:none;border:none;padding:0 0 12px;font:700 15px var(--font-ui);color:var(--text,#e6e9ef);cursor:pointer;">Creează cont<span class="tab-underline" style="position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--accent,#2E74D6);"></span></button>
|
||||
<button type="button" data-act="tab" data-tab="login" class="auth-tab" style="position:relative;background:none;border:none;padding:0 0 12px;font:700 15px var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;">Autentificare<span class="tab-underline" style="position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--accent,#2E74D6);"></span></button>
|
||||
</div>
|
||||
<form method="post" action="/signup" data-pane="register">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Companie</span><input type="text" name="name" required placeholder="SC Service Auto SRL" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
|
||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="free" selected>Gratuit — 0 lei/lună</option><option value="standard">Standard — 49 lei/lună</option><option value="pro">Pro — 59 lei/lună</option><option value="premium">Premium — la cerere</option></select></label>
|
||||
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
|
||||
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button>
|
||||
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div>
|
||||
</form>
|
||||
<form method="post" action="/login" data-pane="login" style="display:none;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||
<label style="display:block;margin-bottom:10px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required placeholder="Parola ta" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||
<div style="text-align:right;margin-bottom:18px;"><a href="/login" style="font:400 13px var(--font-ui);color:var(--accent,#2E74D6);cursor:pointer;">Ai uitat parola?</a></div>
|
||||
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease;">Autentificare</button>
|
||||
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Nu ai cont? <a data-act="tab" data-tab="register" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Creează unul gratuit</a></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;">
|
||||
<div style="font:700 18px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div>
|
||||
<div style="display:flex;gap:26px;font:400 14px var(--font-ui);color:var(--sub,#8b93a7);">
|
||||
<span>Termeni</span><span>Confidențialitate / GDPR</span><span>Documentație API</span><span>Contact</span>
|
||||
</div>
|
||||
<div style="font:400 13px var(--font-ui);color:var(--mut,#5c6473);">© 2026 ROMFAST</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
(function(){
|
||||
var THEMES=[['grafit','Grafit'],['cobalt','Cobalt'],['cupru','Cupru'],['hartie','Hârtie']];
|
||||
var body=document.body;
|
||||
function curIndex(){var t=body.getAttribute('data-theme');for(var i=0;i<THEMES.length;i++){if(THEMES[i][0]===t)return i;}return 0;}
|
||||
function applyTheme(i){i=((i%THEMES.length)+THEMES.length)%THEMES.length;body.setAttribute('data-theme',THEMES[i][0]);var l=document.getElementById('theme-label');if(l)l.textContent=THEMES[i][1];try{localStorage.setItem('lp-theme',THEMES[i][0]);}catch(e){}}
|
||||
applyTheme(curIndex());
|
||||
|
||||
// style-hover: framework-ul de design folosea atributul style-hover; il aplicam la hover.
|
||||
function parseStyle(str){var o={};str.split(';').forEach(function(p){var idx=p.indexOf(':');if(idx>0)o[p.slice(0,idx).trim()]=p.slice(idx+1).trim();});return o;}
|
||||
document.querySelectorAll('[style-hover]').forEach(function(el){
|
||||
var hov=parseStyle(el.getAttribute('style-hover'));var keys=Object.keys(hov);var saved={};
|
||||
el.addEventListener('mouseenter',function(){keys.forEach(function(k){saved[k]=el.style.getPropertyValue(k);el.style.setProperty(k,hov[k]);});});
|
||||
el.addEventListener('mouseleave',function(){keys.forEach(function(k){el.style.setProperty(k,saved[k]);});});
|
||||
});
|
||||
|
||||
// Calculator: cost raportare manuala (2,5 min/prestatie).
|
||||
var pres=document.getElementById('calc-pres'),rate=document.getElementById('calc-rate');
|
||||
var nf=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:0});
|
||||
var nf1=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:1});
|
||||
function recalc(){
|
||||
var p=+pres.value,r=+rate.value,minPer=2.5;
|
||||
var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
|
||||
document.getElementById('out-pres').textContent=p;
|
||||
document.getElementById('out-rate').textContent=r;
|
||||
var map={leiMonth:nf.format(Math.round(leiMonth)),hMonth:nf.format(Math.round(hMonth)),leiYear:nf.format(Math.round(leiMonth*12)),days:nf.format(Math.round((hMonth*12)/8))};
|
||||
Object.keys(map).forEach(function(k){document.querySelectorAll('[data-calc="'+k+'"]').forEach(function(n){n.textContent=map[k];});});
|
||||
}
|
||||
if(pres&&rate){pres.addEventListener('input',recalc);rate.addEventListener('input',recalc);recalc();}
|
||||
|
||||
// Tab-uri autentificare/inregistrare.
|
||||
function setTab(tab){
|
||||
document.querySelectorAll('[data-pane]').forEach(function(f){f.style.display=(f.getAttribute('data-pane')===tab)?'':'none';});
|
||||
document.querySelectorAll('.auth-tab').forEach(function(b){
|
||||
var on=b.getAttribute('data-tab')===tab;b.classList.toggle('is-active',on);
|
||||
b.style.color=on?'var(--text,#e6e9ef)':'var(--sub,#8b93a7)';
|
||||
var u=b.querySelector('.tab-underline');if(u)u.style.display=on?'':'none';
|
||||
});
|
||||
}
|
||||
setTab('register');
|
||||
|
||||
document.addEventListener('click',function(e){
|
||||
var t=e.target.closest('[data-act]');if(!t)return;
|
||||
var act=t.getAttribute('data-act');
|
||||
if(act==='theme'){applyTheme(curIndex()+1);}
|
||||
else if(act==='tab'){e.preventDefault();setTab(t.getAttribute('data-tab'));}
|
||||
else if(act==='auth'){
|
||||
e.preventDefault();
|
||||
setTab(t.getAttribute('data-tab')||'register');
|
||||
var plan=t.getAttribute('data-plan'),sel=document.getElementById('plan-select');
|
||||
if(plan&&sel)sel.value=plan;
|
||||
var a=document.getElementById('inregistrare');if(a)a.scrollIntoView({behavior:'smooth',block:'start'});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Autentificare — Gateway RAR AUTOPASS{% endblock %}
|
||||
{% block title %}Autentificare — ROA AUTOPASS{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card auth-card" style="max-width:400px;margin:40px auto;">
|
||||
<h2 style="margin-top:0;">Autentificare</h2>
|
||||
{# US-010 (PRD 5.16): /login — layout 2 coloane branduit.
|
||||
Stanga: logo + tagline + puncte de incredere.
|
||||
Dreapta: formular de autentificare (neschimbat: CSRF, POST /login, link signup).
|
||||
Pe mobil (<640px): se stivuiesc, partea dreapta (formular) iese prima. #}
|
||||
<div class="login-2col" style="max-width:860px; margin:32px auto;">
|
||||
{# Antet minimal deja randat in base.html (fara RAR dot, fara burger, fara account_name) #}
|
||||
<div class="login-shell">
|
||||
{# === Formular autentificare === #}
|
||||
<div class="login-form-col">
|
||||
<h3 style="font-size:var(--fs-xl); margin:0 0 4px;">Autentificare</h3>
|
||||
<p style="font-size:var(--fs-sm); color:var(--muted); margin:0 0 22px;">
|
||||
Intra in contul service-ului tau.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="banner" style="margin-bottom:14px; padding:8px 12px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p>
|
||||
<label>Email</label><br>
|
||||
<input type="email" name="email" required style="width:100%;">
|
||||
</p>
|
||||
<p>
|
||||
<label>Parola</label><br>
|
||||
<input type="password" name="parola" required style="width:100%;">
|
||||
</p>
|
||||
<button type="submit" style="width:100%;margin-top:8px;">Intra in cont</button>
|
||||
</form>
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="camp-slim">
|
||||
<label for="lf-email">Email</label>
|
||||
<input id="lf-email" type="email" name="email" required autocomplete="email">
|
||||
</div>
|
||||
<div class="camp-slim" style="margin-bottom:14px;">
|
||||
<label for="lf-parola">Parola</label>
|
||||
<input id="lf-parola" type="password" name="parola" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary-full">Intra in cont</button>
|
||||
</form>
|
||||
|
||||
<p style="text-align:center;font-size:13px;margin-top:16px;">
|
||||
Cont nou? <a href="/signup">Inregistrare</a>
|
||||
</p>
|
||||
<p class="login-foot">
|
||||
Cont nou? <a href="/signup" style="color:var(--accent);">Inregistreaza service-ul</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* US-010 PRD 5.16: layout /login profesional 2 coloane. */
|
||||
.login-shell {
|
||||
display:grid; grid-template-columns:1fr;
|
||||
border:1px solid var(--line); border-radius:16px; overflow:hidden;
|
||||
background:var(--card); max-width:460px; margin:0 auto;
|
||||
}
|
||||
.login-form-col { padding:40px 38px; display:flex; flex-direction:column; justify-content:center; }
|
||||
.btn-primary-full { width:100%; min-height:46px; font-family:var(--font-ui); font-size:var(--fs-md);
|
||||
font-weight:600; background:var(--accent); color:#fff; border:none;
|
||||
border-radius:8px; cursor:pointer; margin-top:4px; }
|
||||
.btn-primary-full:hover { filter:brightness(1.08); }
|
||||
.login-foot { text-align:center; font-size:var(--fs-sm); color:var(--muted); margin-top:18px; }
|
||||
@media (max-width:640px) {
|
||||
.login-form-col { padding:28px 22px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -37,33 +37,53 @@
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<h2 style="margin-top:0;">Inregistrare cont nou</h2>
|
||||
<h2 style="margin-top:0;">Creează cont nou</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# Format aliniat la formularul de inregistrare din landing (#inregistrare): aceleasi campuri,
|
||||
etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro/premium),
|
||||
normalizate server-side. #}
|
||||
<form method="post" action="/signup">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p>
|
||||
<label>Companie <span style="color:var(--err)">*</span></label><br>
|
||||
<input type="text" name="name" value="{{ name or '' }}" required style="width:100%;">
|
||||
</p>
|
||||
<p>
|
||||
<label>CUI <span style="color:var(--err)">*</span></label><br>
|
||||
<input type="text" name="cui" value="{{ cui or '' }}" required style="width:100%;">
|
||||
</p>
|
||||
<p>
|
||||
<label>Email <span style="color:var(--err)">*</span></label><br>
|
||||
<input type="email" name="email" value="{{ email or '' }}" required style="width:100%;">
|
||||
</p>
|
||||
<p>
|
||||
<label>Parola <span style="color:var(--err)">*</span>
|
||||
<span style="color:var(--muted);font-size:12px;">(minim 10 caractere)</span>
|
||||
</label><br>
|
||||
<input type="password" name="parola" required style="width:100%;">
|
||||
</p>
|
||||
<button type="submit" style="width:100%;margin-top:8px;">Creeaza cont</button>
|
||||
<label style="display:block;margin-bottom:14px;">
|
||||
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Companie</span>
|
||||
<input type="text" name="name" value="{{ name or '' }}" required placeholder="SC Service Auto SRL"
|
||||
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||
</label>
|
||||
<label style="display:block;margin-bottom:14px;">
|
||||
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">CUI</span>
|
||||
<input type="text" name="cui" value="{{ cui or '' }}" required placeholder="RO12345678"
|
||||
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-mono);outline:none;">
|
||||
</label>
|
||||
<label style="display:block;margin-bottom:14px;">
|
||||
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Email</span>
|
||||
<input type="email" name="email" value="{{ email or '' }}" required placeholder="nume@service.ro"
|
||||
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||
</label>
|
||||
<label style="display:block;margin-bottom:14px;">
|
||||
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Parolă</span>
|
||||
<input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere"
|
||||
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||
</label>
|
||||
<label style="display:block;margin-bottom:16px;">
|
||||
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Pachet ales</span>
|
||||
<select name="plan"
|
||||
style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;cursor:pointer;">
|
||||
<option value="free"{% if not plan or plan == 'free' %} selected{% endif %}>Gratuit — 0 lei/lună</option>
|
||||
<option value="standard"{% if plan == 'standard' %} selected{% endif %}>Standard — 49 lei/lună</option>
|
||||
<option value="pro"{% if plan == 'pro' %} selected{% endif %}>Pro — 59 lei/lună</option>
|
||||
<option value="premium"{% if plan == 'premium' %} selected{% endif %}>Premium — la cerere</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--muted);cursor:pointer;">
|
||||
<input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent);width:16px;height:16px;flex-shrink:0;">
|
||||
Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).
|
||||
</label>
|
||||
<button type="submit"
|
||||
style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;">Creează cont gratuit</button>
|
||||
</form>
|
||||
<p style="text-align:center;font-size:13px;margin-top:16px;">
|
||||
Ai deja cont? <a href="/login">Autentificare</a>
|
||||
|
||||
@@ -34,7 +34,7 @@ import httpx
|
||||
from .. import errors
|
||||
from ..config import Settings, get_settings, load_test_credentials
|
||||
from ..crypto import decrypt_creds
|
||||
from ..db import get_connection, init_db, write_heartbeat
|
||||
from ..db import get_connection, init_db, read_heartbeat, write_heartbeat
|
||||
from ..observ import log_event, set_source
|
||||
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
|
||||
from ..payload import build_rar_payload
|
||||
@@ -428,6 +428,68 @@ def _creds_from_account(conn, account_id: int) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _keepalive_target(conn, settings: Settings) -> tuple[int | None, dict | None]:
|
||||
"""Un cont cu creds durabile pentru login-ul de proba (sau creds <test> in dev).
|
||||
|
||||
Sare conturile ale caror creds NU se decripteaza sub cheia curenta — in dev
|
||||
`start.sh both` genereaza o cheie efemera noua la fiecare pornire, deci creds-urile
|
||||
durabile criptate sub cheia veche dau decrypt -> None. Fallback la creds <test>.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT id, rar_creds_enc FROM accounts "
|
||||
"WHERE rar_creds_enc IS NOT NULL ORDER BY id"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
creds = decrypt_creds(row["rar_creds_enc"])
|
||||
if creds and creds.get("email") and creds.get("password"):
|
||||
return row["id"], creds
|
||||
if settings.worker_use_test_creds:
|
||||
return DEFAULT_ACCOUNT_ID, load_test_credentials()
|
||||
return None, None
|
||||
|
||||
|
||||
def _maybe_keepalive(conn, settings: Settings, sessions: "AccountSessions", state: dict) -> None:
|
||||
"""Login de proba periodic cand coada e goala — verifica reachability RAR si
|
||||
pastreaza last_rar_login_ok proaspat ca dashboard-ul sa nu afiseze fals
|
||||
'RAR inaccesibil' doar din lipsa de trafic.
|
||||
|
||||
Sondeaza la cel mult o data pe interval (si pe succes, si pe esec): pe succes
|
||||
heartbeat-ul se reimprospateaza singur; pe esec real (RAR jos) last_rar_login_ok
|
||||
ramane vechi -> dashboard-ul degradeaza corect. Forteaza login real (invalideaza
|
||||
sesiunea cache-uita) ca proba sa fie autentica, nu un token vechi din cache.
|
||||
"""
|
||||
interval = settings.worker_rar_keepalive_interval_s
|
||||
if interval <= 0:
|
||||
return
|
||||
hb = read_heartbeat(conn)
|
||||
last = hb["last_rar_login_ok"] if hb else None
|
||||
if last:
|
||||
try:
|
||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds()
|
||||
if age < interval:
|
||||
return # login inca proaspat — nimic de facut
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
now_ts = time.time()
|
||||
if now_ts - state["last_attempt"] < interval:
|
||||
return # deja am incercat recent (nu hartui RAR daca e jos)
|
||||
state["last_attempt"] = now_ts
|
||||
|
||||
account_id, creds = _keepalive_target(conn, settings)
|
||||
if account_id is None or not creds:
|
||||
return # niciun cont cu creds durabile — nimic de sondat
|
||||
sessions.invalidate(account_id) # forteaza login real, nu token din cache
|
||||
try:
|
||||
sessions.get_token(conn, account_id, creds) # reimprospateaza last_rar_login_ok la succes
|
||||
except RarAuthError:
|
||||
pass # creds invalide — deja logat in get_token (WARNING)
|
||||
except Exception as exc:
|
||||
# RAR indisponibil: last_rar_login_ok ramane vechi (corect). Nu propaga.
|
||||
log_event("rar_keepalive", nivel="WARNING", account_id=account_id,
|
||||
mesaj=f"keepalive RAR esuat (cont {account_id}): {type(exc).__name__}",
|
||||
context={"rezultat": "esuat"}, conn=conn, sursa="worker")
|
||||
|
||||
|
||||
def run() -> int:
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
@@ -440,6 +502,7 @@ def run() -> int:
|
||||
|
||||
sessions = AccountSessions(settings)
|
||||
_last_purge_time: float = 0.0
|
||||
_keepalive_state = {"last_attempt": 0.0}
|
||||
|
||||
while _running:
|
||||
try:
|
||||
@@ -466,6 +529,9 @@ def run() -> int:
|
||||
# Nimic de trimis: recupereaza orfanii conturilor deja logate.
|
||||
for acct, rar, tok in sessions.active():
|
||||
recover_orphans(conn, settings, rar, tok, account_id=acct)
|
||||
# Login de proba periodic ca dashboard-ul sa nu afiseze fals
|
||||
# "RAR inaccesibil" din lipsa de trafic (vezi _maybe_keepalive).
|
||||
_maybe_keepalive(conn, settings, sessions, _keepalive_state)
|
||||
time.sleep(settings.worker_poll_interval_s)
|
||||
continue
|
||||
|
||||
|
||||
@@ -13,9 +13,15 @@ services:
|
||||
- autopass-data:/data
|
||||
environment:
|
||||
AUTOPASS_DB_PATH: /data/autopass.db
|
||||
AUTOPASS_RAR_ENV: prod
|
||||
# Override din environment (Dokploy) pentru staging; default = prod.
|
||||
AUTOPASS_RAR_ENV: ${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)"]
|
||||
@@ -30,10 +36,11 @@ services:
|
||||
- autopass-data:/data
|
||||
environment:
|
||||
AUTOPASS_DB_PATH: /data/autopass.db
|
||||
AUTOPASS_RAR_ENV: test
|
||||
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-test}
|
||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||
# Send dezactivat by default; activeaza pentru proba end-to-end.
|
||||
AUTOPASS_WORKER_SEND_ENABLED: "true"
|
||||
# Send activ by default (prod); pe staging seteaza AUTOPASS_WORKER_SEND_ENABLED=false
|
||||
# in Dokploy ca worker-ul sa NU trimita declaratii reale la RAR (Legea 142/2023).
|
||||
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:-true}
|
||||
restart: always
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -494,5 +494,18 @@ Record de test creat: `data.id = 68514` (FINALIZATA, permanent pe test). Confirm
|
||||
- header `User-Agent` obligatoriu (altfel 403 WAF).
|
||||
|
||||
Rămas neprobat: ce alte valori `sistemReparat` (în afară de `"null"`) acceptă (Open Q #2).
|
||||
|
||||
## Note integrare — planuri de cont (PRD 5.17)
|
||||
|
||||
**Poți dezvolta și testa pe planul Gratuit** fără niciun upgrade — `POST /v1/prezentari/valideaza`
|
||||
(dry-run) e permis pe orice plan, nu face enqueue și nu consumă cotă lunară. Primești același
|
||||
răspuns de validare (câmpuri, cod_prestatie, rezolvare operație) ca la trimiterea reală.
|
||||
|
||||
**Trimiterea reală cere planul Pro** (sau trial Pro activ): rutele `POST /v1/prezentari`,
|
||||
`POST /v1/import` și `POST /v1/import/{id}/commit` sunt gate-uite pe `api_access=True`
|
||||
(Pro/Premium). Un cont Free/Standard primește `403 PLAN_FARA_API`. Contactează-ne pentru upgrade.
|
||||
|
||||
Planul Gratuit are limită de **60 prezentări/lună** (indiferent de canal). La depășire: `422 PLAN_LIMITA_LUNARA`.
|
||||
Planul Pro nu are limită de volum. `GET /v1/nomenclator` rămâne public pe orice plan (exploatare pre-upgrade).
|
||||
</content>
|
||||
</invoke>
|
||||
|
||||
@@ -102,7 +102,7 @@ Un singur prag conceptual mobil la **768px**; un prag de densitate la **1024px**
|
||||
| Interval | Numit | Regula |
|
||||
|----------|-------|--------|
|
||||
| `>= 1024px` | desktop | layout complet; aplica si compactarile globale (wizard) |
|
||||
| `768–1024px` | tableta | **card-uri** pentru tabelele actionabile (preview, mapari), 2 pe rand; tabelele dense read-only raman cu scroll contained |
|
||||
| `768–1024px` | tableta | **card-uri** pentru tabelele actionabile (Trimiteri, Preview, Mapari), UN card pe rand (nu 2/rand); tabelele dense read-only raman cu scroll contained |
|
||||
| `< 768px` | mobil | un card pe rand, o coloana, tinte touch 44px |
|
||||
|
||||
CSS custom properties NU functioneaza in `@media`; pragul se scrie literal
|
||||
@@ -167,8 +167,9 @@ flex" — sparge valorile pe verticala. In schimb:
|
||||
- **Card semantic**: linie titlu (identificator + stare), linii secundare mici. Preferat
|
||||
pentru liste lungi (Trimiteri, Preview).
|
||||
|
||||
Sub 768px: un card pe rand. 768–1024px: grid 2 carduri pe rand
|
||||
(`grid-template-columns:repeat(2,1fr); gap:12px`).
|
||||
Listele actionabile (Trimiteri, Preview, Mapari) raman **O COLOANA (un card pe rand)** pe
|
||||
TOT intervalul sub 1024px — nu se foloseste grila 2/rand. Decizie confirmata cu userul
|
||||
(gate 2026-06-27): simplitate si consecventa primeaza fata de densitate pe tableta.
|
||||
|
||||
Tabelele **dense read-only** (Jurnal, Nomenclator, Admin) raman tabel cu scroll orizontal
|
||||
**contained in card** (`.tablewrap { overflow-x:auto }`), nu se cardifica.
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
# Design 5.5 — Uniformizare & standardizare UI/UX
|
||||
|
||||
**Stare**: aprobat (decizii utilizator 2026-06-23, vezi §10)
|
||||
**Context**: dashboard web HTMX (`app/web/templates/`), paleta dark/light deja livrata (5.3),
|
||||
erori 3-niveluri (5.4). Acest document = sursa de adevar **vizuala** pentru PRD 5.5. Unde PRD-ul
|
||||
descrie *ce* livram pe stories, aici descriem *cum arata* si *de ce*.
|
||||
|
||||
> Nu reinventam estetica. Paleta, tipografia si tokenii din `base.html` (5.3) raman **NESCHIMBATI
|
||||
> la octet**. Standardizarea = aducem toate tabelele si paginile la acelasi vocabular de componente
|
||||
> care exista deja in tabelul Trimiteri (`_submissions.html`), tabelul considerat corect de referinta.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problema (audit pe codul real)
|
||||
|
||||
Inventar al neuniformitatii curente:
|
||||
|
||||
| Suprafata | Simptom | Referinta corecta |
|
||||
|-----------|---------|-------------------|
|
||||
| Tabel **Mapari** (`_mapari.html`) | Labartat: coloana "Punere in coada" injecteaza prin macro `autosend_toggle` 3 randuri de text explicativ pe **fiecare** linie → randuri inalte, butoanele **Salveaza/Sterge** ies din viewport, trebuie scroll orizontal | grila Trimiteri |
|
||||
| Tabel **Nomenclator** (`_nomenclator.html`) | Functional dar minim; nu imparte exact acelasi aspect/hover/aliniere cu Trimiteri | grila Trimiteri |
|
||||
| **Acasa** (`_acasa.html`) | Sectiune "Ajutor: Mapari / Coduri RAR" redundanta (wayfinding repetat) | — (se elimina) |
|
||||
| **Navigare** | Cont, Integrare, Nomenclator stau ca tab-uri amestecate cu lucrul zilnic; logout + link admin sunt agatate ad-hoc in coltul dreapta-sus al dashboard-ului, absente pe alte pagini | meniu de cont dedicat |
|
||||
| **Panou admin** (`admin.html`) | Conturile in asteptare au doar "Activeaza" per-rand; lipsesc selectie multipla si actiunile blocare/arhivare/stergere. Nota "Cont dev implicit" e jargon intern nederivabil | tabel cu selectie + bara bulk |
|
||||
|
||||
Principiu de standardizare: **un singur tabel, o singura componenta de antet de sectiune, un singur
|
||||
loc pentru ajutor** (link/disclosure, nu text inline repetat pe randuri).
|
||||
|
||||
---
|
||||
|
||||
## 2. Design tokens (existenti — se reutilizeaza, nu se modifica)
|
||||
|
||||
Din `base.html` (`:root` dark + `[data-theme="light"]`). Citat aici doar ca referinta; **nicio
|
||||
valoare noua de culoare**. Orice suprafata noua foloseste `color-mix(... var(--card))` pentru stari
|
||||
(lectia 5.3: zero literali hardcodati, altfel se sparge light mode).
|
||||
|
||||
```
|
||||
--bg --card --ink --muted --line
|
||||
--ok (verde) --warn (chihlimbar) --err (rosu) --accent (albastru)
|
||||
```
|
||||
|
||||
Spacing: cardurile 16-20px padding; celule tabel `8px 10px`; gap-uri 6/8/12/16px (scara existenta).
|
||||
Radius: 6px controale, 10px carduri, 99px pill-uri. Tipografie: tabel 14px `tabular-nums`,
|
||||
antet `th` 12px uppercase `--muted`. **Nu introducem fonturi sau marimi noi.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Componenta canonica: Tabelul standard
|
||||
|
||||
Tabelul Trimiteri defineste contractul. Orice tabel din aplicatie il respecta:
|
||||
|
||||
```
|
||||
.tablewrap > table
|
||||
thead th -> 12px uppercase, color --muted, font-weight 500, white-space nowrap
|
||||
tbody td -> 14px, padding 8px 10px, border-bottom 1px var(--line), nowrap implicit
|
||||
stare -> <span class="pill {s-*}">{text uman}</span> (glifa+text, nu doar culoare)
|
||||
coloana lunga (motiv) -> white-space:normal; max-width:280px (singura exceptie de la nowrap)
|
||||
empty state -> .empty (centrat, --muted, cu CTA contextual)
|
||||
```
|
||||
|
||||
Reguli care fac diferenta vizibila fata de "labartat":
|
||||
1. **Coloanele de control sunt inguste si nowrap.** Niciun text explicativ in celule. Explicatiile
|
||||
traiesc o singura data, in antetul cardului (link "Ajutor") sau intr-un `<details>`.
|
||||
2. **Actiunile incap fara scroll orizontal.** Coloana "Actiuni" la dreapta, `white-space:nowrap`,
|
||||
butoane scurte. Pe ecrane inguste scroll-ul ramane IN card (`.tablewrap`), nu in pagina.
|
||||
3. **Densitate constanta.** Inaltimea randului = o linie de text + padding. Sub-text (ex. "2 blocate",
|
||||
"acum: COD") merge in `<div class="muted" style="font-size:12px">` sub valoarea principala, nu
|
||||
pe coloana separata.
|
||||
|
||||
### 3.1 Antet de sectiune standard (cu Ajutor)
|
||||
|
||||
```
|
||||
+--------------------------------------------------------------+
|
||||
| De rezolvat [ Ajutor ] | <- h2 15px la stanga, link la dreapta
|
||||
+--------------------------------------------------------------+
|
||||
```
|
||||
|
||||
`Ajutor` = link discret `.cardlink` care comuta un `<details>`/panou de text (vezi §5). Mutam acolo
|
||||
toata proza care azi se repeta pe randuri. Un singur loc, citit la nevoie.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tabelul Mapari — inainte / dupa
|
||||
|
||||
### Inainte (labartat)
|
||||
Fiecare rand din "De rezolvat" si "Mapari salvate" poarta `autosend_toggle`, care randeaza:
|
||||
- "La fisierele viitoare cu aceasta operatie:" (12px)
|
||||
- checkbox + **"Pune automat in coada"**
|
||||
- "Nebifat = «Tine pentru verificare». Doar pentru aceasta operatie; nimic nu pleaca la RAR..." (11px)
|
||||
|
||||
x N randuri. Coloana e mai lata decat selectul de cod; Salveaza/Sterge sunt impinse afara.
|
||||
|
||||
### Dupa (compact, ca Trimiteri)
|
||||
|
||||
```
|
||||
De rezolvat [ Ajutor ]
|
||||
-----------------------------------------------------------------
|
||||
OPERATIE SUGESTII COD RAR IN COADA ACTIUNI
|
||||
RevTehBP A012 (88%) [ A012 v ] (o) Auto [Salveaza]
|
||||
2 blocate ( ) Manual
|
||||
-----------------------------------------------------------------
|
||||
```
|
||||
|
||||
- Coloana **IN COADA** = comutator scurt cu doua stari etichetate **Auto** / **Manual** (radio sau
|
||||
switch), fara nicio propozitie. Tooltip pe control: "Auto = pune automat in coada la fisierele
|
||||
viitoare cu aceasta operatie; Manual = tine pentru verificare."
|
||||
- Explicatia completa (de ce exista maparile, ce inseamna Auto vs Manual, ce e blocat) → in panoul
|
||||
**Ajutor** din antet, scris o singura data.
|
||||
- **Invariant backend pastrat**: controlul emite tot `name="auto_send" value="true"` cu semantica de
|
||||
prezenta (bifat→true, absent→false), exact ca azi. Zero atingere backend (lectia 5.3/3.6:
|
||||
reskin la nivel de macro, parserele `/mapari` si `/_import/.../mapare-operatie` raman valide).
|
||||
- "Mapari salvate" si "Formate de coloane" → aceeasi grila; sub-textul ("acum: COD — nume",
|
||||
"N coloane", maparea coloana→camp) ramane `muted` 12px sub valoare, nu pe coloane separate verbose.
|
||||
|
||||
Macro-ul `autosend_toggle` se rescrie compact (acelasi `name`/`form`/`checked`), deci se schimba
|
||||
intr-un singur loc si se propaga si in fluxul de import (mapcoloane) unde e refolosit.
|
||||
|
||||
---
|
||||
|
||||
## 5. Panoul Ajutor (mapari)
|
||||
|
||||
Un `<details>` nativ in antetul cardului "De rezolvat" (sau link care expandeaza acelasi `<details>`),
|
||||
inchis implicit, fara JS:
|
||||
|
||||
```
|
||||
Ajutor (v)
|
||||
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
|
||||
- Operatii necunoscute raman blocate in needs_mapping si NU pleaca la RAR pana le mapezi.
|
||||
- Sugestiile (%) vin din potrivire fuzzy pe denumire — verifica-le inainte sa salvezi.
|
||||
- In coada: Auto = la urmatoarele fisiere cu aceasta operatie, randurile intra automat in coada.
|
||||
Manual = raman pentru verificare; nimic nu pleaca la RAR pana confirmi tu.
|
||||
- La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
|
||||
```
|
||||
|
||||
Avantaj: text scris o data, accesibil, fara cost de inaltime pe fiecare rand. `<details>` =
|
||||
accesibil din tastatura nativ, fara dependente.
|
||||
|
||||
---
|
||||
|
||||
## 6. Navigare: meniu hamburger (decizie: dropdown ancorat dreapta-sus)
|
||||
|
||||
### 6.1 Header
|
||||
|
||||
```
|
||||
[Gateway RAR AUTOPASS] [test] [☀] v1.0 [☰]
|
||||
|
|
||||
+------------------+
|
||||
| Cont |
|
||||
| Integrare |
|
||||
| Nomenclator |
|
||||
| Panou admin | <- doar admin
|
||||
|------------------|
|
||||
| Iesi din cont | <- form POST /logout
|
||||
+------------------+
|
||||
```
|
||||
|
||||
- Iconita `☰` (`min 36x36`, `aria-label="Meniu cont"`, `aria-expanded`, `aria-controls`) langa toggle-ul
|
||||
de tema. Dropdown ancorat sub iconita, aliniat la dreapta. **Fara overlay** pe pagina.
|
||||
- Inchidere: click in afara, `Esc`, sau selectarea unui element. Focus trap minimal: `Esc` readuce
|
||||
focusul pe `☰`. Navigare cu sageti optionala (consistent cu pattern-ul tab existent), dar `Tab`
|
||||
natural e suficient.
|
||||
- Continut **dependent de autentificare** (vezi §6.3).
|
||||
|
||||
### 6.2 Tab-bar dupa mutare (decizie: doar Acasa · Mapari)
|
||||
|
||||
```
|
||||
[ Acasa ] [ Mapari ]
|
||||
```
|
||||
|
||||
Cont, Integrare, Nomenclator parasesc tab-bar-ul → meniul `☰`. Raman doar cele doua suprafete de
|
||||
**lucru zilnic**. Badge-urile de contoare (Mapari) raman pe tab. Deep-link `?tab=` si rutele
|
||||
`/_fragments/{cont,integrare,nomenclator}` raman valide (accesate acum din meniu, nu din tab-bar) —
|
||||
deci zero rute moarte, doar punctul de intrare se muta.
|
||||
|
||||
### 6.3 Stare de autentificare in header
|
||||
|
||||
`base.html` e partajat de `login.html`, `signup.html`, `dashboard.html`, `admin.html`. Meniul trebuie
|
||||
sa stie daca esti logat:
|
||||
|
||||
- **Autentificat**: arata Cont, Integrare, Nomenclator, (Panou admin daca `is_admin`), separator,
|
||||
"Iesi din cont" (form `POST /logout` cu `csrf_token`).
|
||||
- **Neautentificat** (login/signup): meniul arata doar "Autentificare" / "Inregistrare" (sau iconita
|
||||
`☰` ascunsa pe aceste pagini — vezi PRD US). Niciun link de cont, niciun logout.
|
||||
|
||||
Necesita ca `base.html` sa primeasca `is_authenticated`, `is_admin`, `csrf_token` in context. Se
|
||||
adauga ca un helper de context partajat (un singur loc), nu duplicat in fiecare render. Acesta e
|
||||
singurul "backend touch" din zona de navigare si trebuie sa fie aditiv si defensiv (lipsa cheilor →
|
||||
meniu in stare neautentificata, nu eroare).
|
||||
|
||||
---
|
||||
|
||||
## 7. Panou admin: selectie + actiuni bulk
|
||||
|
||||
### 7.1 Tabel conturi in asteptare (si analog conturi active)
|
||||
|
||||
```
|
||||
[v] Selecteaza tot 2 selectate:
|
||||
[Activeaza] [Blocheaza] [Arhiveaza] [Sterge] <- bara bulk, apare la selectie
|
||||
-----------------------------------------------------------------------
|
||||
[v] ID COMPANIE CUI EMAIL INREGISTRAT ACTIUNI
|
||||
[v] 7 Auto SRL RO123 a@b.ro 12.06.2026 [ ... ]
|
||||
[ ] 8 Moto SA RO456 c@d.ro 13.06.2026 [ ... ]
|
||||
-----------------------------------------------------------------------
|
||||
```
|
||||
|
||||
- Coloana de **checkbox** la stanga + un master "selecteaza tot" in antet.
|
||||
- **Bara de actiuni bulk** ascunsa pana exista o selectie; afiseaza numarul selectat si butoanele
|
||||
contextuale. Actioneaza pe toate randurile bifate (POST cu lista de `account_id`).
|
||||
- **Actiuni per-rand** in meniul `[ ... ]` (kebab): aceleasi verbe, pentru o singura tinta.
|
||||
- Verbele, ca stari de cont distincte (vezi §7.2): **Activeaza, Blocheaza, Arhiveaza, Sterge**.
|
||||
- `Sterge` = actiune distructiva → `hx-confirm` / dialog de confirmare obligatoriu.
|
||||
- `Blocheaza`/`Arhiveaza` reversibile → confirmare doar pe bulk (cantitate).
|
||||
- Stari vizuale ale verbelor: distructiv (`Sterge`) cu `color:var(--err)`; restul neutre `.cardlink`.
|
||||
|
||||
### 7.2 Model de stare cont (impact backend — vezi PRD riscuri)
|
||||
|
||||
Azi: `accounts.active` (0/1); "pending" = inregistrat dar `active=0`. Cele 4 verbe cer stari
|
||||
distincte care nu incap intr-un bool. Propunere (PRD o ratifica): coloana `accounts.status`
|
||||
TEXT, migrare defensiva, derivata din `active` la prima rulare:
|
||||
|
||||
```
|
||||
pending -> inregistrat, neactivat inca (active=0, status nesetat istoric)
|
||||
active -> operational (active=1)
|
||||
blocked -> suspendat reversibil (nu logheaza, worker nu trimite)
|
||||
archived -> ascuns din liste, date pastrate (read-only)
|
||||
deleted -> stergere (GDPR/L.142) (hard delete SAU soft cu purge)
|
||||
```
|
||||
|
||||
- Worker `claim_one` gate-uieste pe **status='active'** (azi pe `COALESCE(active,1)=1`) — schimbare
|
||||
semantica de pastrat compatibila: `active=1 ⇔ status='active'`.
|
||||
- **Contul dev id=1 e protejat** de Blocheaza/Arhiveaza/Sterge (cont de sistem), exact ca azi la
|
||||
activate/deactivate. Daca e selectat in bulk, e sarit, nu eroare.
|
||||
- Nota "Cont dev implicit (id=1)" din pagina **se elimina** (jargon intern, nederivabil de operator).
|
||||
Protectia ramane in cod, nu o explicam in UI.
|
||||
|
||||
> Aceasta e singura zona cu schema/backend real. Restul livrabilei e UI pur (reskin + reasezare).
|
||||
|
||||
---
|
||||
|
||||
## 8. Accesibilitate & paritate tema
|
||||
|
||||
- **AA pe light+dark** pentru orice text nou (lectia 5.3: verzi/rosii hardcodate cad sub AA). Stari
|
||||
doar prin `color-mix(... var(--card))`, niciun literal.
|
||||
- Stare = **glifa + text**, nu doar culoare (pill-urile existente respecta deja).
|
||||
- Meniul `☰`: `aria-expanded`, `aria-controls`, inchidere pe `Esc`, focus readus pe trigger.
|
||||
- `<details>` Ajutor: accesibil nativ din tastatura.
|
||||
- Checkbox-uri admin: `aria-label` per rand ("Selecteaza contul {companie}"); master = "Selecteaza tot".
|
||||
- Toate controalele >=36px zona de atins (consistent cu toggle tema / `.cardlink`).
|
||||
|
||||
## 9. Motiune
|
||||
|
||||
Minima, consistenta cu existentul: dropdown `☰` fade/translate scurt (~120ms, ca `.tab-link`
|
||||
transition). `<details>` = comportament nativ. Fara animatii noi de amploare. Respecta
|
||||
`prefers-reduced-motion` daca adaugam tranzitii (omitere la cerere).
|
||||
|
||||
## 10. Decizii utilizator (2026-06-23)
|
||||
|
||||
1. Meniu hamburger = **dropdown ancorat dreapta-sus** (nu drawer).
|
||||
2. Tab-bar = **Acasa · Mapari**; Nomenclator + Cont + Integrare + Panou admin → meniul `☰`.
|
||||
3. Mapari = **grila compacta ca Trimiteri**, toggle scurt **Auto/Manual**, link **Ajutor** in antet;
|
||||
textul repetat de pe randuri se elimina.
|
||||
4. Admin = **selectie cu bife + bara de actiuni bulk** (Activeaza/Blocheaza/Arhiveaza/Sterge) +
|
||||
actiuni per-rand; nota "cont dev implicit" **eliminata**.
|
||||
5. Sectiunea "Ajutor" de pe Acasa se **elimina**.
|
||||
6. Nomenclator capata exact aspectul tabelului Trimiteri.
|
||||
|
||||
## 11. Componente atinse (harta pentru PRD)
|
||||
|
||||
| Componenta | Fisier | Tip schimbare |
|
||||
|------------|--------|---------------|
|
||||
| Header + meniu `☰` + context auth | `base.html` (+ context render rute) | reasezare + mic backend context |
|
||||
| Tab-bar redus | `dashboard.html` | reasezare |
|
||||
| Acasa fara Ajutor | `_acasa.html` | stergere |
|
||||
| Mapari standard + toggle compact + Ajutor | `_mapari.html`, `_macros.html` | reskin UI (zero backend) |
|
||||
| Nomenclator ca Trimiteri | `_nomenclator.html` | reskin UI |
|
||||
| Admin selectie + bulk + verbe noi | `admin.html` + rute `/admin/*` | UI + **backend (status)** |
|
||||
| Model stare cont | `schema.sql`, `users.py`, worker gate | **backend + migrare** |
|
||||
112
docs/landing-page-prompt.md
Normal file
112
docs/landing-page-prompt.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Prompt landing page — claude.ai/design
|
||||
|
||||
Referinta pentru generarea unui mockup de landing page comerciala (homepage public) a
|
||||
produsului. De copiat ca atare in claude.ai/design. Tokenii de culoare/tipografie/control sunt
|
||||
preluati din `design.md` (sursa de adevar a sistemului de design) — orice modificare la paleta
|
||||
sau scara de control se face intai acolo, apoi se reflecta aici.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
Creează un mockup pentru o LANDING PAGE comercială (homepage public) a unui produs SaaS B2B românesc.
|
||||
|
||||
== PRODUS ==
|
||||
Nume: Gateway RAR AUTOPASS (de la ROMFAST).
|
||||
Ce face: un serviciu web prin care atelierele service-auto din România își declară automat
|
||||
prestațiile la sistemul RAR AUTOPASS — o obligație legală (Legea 142/2023, OM 210/2024).
|
||||
Înlocuiește raportarea manuală. Publicul țintă: administratori și recepționeri de service auto,
|
||||
oameni ne-tehnici, ocupați. Mesajul cheie: "Conformitate RAR fără bătaie de cap — încarci un
|
||||
fișier sau conectezi softul de service, noi trimitem la RAR în siguranță."
|
||||
|
||||
Tonul: serios, de încredere, instituțional dar modern. NU startup glumeț, NU emoji.
|
||||
Limba: română (cu diacritice în text vizibil). Fără emoji nicăieri.
|
||||
|
||||
== OBIECTIV PAGINĂ ==
|
||||
Convinge un service auto că produsul rezolvă obligația legală RAR simplu și sigur, și să se
|
||||
înregistreze. Acțiune dominantă: "Creează cont" / "Autentificare".
|
||||
Cârlig comercial principal: GRATUIT pentru service-urile mici, până la 100 de prezentări pe lună.
|
||||
|
||||
== SISTEM DE CULORI (folosește exact aceste valori — temă întunecată ca implicit) ==
|
||||
Fundal pagină: #0f1218
|
||||
Suprafață card: #181c24
|
||||
Text principal: #e6e9ef
|
||||
Text secundar: #8b93a7
|
||||
Borduri/linii: #262b36
|
||||
Accent (CTA/link): #2E74D6 (azur ROMFAST)
|
||||
Succes: #2FBF8F
|
||||
Atenție: #E0A93B
|
||||
Eroare: #E05D5D
|
||||
Oferă și o variantă pe temă LIGHT: fundal #f5f7fa, card #ffffff, text #1a1d24,
|
||||
text secundar #5c6473, linii #e2e5ea, accent #1F66C9.
|
||||
|
||||
== TIPOGRAFIE ==
|
||||
Font UI: IBM Plex Sans (weights 400/500/700).
|
||||
Font mono (pentru coduri, VIN, ID-uri tehnice dacă apar): IBM Plex Mono.
|
||||
Titlu hero mare și greu (700, letter-spacing ușor negativ). Corp 15–16px, secundar 12–13px.
|
||||
|
||||
== STIL CONTROALE ==
|
||||
Butoane: radius 6px, padding ~10px 18px, înălțime ~44px.
|
||||
- Primar: fundal #2E74D6, text alb, hover ușor mai luminos.
|
||||
- Secundar: transparent, text deschis, bordură #262b36.
|
||||
Carduri: fundal #181c24, bordură 1px #262b36, radius 10px, plat (umbră doar la elemente plutitoare).
|
||||
Pill/badge: radius 99px. Iconițe simple liniare, monocrome, NU ilustrații colorate gen 3D.
|
||||
Aspect plat, sobru, mult spațiu de respirație. Densitate compactă dar aerisită.
|
||||
|
||||
== STRUCTURĂ PAGINĂ (de sus în jos) ==
|
||||
1. HEADER fix: stânga logo "ROMFAST" (text wordmark, accent azur pe "FAST"); dreapta două
|
||||
butoane: "Autentificare" (secundar) + "Creează cont" (primar). Comutator temă opțional.
|
||||
|
||||
2. HERO: titlu puternic (ex. "Declară prestațiile la RAR AUTOPASS, automat"), subtitlu de o frază
|
||||
despre conformitate legală fără efort. Sub titlu, un badge/pill verde vizibil:
|
||||
"Gratuit până la 100 de prezentări pe lună". Apoi un buton primar mare "Creează cont gratuit"
|
||||
+ un buton secundar "Vezi cum funcționează". Sub butoane, o linie mică de încredere
|
||||
(ex. "Conform Legii 142/2023 · datele tale criptate · fără card bancar"). În dreapta hero:
|
||||
un mockup vizual al dashboardului aplicației (un card cu o listă de "Trimiteri" cu pill-uri de
|
||||
stare colorate: Trimis=verde, În coadă=albastru, Eroare=roșu).
|
||||
|
||||
3. BANDĂ DE ÎNCREDERE: "Construit de ROMFAST" + mențiune că înlocuiește integrarea/raportarea manuală.
|
||||
|
||||
4. SECȚIUNE "Cum funcționează" — 3 pași cu iconițe liniare:
|
||||
(1) Încarci fișierul (xlsx/csv) sau conectezi softul de service prin API,
|
||||
(2) Verifici și mapezi coloanele o singură dată (le ținem minte pentru data viitoare),
|
||||
(3) Trimitem automat la RAR, tu urmărești starea live.
|
||||
|
||||
5. SECȚIUNE BENEFICII — de ce merită această interfață (6 carduri scurte, fiecare cu iconiță liniară):
|
||||
- "Zero raportare manuală" — încarci un fișier, gata; nu mai introduci prezentări una câte una în portalul RAR.
|
||||
- "Mapare reținută" — potrivești coloanele o singură dată per format; fișierele următoare se completează singure.
|
||||
- "Anti-duplicat" — verificare automată ca aceeași prezentare să nu ajungă de două ori la RAR (RAR nu permite anulare).
|
||||
- "Validare înainte de trimitere" — erorile (VIN, cod prestație, kilometraj) sunt prinse și explicate înainte să meargă la RAR.
|
||||
- "Date criptate (GDPR)" — datele cu caracter personal sunt criptate și șterse automat după perioada legală.
|
||||
- "Stare live" — vezi în timp real ce s-a trimis, ce e în coadă și ce trebuie corectat.
|
||||
|
||||
6. SECȚIUNE INTEGRARE API (pentru service-uri cu soft propriu / ROAAUTO):
|
||||
Card mai mare, pe două coloane. Stânga: titlu "Ai deja un soft de service? Conectează-l direct."
|
||||
text scurt despre faptul că nu mai e nevoie de export manual — softul tău trimite prezentările
|
||||
automat printr-un singur apel API, cu cheie API per cont. Dreapta: un mic bloc de cod cu font
|
||||
mono (IBM Plex Mono) care arată un exemplu de request, ex.:
|
||||
POST /v1/prezentari
|
||||
Authorization: rfak_••••••••
|
||||
{ "vin": "...", "cod_prestatie": "...", "odometru": ... }
|
||||
Sub el un link/buton secundar "Vezi documentația API".
|
||||
|
||||
7. SECȚIUNE PREȚ — simplă, 2 planuri unul lângă altul:
|
||||
- "Gratuit": "0 lei/lună · până la 100 de prezentări/lună · import web + API · toate funcțiile de bază".
|
||||
Marcat ca recomandat pentru service-uri mici. Buton primar "Începe gratuit".
|
||||
- "Volum mare": "Pentru service-uri cu peste 100 de prezentări/lună · contactează-ne".
|
||||
Buton secundar "Contact".
|
||||
Accent pe ideea că pentru un service mic nu costă nimic, fără card bancar la înscriere.
|
||||
|
||||
8. CTA FINAL: card mare centrat "Începe să declari la RAR în câteva minute" + buton primar "Creează cont gratuit".
|
||||
|
||||
9. FOOTER: linkuri (Termeni, Confidențialitate/GDPR, Documentație API, Contact), © ROMFAST, mențiune legală.
|
||||
|
||||
== RESPONSIVE ==
|
||||
Arată DOUĂ artboard-uri: desktop (1280px) și mobil (375px). Pe mobil: o coloană, butoanele CTA
|
||||
full-width, header colapsat, cardurile de preț stivuite vertical. Ținte de atins minim 44px pe mobil.
|
||||
|
||||
== ACCESIBILITATE ==
|
||||
Contrast text minim 4.5:1. Starea comunicată prin text + culoare (nu doar culoare) — pill-urile de
|
||||
stare au etichetă text, nu doar pastilă colorată.
|
||||
|
||||
Livrează mockup-ul în tema întunecată ca varianta principală, plus un preview al heroului pe tema light.
|
||||
```
|
||||
194
docs/mockups/prd-5.15-mockups.html
Normal file
194
docs/mockups/prd-5.15-mockups.html
Normal file
@@ -0,0 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.15 — mockup-uri piese lipsa (D6 strip / E4 picker / odo reveal)</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;}
|
||||
/* grafit (default dark) — aceleasi tokenuri ca landing.html */
|
||||
:root{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--warn:#E0A93B;}
|
||||
body{margin:0;padding:32px;background:#0b0e13;font-family:'IBM Plex Sans',system-ui,sans-serif;-webkit-font-smoothing:antialiased;color:var(--text);}
|
||||
h1{font:700 22px 'IBM Plex Sans';margin:0 0 4px;}
|
||||
.pgsub{font:400 13px 'IBM Plex Sans';color:var(--sub);margin:0 0 28px;}
|
||||
.seclabel{font:500 13px 'IBM Plex Sans';color:var(--sub);letter-spacing:.04em;text-transform:uppercase;margin:34px 0 14px;border-top:1px solid var(--line);padding-top:18px;}
|
||||
.frames{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;}
|
||||
.frlabel{font:500 12px 'IBM Plex Sans';color:var(--sub);margin-bottom:10px;}
|
||||
/* componente slim */
|
||||
.counter{flex:1;background:var(--card2);border:1px solid var(--line);border-radius:8px;padding:10px 12px;}
|
||||
.cnum{font:700 22px 'IBM Plex Sans';line-height:1;}
|
||||
.clabel{font:400 11px 'IBM Plex Sans';color:var(--sub);margin-top:5px;}
|
||||
.csub{font:400 10px 'IBM Plex Mono';color:var(--sub);margin-top:3px;}
|
||||
.row{display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2);}
|
||||
.vin{font:500 13px 'IBM Plex Mono';color:var(--text);}
|
||||
.meta{font:400 11px 'IBM Plex Sans';color:var(--sub);margin-top:3px;}
|
||||
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;font:500 12px 'IBM Plex Sans';}
|
||||
.dot{width:6px;height:6px;border-radius:99px;}
|
||||
.lab{font:400 11px 'IBM Plex Sans';color:var(--sub);margin-bottom:4px;}
|
||||
.fld{height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);font:400 12px 'IBM Plex Mono';color:var(--text);}
|
||||
.fldsans{font-family:'IBM Plex Sans';}
|
||||
.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:600 11px 'IBM Plex Mono';}
|
||||
.chipx{opacity:.7;cursor:pointer;}
|
||||
.chipbox{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);}
|
||||
.addcode{display:inline-flex;align-items:center;height:22px;padding:0 7px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;color:var(--accent);font:500 10px 'IBM Plex Sans';cursor:pointer;}
|
||||
.oprow{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;}
|
||||
.opname{font:500 12px 'IBM Plex Sans';color:var(--text);}
|
||||
.picker{height:26px;display:inline-flex;align-items:center;gap:6px;padding:0 8px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);font:500 11px 'IBM Plex Sans';cursor:pointer;}
|
||||
.saverule{font:400 10px 'IBM Plex Sans';color:var(--okt);margin-top:3px;display:inline-flex;align-items:center;gap:4px;}
|
||||
.btn{margin-top:6px;height:34px;padding:0 16px;border-radius:6px;background:var(--accent);border:none;color:#fff;font:600 12px 'IBM Plex Sans';cursor:pointer;align-self:flex-start;}
|
||||
.form{display:flex;flex-direction:column;gap:11px;padding:18px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>PRD 5.15 — mockup-uri pentru piesele fara design</h1>
|
||||
<p class="pgsub">Tema grafit (dark), tokenuri identice cu landing.html. Trei piese pe care mockup-urile existente nu le acopera: stripul de sanatate D6, pickerul prestatii E4 (op↔cod), si reveal-ul odometru initial.</p>
|
||||
|
||||
<!-- ===================== D6 STRIP SANATATE ===================== -->
|
||||
<div class="seclabel">1 · D6 — strip sanatate mereu-vizibil (deasupra contoarelor)</div>
|
||||
<div class="frames">
|
||||
|
||||
<div>
|
||||
<div class="frlabel">Stare BLOCAT (rosu) — declaratiile NU pleaca</div>
|
||||
<div style="width:600px;background:var(--bg);border:1px solid var(--line);border-radius:12px;overflow:hidden;">
|
||||
<div style="padding:14px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-radius:8px;background:color-mix(in srgb,var(--errt) 16%,var(--card));border:1px solid color-mix(in srgb,var(--errt) 40%,transparent);margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:center;gap:9px;">
|
||||
<span aria-hidden="true" style="font:700 15px 'IBM Plex Sans';color:var(--errt);">✗</span>
|
||||
<span style="font:700 13px 'IBM Plex Sans';color:var(--text);">Blocat: worker oprit — declaratiile NU pleaca</span>
|
||||
</div>
|
||||
<span style="font:400 11px 'IBM Plex Mono';color:var(--sub);white-space:nowrap;">Ultima autentificare RAR: azi 08:14</span>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<div style="font:700 17px 'IBM Plex Sans';color:var(--text);">Trimiteri RAR AUTOPASS</div>
|
||||
<div style="font:400 12px 'IBM Plex Sans';color:var(--sub);margin-top:2px;">Service Auto Valcea · 28 iun 2026</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:14px;">
|
||||
<div class="counter"><div class="cnum" style="color:var(--text);">847</div><div class="clabel">Trimise (total)</div><div class="csub">luna 124 · azi 9</div></div>
|
||||
<div class="counter"><div class="cnum" style="color:var(--accent);">12</div><div class="clabel">In coada</div></div>
|
||||
<div class="counter"><div class="cnum" style="color:var(--errt);">2</div><div class="clabel">De corectat</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="row"><div><div class="vin">WBA8E9...K7F2</div><div class="meta">Inspectie tehnica · 09:42</div></div><span class="pill" style="background:color-mix(in srgb,var(--okt) 13%,transparent);color:var(--okt);"><span class="dot" style="background:var(--okt);"></span>Trimis</span></div>
|
||||
<div class="row"><div><div class="vin">WVWZZZ...3M1</div><div class="meta">Revizie periodica · 09:38</div></div><span class="pill" style="background:color-mix(in srgb,var(--accent) 14%,transparent);color:var(--infot);"><span class="dot" style="background:var(--accent);"></span>In coada</span></div>
|
||||
<div class="row" style="border-bottom:none;"><div><div class="vin">VF1RFB...A88</div><div class="meta">Sistem franare · 09:31</div></div><span class="pill" style="background:color-mix(in srgb,var(--errt) 14%,transparent);color:var(--errt);"><span class="dot" style="background:var(--errt);"></span>De corectat</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="frlabel">Stare OK (verde) — declaratiile curg</div>
|
||||
<div style="width:600px;background:var(--bg);border:1px solid var(--line);border-radius:12px;overflow:hidden;">
|
||||
<div style="padding:14px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-radius:8px;background:color-mix(in srgb,var(--okt) 13%,transparent);border:1px solid color-mix(in srgb,var(--okt) 30%,transparent);margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:center;gap:9px;">
|
||||
<span aria-hidden="true" style="font:700 15px 'IBM Plex Sans';color:var(--okt);">✓</span>
|
||||
<span style="font:600 13px 'IBM Plex Sans';color:var(--text);">Declaratiile curg normal</span>
|
||||
</div>
|
||||
<span style="font:400 11px 'IBM Plex Mono';color:var(--sub);white-space:nowrap;">Ultima autentificare RAR: azi 09:12</span>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<div style="font:700 17px 'IBM Plex Sans';color:var(--text);">Trimiteri RAR AUTOPASS</div>
|
||||
<div style="font:400 12px 'IBM Plex Sans';color:var(--sub);margin-top:2px;">Service Auto Valcea · 28 iun 2026</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:14px;">
|
||||
<div class="counter"><div class="cnum" style="color:var(--text);">847</div><div class="clabel">Trimise (total)</div><div class="csub">luna 124 · azi 9</div></div>
|
||||
<div class="counter"><div class="cnum" style="color:var(--accent);">3</div><div class="clabel">In coada</div></div>
|
||||
<div class="counter"><div class="cnum" style="color:var(--sub);">0</div><div class="clabel">De corectat</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="row"><div><div class="vin">WBA8E9...K7F2</div><div class="meta">Inspectie tehnica · 09:42</div></div><span class="pill" style="background:color-mix(in srgb,var(--okt) 13%,transparent);color:var(--okt);"><span class="dot" style="background:var(--okt);"></span>Trimis</span></div>
|
||||
<div class="row" style="border-bottom:none;"><div><div class="vin">ZAR937...C04</div><div class="meta">Schimb ulei · 09:24</div></div><span class="pill" style="background:color-mix(in srgb,var(--okt) 13%,transparent);color:var(--okt);"><span class="dot" style="background:var(--okt);"></span>Trimis</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ===================== E4 PICKER PRESTATII ===================== -->
|
||||
<div class="seclabel">2 · E4 — formular editare slim: VIN unic + Observatii + picker prestatii PE operatie</div>
|
||||
<div class="frames">
|
||||
<div>
|
||||
<div class="frlabel">Editare trimitere (needs_data)</div>
|
||||
<div style="width:640px;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;">
|
||||
<div class="form">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:2px;">
|
||||
<span style="font:500 12px 'IBM Plex Mono';color:var(--sub);">corecteaza · needs_data</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,var(--errt) 14%,transparent);color:var(--errt);font:600 11px 'IBM Plex Sans';">Date incomplete</span>
|
||||
</div>
|
||||
<div><div class="lab">VIN (serie sasiu)</div><div class="fld">U1234567890123456</div></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:11px;">
|
||||
<div><div class="lab">Data prestatiei</div><div class="fld">2026-06-22</div></div>
|
||||
<div><div class="lab">Numar inmatriculare</div><div class="fld">CT88NOE</div></div>
|
||||
</div>
|
||||
<div><div class="lab">Observatii (operatiile efectuate)</div><div class="fld fldsans" style="height:auto;min-height:48px;align-items:flex-start;padding:8px 10px;">Revizie; schimbare placute frana</div></div>
|
||||
<div>
|
||||
<div class="lab">Prestatii — cod RAR pe fiecare operatie</div>
|
||||
<div class="oprow">
|
||||
<span class="opname">REVIZIE PERIODICA</span>
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
<span class="chip"><span class="chipx">×</span>REV2</span>
|
||||
<span class="picker">+ alt cod</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="saverule">✓ salveaza regula REVIZIE PERIODICA → REV2 (auto-rezolva data viitoare)</div>
|
||||
<div class="oprow" style="border-color:color-mix(in srgb,var(--warn) 45%,var(--line));margin-top:10px;">
|
||||
<span class="opname">SCHIMBARE PLACUTE FRANA <span style="color:var(--warn);font:500 10px 'IBM Plex Sans';">· lipsa cod</span></span>
|
||||
<span class="picker" style="border-style:solid;border-color:var(--warn);color:var(--warn);">alege cod RAR ▾</span>
|
||||
</div>
|
||||
<div style="font:400 10px 'IBM Plex Sans';color:var(--sub);margin-top:8px;">Fara operatie (corectie pura): chip-uri de coduri libere · dedupare per-pereche (op,cod), nu doar dupa cod.</div>
|
||||
</div>
|
||||
<button class="btn">Salveaza si retrimite</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== ODO REVEAL ===================== -->
|
||||
<div class="seclabel">3 · Reveal odometru initial — apare doar la coduri R-ODO / I-ODO (server-driven, E6)</div>
|
||||
<div class="frames">
|
||||
|
||||
<div>
|
||||
<div class="frlabel">Inainte · niciun R-ODO → odometru initial ascuns</div>
|
||||
<div style="width:480px;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;">
|
||||
<div class="form">
|
||||
<div><div class="lab">Prestatii</div>
|
||||
<div class="chipbox"><span class="chip"><span class="chipx">×</span>REV2</span><span class="addcode">+ cod</span></div>
|
||||
</div>
|
||||
<div><div class="lab">Odometru final</div><div class="fld">39000</div></div>
|
||||
<div style="font:400 10px 'IBM Plex Sans';color:var(--sub);font-style:italic;">Odometru initial se cere doar pentru coduri R-ODO / I-ODO.</div>
|
||||
<button class="btn">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="frlabel">Dupa · adaugi R-ODO → campul apare</div>
|
||||
<div style="width:480px;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;">
|
||||
<div class="form">
|
||||
<div><div class="lab">Prestatii</div>
|
||||
<div class="chipbox">
|
||||
<span class="chip"><span class="chipx">×</span>REV2</span>
|
||||
<span class="chip" style="background:color-mix(in srgb,var(--warn) 22%,transparent);color:var(--warn);"><span class="chipx">×</span>R-ODO</span>
|
||||
<span class="addcode">+ cod</span>
|
||||
</div>
|
||||
</div>
|
||||
<div><div class="lab">Odometru final</div><div class="fld">39000</div></div>
|
||||
<div style="border-left:2px solid var(--warn);padding-left:10px;margin-left:-12px;">
|
||||
<div class="lab" style="color:var(--warn);">Odometru initial · necesar pentru R-ODO</div>
|
||||
<div class="fld" style="border-color:color-mix(in srgb,var(--warn) 50%,var(--line));">12500</div>
|
||||
</div>
|
||||
<button class="btn">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
221
docs/mockups/prd-5.16-dashboard-mobil.html
Normal file
221
docs/mockups/prd-5.16-dashboard-mobil.html
Normal file
@@ -0,0 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.16 — Dashboard mobil 390px (RAR dot in antet + meniu)</title>
|
||||
<style>
|
||||
:root{
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px; --fs-lg:18px; --fs-xl:20px; --fs-2xl:28px;
|
||||
--bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7;
|
||||
--line:#262b36; --line2:#1f2530; --accent:#6ea2ec; --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D;
|
||||
--hbg:rgba(15,18,24,.95);
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0; background:#05070b; font-family:var(--font-ui); -webkit-font-smoothing:antialiased; padding:24px;}
|
||||
.mono{font-family:var(--font-mono);} .muted{color:var(--muted);}
|
||||
.stage{display:flex; gap:34px; justify-content:center; align-items:flex-start; flex-wrap:wrap;}
|
||||
.cap{text-align:center; color:#9aa3b2; font-size:13px; margin-top:10px; max-width:390px;}
|
||||
.phone{width:390px; background:var(--bg); color:var(--ink); border-radius:30px; border:10px solid #20242c; overflow:hidden; box-shadow:0 30px 70px -20px rgba(0,0,0,.7);}
|
||||
.phone .screen{height:720px; overflow:hidden; position:relative;}
|
||||
.scroll{height:100%; overflow:auto;}
|
||||
|
||||
header{position:sticky; top:0; z-index:5; display:flex; align-items:center; justify-content:space-between; gap:8px; height:56px; padding:0 12px; background:var(--hbg); backdrop-filter:blur(8px); border-bottom:1px solid var(--line);}
|
||||
.logo-fallback{display:inline-flex; align-items:center; gap:4px; font-weight:800; font-size:var(--fs-base);}
|
||||
.logo-fallback .rom{color:#D1342F;} .logo-fallback .fast{color:var(--accent);}
|
||||
.h-center{flex:1; text-align:center; line-height:1.1; min-width:0;}
|
||||
.h-title{font-size:var(--fs-sm); font-weight:700;} .h-title .accent{color:var(--accent);}
|
||||
.tier{display:inline-block; margin-left:5px; padding:0 7px; border-radius:99px; font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--accent); background:color-mix(in srgb,var(--accent) 16%,transparent); vertical-align:middle;}
|
||||
.h-sub{font-size:11px; color:var(--muted); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
|
||||
.h-sub .svc{color:var(--ink); font-weight:600;}
|
||||
.h-right{display:flex; align-items:center; gap:7px;}
|
||||
/* RAR online = dot compact in antet (title pe hover); blocat => rosu */
|
||||
.rar-dot{width:38px; height:38px; border-radius:9px; border:1px solid color-mix(in srgb,var(--ok) 35%,var(--line)); background:color-mix(in srgb,var(--ok) 10%,transparent); display:inline-flex; align-items:center; justify-content:center; cursor:default;}
|
||||
.rar-dot .d{width:11px; height:11px; border-radius:99px; background:var(--ok); box-shadow:0 0 0 4px color-mix(in srgb,var(--ok) 22%,transparent);}
|
||||
.icon-btn{width:40px; height:40px; border-radius:9px; border:1px solid var(--line); background:transparent; color:var(--ink); cursor:pointer; display:inline-flex; align-items:center; justify-content:center;}
|
||||
|
||||
.body{padding:12px; display:flex; flex-direction:column; gap:12px;}
|
||||
|
||||
/* CARDURI compacte — doar numere, un rand */
|
||||
.stats{display:flex; background:var(--card2); border:1px solid var(--line); border-radius:11px; overflow:hidden;}
|
||||
.stat{flex:1; text-align:center; padding:10px 4px; border-right:1px solid var(--line2);}
|
||||
.stat:last-child{border-right:none;}
|
||||
.stat .n{font-size:var(--fs-xl); font-weight:700; line-height:1;}
|
||||
.stat .l{font-size:11px; color:var(--muted); margin-top:4px;}
|
||||
.s-ok{color:var(--ok);} .s-acc{color:var(--accent);} .s-err{color:var(--err);}
|
||||
|
||||
/* IMPORT colapsat */
|
||||
.import-collapse{border:1px solid var(--line); border-radius:11px; background:var(--card); overflow:hidden;}
|
||||
.import-collapse>summary{list-style:none; cursor:pointer; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:13px 14px; font-size:var(--fs-base); font-weight:600; color:var(--ink); min-height:48px;}
|
||||
.import-collapse>summary::-webkit-details-marker{display:none;}
|
||||
.import-collapse>summary .ic-l{display:flex; align-items:center; gap:9px;}
|
||||
.import-collapse .plus{display:inline-flex; width:24px; height:24px; align-items:center; justify-content:center; border-radius:7px; background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent); font-size:17px; line-height:1;}
|
||||
.import-collapse>summary .chev{font-size:var(--fs-sm); color:var(--muted);}
|
||||
|
||||
/* NAV */
|
||||
.subnav{display:flex; gap:6px; border-bottom:1px solid var(--line);}
|
||||
.subnav a{flex:1; text-align:center; font-size:var(--fs-sm); font-weight:600; padding:10px 0; border-radius:9px 9px 0 0; color:var(--muted); text-decoration:none; border:1px solid transparent; border-bottom:none; margin-bottom:-1px;}
|
||||
.subnav a.active{color:var(--ink); background:var(--card); border-color:var(--line); border-bottom:1px solid var(--card);}
|
||||
.badge{display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:5px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;}
|
||||
|
||||
/* LISTA — filtre se ASEAZA pe randuri (wrap), FARA linie de scroll */
|
||||
.panel{background:var(--card); border:1px solid var(--line); border-radius:0 11px 11px 11px; overflow:hidden;}
|
||||
.filtre{display:flex; gap:7px; flex-wrap:wrap; padding:11px 12px; border-bottom:1px solid var(--line2);}
|
||||
.pillf{font-size:var(--fs-sm); padding:7px 14px; border-radius:99px; border:1px solid var(--line); background:transparent; color:var(--muted);}
|
||||
.pillf.on{background:color-mix(in srgb,var(--accent) 16%,transparent); border-color:transparent; color:var(--accent); font-weight:600;}
|
||||
.rand{display:flex; align-items:center; justify-content:space-between; gap:10px; padding:13px 13px; border-bottom:1px solid var(--line2); min-height:56px;}
|
||||
.rand:last-child{border-bottom:none;}
|
||||
.slim-vin{font-family:var(--font-mono); font-size:var(--fs-md); font-weight:500;}
|
||||
.slim-meta{font-size:var(--fs-sm); color:var(--muted); margin-top:3px;}
|
||||
.pill{display:inline-flex; align-items:center; gap:6px; padding:5px 11px; border-radius:99px; font-size:var(--fs-sm); font-weight:500; flex-shrink:0;}
|
||||
.pill .pdot{width:7px; height:7px; border-radius:99px;}
|
||||
.pill.sent{background:color-mix(in srgb,var(--ok) 14%,transparent); color:var(--ok);} .pill.sent .pdot{background:var(--ok);}
|
||||
.pill.coada{background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent);} .pill.coada .pdot{background:var(--accent);}
|
||||
.pill.err{background:color-mix(in srgb,var(--err) 14%,transparent); color:var(--err);} .pill.err .pdot{background:var(--err);}
|
||||
|
||||
/* meniu burger deschis */
|
||||
.scrim{position:absolute; inset:0; background:rgba(0,0,0,.45); z-index:8;}
|
||||
.menu{position:absolute; top:52px; right:10px; width:240px; background:var(--card); border:1px solid var(--line); border-radius:12px; box-shadow:0 20px 50px -16px rgba(0,0,0,.7); padding:7px; z-index:9;}
|
||||
.menu-status{display:flex; align-items:center; gap:9px; padding:11px 11px; font-size:var(--fs-base); font-weight:600; color:var(--ok);}
|
||||
.menu-status .d{width:10px; height:10px; border-radius:99px; background:var(--ok); box-shadow:0 0 0 4px color-mix(in srgb,var(--ok) 22%,transparent);}
|
||||
.menu-status small{font-weight:400; color:var(--muted); font-family:var(--font-mono); font-size:11px;}
|
||||
.menu-plan{display:flex; align-items:center; justify-content:space-between; padding:6px 11px 8px; font-size:var(--fs-sm); color:var(--muted);}
|
||||
.menu-plan b{color:var(--accent);} .menu-plan .trial{font-size:11px;}
|
||||
.menu a{display:flex; align-items:center; justify-content:space-between; padding:12px 11px; border-radius:8px; font-size:var(--fs-base); color:var(--ink); text-decoration:none;}
|
||||
.menu a:active{background:var(--card2);}
|
||||
.menu hr{border:none; border-top:1px solid var(--line); margin:5px 4px;}
|
||||
|
||||
/* ecran editare full-screen */
|
||||
.modal-head{display:flex; align-items:center; justify-content:space-between; height:56px; padding:0 12px; border-bottom:1px solid var(--line); background:var(--hbg); position:sticky; top:0; z-index:5;}
|
||||
.modal-head .t{font-size:var(--fs-md); font-weight:700;}
|
||||
.field{margin-bottom:14px;}
|
||||
.field label{display:block; font-size:var(--fs-sm); color:var(--muted); margin-bottom:6px;}
|
||||
.field input, .field textarea, .field select{width:100%; font-family:var(--font-ui); font-size:var(--fs-md); color:var(--ink); background:var(--card2); border:1px solid var(--line); border-radius:9px; padding:11px 13px; min-height:46px;}
|
||||
.field input.mono{font-family:var(--font-mono);}
|
||||
.grid2{display:grid; grid-template-columns:1fr 1fr; gap:10px;}
|
||||
.op-row{padding:11px 0; border-bottom:1px solid var(--line2);}
|
||||
.op-name{font-size:var(--fs-md); font-weight:600; display:block; margin-bottom:8px;} .op-name small{font-weight:400; color:var(--muted); font-size:var(--fs-sm);}
|
||||
.op-ctl{display:flex; align-items:center; gap:8px;}
|
||||
.chip{display:inline-flex; align-items:center; gap:6px; font-family:var(--font-mono); font-size:var(--fs-sm); background:color-mix(in srgb,var(--accent) 18%,transparent); color:var(--accent); padding:7px 11px; border-radius:8px;}
|
||||
.chip button{background:none; border:none; color:inherit; cursor:pointer; font-size:var(--fs-md);}
|
||||
.addcode{width:100%; font-size:var(--fs-sm); border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line)); background:transparent; color:var(--accent); border-radius:9px; padding:11px; cursor:pointer; margin-top:10px;}
|
||||
.actrow{display:flex; flex-direction:column; gap:10px; margin-top:18px;}
|
||||
.btn-primary{width:100%; font-size:var(--fs-md); font-weight:600; height:46px; background:var(--accent); color:#fff; border:none; border-radius:9px; cursor:pointer;}
|
||||
.btn-ghost{width:100%; font-size:var(--fs-md); height:46px; background:transparent; color:var(--ink); border:1px solid var(--line); border-radius:9px; cursor:pointer;}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
<div class="stage">
|
||||
|
||||
<!-- ECRAN 1: DASHBOARD curat (RAR dot in antet, fara linie de scroll la filtre) -->
|
||||
<div>
|
||||
<div class="phone"><div class="screen"><div class="scroll">
|
||||
<header>
|
||||
<span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span>
|
||||
<div class="h-center">
|
||||
<div class="h-title">ROMFAST <span class="accent">AUTOPASS</span><span class="tier">Pro</span></div>
|
||||
<div class="h-sub">Service auto: <span class="svc">Service Auto Vâlcea SRL</span></div>
|
||||
</div>
|
||||
<div class="h-right">
|
||||
<span class="rar-dot" title="RAR online · ultima autentificare 28.06.2026 09:41"><span class="d"></span></span>
|
||||
<button class="icon-btn" title="Temă: Grafit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg></button>
|
||||
<button class="icon-btn" title="Meniu">☰</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="n s-ok">847</div><div class="l">Total</div></div>
|
||||
<div class="stat"><div class="n s-ok">124</div><div class="l">Lună</div></div>
|
||||
<div class="stat"><div class="n s-ok">9</div><div class="l">Azi</div></div>
|
||||
<div class="stat"><div class="n s-acc">12</div><div class="l">Coadă</div></div>
|
||||
<div class="stat"><div class="n s-err">2</div><div class="l">Corectat</div></div>
|
||||
</div>
|
||||
<details class="import-collapse">
|
||||
<summary><span class="ic-l"><span class="plus">+</span> Importă fișier (XLSX / CSV)</span><span class="chev">▾</span></summary>
|
||||
</details>
|
||||
<div>
|
||||
<div class="subnav">
|
||||
<a href="#" class="active">Trimiteri</a>
|
||||
<a href="#">Mapări <span class="badge">2</span></a>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="filtre">
|
||||
<button class="pillf on">Toate</button>
|
||||
<button class="pillf">În coadă</button>
|
||||
<button class="pillf">Trimise</button>
|
||||
<button class="pillf">De corectat</button>
|
||||
</div>
|
||||
<div class="rand"><div><div class="slim-vin">WBA8E9...K7F2</div><div class="slim-meta">Inspecție tehnică · 09:42</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">WVWZZZ...3M1</div><div class="slim-meta">Revizie periodică · 09:38</div></div><span class="pill coada"><span class="pdot"></span>În coadă</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">VF1RFB...A88</div><div class="slim-meta">Sistem frânare · 09:31</div></div><span class="pill err"><span class="pdot"></span>De corectat</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">ZAR937...C04</div><div class="slim-meta">Schimb ulei · 09:24</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div></div>
|
||||
<div class="cap">390px · Acasă — RAR online = dot în antet (dată/oră pe hover), filtre fără linie de scroll</div>
|
||||
</div>
|
||||
|
||||
<!-- ECRAN 2: meniu burger deschis (RAR online si aici) -->
|
||||
<div>
|
||||
<div class="phone"><div class="screen">
|
||||
<header>
|
||||
<span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span>
|
||||
<div class="h-center">
|
||||
<div class="h-title">ROMFAST <span class="accent">AUTOPASS</span><span class="tier">Pro</span></div>
|
||||
<div class="h-sub">Service auto: <span class="svc">Service Auto Vâlcea SRL</span></div>
|
||||
</div>
|
||||
<div class="h-right">
|
||||
<span class="rar-dot" title="RAR online"><span class="d"></span></span>
|
||||
<button class="icon-btn" title="Temă: Grafit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg></button>
|
||||
<button class="icon-btn" title="Închide meniu">×</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="scrim"></div>
|
||||
<div class="menu">
|
||||
<div class="menu-status"><span class="d"></span> RAR online <small>· 09:41</small></div>
|
||||
<div class="menu-plan">Plan: <b>Pro</b> <span class="trial">trial · 18 zile</span></div>
|
||||
<hr>
|
||||
<a href="#">Trimiteri</a>
|
||||
<a href="#">Mapări <span class="badge">2</span></a>
|
||||
<hr>
|
||||
<a href="#">Nomenclator</a>
|
||||
<hr>
|
||||
<a href="#">Cont</a>
|
||||
<a href="#">Integrare</a>
|
||||
<a href="#">Jurnal</a>
|
||||
<hr>
|
||||
<a href="#">Ieși din cont</a>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="cap">390px · Meniu burger — RAR online + Plan (Pro) + separatoare între secțiuni</div>
|
||||
</div>
|
||||
|
||||
<!-- ECRAN 3: editare full-screen (trimitere nefinalizata) -->
|
||||
<div>
|
||||
<div class="phone"><div class="screen"><div class="scroll">
|
||||
<div class="modal-head"><span class="t">Corectează trimiterea</span><button class="icon-btn" title="Închide">×</button></div>
|
||||
<div class="body" style="gap:0;">
|
||||
<div class="field"><label>VIN (serie șasiu)</label><input class="mono" value="VF1RFB00A88142073"></div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>Data prestației</label><input class="mono" value="2026-06-22"></div>
|
||||
<div class="field"><label>Nr. înmatriculare</label><input class="mono" value="CT88NOE"></div>
|
||||
</div>
|
||||
<div class="field"><label>Observații (operațiile efectuate)</label><textarea rows="2">Schimbare plăcuțe frână față</textarea></div>
|
||||
<div class="field" style="margin-bottom:6px;">
|
||||
<label>Prestații — cod RAR pe fiecare operație</label>
|
||||
<div class="op-row"><span class="op-name">REVIZIE PERIODICĂ <small>— la 15.000 km</small></span><div class="op-ctl"><span class="chip">REV2 <button>×</button></span></div></div>
|
||||
<div class="op-row" style="border-left:2px solid var(--warn); padding-left:10px;"><span class="op-name">SCHIMB PLĂCUȚE FRÂNĂ <small style="color:var(--warn)">— lipsă cod</small></span><div class="op-ctl"><select><option>— alege cod RAR —</option><option>FRN1 — Sistem de frânare</option></select></div></div>
|
||||
<button class="addcode">+ Adaugă altă operație / cod RAR</button>
|
||||
</div>
|
||||
<div class="actrow"><button class="btn-primary">Salvează și retrimite</button><button class="btn-ghost">Renunță</button></div>
|
||||
</div>
|
||||
</div></div></div>
|
||||
<div class="cap">390px · Editare full-screen — trimitere nefinalizată (picker cod+denumire, Renunță)</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
241
docs/mockups/prd-5.16-dashboard.html
Normal file
241
docs/mockups/prd-5.16-dashboard.html
Normal file
@@ -0,0 +1,241 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.16 — Dashboard aplicatie (compact, minimalist)</title>
|
||||
<style>
|
||||
:root{
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px; --fs-lg:18px; --fs-xl:20px; --fs-2xl:28px;
|
||||
--bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7;
|
||||
--line:#262b36; --line2:#1f2530; --accent:#6ea2ec; --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D;
|
||||
--hbg:rgba(15,18,24,.9);
|
||||
}
|
||||
body[data-theme="hartie"]{ --bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052; --line:#e2dccc; --line2:#ece6d9; --accent:#1F5FBF; --ok:#1c7d5d; --warn:#b45309; --err:#bd463c; --hbg:rgba(255,253,247,.92); }
|
||||
body[data-theme="cobalt"]{ --bg:#080d1c; --card:#111a33; --card2:#0b1226; --ink:#e9ecfb; --muted:#8a93b8; --line:#1d2747; --line2:#161f3a; --accent:#8aa0ff; --ok:#2fd0a6; --err:#f06a7a; --hbg:rgba(8,13,28,.92); }
|
||||
body[data-theme="cupru"]{ --bg:#15110b; --card:#211a12; --card2:#15110b; --ink:#efe6d6; --muted:#a89a85; --line:#36291c; --line2:#281e14; --accent:#dfa45c; --ok:#67b98c; --err:#e2685a; --hbg:rgba(21,17,11,.92); }
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0; background:var(--bg); color:var(--ink); font-family:var(--font-ui); font-size:var(--fs-base); line-height:1.55; -webkit-font-smoothing:antialiased;}
|
||||
.mono{font-family:var(--font-mono);} .muted{color:var(--muted);}
|
||||
|
||||
/* HEADER branded (numele service e DOAR aici, nu se mai duplica jos) */
|
||||
header{position:sticky; top:0; z-index:5; display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:16px; height:64px; padding:0 22px; background:var(--hbg); backdrop-filter:blur(8px); border-bottom:1px solid var(--line);}
|
||||
.logo-fallback{display:inline-flex; align-items:center; gap:6px; font-weight:800; font-size:var(--fs-lg);}
|
||||
.logo-fallback .rom{color:#D1342F;} .logo-fallback .fast{color:var(--accent);}
|
||||
.h-center{text-align:center; line-height:1.15;}
|
||||
.h-title{font-size:var(--fs-md); font-weight:700;} .h-title .accent{color:var(--accent);}
|
||||
.h-sub{font-size:var(--fs-xs); color:var(--muted); margin-top:2px;} .h-sub .svc{color:var(--ink); font-weight:600;}
|
||||
.env{display:inline-block; margin-left:8px; padding:1px 7px; border-radius:99px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--warn); background:color-mix(in srgb,var(--warn) 16%,transparent);}
|
||||
/* badge tip cont (Gratuit/Standard/Pro/Premium) */
|
||||
.tier{display:inline-block; margin-left:6px; padding:1px 8px; border-radius:99px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--accent); background:color-mix(in srgb,var(--accent) 16%,transparent);}
|
||||
.h-right{display:flex; align-items:center; justify-content:flex-end; gap:10px;}
|
||||
/* dot RAR online compact in antet (inlocuieste banda) — datetime pe title/hover */
|
||||
.rar-chip{display:flex; align-items:center; gap:8px; height:38px; padding:0 13px; border-radius:99px; border:1px solid color-mix(in srgb,var(--ok) 35%,var(--line)); background:color-mix(in srgb,var(--ok) 10%,transparent); color:var(--ok); font-size:var(--fs-sm); font-weight:600; cursor:default;}
|
||||
.rar-chip.blocat{border-color:color-mix(in srgb,var(--err) 45%,var(--line)); background:color-mix(in srgb,var(--err) 12%,transparent); color:var(--err);}
|
||||
.rar-chip .dot{width:9px; height:9px; border-radius:99px; background:currentColor; box-shadow:0 0 0 4px color-mix(in srgb,currentColor 22%,transparent);}
|
||||
.tema-btn{display:flex; align-items:center; gap:8px; height:38px; padding:0 13px; border-radius:8px; background:transparent; border:1px solid var(--line); color:var(--muted); font-family:var(--font-ui); font-size:var(--fs-sm); cursor:pointer;}
|
||||
.tema-btn:hover{border-color:var(--accent); color:var(--ink);}
|
||||
.ver{font-size:var(--fs-xs); color:var(--muted);}
|
||||
.icon-btn{width:38px; height:38px; border-radius:8px; border:1px solid var(--line); background:transparent; color:var(--ink); font-size:18px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; position:relative;}
|
||||
/* meniu burger deschis (mockup) — contine si starea RAR */
|
||||
.menu{position:absolute; top:46px; right:0; width:230px; background:var(--card); border:1px solid var(--line); border-radius:10px; box-shadow:0 18px 40px -16px rgba(0,0,0,.6); padding:6px; z-index:10; text-align:left;}
|
||||
.menu-status{display:flex; align-items:center; gap:8px; padding:9px 10px; font-size:var(--fs-sm); font-weight:600; color:var(--ok);}
|
||||
.menu-status small{font-weight:400; color:var(--muted); font-family:var(--font-mono); font-size:11px;}
|
||||
.menu-plan{display:flex; align-items:center; justify-content:space-between; padding:8px 10px 4px; font-size:var(--fs-sm); color:var(--muted);}
|
||||
.menu-plan b{color:var(--accent);}
|
||||
.menu-plan .trial{font-size:11px; color:var(--muted);}
|
||||
.menu a{display:flex; align-items:center; justify-content:space-between; padding:9px 10px; border-radius:7px; font-size:var(--fs-sm); color:var(--ink); text-decoration:none;}
|
||||
.menu a:hover{background:var(--card2);}
|
||||
.menu hr{border:none; border-top:1px solid var(--line); margin:5px 4px;}
|
||||
.menu .badge{display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;}
|
||||
|
||||
.wrap{max-width:1000px; margin:0 auto; padding:16px 22px 70px; display:flex; flex-direction:column; gap:14px;}
|
||||
|
||||
/* Banda de stare — APARE DOAR cand e blocat (zero-silent-failures) */
|
||||
.strip{display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 16px; border-radius:10px;
|
||||
background:color-mix(in srgb, var(--ok) 13%, transparent); border:1px solid color-mix(in srgb, var(--ok) 30%, transparent);}
|
||||
.strip.blocat{background:color-mix(in srgb, var(--err) 13%, transparent); border-color:color-mix(in srgb, var(--err) 35%, transparent); color:var(--err);}
|
||||
.strip-left{display:flex; align-items:center; gap:10px; font-weight:700; font-size:var(--fs-md);}
|
||||
.strip .dot{width:10px; height:10px; border-radius:99px; background:var(--ok); flex-shrink:0; box-shadow:0 0 0 4px color-mix(in srgb, var(--ok) 22%, transparent);}
|
||||
.strip.blocat .dot{background:var(--err); box-shadow:0 0 0 4px color-mix(in srgb, var(--err) 22%, transparent);}
|
||||
.strip-right{font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted);}
|
||||
|
||||
/* 2. CARDURI contor — standalone, fara titlu de sectiune */
|
||||
.contoare{display:grid; grid-template-columns:repeat(5,1fr); gap:10px;}
|
||||
.contor-card{background:var(--card2); border:1px solid var(--line); border-radius:10px; padding:14px 16px;}
|
||||
.contor-card.primar{border-color:color-mix(in srgb,var(--ok) 40%,var(--line));}
|
||||
.contor-cifra{font-size:var(--fs-2xl); font-weight:700; line-height:1;}
|
||||
.contor-label{font-size:var(--fs-sm); color:var(--muted); margin-top:7px;}
|
||||
.s-ok{color:var(--ok);} .s-acc{color:var(--accent);} .s-err{color:var(--err);}
|
||||
|
||||
/* 3. IMPORT colapsat */
|
||||
.import-collapse{border:1px solid var(--line); border-radius:10px; background:var(--card); overflow:hidden;}
|
||||
.import-collapse>summary{list-style:none; cursor:pointer; display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px 16px; font-size:var(--fs-sm); font-weight:600; color:var(--ink);}
|
||||
.import-collapse>summary::-webkit-details-marker{display:none;}
|
||||
.import-collapse>summary .ic-l{display:flex; align-items:center; gap:10px;}
|
||||
.import-collapse .plus{display:inline-flex; width:24px; height:24px; align-items:center; justify-content:center; border-radius:7px; background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent); font-size:17px; line-height:1;}
|
||||
.import-collapse>summary .ic-r{font-size:var(--fs-xs); color:var(--muted);}
|
||||
.import-collapse[open]>summary{border-bottom:1px solid var(--line);}
|
||||
.import-body{display:flex; align-items:center; justify-content:space-between; gap:14px; padding:16px; border:1px dashed color-mix(in srgb,var(--accent) 45%,var(--line)); border-radius:10px; margin:12px;}
|
||||
.import-body .u-tx{font-size:var(--fs-md); font-weight:600;}
|
||||
.import-body .u-sub{font-size:var(--fs-sm); color:var(--muted); margin-top:2px;}
|
||||
.btn-primary{font-family:var(--font-ui); font-size:var(--fs-md); font-weight:600; height:42px; padding:0 20px; background:var(--accent); color:#fff; border:none; border-radius:8px; cursor:pointer;}
|
||||
|
||||
/* 4. NAV tab-uri Trimiteri / Mapari */
|
||||
.subnav{display:flex; gap:6px; border-bottom:1px solid var(--line);}
|
||||
.subnav a{font-size:var(--fs-sm); font-weight:600; padding:9px 16px; border-radius:8px 8px 0 0; color:var(--muted); text-decoration:none; border:1px solid transparent; border-bottom:none; margin-bottom:-1px;}
|
||||
.subnav a.active{color:var(--ink); background:var(--card); border-color:var(--line); border-bottom:1px solid var(--card);}
|
||||
.subnav .badge{display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;}
|
||||
|
||||
/* 5. LISTA (fara titlu/subtitlu de sectiune) */
|
||||
.panel{background:var(--card); border:1px solid var(--line); border-radius:0 12px 12px 12px; overflow:hidden;}
|
||||
.filtre{display:flex; gap:8px; padding:12px 16px; flex-wrap:wrap; border-bottom:1px solid var(--line2);}
|
||||
.pillf{font-size:var(--fs-sm); padding:6px 13px; border-radius:99px; border:1px solid var(--line); background:transparent; color:var(--muted); cursor:pointer;}
|
||||
.pillf.on{background:color-mix(in srgb,var(--accent) 16%,transparent); border-color:transparent; color:var(--accent); font-weight:600;}
|
||||
.rand{display:flex; align-items:center; justify-content:space-between; padding:13px 16px; border-bottom:1px solid var(--line2); cursor:pointer;}
|
||||
.rand:hover{background:color-mix(in srgb,var(--accent) 6%,transparent);}
|
||||
.rand:last-child{border-bottom:none;}
|
||||
.slim-vin{font-family:var(--font-mono); font-size:var(--fs-md); font-weight:500;}
|
||||
.slim-meta{font-size:var(--fs-sm); color:var(--muted); margin-top:3px;}
|
||||
.pill{display:inline-flex; align-items:center; gap:7px; padding:5px 12px; border-radius:99px; font-size:var(--fs-sm); font-weight:500;}
|
||||
.pill .pdot{width:7px; height:7px; border-radius:99px;}
|
||||
.pill.sent{background:color-mix(in srgb,var(--ok) 14%,transparent); color:var(--ok);} .pill.sent .pdot{background:var(--ok);}
|
||||
.pill.coada{background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent);} .pill.coada .pdot{background:var(--accent);}
|
||||
.pill.err{background:color-mix(in srgb,var(--err) 14%,transparent); color:var(--err);} .pill.err .pdot{background:var(--err);}
|
||||
|
||||
/* MODAL editare trimitere nefinalizata (la click pe rand) */
|
||||
.editmodal{max-width:560px; background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden;}
|
||||
.editmodal .mhead{display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid var(--line);}
|
||||
.editmodal .mhead .t{font-size:var(--fs-md); font-weight:700;}
|
||||
.editmodal .mbody{padding:18px;}
|
||||
.field{margin-bottom:14px;}
|
||||
.field label{display:block; font-size:var(--fs-sm); color:var(--muted); margin-bottom:6px;}
|
||||
.field input, .field textarea, .field select{width:100%; font-family:var(--font-ui); font-size:var(--fs-md); color:var(--ink); background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:9px 12px; min-height:40px;}
|
||||
.field input.mono{font-family:var(--font-mono);}
|
||||
.grid2{display:grid; grid-template-columns:1fr 1fr; gap:12px;}
|
||||
.op-row{display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 0; border-bottom:1px solid var(--line2);}
|
||||
.op-name{font-size:var(--fs-md); font-weight:600;} .op-name small{font-weight:400; color:var(--muted); font-size:var(--fs-sm);}
|
||||
.chip{display:inline-flex; align-items:center; gap:6px; font-family:var(--font-mono); font-size:var(--fs-sm); background:color-mix(in srgb,var(--accent) 18%,transparent); color:var(--accent); padding:5px 10px; border-radius:7px;}
|
||||
.chip button{background:none; border:none; color:inherit; cursor:pointer; font-size:var(--fs-md);}
|
||||
.addcode{font-size:var(--fs-sm); border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line)); background:transparent; color:var(--accent); border-radius:7px; padding:6px 12px; cursor:pointer;}
|
||||
.btn-ghost{font-size:var(--fs-md); height:42px; padding:0 18px; background:transparent; color:var(--ink); border:1px solid var(--line); border-radius:8px; cursor:pointer;}
|
||||
.actrow{display:flex; gap:10px; margin-top:16px;}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
|
||||
<header>
|
||||
<div><span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span></div>
|
||||
<div class="h-center">
|
||||
<div class="h-title">ROMFAST <span class="accent">AUTOPASS</span><span class="env">test</span><span class="tier">Pro</span></div>
|
||||
<div class="h-sub">Service auto: <span class="svc">Service Auto Vâlcea SRL</span></div>
|
||||
</div>
|
||||
<div class="h-right">
|
||||
<div class="rar-chip" title="Ultima autentificare RAR: 28.06.2026 09:41"><span class="dot"></span> RAR online</div>
|
||||
<button class="tema-btn" onclick="cycle()">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||
<span id="t-label">Grafit</span>
|
||||
</button>
|
||||
<span class="ver">v5.16</span>
|
||||
<button class="icon-btn" title="Meniu cont">☰
|
||||
<div class="menu">
|
||||
<div class="menu-status"><span class="rar-chip" style="height:auto;padding:0;border:none;background:none;"><span class="dot"></span></span> RAR online <small>· 09:41</small></div>
|
||||
<div class="menu-plan">Plan: <b>Pro</b> <span class="trial">trial · 18 zile rămase</span></div>
|
||||
<hr>
|
||||
<a href="#">Trimiteri</a>
|
||||
<a href="#">Mapări <span class="badge">2</span></a>
|
||||
<hr>
|
||||
<a href="#">Nomenclator</a>
|
||||
<hr>
|
||||
<a href="#">Cont</a>
|
||||
<a href="#">Integrare</a>
|
||||
<a href="#">Jurnal</a>
|
||||
<hr>
|
||||
<a href="#">Ieși din cont</a>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<!-- CARDURI (fara titlu de sectiune; RAR online e acum dot in antet) -->
|
||||
<div class="contoare">
|
||||
<div class="contor-card primar"><div class="contor-cifra s-ok">847</div><div class="contor-label">Total trimise</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-ok">124</div><div class="contor-label">Luna asta</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-ok">9</div><div class="contor-label">Azi</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-acc">12</div><div class="contor-label">În coadă</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-err">2</div><div class="contor-label">De corectat</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. IMPORT colapsat -->
|
||||
<details class="import-collapse">
|
||||
<summary>
|
||||
<span class="ic-l"><span class="plus">+</span> Importă fișier (XLSX / CSV)</span>
|
||||
<span class="ic-r">trage-l aici sau apasă pentru a deschide ▾</span>
|
||||
</summary>
|
||||
<div class="import-body">
|
||||
<div><div class="u-tx">Încarcă un fișier sau trage-l aici</div><div class="u-sub">Mapezi coloanele o singură dată — apoi trimitem la RAR automat.</div></div>
|
||||
<button class="btn-primary">Alege fișier</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 4 + 5. NAV + LISTA -->
|
||||
<div>
|
||||
<div class="subnav">
|
||||
<a href="#" class="active">Trimiteri</a>
|
||||
<a href="#">Mapări <span class="badge">2</span></a>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="filtre">
|
||||
<button class="pillf on">Toate</button>
|
||||
<button class="pillf">În coadă</button>
|
||||
<button class="pillf">Trimise</button>
|
||||
<button class="pillf">De corectat</button>
|
||||
</div>
|
||||
<div class="rand"><div><div class="slim-vin">WBA8E9...K7F2</div><div class="slim-meta">Inspecție tehnică · 09:42</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">WVWZZZ...3M1</div><div class="slim-meta">Revizie periodică · 09:38</div></div><span class="pill coada"><span class="pdot"></span>În coadă</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">VF1RFB...A88</div><div class="slim-meta">Sistem frânare · 09:31</div></div><span class="pill err"><span class="pdot"></span>De corectat</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">ZAR937...C04</div><div class="slim-meta">Schimb ulei · 09:24</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">JTDBR...9920</div><div class="slim-meta">Inspecție tehnică · 09:18</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DOAR cand e BLOCAT: banda rosie reapare (zero-silent-failures) -->
|
||||
<div style="margin-top:18px; font-size:var(--fs-xs); text-transform:uppercase; letter-spacing:.08em; color:var(--err); font-weight:700;">Stare BLOCAT — banda apare DOAR atunci (worker oprit / RAR inaccesibil)</div>
|
||||
<div class="strip blocat">
|
||||
<span class="strip-left"><span class="dot"></span> Blocat: RAR inaccesibil — declarațiile NU pleacă</span>
|
||||
<span class="strip-right">Ultima autentificare RAR: 28.06.2026 09:41</span>
|
||||
</div>
|
||||
|
||||
<!-- MODAL editare: apare la click pe o trimitere nefinalizata (needs_data / needs_mapping / error) -->
|
||||
<div style="margin-top:22px; font-size:var(--fs-xs); text-transform:uppercase; letter-spacing:.08em; color:var(--accent); font-weight:700;">Modal editare — la click pe o trimitere nefinalizată (needs_data / needs_mapping)</div>
|
||||
<div class="editmodal" style="margin-top:8px;">
|
||||
<div class="mhead"><span class="t">Corectează trimiterea</span><button class="icon-btn" title="Închide">×</button></div>
|
||||
<div class="mbody">
|
||||
<div class="field"><label>VIN (serie șasiu)</label><input class="mono" value="VF1RFB00A88142073"></div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>Data prestației</label><input class="mono" value="2026-06-22"></div>
|
||||
<div class="field"><label>Număr înmatriculare</label><input class="mono" value="CT88NOE"></div>
|
||||
</div>
|
||||
<div class="field"><label>Observații (operațiile efectuate)</label><textarea rows="2">Schimbare plăcuțe frână față</textarea></div>
|
||||
<div class="field">
|
||||
<label>Prestații — cod RAR pe fiecare operație</label>
|
||||
<div class="op-row"><span class="op-name">REVIZIE PERIODICĂ <small>— la 15.000 km</small></span><span class="chip">REV2 <button>×</button></span></div>
|
||||
<div class="op-row" style="border-left:2px solid var(--warn); padding-left:10px;"><span class="op-name">SCHIMB PLĂCUȚE FRÂNĂ <small style="color:var(--warn)">— lipsă cod</small></span><select><option>— alege cod RAR —</option><option>FRN1 — Sistem de frânare</option><option>REV2 — Revizie periodică</option></select></div>
|
||||
<div style="margin-top:10px;"><button class="addcode">+ Adaugă altă operație / cod RAR</button></div>
|
||||
</div>
|
||||
<div class="actrow"><button class="btn-primary">Salvează și retrimite</button><button class="btn-ghost">Renunță</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var THEMES=[['grafit','Grafit'],['cobalt','Cobalt'],['cupru','Cupru'],['hartie','Hârtie']];
|
||||
var i=0;
|
||||
function cycle(){ i=(i+1)%THEMES.length; document.body.setAttribute('data-theme',THEMES[i][0]); document.getElementById('t-label').textContent=THEMES[i][1]; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
187
docs/mockups/prd-5.16-fonturi-system-stack.html
Normal file
187
docs/mockups/prd-5.16-fonturi-system-stack.html
Normal file
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.16 — Preview fonturi system-stack + scala tipografica</title>
|
||||
<style>
|
||||
/* ============================================================
|
||||
PROPUNERE 5.16: fonturi STANDARD WEB (system font stack).
|
||||
ZERO fisiere de font descarcate. Arata nativ pe fiecare OS.
|
||||
Inlocuieste IBM Plex self-hostat din /static/fonts.
|
||||
============================================================ */
|
||||
:root{
|
||||
/* Stive de font standard web (fara @font-face, fara /static/fonts) */
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||
|
||||
/* SCALA TIPOGRAFICA UNIFORMA (sursa unica de adevar; azi e ad-hoc 10/11/13px) */
|
||||
--fs-xs: 12px; /* meta, sub-linii mono, hint-uri (azi: 10px) */
|
||||
--fs-sm: 13.5px; /* label-uri formular, pill-uri (azi: 11px) */
|
||||
--fs-base: 15px; /* text body implicit (azi: ~13px) */
|
||||
--fs-md: 16px; /* input-uri, text card (azi: 13px) */
|
||||
--fs-lg: 18px; /* titluri de sectiune mici */
|
||||
--fs-xl: 20px; /* sub-titluri */
|
||||
--fs-2xl: 28px; /* cifra contor (azi: 22px) */
|
||||
--fs-3xl: 34px; /* titlu pagina */
|
||||
--lh-tight: 1.25;
|
||||
--lh-body: 1.55;
|
||||
|
||||
/* paleta grafit (din DESIGN.md) — doar pentru context vizual */
|
||||
--bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7;
|
||||
--line:#262b36; --line2:#1f2530; --accent:#6ea2ec; --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D;
|
||||
}
|
||||
body[data-theme="hartie"]{
|
||||
--bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052;
|
||||
--line:#e2dccc; --line2:#ece6d9; --accent:#1F5FBF; --ok:#1c7d5d; --warn:#b45309; --err:#bd463c;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{
|
||||
margin:0; background:var(--bg); color:var(--ink);
|
||||
font-family:var(--font-ui);
|
||||
font-size:var(--fs-base); line-height:var(--lh-body);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{max-width:1100px; margin:0 auto; padding:28px 22px 80px;}
|
||||
.mono{font-family:var(--font-mono);}
|
||||
h1{font-size:var(--fs-3xl); line-height:var(--lh-tight); margin:0 0 6px; letter-spacing:-.02em;}
|
||||
.lead{color:var(--muted); font-size:var(--fs-md); margin:0 0 22px;}
|
||||
.sec{font-size:var(--fs-lg); margin:34px 0 12px; padding-bottom:6px; border-bottom:1px solid var(--line);}
|
||||
.toolbar{display:flex; gap:10px; align-items:center; margin-bottom:8px;}
|
||||
.toolbar button{font-family:var(--font-ui); font-size:var(--fs-sm); height:36px; padding:0 14px;
|
||||
border-radius:7px; border:1px solid var(--line); background:var(--card); color:var(--ink); cursor:pointer;}
|
||||
.note{font-size:var(--fs-sm); color:var(--muted); margin:2px 0 0;}
|
||||
|
||||
/* ---- carduri-contor (aerisite, text mai mare) ---- */
|
||||
.contoare{display:grid; grid-template-columns:repeat(3,1fr); gap:14px;}
|
||||
.contor-card{background:var(--card2); border:1px solid var(--line); border-radius:12px; padding:18px 18px;}
|
||||
.contor-cifra{font-size:var(--fs-2xl); font-weight:700; line-height:1;}
|
||||
.contor-label{font-size:var(--fs-sm); color:var(--muted); margin-top:8px;}
|
||||
.contor-sub{font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted); margin-top:4px;}
|
||||
.s-ok{color:var(--ok);} .s-acc{color:var(--accent);} .s-err{color:var(--err);} .s-muted{color:var(--muted);}
|
||||
|
||||
/* ---- strip sanatate cu DOT (nu bifa) pentru RAR online ---- */
|
||||
.strip{display:flex; align-items:center; justify-content:space-between; gap:12px;
|
||||
padding:12px 16px; border-radius:10px; margin-bottom:14px;
|
||||
background:color-mix(in srgb, var(--ok) 13%, transparent);
|
||||
border:1px solid color-mix(in srgb, var(--ok) 30%, transparent);}
|
||||
.strip-left{display:flex; align-items:center; gap:10px; font-weight:700; font-size:var(--fs-md);}
|
||||
.dot{width:10px; height:10px; border-radius:99px; background:var(--ok); flex-shrink:0;
|
||||
box-shadow:0 0 0 4px color-mix(in srgb, var(--ok) 22%, transparent);}
|
||||
.dot.live{animation:pulse 2s ease-in-out infinite;}
|
||||
@keyframes pulse{0%,100%{opacity:1;} 50%{opacity:.55;}}
|
||||
.strip-right{font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted);}
|
||||
|
||||
/* ---- lista slim ---- */
|
||||
.lista{background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden; margin-top:14px;}
|
||||
.rand{display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid var(--line2);}
|
||||
.rand:last-child{border-bottom:none;}
|
||||
.slim-vin{font-family:var(--font-mono); font-size:var(--fs-md); font-weight:500;}
|
||||
.slim-meta{font-size:var(--fs-sm); color:var(--muted); margin-top:3px;}
|
||||
.pill{display:inline-flex; align-items:center; gap:7px; padding:5px 12px; border-radius:99px; font-size:var(--fs-sm); font-weight:500;}
|
||||
.pill .pdot{width:7px; height:7px; border-radius:99px;}
|
||||
.pill.sent{background:color-mix(in srgb,var(--ok) 14%,transparent); color:var(--ok);}
|
||||
.pill.sent .pdot{background:var(--ok);}
|
||||
.pill.coada{background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent);}
|
||||
.pill.coada .pdot{background:var(--accent);}
|
||||
.pill.err{background:color-mix(in srgb,var(--err) 14%,transparent); color:var(--err);}
|
||||
.pill.err .pdot{background:var(--err);}
|
||||
|
||||
/* ---- formular editare slim ---- */
|
||||
.form-card{background:var(--card); border:1px solid var(--line); border-radius:12px; padding:22px; margin-top:14px; max-width:560px;}
|
||||
.camp{margin-bottom:14px;}
|
||||
.camp label{display:block; font-size:var(--fs-sm); color:var(--muted); margin-bottom:6px;}
|
||||
.camp input, .camp textarea, .camp select{
|
||||
width:100%; font-family:var(--font-ui); font-size:var(--fs-md); color:var(--ink);
|
||||
background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:9px 12px; min-height:40px;}
|
||||
.camp input.mono{font-family:var(--font-mono);}
|
||||
.grid2{display:grid; grid-template-columns:1fr 1fr; gap:12px;}
|
||||
.op-row{display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 0; border-bottom:1px solid var(--line2);}
|
||||
.op-name{font-size:var(--fs-md); font-weight:600;}
|
||||
.op-name small{font-weight:400; color:var(--muted); font-size:var(--fs-sm);}
|
||||
.chip{display:inline-flex; align-items:center; gap:6px; font-family:var(--font-mono); font-size:var(--fs-sm);
|
||||
background:color-mix(in srgb,var(--accent) 18%,transparent); color:var(--accent); padding:5px 10px; border-radius:7px;}
|
||||
.chip button{background:none; border:none; color:inherit; cursor:pointer; font-size:var(--fs-md); line-height:1;}
|
||||
.addcode{font-size:var(--fs-sm); border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));
|
||||
background:transparent; color:var(--accent); border-radius:7px; padding:6px 12px; cursor:pointer;}
|
||||
.btn-primary{font-family:var(--font-ui); font-size:var(--fs-md); font-weight:600; height:42px; padding:0 20px;
|
||||
background:var(--accent); color:#fff; border:none; border-radius:8px; cursor:pointer;}
|
||||
.btn-ghost{font-family:var(--font-ui); font-size:var(--fs-md); height:42px; padding:0 18px;
|
||||
background:transparent; color:var(--ink); border:1px solid var(--line); border-radius:8px; cursor:pointer;}
|
||||
|
||||
/* tabel scala — referinta rapida */
|
||||
table.scala{width:100%; border-collapse:collapse; font-size:var(--fs-sm); margin-top:8px;}
|
||||
table.scala td{padding:7px 10px; border-bottom:1px solid var(--line2);}
|
||||
table.scala td:first-child{font-family:var(--font-mono); color:var(--accent); white-space:nowrap;}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
<div class="wrap">
|
||||
<div class="toolbar">
|
||||
<button onclick="document.body.setAttribute('data-theme', document.body.getAttribute('data-theme')==='grafit'?'hartie':'grafit')">Comuta tema (grafit / hartie)</button>
|
||||
<span class="note">Fonturi: <span class="mono">system-ui, -apple-system, Segoe UI, Roboto…</span> — zero fisiere descarcate.</span>
|
||||
</div>
|
||||
|
||||
<h1>Gateway RAR AUTOPASS</h1>
|
||||
<p class="lead">Preview tipografie 5.16 — font stack nativ + scala uniforma, carduri aerisite, text mai mare.</p>
|
||||
|
||||
<div class="sec">Scala tipografica unica (tokeni)</div>
|
||||
<table class="scala">
|
||||
<tr><td>--fs-xs 12px</td><td style="font-size:var(--fs-xs)">Meta, hint-uri, sub-linii mono (azi 10px — prea mic)</td></tr>
|
||||
<tr><td>--fs-sm 13.5px</td><td style="font-size:var(--fs-sm)">Label-uri formular, pill-uri de stare (azi 11px)</td></tr>
|
||||
<tr><td>--fs-base 15px</td><td style="font-size:var(--fs-base)">Text body implicit pe toate paginile</td></tr>
|
||||
<tr><td>--fs-md 16px</td><td style="font-size:var(--fs-md)">Input-uri, VIN mono, text de card (azi 13px)</td></tr>
|
||||
<tr><td>--fs-2xl 28px</td><td style="font-size:var(--fs-2xl);font-weight:700">Cifra contor (azi 22px)</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="sec">Dashboard — strip sanatate (DOT, nu bifa) + carduri-contor</div>
|
||||
<div class="strip">
|
||||
<span class="strip-left"><span class="dot live"></span> RAR online · declaratiile curg normal</span>
|
||||
<span class="strip-right">Ultima autentificare RAR: 28.06.2026 09:41</span>
|
||||
</div>
|
||||
<div class="contoare">
|
||||
<div class="contor-card"><div class="contor-cifra s-ok">847</div><div class="contor-label">Trimise (total)</div><div class="contor-sub">luna 124 · azi 9</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-acc">12</div><div class="contor-label">In coada</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-muted">0</div><div class="contor-label">De corectat</div></div>
|
||||
</div>
|
||||
|
||||
<div class="sec">Lista trimiteri — rand slim</div>
|
||||
<div class="lista">
|
||||
<div class="rand"><div><div class="slim-vin">WBA8E9...K7F2</div><div class="slim-meta">Inspectie tehnica · 09:42</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">WVWZZZ...3M1</div><div class="slim-meta">Revizie periodica · 09:38</div></div><span class="pill coada"><span class="pdot"></span>In coada</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">VF1RFB...A88</div><div class="slim-meta">Sistem franare · 09:31</div></div><span class="pill err"><span class="pdot"></span>De corectat</span></div>
|
||||
</div>
|
||||
|
||||
<div class="sec">Formular editare — denumiri operatii in picker + adaugare operatie</div>
|
||||
<div class="form-card">
|
||||
<div class="camp"><label>VIN (serie sasiu)</label><input class="mono" value="WBA8E9C5K7F20143"></div>
|
||||
<div class="grid2">
|
||||
<div class="camp"><label>Data prestatiei</label><input class="mono" value="2026-06-22"></div>
|
||||
<div class="camp"><label>Numar inmatriculare</label><input class="mono" value="CT88NOE"></div>
|
||||
</div>
|
||||
<div class="camp"><label>Observatii (operatiile efectuate)</label><textarea rows="2">Revizie; schimbare placute frana</textarea></div>
|
||||
|
||||
<div class="camp">
|
||||
<label>Prestatii — cod RAR pe fiecare operatie</label>
|
||||
<div class="op-row">
|
||||
<span class="op-name">REVIZIE PERIODICA <small>— revizie la 15.000 km</small></span>
|
||||
<span style="display:flex;gap:8px;align-items:center;"><span class="chip">REV2 <button>×</button></span></span>
|
||||
</div>
|
||||
<div class="op-row" style="border-left:2px solid var(--warn); padding-left:10px;">
|
||||
<span class="op-name">SCHIMB PLACUTE FRANA <small style="color:var(--warn)">— lipsa cod</small></span>
|
||||
<select><option>— alege cod RAR —</option><option>FRN1 — Sistem de franare</option><option>REV2 — Revizie periodica</option></select>
|
||||
</div>
|
||||
<div style="margin-top:10px;"><button class="addcode">+ Adauga alta operatie / cod RAR</button></div>
|
||||
<p class="note">Picker-ul arata <strong>cod + denumire</strong> (FRN1 — Sistem de franare), nu doar codul.</p>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:10px; margin-top:18px;">
|
||||
<button class="btn-primary">Salveaza si retrimite</button>
|
||||
<button class="btn-ghost">Renunta</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note" style="margin-top:30px;">Nota: tema/culorile sunt doar context. Subiectul acestui preview e <strong>fontul</strong> (system-ui) si <strong>scala</strong> (dimensiuni mai mari, uniforme). Deschide pe Windows si pe Mac ca sa vezi cum cade fontul nativ pe fiecare.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
173
docs/mockups/prd-5.16-header-login-tema.html
Normal file
173
docs/mockups/prd-5.16-header-login-tema.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.16 — Header profesional + /login + selector tema stil landing</title>
|
||||
<style>
|
||||
:root{
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px; --fs-lg:18px; --fs-xl:20px; --fs-2xl:28px;
|
||||
--bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7;
|
||||
--line:#262b36; --line2:#1f2530; --accent:#6ea2ec; --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D;
|
||||
--hbg:rgba(15,18,24,.88);
|
||||
}
|
||||
body[data-theme="hartie"]{
|
||||
--bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052;
|
||||
--line:#e2dccc; --line2:#ece6d9; --accent:#1F5FBF; --ok:#1c7d5d; --warn:#b45309; --err:#bd463c;
|
||||
--hbg:rgba(255,253,247,.9);
|
||||
}
|
||||
body[data-theme="cobalt"]{ --bg:#080d1c; --card:#111a33; --card2:#0b1226; --ink:#e9ecfb; --muted:#8a93b8; --line:#1d2747; --line2:#161f3a; --accent:#8aa0ff; --ok:#2fd0a6; --err:#f06a7a; --hbg:rgba(8,13,28,.9); }
|
||||
body[data-theme="cupru"]{ --bg:#15110b; --card:#211a12; --card2:#15110b; --ink:#efe6d6; --muted:#a89a85; --line:#36291c; --line2:#281e14; --accent:#dfa45c; --ok:#67b98c; --err:#e2685a; --hbg:rgba(21,17,11,.9); }
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0; background:var(--bg); color:var(--ink); font-family:var(--font-ui); font-size:var(--fs-base); -webkit-font-smoothing:antialiased;}
|
||||
.mono{font-family:var(--font-mono);}
|
||||
.muted{color:var(--muted);}
|
||||
|
||||
/* ===== HEADER aplicatie (logat) — profesional, branded ===== */
|
||||
header{
|
||||
display:grid; grid-template-columns:1fr auto 1fr; align-items:center;
|
||||
gap:16px; height:64px; padding:0 22px; background:var(--card); border:1px solid var(--line); border-radius:12px;
|
||||
}
|
||||
/* antet MINIMAL pe /login (neautentificat): doar logo + titlu + tema */
|
||||
.login-topbar{display:flex; align-items:center; justify-content:space-between; gap:16px; height:60px; padding:0 22px; background:var(--card); border:1px solid var(--line); border-radius:12px 12px 0 0; border-bottom:none;}
|
||||
.login-topbar .lt-brand{display:flex; align-items:center; gap:10px; font-weight:700; font-size:var(--fs-md);}
|
||||
.login-topbar .lt-brand .accent{color:var(--accent);}
|
||||
.h-left{display:flex; align-items:center; gap:12px;}
|
||||
.logo{height:32px; width:auto; display:block;}
|
||||
/* wordmark fallback in mockup (in app: PNG real ROMFAST) */
|
||||
.logo-fallback{display:inline-flex; align-items:center; gap:7px; font-weight:800; letter-spacing:-.01em; font-size:var(--fs-lg);}
|
||||
.logo-fallback .rom{color:#D1342F;} .logo-fallback .fast{color:var(--accent);}
|
||||
.h-center{text-align:center; line-height:1.15;}
|
||||
.h-title{font-size:var(--fs-md); font-weight:700; letter-spacing:.01em;}
|
||||
.h-title .accent{color:var(--accent);}
|
||||
.h-sub{font-size:var(--fs-xs); color:var(--muted); margin-top:2px;}
|
||||
.h-sub .svc{color:var(--ink); font-weight:600;}
|
||||
.env{display:inline-block; margin-left:8px; padding:1px 7px; border-radius:99px; font-size:10px; font-weight:700;
|
||||
text-transform:uppercase; letter-spacing:.04em; color:var(--warn); background:color-mix(in srgb,var(--warn) 16%,transparent);}
|
||||
.tier{display:inline-block; margin-left:6px; padding:1px 8px; border-radius:99px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--accent); background:color-mix(in srgb,var(--accent) 16%,transparent);}
|
||||
.h-right{display:flex; align-items:center; justify-content:flex-end; gap:10px;}
|
||||
.rar-chip{display:flex; align-items:center; gap:8px; height:38px; padding:0 13px; border-radius:99px; border:1px solid color-mix(in srgb,var(--ok) 35%,var(--line)); background:color-mix(in srgb,var(--ok) 10%,transparent); color:var(--ok); font-size:var(--fs-sm); font-weight:600; cursor:default;}
|
||||
.rar-chip .dot{width:9px; height:9px; border-radius:99px; background:currentColor; box-shadow:0 0 0 4px color-mix(in srgb,currentColor 22%,transparent);}
|
||||
|
||||
/* selector tema STIL LANDING: pill cu icon + eticheta tema curenta */
|
||||
.tema-btn{display:flex; align-items:center; gap:8px; height:38px; padding:0 13px; border-radius:8px;
|
||||
background:transparent; border:1px solid var(--line); color:var(--muted); font-family:var(--font-ui);
|
||||
font-size:var(--fs-sm); cursor:pointer; transition:border-color .15s, color .15s;}
|
||||
.tema-btn:hover{border-color:var(--accent); color:var(--ink);}
|
||||
.tema-btn svg{flex-shrink:0;}
|
||||
.ver{font-size:var(--fs-xs); color:var(--muted);}
|
||||
.icon-btn{width:38px; height:38px; border-radius:8px; border:1px solid var(--line); background:transparent;
|
||||
color:var(--ink); font-size:18px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center;}
|
||||
|
||||
.wrap{max-width:1100px; margin:0 auto; padding:24px 22px 60px;}
|
||||
.sec{font-size:var(--fs-lg); margin:30px 0 12px; padding-bottom:6px; border-bottom:1px solid var(--line);}
|
||||
.note{font-size:var(--fs-sm); color:var(--muted);}
|
||||
.toolbar{display:flex; gap:10px; align-items:center; margin:14px 0;}
|
||||
.toolbar button{font-family:var(--font-ui); font-size:var(--fs-sm); height:34px; padding:0 12px; border-radius:7px; border:1px solid var(--line); background:var(--card); color:var(--ink); cursor:pointer;}
|
||||
|
||||
/* ===== /login profesional ===== */
|
||||
.login-shell{min-height:520px; display:grid; grid-template-columns:1.1fr .9fr; border:1px solid var(--line); border-radius:16px; overflow:hidden; background:var(--card);}
|
||||
.login-aside{padding:40px 38px; background:linear-gradient(160deg, color-mix(in srgb,var(--accent) 14%,var(--card)), var(--card)); border-right:1px solid var(--line); display:flex; flex-direction:column; justify-content:center;}
|
||||
.login-brand{display:flex; align-items:center; gap:10px; margin-bottom:22px;}
|
||||
.login-brand .logo-fallback{font-size:var(--fs-xl);}
|
||||
.login-aside h2{font-size:var(--fs-2xl); line-height:1.2; margin:0 0 12px; letter-spacing:-.02em;}
|
||||
.login-aside p{font-size:var(--fs-md); color:var(--muted); line-height:1.6; margin:0 0 18px; max-width:380px;}
|
||||
.trust{display:flex; flex-direction:column; gap:9px; margin-top:6px;}
|
||||
.trust div{display:flex; align-items:center; gap:9px; font-size:var(--fs-sm); color:var(--ink);}
|
||||
.trust svg{flex-shrink:0; color:var(--ok);}
|
||||
.login-form{padding:40px 38px; display:flex; flex-direction:column; justify-content:center;}
|
||||
.login-form h3{font-size:var(--fs-xl); margin:0 0 4px;}
|
||||
.login-form .lead{font-size:var(--fs-sm); color:var(--muted); margin:0 0 22px;}
|
||||
.field{margin-bottom:16px;}
|
||||
.field label{display:block; font-size:var(--fs-sm); color:var(--muted); margin-bottom:6px;}
|
||||
.field input{width:100%; font-family:var(--font-ui); font-size:var(--fs-md); color:var(--ink); background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:11px 13px; min-height:44px;}
|
||||
.field input:focus{outline:2px solid var(--accent); border-color:var(--accent);}
|
||||
.btn-primary{width:100%; height:46px; font-family:var(--font-ui); font-size:var(--fs-md); font-weight:600; background:var(--accent); color:#fff; border:none; border-radius:8px; cursor:pointer; margin-top:4px;}
|
||||
.row-between{display:flex; align-items:center; justify-content:space-between; margin:-4px 0 18px;}
|
||||
.link{color:var(--accent); font-size:var(--fs-sm); text-decoration:none;}
|
||||
.login-foot{text-align:center; font-size:var(--fs-sm); color:var(--muted); margin-top:18px;}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
|
||||
<div class="wrap">
|
||||
<div class="toolbar">
|
||||
<span class="note">Comuta tema cu butonul de tema (stil landing: icon + eticheta).</span>
|
||||
</div>
|
||||
|
||||
<!-- ===== A. Antet aplicatie — LOGAT ===== -->
|
||||
<div class="sec">Antet aplicatie — LOGAT (branded)</div>
|
||||
<header>
|
||||
<div class="h-left">
|
||||
<span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span>
|
||||
<span class="note" style="font-size:var(--fs-xs)">(in app: PNG logo real)</span>
|
||||
</div>
|
||||
<div class="h-center">
|
||||
<div class="h-title">ROMFAST <span class="accent">AUTOPASS</span><span class="env">test</span><span class="tier">Pro</span></div>
|
||||
<div class="h-sub">Service auto: <span class="svc">Service Auto Vâlcea SRL</span></div>
|
||||
</div>
|
||||
<div class="h-right">
|
||||
<div class="rar-chip" title="Ultima autentificare RAR: 28.06.2026 09:41"><span class="dot"></span> RAR online</div>
|
||||
<button class="tema-btn" onclick="cycle()">
|
||||
<svg id="t-ic" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||
<span id="t-label">Grafit</span>
|
||||
</button>
|
||||
<span class="ver">v5.16</span>
|
||||
<button class="icon-btn" title="Meniu cont">☰</button>
|
||||
</div>
|
||||
</header>
|
||||
<p class="note">Doar cand esti LOGAT: titlu <strong>ROMFAST AUTOPASS</strong> + badge plan
|
||||
(<span class="mono">accounts.tier</span>) + sub titlu numele service-ului (<span class="mono">accounts.name</span>);
|
||||
dreapta dot <strong>RAR online</strong> + selector tema + meniu cont. Toate gate-uite pe
|
||||
<span class="mono">is_authenticated</span>.</p>
|
||||
|
||||
<!-- ===== B. /login — NEAUTENTIFICAT (antet minimal) ===== -->
|
||||
<div class="sec">Pagina /login — NEAUTENTIFICAT (antet minimal)</div>
|
||||
<div class="login-topbar">
|
||||
<span class="lt-brand"><span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span> ROMFAST <span class="accent">AUTOPASS</span></span>
|
||||
<button class="tema-btn" onclick="cycle()">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||
<span id="t-label2">Grafit</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="login-shell" style="border-radius:0 0 16px 16px; border-top:none;">
|
||||
<div class="login-aside">
|
||||
<div class="login-brand"><span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span></div>
|
||||
<h2>ROMFAST <span style="color:var(--accent)">AUTOPASS</span></h2>
|
||||
<p>Declară prestațiile de service-auto la RAR AUTOPASS, automat. Conform Legii 142/2023.</p>
|
||||
<div class="trust">
|
||||
<div><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M20 6L9 17l-5-5"/></svg> Conform Legii 142/2023 și OMTI 210/2024</div>
|
||||
<div><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg> Datele tale criptate, șterse la 3 luni</div>
|
||||
<div><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg> Parte din familia ROA — Romfast Applications</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-form">
|
||||
<h3>Autentificare</h3>
|
||||
<p class="lead">Intră în contul service-ului tău.</p>
|
||||
<div class="field"><label>Email</label><input type="email" value="contact@service-valcea.ro"></div>
|
||||
<div class="field"><label>Parolă</label><input type="password" value="••••••••••"></div>
|
||||
<div class="row-between"><span></span><a class="link" href="#">Ai uitat parola?</a></div>
|
||||
<button class="btn-primary">Intră în cont</button>
|
||||
<div class="login-foot">Cont nou? <a class="link" href="/signup">Înregistrează service-ul</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note">Antetul de <span class="mono">/login</span> NU are dot RAR, nume service sau badge plan —
|
||||
utilizatorul nu e logat inca. Doar logo + titlu <strong>ROMFAST AUTOPASS</strong> + selector tema.
|
||||
(RAR/service/plan/meniu apar abia dupa autentificare.)</p>
|
||||
|
||||
<div class="sec">Landing — butonul „Autentificare" duce la /login</div>
|
||||
<p class="note">Pe landing, „Autentificare" (azi deschide modalul de register din landing pe tab-ul
|
||||
login) devine un link real către <span class="mono">/login</span> (pagina de mai sus). „Creează cont"
|
||||
rămâne neschimbat. Selectorul de teme din landing e exact modelul pe care îl preia aplicația.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var THEMES=[['grafit','Grafit'],['cobalt','Cobalt'],['cupru','Cupru'],['hartie','Hârtie']];
|
||||
var i=0;
|
||||
function cycle(){ i=(i+1)%THEMES.length; document.body.setAttribute('data-theme',THEMES[i][0]); document.getElementById('t-label').textContent=THEMES[i][1]; var l2=document.getElementById('t-label2'); if(l2)l2.textContent=THEMES[i][1]; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
275
docs/mockups/prd-5.16-import-wizard.html
Normal file
275
docs/mockups/prd-5.16-import-wizard.html
Normal file
@@ -0,0 +1,275 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.16 — Wizard import fișier (4 pași) + editare/corecție</title>
|
||||
<style>
|
||||
:root{
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px; --fs-lg:18px; --fs-xl:20px; --fs-2xl:28px;
|
||||
--bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7;
|
||||
--line:#262b36; --line2:#1f2530; --accent:#6ea2ec; --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D;
|
||||
--hbg:rgba(15,18,24,.9);
|
||||
}
|
||||
body[data-theme="hartie"]{ --bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052; --line:#e2dccc; --line2:#ece6d9; --accent:#1F5FBF; --ok:#1c7d5d; --warn:#b45309; --err:#bd463c; --hbg:rgba(255,253,247,.92); }
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0; background:var(--bg); color:var(--ink); font-family:var(--font-ui); font-size:var(--fs-base); -webkit-font-smoothing:antialiased;}
|
||||
.mono{font-family:var(--font-mono);} .muted{color:var(--muted);}
|
||||
|
||||
header{position:sticky; top:0; z-index:5; display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:16px; height:64px; padding:0 22px; background:var(--hbg); backdrop-filter:blur(8px); border-bottom:1px solid var(--line);}
|
||||
.logo-fallback{display:inline-flex; align-items:center; gap:6px; font-weight:800; font-size:var(--fs-lg);}
|
||||
.logo-fallback .rom{color:#D1342F;} .logo-fallback .fast{color:var(--accent);}
|
||||
.h-center{text-align:center; line-height:1.15;}
|
||||
.h-title{font-size:var(--fs-md); font-weight:700;} .h-title .accent{color:var(--accent);}
|
||||
.h-sub{font-size:var(--fs-xs); color:var(--muted); margin-top:2px;} .h-sub .svc{color:var(--ink); font-weight:600;}
|
||||
.env{display:inline-block; margin-left:8px; padding:1px 7px; border-radius:99px; font-size:10px; font-weight:700; text-transform:uppercase; color:var(--warn); background:color-mix(in srgb,var(--warn) 16%,transparent);}
|
||||
.tier{display:inline-block; margin-left:6px; padding:1px 8px; border-radius:99px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--accent); background:color-mix(in srgb,var(--accent) 16%,transparent);}
|
||||
.h-right{display:flex; align-items:center; justify-content:flex-end; gap:10px;}
|
||||
.rar-chip{display:flex; align-items:center; gap:8px; height:38px; padding:0 13px; border-radius:99px; border:1px solid color-mix(in srgb,var(--ok) 35%,var(--line)); background:color-mix(in srgb,var(--ok) 10%,transparent); color:var(--ok); font-size:var(--fs-sm); font-weight:600; cursor:default;}
|
||||
.rar-chip .dot{width:9px; height:9px; border-radius:99px; background:currentColor; box-shadow:0 0 0 4px color-mix(in srgb,currentColor 22%,transparent);}
|
||||
.tema-btn{display:flex; align-items:center; gap:8px; height:38px; padding:0 13px; border-radius:8px; background:transparent; border:1px solid var(--line); color:var(--muted); font-size:var(--fs-sm); cursor:pointer;}
|
||||
.icon-btn{width:38px; height:38px; border-radius:8px; border:1px solid var(--line); background:transparent; color:var(--ink); font-size:18px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center;}
|
||||
|
||||
.wrap{max-width:1000px; margin:0 auto; padding:22px 22px 70px;}
|
||||
.screen-cap{font-size:var(--fs-xs); text-transform:uppercase; letter-spacing:.08em; color:var(--accent); font-weight:700; margin:30px 0 10px;}
|
||||
|
||||
/* stepper slim */
|
||||
.stepper{display:flex; align-items:center; gap:0; background:var(--card); border:1px solid var(--line); border-radius:11px; padding:6px; margin-bottom:14px;}
|
||||
.step{flex:1; display:flex; align-items:center; gap:9px; padding:9px 12px; border-radius:8px; font-size:var(--fs-sm);}
|
||||
.step .num{display:inline-flex; width:24px; height:24px; align-items:center; justify-content:center; border-radius:99px; font-size:var(--fs-sm); font-weight:700; background:var(--card2); border:1px solid var(--line); color:var(--muted); flex-shrink:0;}
|
||||
.step.done .num{background:color-mix(in srgb,var(--ok) 20%,transparent); border-color:transparent; color:var(--ok);}
|
||||
.step.active{background:color-mix(in srgb,var(--accent) 14%,transparent);}
|
||||
.step.active .num{background:var(--accent); border-color:transparent; color:#fff;}
|
||||
.step.active .t{color:var(--ink); font-weight:600;} .step .t{color:var(--muted);}
|
||||
.step .sep{color:var(--line);}
|
||||
|
||||
.panel{background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden;}
|
||||
.panel-head{padding:16px 18px; border-bottom:1px solid var(--line);}
|
||||
.panel-head h3{margin:0; font-size:var(--fs-lg);}
|
||||
.panel-head p{margin:4px 0 0; font-size:var(--fs-sm); color:var(--muted);}
|
||||
.panel-body{padding:18px;}
|
||||
.foot{display:flex; align-items:center; justify-content:space-between; gap:12px; padding:14px 18px; border-top:1px solid var(--line); background:var(--card2);}
|
||||
.btn-primary{font-size:var(--fs-md); font-weight:600; height:44px; padding:0 22px; background:var(--accent); color:#fff; border:none; border-radius:9px; cursor:pointer;}
|
||||
.btn-ghost{font-size:var(--fs-md); height:44px; padding:0 18px; background:transparent; color:var(--ink); border:1px solid var(--line); border-radius:9px; cursor:pointer;}
|
||||
|
||||
/* PAS 1 — drop zone */
|
||||
.drop{border:2px dashed color-mix(in srgb,var(--accent) 45%,var(--line)); border-radius:12px; padding:46px 20px; text-align:center; background:var(--card2);}
|
||||
.drop .ic{width:54px; height:54px; border-radius:12px; margin:0 auto 14px; display:flex; align-items:center; justify-content:center; background:color-mix(in srgb,var(--accent) 14%,transparent); color:var(--accent);}
|
||||
.drop .big{font-size:var(--fs-lg); font-weight:700;}
|
||||
.drop .sm{font-size:var(--fs-sm); color:var(--muted); margin:6px 0 16px;}
|
||||
.formate{display:inline-flex; gap:8px; margin-top:14px;}
|
||||
.badge-fmt{font-family:var(--font-mono); font-size:var(--fs-xs); padding:3px 9px; border-radius:6px; background:var(--card); border:1px solid var(--line); color:var(--muted);}
|
||||
|
||||
/* PAS 2 — mapare coloane */
|
||||
.memo{display:flex; align-items:center; gap:9px; font-size:var(--fs-sm); color:var(--ok); background:color-mix(in srgb,var(--ok) 12%,transparent); border:1px solid color-mix(in srgb,var(--ok) 28%,transparent); border-radius:9px; padding:10px 14px; margin-bottom:14px;}
|
||||
table{width:100%; border-collapse:collapse; font-size:var(--fs-base);}
|
||||
.map th{text-align:left; font-size:var(--fs-xs); text-transform:uppercase; letter-spacing:.05em; color:var(--muted); padding:0 12px 8px; font-weight:700;}
|
||||
.map td{padding:9px 12px; border-top:1px solid var(--line2); vertical-align:middle;}
|
||||
.col-name{font-family:var(--font-mono); font-size:var(--fs-sm); font-weight:600;}
|
||||
.col-sample{font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted);}
|
||||
.map select{width:100%; font-family:var(--font-ui); font-size:var(--fs-base); color:var(--ink); background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:8px 10px; min-height:38px;}
|
||||
.map .ignored select{color:var(--muted);}
|
||||
.switch{display:inline-flex; align-items:center; gap:9px; font-size:var(--fs-sm); color:var(--muted);}
|
||||
.switch .track{width:38px; height:22px; border-radius:99px; background:color-mix(in srgb,var(--accent) 70%,var(--line)); position:relative;}
|
||||
.switch .knob{position:absolute; top:2px; right:2px; width:18px; height:18px; border-radius:99px; background:#fff;}
|
||||
|
||||
/* PAS 3 — preview */
|
||||
.summary{display:flex; gap:10px; flex-wrap:wrap; margin-bottom:14px;}
|
||||
.chipc{display:flex; align-items:center; gap:8px; font-size:var(--fs-sm); padding:7px 13px; border-radius:99px; border:1px solid var(--line); background:var(--card2);}
|
||||
.chipc b{font-size:var(--fs-md);}
|
||||
.pv th{text-align:left; font-size:var(--fs-xs); text-transform:uppercase; letter-spacing:.05em; color:var(--muted); padding:0 12px 9px; font-weight:700;}
|
||||
.pv td{padding:11px 12px; border-top:1px solid var(--line2); font-size:var(--fs-sm);}
|
||||
.pv .vin{font-family:var(--font-mono); font-size:var(--fs-sm);}
|
||||
.pill{display:inline-flex; align-items:center; gap:6px; padding:4px 11px; border-radius:99px; font-size:var(--fs-xs); font-weight:600;}
|
||||
.pill .pdot{width:7px; height:7px; border-radius:99px;}
|
||||
.ok{background:color-mix(in srgb,var(--ok) 14%,transparent); color:var(--ok);} .ok .pdot{background:var(--ok);}
|
||||
.warn{background:color-mix(in srgb,var(--warn) 16%,transparent); color:var(--warn);} .warn .pdot{background:var(--warn);}
|
||||
.err{background:color-mix(in srgb,var(--err) 14%,transparent); color:var(--err);} .err .pdot{background:var(--err);}
|
||||
.lnk{color:var(--accent); font-size:var(--fs-sm); cursor:pointer; background:none; border:none; padding:0; text-decoration:underline;}
|
||||
tr.editing{background:color-mix(in srgb,var(--accent) 7%,transparent);}
|
||||
|
||||
/* editare inline / corectie (slim form) */
|
||||
.editbox{margin:2px 12px 12px; border:1px solid color-mix(in srgb,var(--accent) 35%,var(--line)); border-radius:11px; background:var(--card2); padding:16px;}
|
||||
.editbox .et{font-size:var(--fs-sm); font-weight:700; margin-bottom:12px; color:var(--accent);}
|
||||
.field{margin-bottom:13px;}
|
||||
.field label{display:block; font-size:var(--fs-sm); color:var(--muted); margin-bottom:6px;}
|
||||
.field input, .field textarea, .field select{width:100%; font-family:var(--font-ui); font-size:var(--fs-md); color:var(--ink); background:var(--card); border:1px solid var(--line); border-radius:8px; padding:9px 12px; min-height:40px;}
|
||||
.field input.mono{font-family:var(--font-mono);}
|
||||
.grid3{display:grid; grid-template-columns:1.3fr 1fr 1fr; gap:12px;}
|
||||
.op-row{display:flex; align-items:center; justify-content:space-between; gap:10px; padding:9px 0; border-bottom:1px solid var(--line2);}
|
||||
.op-name{font-size:var(--fs-md); font-weight:600;} .op-name small{font-weight:400; color:var(--muted); font-size:var(--fs-sm);}
|
||||
.chip{display:inline-flex; align-items:center; gap:6px; font-family:var(--font-mono); font-size:var(--fs-sm); background:color-mix(in srgb,var(--accent) 18%,transparent); color:var(--accent); padding:5px 10px; border-radius:7px;}
|
||||
.chip button{background:none; border:none; color:inherit; cursor:pointer; font-size:var(--fs-md);}
|
||||
.addcode{font-size:var(--fs-sm); border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line)); background:transparent; color:var(--accent); border-radius:7px; padding:6px 12px; cursor:pointer;}
|
||||
.save-rule{font-size:var(--fs-xs); color:var(--muted); text-decoration:underline; background:none; border:none; cursor:pointer;}
|
||||
.actrow{display:flex; gap:10px; margin-top:14px;}
|
||||
|
||||
/* PAS 4 — confirma */
|
||||
.confirm-big{text-align:center; padding:8px 0 4px;}
|
||||
.confirm-big .n{font-size:42px; font-weight:700; color:var(--ok); line-height:1;}
|
||||
.confirm-big .l{font-size:var(--fs-md); color:var(--muted); margin-top:6px;}
|
||||
.breakdown{display:flex; gap:10px; justify-content:center; margin:16px 0;}
|
||||
.atest{display:flex; align-items:flex-start; gap:10px; font-size:var(--fs-sm); color:var(--ink); background:var(--card2); border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin-top:6px;}
|
||||
.atest input{margin-top:3px; width:18px; height:18px;}
|
||||
.warn-note{display:flex; align-items:center; gap:9px; font-size:var(--fs-sm); color:var(--warn); margin-top:12px;}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
|
||||
<header>
|
||||
<span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span>
|
||||
<div class="h-center">
|
||||
<div class="h-title">ROMFAST <span class="accent">AUTOPASS</span><span class="env">test</span><span class="tier">Pro</span></div>
|
||||
<div class="h-sub">Service auto: <span class="svc">Service Auto Vâlcea SRL</span></div>
|
||||
</div>
|
||||
<div class="h-right">
|
||||
<div class="rar-chip" title="Ultima autentificare RAR: 28.06.2026 09:41"><span class="dot"></span> RAR online</div>
|
||||
<button class="tema-btn"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg> Grafit</button>
|
||||
<button class="icon-btn">☰</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<!-- ============ PAS 1 ============ -->
|
||||
<div class="screen-cap">Pas 1 — Încarcă fișier</div>
|
||||
<div class="stepper">
|
||||
<div class="step active"><span class="num">1</span><span class="t">Încarcă</span></div>
|
||||
<div class="step"><span class="num">2</span><span class="t">Potrivește</span></div>
|
||||
<div class="step"><span class="num">3</span><span class="t">Verifică</span></div>
|
||||
<div class="step"><span class="num">4</span><span class="t">Confirmă</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-head"><h3>Încarcă fișierul cu prestații</h3><p>Trage un fișier xlsx/csv aici sau folosește butonul de alegere.</p></div>
|
||||
<div class="panel-body">
|
||||
<div class="drop">
|
||||
<div class="ic"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/></svg></div>
|
||||
<div class="big">Trage fișierul aici</div>
|
||||
<div class="sm">sau apasă pentru a alege de pe calculator · max 5 MB</div>
|
||||
<button class="btn-primary">Alege fișier</button>
|
||||
<div class="formate"><span class="badge-fmt">.xlsx</span><span class="badge-fmt">.csv</span><span class="badge-fmt">.xls</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ PAS 2 ============ -->
|
||||
<div class="screen-cap">Pas 2 — Potrivește coloanele</div>
|
||||
<div class="stepper">
|
||||
<div class="step done"><span class="num">✓</span><span class="t">Încarcă</span></div>
|
||||
<div class="step active"><span class="num">2</span><span class="t">Potrivește</span></div>
|
||||
<div class="step"><span class="num">3</span><span class="t">Verifică</span></div>
|
||||
<div class="step"><span class="num">4</span><span class="t">Confirmă</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-head"><h3>Potrivește coloanele fișierului cu câmpurile RAR</h3><p>Spune-ne ce coloană din fișier corespunde cu ce câmp RAR. <span class="mono">prestatii-iunie.xlsx</span> · 38 rânduri.</p></div>
|
||||
<div class="panel-body">
|
||||
<div class="memo"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg> Format recunoscut — am reaplicat maparea salvată pentru aceste coloane.</div>
|
||||
<table class="map">
|
||||
<thead><tr><th style="width:34%">Coloană din fișier</th><th style="width:30%">Exemplu</th><th style="width:36%">Câmp RAR</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="col-name">SASIU</td><td class="col-sample">WBA8E9C5K7F20143</td><td><select><option>VIN (serie șasiu)</option></select></td></tr>
|
||||
<tr><td class="col-name">DATA</td><td class="col-sample">22.06.2026</td><td><select><option>Data prestației</option></select></td></tr>
|
||||
<tr><td class="col-name">NR_AUTO</td><td class="col-sample">CT88NOE</td><td><select><option>Număr înmatriculare</option></select></td></tr>
|
||||
<tr><td class="col-name">KM</td><td class="col-sample">142500</td><td><select><option>Odometru (km)</option></select></td></tr>
|
||||
<tr><td class="col-name">OPERATIE</td><td class="col-sample">Revizie periodică</td><td><select><option>Operație service → cod RAR</option></select></td></tr>
|
||||
<tr class="ignored"><td class="col-name">PRET</td><td class="col-sample">350 lei</td><td><select><option>— ignoră coloana —</option></select></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<label class="switch"><span class="track"><span class="knob"></span></span> Ține minte maparea pentru acest format</label>
|
||||
<div style="display:flex; gap:10px;"><button class="btn-ghost">Înapoi</button><button class="btn-primary">Continuă spre verificare</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ PAS 3 ============ -->
|
||||
<div class="screen-cap">Pas 3 — Verifică (cu editare/corecție rând)</div>
|
||||
<div class="stepper">
|
||||
<div class="step done"><span class="num">✓</span><span class="t">Încarcă</span></div>
|
||||
<div class="step done"><span class="num">✓</span><span class="t">Potrivește</span></div>
|
||||
<div class="step active"><span class="num">3</span><span class="t">Verifică</span></div>
|
||||
<div class="step"><span class="num">4</span><span class="t">Confirmă</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-head"><h3>Verifică rândurile înainte să le trimiți la RAR</h3><p>Corectează rândurile marcate. Restul sunt gata de trimis.</p></div>
|
||||
<div class="panel-body">
|
||||
<div class="summary">
|
||||
<span class="chipc"><span class="pill ok"><span class="pdot"></span></span> <b>33</b> gata</span>
|
||||
<span class="chipc"><span class="pill warn"><span class="pdot"></span></span> <b>2</b> Cod RAR lipsă</span>
|
||||
<span class="chipc"><span class="pill err"><span class="pdot"></span></span> <b>1</b> Date incomplete</span>
|
||||
<span class="chipc"><span class="pill warn"><span class="pdot"></span></span> <b>1</b> Duplicat în fișier</span>
|
||||
<span class="chipc"><span class="pill ok"><span class="pdot"></span></span> <b>1</b> Deja trimis</span>
|
||||
</div>
|
||||
<table class="pv">
|
||||
<thead><tr><th>VIN</th><th>Operație</th><th>Data</th><th>Stare</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="vin">WBA8E9...K7F2</td><td>Inspecție tehnică</td><td class="mono">22.06.2026</td><td><span class="pill ok"><span class="pdot"></span>Gata</span></td><td><button class="lnk">editează</button></td></tr>
|
||||
<!-- rand in editare/corectie -->
|
||||
<tr class="editing"><td class="vin">VF1RFB...A88</td><td>Schimb plăcuțe frână</td><td class="mono">22.06.2026</td><td><span class="pill warn"><span class="pdot"></span>Cod RAR lipsă</span></td><td><button class="lnk">închide</button></td></tr>
|
||||
<tr class="editing"><td colspan="5" style="padding:0;">
|
||||
<div class="editbox">
|
||||
<div class="et">Corectează rândul — VF1RFB...A88</div>
|
||||
<div class="grid3">
|
||||
<div class="field"><label>VIN (serie șasiu)</label><input class="mono" value="VF1RFB00A88142073"></div>
|
||||
<div class="field"><label>Data prestației</label><input class="mono" value="2026-06-22"></div>
|
||||
<div class="field"><label>Nr. înmatriculare</label><input class="mono" value="CT88NOE"></div>
|
||||
</div>
|
||||
<div class="field"><label>Observații (operațiile efectuate)</label><textarea rows="2">Schimbare plăcuțe frână față</textarea></div>
|
||||
<div class="field">
|
||||
<label>Prestații — cod RAR pe fiecare operație</label>
|
||||
<div class="op-row" style="border-left:2px solid var(--warn); padding-left:10px;">
|
||||
<span class="op-name">SCHIMB PLĂCUȚE FRÂNĂ <small style="color:var(--warn)">— lipsă cod</small></span>
|
||||
<span style="display:flex; gap:8px; align-items:center;">
|
||||
<select><option>— alege cod RAR —</option><option>FRN1 — Sistem de frânare</option><option>REV2 — Revizie periodică</option></select>
|
||||
</span>
|
||||
</div>
|
||||
<div style="margin-top:8px; display:flex; align-items:center; gap:12px;">
|
||||
<button class="addcode">+ Adaugă altă operație / cod RAR</button>
|
||||
<button class="save-rule">salvează ca regulă op→cod (deblochează rândurile la fel)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actrow"><button class="btn-primary">Salvează rândul</button><button class="btn-ghost">Renunță</button></div>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td class="vin">ZAR937...C04</td><td>Schimb ulei</td><td class="mono">21.06.2026</td><td><span class="pill err"><span class="pdot"></span>Date incomplete</span></td><td><button class="lnk">editează</button></td></tr>
|
||||
<tr><td class="vin">WVWZZZ...3M1</td><td>Revizie periodică</td><td class="mono">22.06.2026</td><td><span class="pill warn"><span class="pdot"></span>Duplicat în fișier</span></td><td><button class="lnk">editează</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<span class="muted" style="font-size:var(--fs-sm);">3 rânduri de corectat înainte de trimitere</span>
|
||||
<div style="display:flex; gap:10px;"><button class="btn-ghost">Înapoi</button><button class="btn-primary">Confirmă valorile →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ PAS 4 ============ -->
|
||||
<div class="screen-cap">Pas 4 — Confirmă trimiterea</div>
|
||||
<div class="stepper">
|
||||
<div class="step done"><span class="num">✓</span><span class="t">Încarcă</span></div>
|
||||
<div class="step done"><span class="num">✓</span><span class="t">Potrivește</span></div>
|
||||
<div class="step done"><span class="num">✓</span><span class="t">Verifică</span></div>
|
||||
<div class="step active"><span class="num">4</span><span class="t">Confirmă</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-head"><h3>Confirmă trimiterea la RAR</h3><p>Acțiunea e ireversibilă — prestațiile pleacă la RAR AUTOPASS.</p></div>
|
||||
<div class="panel-body">
|
||||
<div class="confirm-big"><div class="n">36</div><div class="l">prestații gata de trimis</div></div>
|
||||
<div class="breakdown">
|
||||
<span class="chipc"><span class="pill ok"><span class="pdot"></span></span> 36 vor pleca</span>
|
||||
<span class="chipc"><span class="pill warn"><span class="pdot"></span></span> 1 sărit (duplicat)</span>
|
||||
<span class="chipc"><span class="pill ok"><span class="pdot"></span></span> 1 deja trimis</span>
|
||||
</div>
|
||||
<label class="atest"><input type="checkbox" checked> Confirm că datele sunt corecte și autorizez trimiterea celor 36 de prestații la RAR AUTOPASS, conform Legii 142/2023.</label>
|
||||
<div class="warn-note"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.8 2 18a2 2 0 0 0 1.7 3h16.6a2 2 0 0 0 1.7-3L13.7 3.8a2 2 0 0 0-3.4 0z"/></svg> O prestație finalizată la RAR nu mai poate fi anulată sau corectată prin aplicație.</div>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<button class="btn-ghost">Înapoi la verificare</button>
|
||||
<button class="btn-primary">Trimite 36 de prestații la RAR</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
13171
docs/operatii-service/operatii-service-automotive.csv
Normal file
13171
docs/operatii-service/operatii-service-automotive.csv
Normal file
File diff suppressed because it is too large
Load Diff
1669
docs/operatii-service/operatii-service-clever.csv
Normal file
1669
docs/operatii-service/operatii-service-clever.csv
Normal file
File diff suppressed because it is too large
Load Diff
3744
docs/operatii-service/operatii-service-sigma.csv
Normal file
3744
docs/operatii-service/operatii-service-sigma.csv
Normal file
File diff suppressed because it is too large
Load Diff
876
docs/operatii-service/operatii-service-south.csv
Normal file
876
docs/operatii-service/operatii-service-south.csv
Normal file
@@ -0,0 +1,876 @@
|
||||
" ";"DENOP";"NR"
|
||||
"51";"DIAGNOZA";"809"
|
||||
"160";"EFECTUAT REVIZIE PERIODICA MICA";"279"
|
||||
"159";"EFECTUAT REVIZIE PERIODICA";"214"
|
||||
"152";"EFECTUARE REVIZIE PERIODICA-MARE";"48"
|
||||
"57";"DIAGNOZA SISTEM AD-BLUE SI SCR";"36"
|
||||
"156";"EFECTUAT REVIZIE MICA";"34"
|
||||
"153";"EFECTUARE REVIZIE PERIODICA-MICA";"33"
|
||||
"625";"INLOCUIT SONDA ADBLUE";"29"
|
||||
"801";"REVIZIE ULEI + FILTRE";"28"
|
||||
"791";"REVIZIE MICA PERIODICA";"28"
|
||||
"792";"REVIZIE MICA PROGRAMATA";"27"
|
||||
"805";"SCHIMB ULEI + FILTRE";"24"
|
||||
"155";"EFECTUAT REVIZIE MARE";"24"
|
||||
"598";"INLOCUIT SENZOR NOX 1";"23"
|
||||
"769";"REPARAT SCARA SPATE STG.";"23"
|
||||
"768";"REPARAT SCARA SPATE DRT.";"23"
|
||||
"149";"DTC CONTROL";"22"
|
||||
"164";"E-PDI";"20"
|
||||
"681";"P.D.I.";"20"
|
||||
"599";"INLOCUIT SENZOR NOX 2";"18"
|
||||
"527";"INLOCUIT POMPA ADBLUE";"18"
|
||||
"789";"REVIZIE MARE PROGRAMATA";"17"
|
||||
"524";"INLOCUIT PLACUTE FRANA FATA";"17"
|
||||
"49";"DEZANSAMBLAT ARC AXA SPATE";"15"
|
||||
"168";"FSA 202322 - ECU UPDATE";"15"
|
||||
"545";"INLOCUIT PTO";"13"
|
||||
"525";"INLOCUIT PLACUTE FRANA SPATE";"13"
|
||||
"171";"FSA 202520-F3V17 BCM UPDATE";"13"
|
||||
"788";"REVIZIE MARE";"11"
|
||||
"827";"VERIFICARE FILTRU ULEI MOTOR";"11"
|
||||
"439";"INLOCUIT KIT AMBREIAJ";"11"
|
||||
"453";"INLOCUIT LAMPA GABARIT CABINA DRT.";"10"
|
||||
"794";"REVIZIE PERIODICA ISUZU";"10"
|
||||
"253";"INLOCUIT BECURI LAMPI SPATE";"10"
|
||||
"705";"REGENERARE FORTATA";"10"
|
||||
"103";"D/R CV DE PE AUTO";"9"
|
||||
"173";"FSA202511A-F3V17 UPDATE BCM";"9"
|
||||
"704";"REGENERARE";"9"
|
||||
"661";"INLOCUIT VALVA RAMPA COMBUSTIBIL";"9"
|
||||
"817";"UPDATE BCM";"9"
|
||||
"435";"INLOCUIT INJECTOR ADBLUE";"9"
|
||||
"798";"REVIZIE PROGRAMATA FILTRE + ULEI";"8"
|
||||
"835";"VERIFICARE PIERDERI ULEI MOTOR";"8"
|
||||
"31";"DEMONTAT ARC SPATE";"8"
|
||||
"151";"EFECTUARE REVIZIE PERIODICA";"8"
|
||||
"454";"INLOCUIT LAMPA GABARIT CABINA STG.";"8"
|
||||
"819";"UPDATE ECM";"7"
|
||||
"515";"INLOCUIT PLACA RELEE MARE";"7"
|
||||
"409";"INLOCUIT FOAIE DE ARC SPATE";"7"
|
||||
"410";"INLOCUIT FOAIE 1 ARC SPATE";"7"
|
||||
"70";"D/R ARC SPATE";"7"
|
||||
"440";"INLOCUIT KIT AMBREIAJ + VOLANTA";"7"
|
||||
"203";"INLOCUIT ALTERNATOR";"7"
|
||||
"441";"INLOCUIT KIT AMBREIAJ, VOLANTA + RULMENT VOLANTA";"7"
|
||||
"799";"REVIZIE PROGRAMATA TRAKKER";"6"
|
||||
"743";"REPARAT CABLAJ LAMPI SPATE";"6"
|
||||
"597";"INLOCUIT SENZOR NOX UPSTREAM DOC 1";"6"
|
||||
"218";"INLOCUIT ARC FATA FORD";"6"
|
||||
"55";"DIAGNOZA RESETARE INTERVAL";"6"
|
||||
"618";"INLOCUIT SIMERING ARBORE SPATE";"6"
|
||||
"832";"VERIFICARE MECANICA GENERALA";"5"
|
||||
"273";"INLOCUIT BUJII MOTOR";"5"
|
||||
"72";"D/R BARA FATA";"5"
|
||||
"75";"D/R BARA PROTECTIE FATA";"5"
|
||||
"543";"INLOCUIT PROIECTOR LUCRU DRT.";"5"
|
||||
"752";"REPARAT CV LA BANC";"5"
|
||||
"455";"INLOCUIT LAMPA LATERALA GABARIT";"5"
|
||||
"186";"INDREPTAT SUPORTI + MONTAT ARIPA DRT. PUNTE MOTRICA";"5"
|
||||
"170";"FSA 202511A - F3V17 BCMUPDATE";"5"
|
||||
"464";"INLOCUIT LAMPA SPATE STG";"5"
|
||||
"800";"REVIZIE PROGRAMATA ULEI + FILTRE";"5"
|
||||
"634";"INLOCUIT SUPAPA RETUR POMPA ADBLUE";"5"
|
||||
"330";"INLOCUIT CONDUCTA A/C";"5"
|
||||
"178";"GOLIT REZERVOR COMBUSTIBIL";"4"
|
||||
"862";"VERIFICAT SI INCARCAT INSTALATIE CU A/C";"4"
|
||||
"100";"D/R CUTIE DE VITEZE";"4"
|
||||
"234";"INLOCUIT BASCULA DR FATA";"4"
|
||||
"169";"FSA 202413";"4"
|
||||
"588";"INLOCUIT SENZOR ABS SPATE DR";"4"
|
||||
"611";"INLOCUIT SENZORI PLACUTE FATA";"4"
|
||||
"462";"INLOCUIT LAMPA SPATE DR";"4"
|
||||
"839";"VERIFICARE SI INCARCARE INSTALATIE A/C";"4"
|
||||
"375";"INLOCUIT DISCURI FRINA SPATE";"4"
|
||||
"607";"INLOCUIT SENZOR TEMPERATURA CATALIZATOR";"4"
|
||||
"829";"VERIFICARE INSTALATIE A/C + INCARCARE CU FREON";"4"
|
||||
"828";"VERIFICARE GENERALA";"4"
|
||||
"552";"INLOCUIT RADIATOR RACIRE MOTOR";"4"
|
||||
"25";"CURATAT REZERVOR ADBLUE";"4"
|
||||
"402";"INLOCUIT FILTRU EPURATOR";"4"
|
||||
"857";"VERIFICAT INSTALATIE AC + INCARCAT FREON";"4"
|
||||
"654";"INLOCUIT TURBOSUFLANTA";"4"
|
||||
"683";"PIAGGIO SCHIMB ULEI + FILTRE";"4"
|
||||
"821";"UPDATE TAQ 766187";"4"
|
||||
"78";"D/R BORD AUTO";"4"
|
||||
"530";"INLOCUIT POMPA APA";"4"
|
||||
"813";"TELESERVICII PENTRU LIMITARE";"4"
|
||||
"617";"INLOCUIT SIMERING ARBORE FATA";"4"
|
||||
"824";"VERIF SI INCARCAT AC";"4"
|
||||
"71";"D/R ARIPA FATA STG";"4"
|
||||
"595";"INLOCUIT SENZOR NOX DUPA DPF (ULTIMUL)";"3"
|
||||
"495";"INLOCUIT OGLINDA PIETON";"3"
|
||||
"139";"D/R ROTI AXA 2FATA STG/DR";"3"
|
||||
"521";"INLOCUIT PLACUTE FRANA";"3"
|
||||
"756";"REPARAT INSTALATIE ELECTRICA LAMPI SPATE";"3"
|
||||
"501";"INLOCUIT ORNAMENT ARIPA FATA";"3"
|
||||
"468";"INLOCUIT LEVIER COMANDA + CAPETE DE BARA TRANSV";"3"
|
||||
"549";"INLOCUIT RADIATOR AEROTERMA";"3"
|
||||
"467";"INLOCUIT LAMPI SPATE";"3"
|
||||
"596";"INLOCUIT SENZOR NOX INAINTE DE DPF";"3"
|
||||
"179";"GRESAT";"3"
|
||||
"489";"INLOCUIT OCHELAR PROIECTOR DRT.";"3"
|
||||
"478";"INLOCUIT MANETA SCHIMBATOR VITEZE";"3"
|
||||
"484";"INLOCUIT MONITOR + CAMERA";"3"
|
||||
"673";"MATERIALE ELECTRICE";"3"
|
||||
"290";"INLOCUIT CAMERA SPATE";"3"
|
||||
"575";"INLOCUIT RULMENTI INTERMEDIARI CARDAN";"3"
|
||||
"573";"INLOCUIT RULMENTI AXA SPATE";"3"
|
||||
"698";"REFACUT CABLAJ ELECTRIC USA STG";"3"
|
||||
"675";"MONTAT ARC AXA SPATE";"3"
|
||||
"581";"INLOCUIT SABOTI FRANA MANA";"3"
|
||||
"331";"INLOCUIT CONDUCTA AC";"3"
|
||||
"343";"INLOCUIT CONTACT PEDALA FRANA";"3"
|
||||
"670";"MANOPERA";"3"
|
||||
"666";"INLOCUIT VOLANTA MOTOR";"3"
|
||||
"662";"INLOCUIT VAS EXPANSIUNE";"3"
|
||||
"741";"REPARAT CABLAJ ELECTRIC LA SENZORUL DE PLACUTE STG FATA";"3"
|
||||
"610";"INLOCUIT SENZORI NOX 1 SI 2";"3"
|
||||
"394";"INLOCUIT FILTRU ADBLUE";"3"
|
||||
"184";"INCARCAT INST A/C + VERIFICAT";"3"
|
||||
"187";"INDREPTAT SUPORTI + MONTAT ARIPA STG. PUNTE MOTRICA";"3"
|
||||
"373";"INLOCUIT DISCURI FRANA STG + DR SPATE";"3"
|
||||
"715";"REMEDIAT CABLAJ PANOU COMANDA SUPRASTRUCTURA";"3"
|
||||
"710";"REGLAT FARURUI";"3"
|
||||
"249";"INLOCUIT BECURI";"3"
|
||||
"222";"INLOCUIT ARIPA + ORNAMENT DRT. PUNTE FATA";"3"
|
||||
"233";"INLOCUIT BARA FATA STG";"3"
|
||||
"235";"INLOCUIT BASCULA INFERIOARA FATA STG";"3"
|
||||
"132";"D/R REZERVOR COMBUSTIBIL";"3"
|
||||
"131";"D/R REZERVOR ADBLUE";"3"
|
||||
"779";"REPARATII ELECTRICE";"3"
|
||||
"822";"UPDATE TAS 766161";"3"
|
||||
"101";"D/R CUTIE VITEZA SI INLOCUIT BOLT SELECTOR";"3"
|
||||
"4";"AERISIT INSTALATIE RACIRE";"3"
|
||||
"122";"D/R MOTOR SI ACCESORII MOTOR";"3"
|
||||
"87";"D/R CARDAN";"3"
|
||||
"806";"SERVICE SCHIMB ULEI";"3"
|
||||
"107";"D/R FATA DE USA STANGA";"3"
|
||||
"16";"CURATAT INSTALATIE ALIMENTARE COMBUSTIBIL";"3"
|
||||
"796";"REVIZIE PERIODICA OTOKAR";"3"
|
||||
"288";"INLOCUIT CABLURI TIMONERIE";"2"
|
||||
"560";"INLOCUIT ROLA AC+CUREA";"2"
|
||||
"264";"INLOCUIT BUCSI BARA TORSIUINE FATA STG";"2"
|
||||
"443";"INLOCUIT KIT CUREA DISTRIBUTIE";"2"
|
||||
"699";"REFACUT CABLAJ LAMPA SPATE STG";"2"
|
||||
"671";"MANOPERA ADITIONALA";"2"
|
||||
"424";"INLOCUIT GARNITURI DPF";"2"
|
||||
"433";"INLOCUIT IMPULSOR CV";"2"
|
||||
"83";"D/R CAPAC OGLINDA INF STG";"2"
|
||||
"569";"INLOCUIT RULMENT ROATA FATA STG";"2"
|
||||
"267";"INLOCUIT BUCSI CABINA FATA";"2"
|
||||
"562";"INLOCUIT ROLE + CUREA";"2"
|
||||
"434";"INLOCUIT INJECTOARE";"2"
|
||||
"81";"D/R CADRU FATA AUTO";"2"
|
||||
"456";"INLOCUIT LAMPA NR DR";"2"
|
||||
"457";"INLOCUIT LAMPA NR STG";"2"
|
||||
"714";"REMEDIAT CABLAJ ELECTRIC LAMPI SPATE";"2"
|
||||
"859";"VERIFICAT INSTALATIE COMPACTARE";"2"
|
||||
"713";"REMEDIAT CABLAJ ELECTRIC";"2"
|
||||
"863";"VERIFICAT SI REPARAT INSTALATIE GIDRAULICA SUPRASTRUCTURA";"2"
|
||||
"723";"REMEDIAT PIERDERI AER PE INSTALATIA DE FRANARE";"2"
|
||||
"861";"VERIFICAT INSTALATIE ELECTRICA SISTEM EVACUARE";"2"
|
||||
"803";"REVIZIE 40 000KM";"2"
|
||||
"550";"INLOCUIT RADIATOR APA";"2"
|
||||
"858";"VERIFICAT INSTALATIE ALIMENTARE CU AD-BLUE";"2"
|
||||
"447";"INLOCUIT KIT SINCROANE REDUCTOR CV";"2"
|
||||
"448";"INLOCUIT KIT SINCROANE VIT. 1- 2";"2"
|
||||
"558";"INLOCUIT REZERVOR COMBUSTIBIL";"2"
|
||||
"255";"INLOCUIT BOLTURI ETRIER SPATE";"2"
|
||||
"444";"INLOCUIT KIT DISTRIBUTIE + POMPA DE APA";"2"
|
||||
"856";"VERIFICAT INSTALATIE A/C";"2"
|
||||
"808";"SERVICII VOPSITORIE";"2"
|
||||
"554";"INLOCUIT REGULATOR PRESIUNE AER";"2"
|
||||
"90";"D/R CHIULASA + GARNITURA";"2"
|
||||
"450";"INLOCUIT LAMPA CABINA STG";"2"
|
||||
"291";"INLOCUIT CAMERA VIDEO";"2"
|
||||
"345";"INLOCUIT CRABOTI SELECTOR CV";"2"
|
||||
"344";"INLOCUIT CONTACTOR PEDALA FRANA";"2"
|
||||
"346";"INLOCUIT CRUCE CARDAN";"2"
|
||||
"627";"INLOCUIT SONDA LITROMETRICA";"2"
|
||||
"605";"INLOCUIT SENZOR PTO";"2"
|
||||
"338";"INLOCUIT CONDUCTA RACIRE Y";"2"
|
||||
"600";"INLOCUIT SENZOR PLACUTE";"2"
|
||||
"601";"INLOCUIT SENZOR PM";"2"
|
||||
"342";"INLOCUIT CONDUCTE RACIRE";"2"
|
||||
"602";"INLOCUIT SENZOR PRESIUNE";"2"
|
||||
"358";"INLOCUIT CUZINETI ARBORE COTIT";"2"
|
||||
"372";"INLOCUIT DISCURI FRANA FATA";"2"
|
||||
"360";"INLOCUIT CUZINETI BIELE";"2"
|
||||
"368";"INLOCUIT DISCURI + ROTI AXA SPATE";"2"
|
||||
"369";"INLOCUIT DISCURI + SET PLACUTE FRANA SPATE";"2"
|
||||
"44";"DEMONTAT/MONTAT CV";"2"
|
||||
"58";"DIAGNOZA SISTEM SCR";"2"
|
||||
"623";"INLOCUIT SIMERING SI INEL RT/SP/DR";"2"
|
||||
"374";"INLOCUIT DISCURI FRINA FATA";"2"
|
||||
"376";"INLOCUIT DISCURI SI PLACUTE FRANA FATA";"2"
|
||||
"633";"INLOCUIT SUPAPA MODUL ADBLUE";"2"
|
||||
"303";"INLOCUIT CAPETE TIRANTI";"2"
|
||||
"664";"INLOCUIT VASCOCUPLAJ VENTILATOR";"2"
|
||||
"658";"INLOCUIT ULEI MOTOR + FILTRU";"2"
|
||||
"651";"INLOCUIT TERMOSTATE SISTEM DE RACIRE";"2"
|
||||
"407";"INLOCUIT FILTRU ULEI HIDRAULIC";"2"
|
||||
"294";"INLOCUIT CAP BARA DR";"2"
|
||||
"77";"D/R BARA TORSIUNE STG";"2"
|
||||
"295";"INLOCUIT CAP BARA STG";"2"
|
||||
"665";"INLOCUIT VENTILATOR AEROTERMA";"2"
|
||||
"76";"D/R BARA TORSIUNE DR";"2"
|
||||
"594";"INLOCUIT SENZOR NIVEL LICHID RACIRE OTOKAR";"2"
|
||||
"325";"INLOCUIT COMPRESOR AC";"2"
|
||||
"390";"INLOCUIT FAR FAZA SCURTA STG.";"2"
|
||||
"332";"INLOCUIT CONDUCTA CU SENZOR INCALZIRE ADBLUE";"2"
|
||||
"39";"DEMONTAT SCAUN SOFER SI INLOCUIT PERNA SEZUT";"2"
|
||||
"399";"INLOCUIT FILTRU DE PARTICULE";"2"
|
||||
"403";"INLOCUIT FILTRU HIDRAULIC";"2"
|
||||
"397";"INLOCUIT FILTRU COMBUSTIBIL";"2"
|
||||
"591";"INLOCUIT SENZOR FILTRU COMBUSTIBIL + FILTRU";"2"
|
||||
"396";"INLOCUIT FILTRU AER";"2"
|
||||
"158";"EFECTUAT REVIZIE M2";"2"
|
||||
"194";"INLOCUIRE GARNITURA CULBUTORI";"2"
|
||||
"748";"REPARAT CABLAJ SUPRASTRUCTURA";"2"
|
||||
"127";"D/R PRAG USA DR";"2"
|
||||
"165";"FACTURA TRACTARE ANB0093/07.04.2026";"2"
|
||||
"502";"INLOCUIT ORNAMENT ARIPA STG. PUNTE FATA";"2"
|
||||
"534";"INLOCUIT POMPA DE ULEI";"2"
|
||||
"157";"EFECTUAT REVIZIE MOTOR AUXILIAR";"2"
|
||||
"129";"D/R RADIATOARE + CONDENSOARE";"2"
|
||||
"507";"INLOCUIT PINION VIT 1";"2"
|
||||
"104";"D/R DIFERENTIAL AXA SPATE";"2"
|
||||
"536";"INLOCUIT POMPA ULEI";"2"
|
||||
"868";"VF. + INLOCUIT MONITOR";"2"
|
||||
"746";"REPARAT CABLAJ SENZORI PLACUTE FRANA";"2"
|
||||
"745";"REPARAT CABLAJ MOTOR";"2"
|
||||
"167";"FSA 202415B1";"2"
|
||||
"172";"FSA202420-INLOCUIT FLANSA + BOLT CARDAN";"2"
|
||||
"8";"COMPLETARE FREON + VERIFICARE INST A/C";"2"
|
||||
"786";"REVIZIE EO-MPE00000";"2"
|
||||
"870";"VF. SI INCARCAT SISTEM AC CU FREON";"2"
|
||||
"176";"GOLIT INSTALATIE RACIRE MOT.";"2"
|
||||
"174";"GOLIT INSTALATIE A/C";"2"
|
||||
"121";"D/R MOTOR DE PE AUTO";"2"
|
||||
"757";"REPARAT INSTALATIE ELECTRICA SUPRASTRUCTURA";"2"
|
||||
"784";"REVIZIE ULEI + FILTRE";"2"
|
||||
"797";"REVIZIE PERIODICA ULEI SI FILTRE";"2"
|
||||
"869";"VF. + REMEDIAT I.E. ILUMINAT EXTERIOR";"2"
|
||||
"490";"INLOCUIT OCHELAR PROIECTOR STG.";"2"
|
||||
"492";"INLOCUIT OGLINDA BORDURA";"2"
|
||||
"499";"INLOCUIT OGLINDA STG ELECTRICA";"2"
|
||||
"531";"INLOCUIT POMPA APA MOTOR";"2"
|
||||
"224";"INLOCUIT ARIPA FATA STG";"2"
|
||||
"219";"INLOCUIT ARC SPATE";"2"
|
||||
"475";"INLOCUIT MACARA STG";"2"
|
||||
"512";"INLOCUIT PIVOTI INF + SUP (AMBELE PARTI)";"2"
|
||||
"466";"INLOCUIT LAMPI LATERALE GABARIT";"2"
|
||||
"514";"INLOCUIT PLACA RELEE";"2"
|
||||
"14";"CURATAT INSTALATIA ADMISIE";"2"
|
||||
"138";"D/R ROTI AXA FATA STG/DR";"2"
|
||||
"469";"INLOCUIT LEVIER DE COMANDA";"2"
|
||||
"98";"D/R CONDUCTE COMBUSTIBIL PARTIAL";"2"
|
||||
"771";"REPARAT TOBA FINALA";"2"
|
||||
"519";"INLOCUIT PLACUTE FR FATA";"2"
|
||||
"133";"D/R RIGIDIZARE TREAPTA STG";"2"
|
||||
"520";"INLOCUIT PLACUTE FR FATA + SPATE";"2"
|
||||
"729";"REMEDIAT PIERDERI ULEI CILINDRU COMPACTARE STG.";"2"
|
||||
"776";"REPARATIE GRUP DIFERENTIAL";"2"
|
||||
"510";"INLOCUIT PIVOTI AXA FATA";"2"
|
||||
"511";"INLOCUIT PIVOTI FATA STG + DR";"2"
|
||||
"99";"D/R CUTIE DE VITEZA AUTO";"2"
|
||||
"217";"INLOCUIT ARBORE MOTOR";"2"
|
||||
"206";"INLOCUIT AMORTIZOARE AXA FATA";"2"
|
||||
"609";"INLOCUIT SENZOR ULEI MOTOR";"1"
|
||||
"517";"INLOCUIT PLACUTE AXA FATA";"1"
|
||||
"586";"INLOCUIT SENZOR ABS DR SPATE";"1"
|
||||
"587";"INLOCUIT SENZOR ABS FATA DR";"1"
|
||||
"834";"VERIFICARE PIERDERI ULEI";"1"
|
||||
"526";"INLOCUIT PLACUTE FRINA";"1"
|
||||
"846";"VERIFICARI ELECTRICE SI UDT";"1"
|
||||
"836";"VERIFICARE PRESIUNE INSTALATIE ADBLUE";"1"
|
||||
"590";"INLOCUIT SENZOR AXA CAME";"1"
|
||||
"845";"VERIFICARE ULEI GRUP DIFERENTIAL";"1"
|
||||
"589";"INLOCUIT SENZOR AMONIAC";"1"
|
||||
"518";"INLOCUIT PLACUTE AXA SPATE";"1"
|
||||
"522";"INLOCUIT PLACUTE FRANA + AERISIT";"1"
|
||||
"523";"INLOCUIT PLACUTE FRANA AXA FATA";"1"
|
||||
"841";"VERIFICARE SISTEM ALIMENTARE";"1"
|
||||
"840";"VERIFICARE SISTEM A/C";"1"
|
||||
"604";"INLOCUIT SENZOR PRESIUNE ULEI";"1"
|
||||
"874";"VOPSIT PRIZA AER STG";"1"
|
||||
"606";"INLOCUIT SENZOR STANGA FATA";"1"
|
||||
"838";"VERIFICARE SERVODIRECTIE";"1"
|
||||
"603";"INLOCUIT SENZOR PRESIUNE DIFERENTIALA";"1"
|
||||
"593";"INLOCUIT SENZOR LIFT PUBELE";"1"
|
||||
"872";"VOPSIT BARA FATA STG";"1"
|
||||
"592";"INLOCUIT SENZOR LICHID DE RACIRE";"1"
|
||||
"844";"VERIFICARE ULEI CV";"1"
|
||||
"608";"INLOCUIT SENZOR ULEI";"1"
|
||||
"837";"VERIFICARE SENZORI UZURA SI ABS";"1"
|
||||
"842";"VERIFICARE SISTEM ELECTRIC AD-BLUE";"1"
|
||||
"873";"VOPSIT CAPOTA MOTOR";"1"
|
||||
"875";"VOPSITORIE + MATERIALE VOPSITORIE";"1"
|
||||
"843";"VERIFICARE SISTEM FRANARE";"1"
|
||||
"585";"INLOCUIT SENZOR + CABLAJ NOX";"1"
|
||||
"539";"INLOCUIT PRESOSTAT";"1"
|
||||
"538";"INLOCUIT POMPITA STERGATOR";"1"
|
||||
"537";"INLOCUIT POMPITA SPALATOR PARBRIZ";"1"
|
||||
"867";"VERIFICAT UZURA CILINDRI SI BLOC MOTOR";"1"
|
||||
"557";"INLOCUIT RELEU BUJII";"1"
|
||||
"540";"INLOCUIT PREZOANE ROATA DR";"1"
|
||||
"559";"INLOCUIT REZISTENTA AEROTERMA";"1"
|
||||
"561";"INLOCUIT ROLA GHIDARE + CUREA TRANSMISIE";"1"
|
||||
"852";"VERIFICAT INCARCARE ALTERNATOR";"1"
|
||||
"563";"INLOCUIT RULMENT AMBREIAJ";"1"
|
||||
"855";"VERIFICAT INSTALATI ELECTRICA A/C";"1"
|
||||
"854";"VERIFICAT INJECTOARE";"1"
|
||||
"853";"VERIFICAT/ INCARCAT INSTALATIE A/C";"1"
|
||||
"541";"INLOCUIT PROIECTOARE";"1"
|
||||
"860";"VERIFICAT INSTALATIE ELECTRICA SI INLOCUIT BUTON AVARIE";"1"
|
||||
"553";"INLOCUIT RAMA PROIECTOR + BEC";"1"
|
||||
"547";"INLOCUIT RACORD RACIRE EGR";"1"
|
||||
"548";"INLOCUIT RADIATOR A/C";"1"
|
||||
"864";"VERIFICAT SISTEM ALIMENTARE";"1"
|
||||
"551";"INLOCUIT RADIATOR RACIRE";"1"
|
||||
"546";"INLOCUIT RACORD FLEXIBIL EVACUARE";"1"
|
||||
"555";"INLOCUIT REGULATOR PRESIUNE COMBUSTIBIL";"1"
|
||||
"556";"INLOCUIT RELEE - 2 BUC";"1"
|
||||
"542";"INLOCUIT PROIECTOR CEATA STG.";"1"
|
||||
"865";"VERIFICAT SISTEM EVACUARE";"1"
|
||||
"544";"INLOCUIT PROTECTII FOAIE DE ARC TRANSVERSALA";"1"
|
||||
"866";"VERIFICAT SISTEM FRANARE";"1"
|
||||
"564";"INLOCUIT RULMENT AXA SPATE";"1"
|
||||
"580";"INLOCUIT SABOTI AXA SPATE";"1"
|
||||
"529";"INLOCUIT POMPA AMOR";"1"
|
||||
"849";"VERIFICAT BUJII + APRINDERE";"1"
|
||||
"577";"INLOCUIT RULMENTI ROTI SPATE";"1"
|
||||
"578";"INLOCUIT RULMRNT PRESIUNE AMBREIAJ";"1"
|
||||
"579";"INLOCUIT RULMRNT PRIZA CV";"1"
|
||||
"582";"INLOCUIT SABOTI SPATE";"1"
|
||||
"847";"VERIFICAT + AERISIT SIST FRANARE";"1"
|
||||
"871";"VF. SI REMEDIAT CABLAJ ELECTROMOTOR";"1"
|
||||
"584";"INLOCUIT SEMNALIZARE DR PE OGLINDA";"1"
|
||||
"528";"INLOCUIT POMPA AMBREIAJ";"1"
|
||||
"583";"INLOCUIT SELECTOR VITEZE";"1"
|
||||
"848";"VERIFICAT ADMISIE AER";"1"
|
||||
"576";"INLOCUIT RULMENTI ROTI FATA AMBELE PARTI";"1"
|
||||
"568";"INLOCUIT RULMENT ROATA FATA DR";"1"
|
||||
"535";"INLOCUIT POMPA INALTA PRESIUNE";"1"
|
||||
"570";"INLOCUIT RULMENT ROATA SPATE";"1"
|
||||
"565";"INLOCUIT RULMENT BUTUC DR. SPATE";"1"
|
||||
"566";"INLOCUIT RULMENT DIFERENTIAL";"1"
|
||||
"567";"INLOCUIT RULMENT GRUP DIF.";"1"
|
||||
"533";"INLOCUIT POMPA DE AMORSARE INST DE COMBUSTIBIL";"1"
|
||||
"574";"INLOCUIT RULMENTI FATA (AMBELE PARTI)";"1"
|
||||
"850";"VERIFICAT CABLAJ ELECTRIC";"1"
|
||||
"532";"INLOCUIT POMPA DE AMBREIAJ";"1"
|
||||
"571";"INLOCUIT RULMENT VOLANTA";"1"
|
||||
"572";"INLOCUIT RULMENTI AXA FATA";"1"
|
||||
"851";"VERIFICAT CONCENTRATIE + COMPLETAT ANTIGEL";"1"
|
||||
"726";"REMEDIAT PIERDERI DE AER PE SISTEMUL DE FRANARE";"1"
|
||||
"725";"REMEDIAT PIERDERI APA LA INSTALATIA DE SPALAT";"1"
|
||||
"728";"REMEDIAT PIERDERI ULEI CILINDRU ACTIONARE LIFT STG.";"1"
|
||||
"727";"REMEDIAT PIERDERI DE ULEI MOTOR";"1"
|
||||
"724";"REMEDIAT PIERDERI ANTIGEL";"1"
|
||||
"720";"REMEDIAT INSTALATIE ELECTRICA";"1"
|
||||
"719";"REMEDIAT FCT. PROIECTOARE SPATE";"1"
|
||||
"722";"REMEDIAT LUMINI MI";"1"
|
||||
"721";"REMEDIAT INTRERUPERI CABLAJ MOTOR";"1"
|
||||
"736";"REPARAT ARIPA SPATE STG";"1"
|
||||
"735";"REPARAT ARC DR FATA";"1"
|
||||
"738";"REPARAT BARA STG. FATA";"1"
|
||||
"737";"REPARAT BARA DRT. FATA";"1"
|
||||
"734";"REMEDIERE PRINDERE COMP AC";"1"
|
||||
"731";"REMEDIAT PRINDERE COLIER TURBOSUFLANTA";"1"
|
||||
"730";"REMEDIAT PIULITA GRUP DIFERENTIAL";"1"
|
||||
"733";"REMEDIAT PRINDERI CONDUCTA A/C";"1"
|
||||
"732";"REMEDIAT PRINDERI ACTIONARE LIFT SUPERIOARE STG. + DRT.";"1"
|
||||
"718";"REMEDIAT DEFECTIUNI PORNIRE";"1"
|
||||
"810";"SPALAT MOTOR CU SOLUTIE";"1"
|
||||
"703";"REFACUT SUPORT TABLOU SIGURANTE";"1"
|
||||
"706";"REGLARE FARURI";"1"
|
||||
"809";"SPALAT INSTALATIE ALIMENTARE AD-BLUE";"1"
|
||||
"702";"REFACUT INSTALATIE ELECTRICA SUPRASTRUCTURA";"1"
|
||||
"700";"REFACUT CABLAJ SENZOR PLACUTE SPATE";"1"
|
||||
"697";"REFACTURARE CURATARE DPF";"1"
|
||||
"811";"TELESERVICII";"1"
|
||||
"701";"REFACUT CABLAJ TABLOU CMD. HYDRO-MAK";"1"
|
||||
"716";"REMEDIAT CABLAJ SENZOR MARSARIER";"1"
|
||||
"807";"SERVICII TERTI FACTURA 30/14.02.2025 REPARATIE INJECTOARE";"1"
|
||||
"804";"REVOPSIT USA STG FATA";"1"
|
||||
"717";"REMEDIAT CONDUCTA EVACURE";"1"
|
||||
"712";"REGLAT VOLAN";"1"
|
||||
"708";"REGLAT CULBUTORI";"1"
|
||||
"707";"REGLAT CABLURI FRANA";"1"
|
||||
"711";"REGLAT FRANA MANA";"1"
|
||||
"709";"REGLAT FARURI";"1"
|
||||
"739";"REPARAT CABLAJ LAMPA SPATE";"1"
|
||||
"782";"RESOFTARE";"1"
|
||||
"765";"REPARAT PARABICICLISTI DR";"1"
|
||||
"766";"REPARAT PRINDERE GIROFAR FATA";"1"
|
||||
"781";"RESETAT INTERVAL REVIZIE";"1"
|
||||
"764";"REPARAT PANOU COMANDA COMPACTOR";"1"
|
||||
"762";"REPARAT MUFE ELECTRICE BOBINE";"1"
|
||||
"785";"REVIZIE DIFERENTIAL";"1"
|
||||
"763";"REPARAT ORNAMENT TOBA";"1"
|
||||
"783";"REV 20000";"1"
|
||||
"774";"REPARATIE BOLT SUPRASTRUCTURA COMPACTARE";"1"
|
||||
"773";"REPARAT USA STG FATA";"1"
|
||||
"777";"REPARATIE LONJERON STG";"1"
|
||||
"775";"REPARATIE CABLAJ ELECTRIC";"1"
|
||||
"772";"REPARAT TRAVERSA SPATE SASIU";"1"
|
||||
"780";"REPROGRAMARE BODY COMPUTER";"1"
|
||||
"767";"REPARAT SCARA DREAPTA";"1"
|
||||
"770";"REPARAT SISTEM EVACUARE";"1"
|
||||
"778";"REPARATIE LUMINI LAMPI SPATE";"1"
|
||||
"761";"REPARAT MUFA ELECTRICA PE CABLAJ NOX";"1"
|
||||
"750";"REPARAT CONDUCTA SISTEM PNEUMATIC SPATE";"1"
|
||||
"749";"REPARAT CABLAJ TABLOU CMD.";"1"
|
||||
"753";"REPARAT INST. ELECTRICA SENZOR PEDALA FR";"1"
|
||||
"751";"REPARAT CUTIE VITEZE";"1"
|
||||
"747";"REPARAT CABLAJ SI INLOCUIT LAMPA SPATE";"1"
|
||||
"802";"REVIZIE ULEI +FILTRE";"1"
|
||||
"740";"REPARAT CABLAJ COMUTATOR FTANA MANA";"1"
|
||||
"744";"REPARAT CABLAJ LUMINI SPATE";"1"
|
||||
"742";"REPARAT CABLAJ ELECTRIC SISTEM EVACUARE";"1"
|
||||
"790";"REVIZIE MICA - PROGRAMATA";"1"
|
||||
"759";"REPARAT LIFT SUPRASTRUCTURA";"1"
|
||||
"760";"REPARAT MANETA COMENZI HIDRAULICE";"1"
|
||||
"787";"REVIZIE FILTRE + ULEI";"1"
|
||||
"758";"REPARAT INSTALATIE HIDRAULICA SUPRASTRUCTURA";"1"
|
||||
"755";"REPARAT INSTALATIE ELECTRICA";"1"
|
||||
"754";"REPARAT INSTALATIE ELECRICA ILUMINAT";"1"
|
||||
"793";"REVIZIE MOTOR AUXILIAR";"1"
|
||||
"795";"REVIZIE PERIODICA MARE";"1"
|
||||
"696";"REFACTURARE ALEZAJ BLOC MOTOR";"1"
|
||||
"638";"INLOCUIT SUPORT FAR STG";"1"
|
||||
"637";"INLOCUIT SUPORT CAPOTA INFERIOR STG";"1"
|
||||
"640";"INLOCUIT SUPORTI BARA SPATE STG + DR";"1"
|
||||
"639";"INLOCUIT SUPORT INF. DR CAPOTA";"1"
|
||||
"823";"UPDATE VBR 768840";"1"
|
||||
"635";"INLOCUIT SUPAPA SISTEM ALIMENTARE RAMPA";"1"
|
||||
"826";"VERIFICARE ELECTRICA";"1"
|
||||
"825";"VERIFICARE AUTO/CONSTATARE";"1"
|
||||
"636";"INLOCUIT SUPAPA SUPRASTRUCTURA";"1"
|
||||
"647";"INLOCUIT TAMPOANE CABINA FATA";"1"
|
||||
"646";"INLOCUIT TAMPOANE ARCURI SPATE";"1"
|
||||
"649";"INLOCUIT TAMPOANE SUPERIOARE RIGIDIZARE FATA";"1"
|
||||
"648";"INLOCUIT TAMPOANE INFERIOARE PUNTE FATA";"1"
|
||||
"645";"INLOCUIT TAMBURI SPATE AXA 3";"1"
|
||||
"642";"INLOCUIT SURUBURI FUZETA SPATE";"1"
|
||||
"641";"INLOCUIT SURUB AMORTIZOR FATA STG/DR";"1"
|
||||
"644";"INLOCUIT TAMBURI FRANA AXA 2 FATA";"1"
|
||||
"643";"INLOCUIT SURUBURI VOLANTA";"1"
|
||||
"632";"INLOCUIT SUPAPA GV";"1"
|
||||
"831";"VERIFICARE INSTALATIE ELECTRA SISTEM AD-BLUE";"1"
|
||||
"833";"VERIFICARE PIERDERI COMBUSTIBIL";"1"
|
||||
"620";"INLOCUIT SIMERING BUTUC SPATE";"1"
|
||||
"619";"INLOCUIT SIMERING AX CAME";"1"
|
||||
"616";"INLOCUIT SIMERERING GRUP DIFERENTIAL";"1"
|
||||
"613";"INLOCUIT SENZOT PTO";"1"
|
||||
"612";"INLOCUIT SENZORI TEMPERATURA EVACUARE";"1"
|
||||
"615";"INLOCUIT SIGURANTA 7.5 AH";"1"
|
||||
"614";"INLOCUIT SERPENTINA ADBLUE";"1"
|
||||
"629";"INLOCUIT STUT";"1"
|
||||
"830";"VERIFICARE INSTALATIE ALIMENTARE CU AD-BLUE";"1"
|
||||
"631";"INLOCUIT SUPAPA EGR";"1"
|
||||
"630";"INLOCUIT SUPAPA CUTIE DE VITEZA";"1"
|
||||
"628";"INLOCUIT SONDA REZERVOR";"1"
|
||||
"622";"INLOCUIT SIMERING PALIER SPATE";"1"
|
||||
"621";"INLOCUIT SIMERING FATA MOTOR";"1"
|
||||
"626";"INLOCUIT SONDA LAMBDA";"1"
|
||||
"624";"INLOCUIT SINE SCAUN SOFER";"1"
|
||||
"650";"INLOCUIT TERMOFLOT";"1"
|
||||
"685";"PREGATIRE ELEMENTE DIN PLASTIC";"1"
|
||||
"684";"PIESE MARUNTE";"1"
|
||||
"686";"PREGATIRE ELEMENTE METALICE";"1"
|
||||
"812";"TELESERVICII IVECO INLOCUIRE INSTRUMENTE BORD";"1"
|
||||
"814";"TELESERVICII- SOFT ALM";"1"
|
||||
"679";"MONTAT PARABICICLISTI STG";"1"
|
||||
"678";"MONTAT CONDUCTA ALIMENTARE PENTRU INCALZIRE AUXILIARA";"1"
|
||||
"682";"PIAGGIO INLOCUIT DISCURI SI PLACUTE FRANA FATA";"1"
|
||||
"680";"MONTAT PROIECTOARE FATA";"1"
|
||||
"693";"RAMPA INJECTOARE DEMONTATA";"1"
|
||||
"692";"PURJAT FILTRU MOTORINA";"1"
|
||||
"695";"RECTIFICAT FILETE COMPRESOR A/C";"1"
|
||||
"694";"RECTIFICAT FILET AXA DR SPATE";"1"
|
||||
"691";"PROGRAMARE CHEIE";"1"
|
||||
"688";"PRINDERE CONDUCTA EVACUARE PE SASIU";"1"
|
||||
"687";"PREGATIRE PENTRU VOPSIRE";"1"
|
||||
"690";"PROGRAMARE BODY COMPUTER";"1"
|
||||
"689";"PRINDERE CONDUCTE A/C";"1"
|
||||
"677";"MONTAT BUTUC ROATA SPATE DR";"1"
|
||||
"660";"INLOCUIT USA STG FATA";"1"
|
||||
"659";"INLOCUIT UNITATE CONTROL BUJII";"1"
|
||||
"818";"UPDATE BCM 76618 ATAQ";"1"
|
||||
"820";"UPDATE SOFT FSA 202418";"1"
|
||||
"657";"INLOCUIT ULEI CV";"1"
|
||||
"653";"INLOCUIT TURBINA MOTOR AUXILIAR";"1"
|
||||
"652";"INLOCUIT TREAPTA CABINA ARIPA DR";"1"
|
||||
"656";"INLOCUIT ULEI CUTIE VITEZE";"1"
|
||||
"655";"INLOCUIT ULEI + FILTRU ULEI";"1"
|
||||
"674";"MONTAT APARATOARE NOROI SPATE";"1"
|
||||
"672";"MASURAT UZURA ARBORE SI INLOCUIT CUZINETI PALIER (6 +1 AXIAL)";"1"
|
||||
"676";"MONTAT ARC AXA SPATE DR";"1"
|
||||
"815";"TEST ACUMULATORI";"1"
|
||||
"816";"TEST FRANARE";"1"
|
||||
"667";"INLOCUIT 2 BIELE MOTOR";"1"
|
||||
"663";"INLOCUIT VAS EXPANSIUNE CU SENZOR";"1"
|
||||
"669";"LIMITARE VITEZA LA 110KM/H";"1"
|
||||
"668";"LIMITARE AUTO 110 KM/H";"1"
|
||||
"183";"INCARCAT INSTALATIE A/C CU FREON";"1"
|
||||
"182";"INCARCARE INSTALATIE A/C";"1"
|
||||
"185";"INDREPTAT CAPAC SPATE PARTEA DRT.";"1"
|
||||
"189";"INL. INCHIZ CAPOTA DR";"1"
|
||||
"188";"INL DUBLURA INTERIOARA LONGERON FATA STG";"1"
|
||||
"181";"IINLOCUIT TERMOSTAT";"1"
|
||||
"166";"FACTURA VOPSITORIE F1897";"1"
|
||||
"163";"EFECTUAT TESTE + REGENERARE";"1"
|
||||
"175";"GOLIT INSTALATIE RACIRE";"1"
|
||||
"180";"GRESAT PUNCTE";"1"
|
||||
"177";"GOLIT REZERVOR AD-BLUE";"1"
|
||||
"198";"INLOCUIRE VENTILATOR AEROTERMA";"1"
|
||||
"197";"INLOCUIRE RIGIDIZARE STALP EXT. INT. DR";"1"
|
||||
"199";"INLOCUIT + REGLAT CABLURI TIMONERIE";"1"
|
||||
"201";"INLOCUIT ACUMULATORI";"1"
|
||||
"200";"INLOCUIT ACUMULATOR";"1"
|
||||
"196";"INLOCUIRE PERNA AER PUNTE MOTRICA DRT. SPRE FATA";"1"
|
||||
"191";"INLOCOUIT FAR FAZA LUNGA STG.";"1"
|
||||
"190";"INL. INCHIZ CAPOTA STG";"1"
|
||||
"192";"INLOCUIRE BARA FATA STG";"1"
|
||||
"195";"INLOCUIRE PERNA AER PUNTE MOTRICA";"1"
|
||||
"193";"INLOCUIRE BARA PROTECTIE FATA";"1"
|
||||
"136";"D/R ROATA SPATE DR AXA 4";"1"
|
||||
"135";"D/R ROATA DR SPATE";"1"
|
||||
"137";"D/R ROTI ( AXA 1/2/3) SI VERIFICAT ELEMENTE DE FRANARE";"1"
|
||||
"141";"D/R SCAUN SOFER";"1"
|
||||
"140";"D/R SCARA USA STG";"1"
|
||||
"134";"D/R ROATA";"1"
|
||||
"125";"D/R POMPA DE APA";"1"
|
||||
"124";"D/R PARASOC STG BARA FATA";"1"
|
||||
"126";"D/R PRAG STG";"1"
|
||||
"130";"D/R RADIATOR APA SI VERIFICAT ETANSEITATE";"1"
|
||||
"128";"D/R PROIECTOR FATA DR";"1"
|
||||
"150";"ECHIPAT ANEXE PE MOTOR";"1"
|
||||
"148";"D/R TURBOSUFLANTA";"1"
|
||||
"154";"EFECTUAT REGLAJ DIRECTIE";"1"
|
||||
"162";"EFECTUAT TEST FRANARE";"1"
|
||||
"161";"EFECTUAT REVIZIE PERIODICA-MICA";"1"
|
||||
"147";"D/R TURBINA";"1"
|
||||
"143";"D/R SI INLOCUIRE SEMNALIZATOR FAT DR";"1"
|
||||
"142";"D/R SEMNALIZARE LATERALA DR";"1"
|
||||
"144";"D/R SUPORT STG BARA FATA";"1"
|
||||
"146";"D/R TREAPTA ARIPA DR CABINA";"1"
|
||||
"145";"D/R SUSPENSIE FATA";"1"
|
||||
"202";"INLOCUIT ALM";"1"
|
||||
"242";"INLOCUIT BEC FAR ST + DR";"1"
|
||||
"241";"INLOCUIT BEC FAR DR";"1"
|
||||
"243";"INLOCUIT BEC FAZA SCURTA";"1"
|
||||
"245";"INLOCUIT BEC POZITIE";"1"
|
||||
"244";"INLOCUIT BEC LAMPA NR";"1"
|
||||
"240";"INLOCUIT BCM";"1"
|
||||
"236";"INLOCUIT BASCULA STG";"1"
|
||||
"232";"INLOCUIT BARA FATA DR";"1"
|
||||
"237";"INLOCUIT BASCULA STG FATA";"1"
|
||||
"239";"INLOCUIT BASCULE FATA";"1"
|
||||
"238";"INLOCUIT BASCULA SUPERIOARA FATA STG";"1"
|
||||
"256";"INLOCUIT BORNA BATERIE MINUS";"1"
|
||||
"254";"INLOCUIT BECURI PE LAMPILE GABARIT";"1"
|
||||
"257";"INLOCUIT BRATE SUSPENSIE FATA";"1"
|
||||
"259";"INLOCUIT BUCSI ARCURI FATA";"1"
|
||||
"258";"INLOCUIT BROASCA USA FATA STG";"1"
|
||||
"252";"INLOCUIT BECURI LAMPI GABARIT";"1"
|
||||
"247";"INLOCUIT BEC STOP";"1"
|
||||
"246";"INLOCUIT BEC SEMNALIZARE SI POZITIE";"1"
|
||||
"248";"INLOCUIT BEC STOP FRANA";"1"
|
||||
"251";"INLOCUIT BECURI FARURI FATA";"1"
|
||||
"250";"INLOCUIT BECURI FARURI";"1"
|
||||
"212";"INLOCUIT AMORTIZOR CABINA FATA STG/DR";"1"
|
||||
"211";"INLOCUIT AMORTIZOR AXA FATA (AMBELE PARTI)";"1"
|
||||
"213";"INLOCUIT AMORTIZOR DR FATA";"1"
|
||||
"215";"INLOCUIT ANSAMBLU BASCULA FATA STG";"1"
|
||||
"214";"INLOCUIT ANSAMBLU BASCULA FATA DR";"1"
|
||||
"210";"INLOCUIT AMORTIZOARE SPATE";"1"
|
||||
"205";"INLOCUIT AMORTIOARE FATA";"1"
|
||||
"204";"INLOCUIT AMBREIAJ";"1"
|
||||
"207";"INLOCUIT AMORTIZOARE CABINA FATA";"1"
|
||||
"209";"INLOCUIT AMORTIZOARE FATA S + D";"1"
|
||||
"208";"INLOCUIT AMORTIZOARE FATA";"1"
|
||||
"228";"INLOCUIT BALAMALE USA STG FATA";"1"
|
||||
"227";"INLOCUIT BALAMALE USA DR";"1"
|
||||
"229";"INLOCUIT BARA DRT. FATA";"1"
|
||||
"231";"INLOCUIT BARA FATA + SUPORTI BARA";"1"
|
||||
"230";"INLOCUIT BARA FATA";"1"
|
||||
"226";"INLOCUIT ARIPA ROATA FATA STG";"1"
|
||||
"220";"INLOCUIT ARC SPATE STG";"1"
|
||||
"216";"INLOCUIT ANSAMBLU USCATOR AER";"1"
|
||||
"221";"INLOCUIT ARC SPATE(O PARTE)";"1"
|
||||
"225";"INLOCUIT ARIPA ROATA FATA DR";"1"
|
||||
"223";"INLOCUIT ARIPA + ORNAMENT STG. PUNTE FATA";"1"
|
||||
"123";"D/R ORNAMENT STG GRILA";"1"
|
||||
"36";"DEMONTAT FURCI CUPLARE CV";"1"
|
||||
"35";"DEMONTAT COMPRESOR A/C";"1"
|
||||
"37";"DEMONTAT REZERVOR COMBUSTIBIL";"1"
|
||||
"40";"DEMONTAT SEGMENTI CILINDRU EXPULZIE";"1"
|
||||
"38";"DEMONTAT ROTI AXA SPATE";"1"
|
||||
"34";"DEMONTAT BORD SI INLOCUIT CEASURI";"1"
|
||||
"29";"DEMONTAT ANEXE DE PE MOTOR";"1"
|
||||
"28";"DEMNTAT BUTUC AXA SPATE DR";"1"
|
||||
"30";"DEMONTAT ARC AXA SPATE STG";"1"
|
||||
"33";"DEMONTAT BORD DREAPTA";"1"
|
||||
"32";"DEMONTAT BORD AUTO";"1"
|
||||
"50";"DEZANSAMBLAT ARC AXA SPATE STG";"1"
|
||||
"48";"DESFACUT SI REPARAT CILINDRI COMPACTARE";"1"
|
||||
"52";"DIAGNOZA NOX";"1"
|
||||
"54";"DIAGNOZA PTO";"1"
|
||||
"53";"DIAGNOZA: MARTOR CHECK ENGINE";"1"
|
||||
"47";"DESCARCAT-INCARCAT INST A/C";"1"
|
||||
"42";"DEMONTAT/MONTAT CAPAC BAIE ULEI";"1"
|
||||
"41";"DEMONTAT SISTEM EVACUARE";"1"
|
||||
"43";"DEMONTAT/MONTAT CILINDRU EXPULZIE";"1"
|
||||
"46";"DEPRESAT RULMENTI";"1"
|
||||
"45";"DEMONTAT/MONTAT CV PE AUTO";"1"
|
||||
"10";"COMPLETAT CU FREON";"1"
|
||||
"9";"COMPLETARE LICHID FRANA";"1"
|
||||
"11";"COMPLETAT INSTALATIE FREON";"1"
|
||||
"13";"CURATAT FILTRU DE PARTICULE";"1"
|
||||
"12";"CURATAT BIELA SI INLOCUIT CUZINETI (6 BIELE)";"1"
|
||||
"7";"APARATOARE ARIPA";"1"
|
||||
"2";"AERISIT INSTALATIE FRANARE";"1"
|
||||
"1";"AERISIT FRANE";"1"
|
||||
"3";"AERISIT INSTALATIE HIDRAULICA";"1"
|
||||
"6";"ANSAMBLAT BORD AUTO";"1"
|
||||
"5";"AERISIT SISTEM HIDRAULIC AMBREIAJ";"1"
|
||||
"23";"CURATAT PINI";"1"
|
||||
"22";"CURATAT MUFE SI CABLAJE";"1"
|
||||
"24";"CURATAT PISTOANE SI INLOCUIT SEGMENTI ( 6 PISTOANE)";"1"
|
||||
"27";"DECONECTAT CONDUCTE COMBUSTIBIL";"1"
|
||||
"26";"CURATAT SUPAPA DEBIT GAZE";"1"
|
||||
"21";"CURATAT INTRECOOLER";"1"
|
||||
"17";"CURATAT INSTALATIE DE AER";"1"
|
||||
"15";"CURATAT INSTALATIE ADBLUE";"1"
|
||||
"18";"CURATAT INSTALATIE DRENARE A/C";"1"
|
||||
"20";"CURATAT INSTALATIE RACIRE";"1"
|
||||
"19";"CURATAT INSTALATIE ELECTRICA PORNIRE";"1"
|
||||
"56";"DIAGNOZA SI UPDATE AUTO";"1"
|
||||
"105";"D/R DISC FRANA SI BUTUC ROATA DR SPATE";"1"
|
||||
"102";"D/R CUTIE VITEZE";"1"
|
||||
"106";"D/R EPURATOR GAZE SI INLOCUIT RACORD";"1"
|
||||
"109";"D/R FILTRU DE PARTICULE SI INLOCUIT GARNITURI + COLIERE";"1"
|
||||
"108";"D/R FATA DE USA STG";"1"
|
||||
"97";"D/R CONDUCTA PRESIUNE POMPA SERVO";"1"
|
||||
"93";"D/R CILINDRU DE EXPULZIE";"1"
|
||||
"92";"D/R CILINDRI HIDRAULICI SPATE COMPACTOR";"1"
|
||||
"94";"D/R CILINDRU EXPULZIE";"1"
|
||||
"96";"D/R COMPONENTE CATALIZATOR";"1"
|
||||
"95";"D/R COLTAR FATA DR";"1"
|
||||
"117";"D/R LAMPA SEMNALIZARE DR";"1"
|
||||
"116";"D/R KIT AMBREIAJ + VOLANTA";"1"
|
||||
"118";"D/R MANER CAPOTA MOTOR";"1"
|
||||
"120";"D/R MOTOR";"1"
|
||||
"119";"D/R MODUL ALIMENTARE AD-BLUE";"1"
|
||||
"115";"D/R JANTA FATA STG";"1"
|
||||
"111";"D/R GEAM INF USA DR";"1"
|
||||
"110";"D/R FILTRU PARTICULE";"1"
|
||||
"112";"D/R GRILA RADIATOR";"1"
|
||||
"114";"D/R JANTA FATA DR";"1"
|
||||
"113";"D/R INJECTOARE";"1"
|
||||
"66";"D/R + REPARAT ELECTROMOTOR";"1"
|
||||
"65";"D/R RADIATOARE";"1"
|
||||
"67";"D/R ANVELOPA FATA DR";"1"
|
||||
"69";"D/R APARATOARE";"1"
|
||||
"68";"D/R ANVELOPA FATA STG";"1"
|
||||
"64";"D/M SI REPARAT CILINDRU LIFT";"1"
|
||||
"60";"D/M FILTRU PARTICULE";"1"
|
||||
"59";"D/M CHIULASA MOTOR SI INLOCUIT GARNITURA CHIULASA";"1"
|
||||
"61";"D/M GRUP DIFERENTIAL";"1"
|
||||
"63";"D/M SI REPARAT CILINDRU GHEARA";"1"
|
||||
"62";"D/M SI REPARAT CILINDRU EXPULZIE";"1"
|
||||
"86";"D/R CAPITONAJ USA DR SP\";"1"
|
||||
"85";"D/R CAPITONAJ FATA DR CABINA";"1"
|
||||
"88";"D/R CATALIZATOR X 2";"1"
|
||||
"91";"D/R CHIULOASA MOTOR";"1"
|
||||
"89";"D/R CAUTATOR CV";"1"
|
||||
"84";"D/R CAPAC SUPERIOR OGLINDA EXT DR";"1"
|
||||
"74";"D/R BARA FATA DR SI INLOCUIT";"1"
|
||||
"73";"D/R BARA FATA DR";"1"
|
||||
"79";"D/R BUTUC ROATA SPATE";"1"
|
||||
"82";"D/R CADRU MOTOR FATA";"1"
|
||||
"80";"D/R BUTUCI ROATA SPATE";"1"
|
||||
"260";"INLOCUIT BUCSI ARCURI SPATE + BRIDE";"1"
|
||||
"418";"INLOCUIT FURTUN RACIRE COMPRESOR";"1"
|
||||
"417";"INLOCUIT FURTUN INTERCOOLER";"1"
|
||||
"419";"INLOCUIT FURTUNE INSTALATIE HIDRAULICA";"1"
|
||||
"421";"INLOCUIT FUZETA FATA DR";"1"
|
||||
"420";"INLOCUIT FURTUNE RACIRE";"1"
|
||||
"416";"INLOCUIT FURTUN INFERIOR RADIATOR APA";"1"
|
||||
"412";"INLOCUIT FOAIE 2 ARC SPATE STG";"1"
|
||||
"411";"INLOCUIT FOAIE 2 ARC SPATE DR";"1"
|
||||
"413";"INLOCUIT FOAIE 3 ARC SPATE";"1"
|
||||
"415";"INLOCUIT FURTUN COMPRESOR AER";"1"
|
||||
"414";"INLOCUIT FULIE ALTERNATOR";"1"
|
||||
"430";"INLOCUIT GEAM OGLINDA MICA DR";"1"
|
||||
"429";"INLOCUIT GEAM OGLINDA MIC STG";"1"
|
||||
"431";"INLOCUIT GIROFAR FATA";"1"
|
||||
"436";"INLOCUIT INTINZATOR ALTERNATOR";"1"
|
||||
"432";"INLOCUIT GRILA SUPERIOARA";"1"
|
||||
"428";"INLOCUIT GARNITURI RACITOR DE ULEI MOTOR";"1"
|
||||
"423";"INLOCUIT GARNITURA CHIULOASA";"1"
|
||||
"422";"INLOCUIT GARNITURA CAPAC CUVA";"1"
|
||||
"425";"INLOCUIT GARNITURI EGR";"1"
|
||||
"427";"INLOCUIT GARNITURI FILTRU";"1"
|
||||
"426";"INLOCUIT GARNITURI ETANSARE DPF";"1"
|
||||
"386";"INLOCUIT ETRIER STG";"1"
|
||||
"385";"INLOCUIT ETRIER FRANA DR SPATE";"1"
|
||||
"387";"INLOCUIT FAR DR";"1"
|
||||
"389";"INLOCUIT FAR DR COMPLET";"1"
|
||||
"388";"INLOCUIT FAR DR + LAMPA POZITIE";"1"
|
||||
"384";"INLOCUIT ELECTROVALVA";"1"
|
||||
"380";"INLOCUIT DOZATOR ADBLUE";"1"
|
||||
"379";"INLOCUIT DISTRIBUITOR LIFT";"1"
|
||||
"381";"INLOCUIT ECU GEARBOX";"1"
|
||||
"383";"INLOCUIT ECU ULEI MOTOR";"1"
|
||||
"382";"INLOCUIT ECU PTO";"1"
|
||||
"404";"INLOCUIT FILTRU POLEN";"1"
|
||||
"401";"INLOCUIT FILTRU DPF";"1"
|
||||
"405";"INLOCUIT FILTRU SCRUF";"1"
|
||||
"408";"INLOCUIT FOAIE DE ARC FATA";"1"
|
||||
"406";"INLOCUIT FILTRU ULEI";"1"
|
||||
"400";"INLOCUIT FILTRU DE ULEI";"1"
|
||||
"392";"INLOCUIT FERODOURI AXA 2 FATA STG/DR";"1"
|
||||
"391";"INLOCUIT FAR STG";"1"
|
||||
"393";"INLOCUIT FILTRE MOTORINA";"1"
|
||||
"398";"INLOCUIT FILTRU DE AER MOTOR";"1"
|
||||
"395";"INLOCUIT FILTRU ADBLUE+CAPAC FILTRU ADBLUE";"1"
|
||||
"437";"INLOCUIT KIT ACCESORII";"1"
|
||||
"491";"INLOCUIT OGLINDA STG";"1"
|
||||
"488";"INLOCUIT NUCA SCHIMBATOR";"1"
|
||||
"493";"INLOCUIT OGLINDA DR";"1"
|
||||
"496";"INLOCUIT OGLINDA SI BUSON REZERVOR";"1"
|
||||
"494";"INLOCUIT OGLINDA MICA STG.";"1"
|
||||
"487";"INLOCUIT MOTOR ELECTRIC ACTIONARE LIFT";"1"
|
||||
"482";"INLOCUIT MODUL DOZARE UPSTREAM CATALIZATOR";"1"
|
||||
"481";"INLOCUIT MODUL - POMPA ADBLUE";"1"
|
||||
"483";"INLOCUIT MODULATOR ABS/EBS";"1"
|
||||
"486";"INLOCUIT MONITOR+CAMERA VIDE";"1"
|
||||
"485";"INLOCUIT MONITOR LED";"1"
|
||||
"508";"INLOCUIT PINION VIT2 + SINCROANE 1/2";"1"
|
||||
"506";"INLOCUIT PINION CV VIT. 6";"1"
|
||||
"509";"INLOCUIT PIVOTI";"1"
|
||||
"516";"INLOCUIT PLACUTE FR";"1"
|
||||
"513";"INLOCUIT PLACA DE COMANDA LIFT";"1"
|
||||
"505";"INLOCUIT PINION CV VIT 2";"1"
|
||||
"498";"INLOCUIT OGLINDA STG";"1"
|
||||
"497";"INLOCUIT OGLINDA ST";"1"
|
||||
"500";"INLOCUIT ORNAMENT ARIPA DRT. PUNTE FATA";"1"
|
||||
"504";"INLOCUIT PERNA AER";"1"
|
||||
"503";"INLOCUIT ORNAMENT PROIECTOR";"1"
|
||||
"458";"INLOCUIT LAMPA NUMAR";"1"
|
||||
"452";"INLOCUIT LAMPA DR PE CABINA";"1"
|
||||
"459";"INLOCUIT LAMPA SEMNAL PE OGLINDA DREAPA";"1"
|
||||
"461";"INLOCUIT LAMPA SEMNALIZARE STG PE OGLINDA";"1"
|
||||
"460";"INLOCUIT LAMPA SEMNAL STANGA IN BARA";"1"
|
||||
"451";"INLOCUIT LAMPA CORN STG SPATE";"1"
|
||||
"442";"INLOCUIT KIT CUREA ACCESORII";"1"
|
||||
"438";"INLOCUIT KIT AMBREAJ";"1"
|
||||
"445";"INLOCUIT KIT GARNITURI CILINDRU EXPULZIE";"1"
|
||||
"449";"INLOCUIT LAMPA CABINA DR";"1"
|
||||
"446";"INLOCUIT KIT HIDRAULIC";"1"
|
||||
"476";"INLOCUIT MANER USA DR FATA";"1"
|
||||
"474";"INLOCUIT MACARA GEAM USA FATA STG";"1"
|
||||
"477";"INLOCUIT MANER USA FATA STG";"1"
|
||||
"480";"INLOCUIT MANSETE LA CILINDRU DE EXPULZIE";"1"
|
||||
"479";"INLOCUIT MANETA SEMNALIZARE";"1"
|
||||
"473";"INLOCUIT MACARA GEAM STG";"1"
|
||||
"465";"INLOCUIT LAMPI";"1"
|
||||
"463";"INLOCUIT LAMPA SPATE DRT.";"1"
|
||||
"470";"INLOCUIT LICHID RACIRE";"1"
|
||||
"472";"INLOCUIT MACARA GEAM DR FATA";"1"
|
||||
"471";"INLOCUIT MACARA CU MOTORAS USA STG";"1"
|
||||
"378";"INLOCUIT DISTRIBUITOR HIDRAULIC PRINCIPAL";"1"
|
||||
"298";"INLOCUIT CAPETE BARA AXA VIRATOARE S +D";"1"
|
||||
"297";"INLOCUIT CAPAC VIZITARE FAR STG SUPERIOR";"1"
|
||||
"299";"INLOCUIT CAPETE BARA ST + DR";"1"
|
||||
"301";"INLOCUIT CAPETE DE BARA LA CASETA DE DIRECTIE";"1"
|
||||
"300";"INLOCUIT CAPETE BARA TRANSVERSALA S + D AXA 1";"1"
|
||||
"296";"INLOCUIT CAPAC VIZITARE FAR STG INFERIOR";"1"
|
||||
"287";"INLOCUIT CABLURI FRANA PARCARE";"1"
|
||||
"286";"INLOCUIT CABLURI FRANA MANA";"1"
|
||||
"289";"INLOCUIT CAMERA SI MONITOR";"1"
|
||||
"293";"INLOCUIT CAP BARA DIRECTIE ST + DR";"1"
|
||||
"292";"INLOCUIT CAP BARA (AMBELE PARTI)";"1"
|
||||
"310";"INLOCUIT CAUTATOR CV";"1"
|
||||
"309";"INLOCUIT CATALIZATOR";"1"
|
||||
"311";"INLOCUIT CHIULASA COMPRESOR";"1"
|
||||
"313";"INLOCUIT CILINDRU LATERAL";"1"
|
||||
"312";"INLOCUIT CILINDRU AMBREIAJ";"1"
|
||||
"308";"INLOCUIT CASETA DE DIRECTIE";"1"
|
||||
"304";"INLOCUIT CAPOTA MOTOR";"1"
|
||||
"302";"INLOCUIT CAPETE DE BARA S + D";"1"
|
||||
"305";"INLOCUIT CARCASA OGLINDA DR";"1"
|
||||
"307";"INLOCUIT CARDAN";"1"
|
||||
"306";"INLOCUIT CARCASA OGLINDA STG.";"1"
|
||||
"270";"INLOCUIT BUJIE INCANDESCENTA (4)";"1"
|
||||
"269";"INLOCUIT BUCSI ETRIERI FATA";"1"
|
||||
"271";"INLOCUIT BUJIE MOTOR 1 BUCATA";"1"
|
||||
"274";"INLOCUIT BUJII 2 BUCATI";"1"
|
||||
"272";"INLOCUIT BUJII";"1"
|
||||
"268";"INLOCUIT BUCSI CABINA SPATE";"1"
|
||||
"262";"INLOCUIT BUCSI BARA STABILIZATIOARE FATA";"1"
|
||||
"261";"INLOCUIT BUCSI BARA STAB SPATE";"1"
|
||||
"263";"INLOCUIT BUCSI BARA STABILIZATOARE FATA";"1"
|
||||
"266";"INLOCUIT BUCSI BASCULA FATA STG + DR";"1"
|
||||
"265";"INLOCUIT BUCSI BARA TORSIUNE FATA DR";"1"
|
||||
"282";"INLOCUIT CABLU AMBREIAJ";"1"
|
||||
"281";"INLOCUIT CABLU ACCELERATIE";"1"
|
||||
"283";"INLOCUIT CABLU TIMONERIE";"1"
|
||||
"285";"INLOCUIT CABLURI FRANA DE MANA";"1"
|
||||
"284";"INLOCUIT CABLURI FRANA DE MANA";"1"
|
||||
"280";"INLOCUIT CABLAJ POMPA ADBLUE";"1"
|
||||
"276";"INLOCUIT BUTUC ROATA";"1"
|
||||
"275";"INLOCUIT BUTUC FATA DREAPTA";"1"
|
||||
"277";"INLOCUIT BUTUC ROATA FATA + RULMENT DR";"1"
|
||||
"279";"INLOCUIT CABLAJ NOXE";"1"
|
||||
"278";"INLOCUIT BUTUCI FATA CU RULMENTI";"1"
|
||||
"314";"INLOCUIT CILINDRU RECEPTOR";"1"
|
||||
"354";"INLOCUIT CUREA ALTERNATOR";"1"
|
||||
"353";"INLOCUIT CUREA ALTERBATOR";"1"
|
||||
"355";"INLOCUIT CUREA +ROLA TRANSMISIE";"1"
|
||||
"357";"INLOCUIT CUREA TRANSMISIE";"1"
|
||||
"356";"INLOCUIT CUREA SI ROLE";"1"
|
||||
"352";"INLOCUIT CUREA A/C";"1"
|
||||
"348";"INLOCUIT CRUCI CARDANICE";"1"
|
||||
"347";"INLOCUIT CRUCE CRADAN";"1"
|
||||
"349";"INLOCUIT CULISANTE ETRIER SPATE STG/DR";"1"
|
||||
"351";"INLOCUIT CUREA + ROLA DISTRIBUTIE";"1"
|
||||
"350";"INLOCUIT CUREA + ROLA AC";"1"
|
||||
"367";"INLOCUIT DISCURI + PLACUTE FATA";"1"
|
||||
"366";"INLOCUIT DISC SI PLACUTE AXA FATA";"1"
|
||||
"370";"INLOCUIT DISCURI FATA";"1"
|
||||
"377";"INLOCUIT DISTRIBUITOR COMPACTARE";"1"
|
||||
"371";"INLOCUIT DISCURI FATA + PLACUTE";"1"
|
||||
"365";"INLOCUIT DISC FRINA + PLACUTE SPATE";"1"
|
||||
"361";"INLOCUIT DISC AMBREIAJ";"1"
|
||||
"359";"INLOCUIT CUZINETI BIELA + 1 BIELA";"1"
|
||||
"362";"INLOCUIT DISC FR SP (AMBELE)";"1"
|
||||
"364";"INLOCUIT DISC FRANA(AMBELE)";"1"
|
||||
"363";"INLOCUIT DISC FRANA FATA STG/DR";"1"
|
||||
"322";"INLOCUIT COMPRESOR + CUREA";"1"
|
||||
"321";"INLOCUIT COMANDA LIFT";"1"
|
||||
"323";"INLOCUIT COMPRESOR A.C.";"1"
|
||||
"326";"INLOCUIT COMPRESOR AER";"1"
|
||||
"324";"INLOCUIT COMPRESOR A/C";"1"
|
||||
"320";"INLOCUIT COLIERE TURBO";"1"
|
||||
"316";"INLOCUIT CIRCUIT HIDRAULIC AMBREIAJ";"1"
|
||||
"315";"INLOCUIT CILINDRU RECEPTOR AMBREIAJ";"1"
|
||||
"317";"INLOCUIT CLAPETA ACCELERATIE";"1"
|
||||
"319";"INLOCUIT COLIERE SI GARNITURI EVACUARE";"1"
|
||||
"318";"INLOCUIT COLIERE FURTUN TURBINA";"1"
|
||||
"337";"INLOCUIT CONDUCTA RACIRE EGR";"1"
|
||||
"336";"INLOCUIT CONDUCTA RACIRE COMPRESOR/CHIULOASA";"1"
|
||||
"339";"INLOCUIT CONDUCTA SISTEM INCALZIRE AD-BLUE";"1"
|
||||
"341";"INLOCUIT CONDUCTE ALIMENTARE COMBUSTIBIL";"1"
|
||||
"340";"INLOCUIT CONDUCTA ULEI TURBINA";"1"
|
||||
"335";"INLOCUIT CONDUCTA POMPA RABATARE";"1"
|
||||
"328";"INLOCUIT COMUTATOT STOP FRANA";"1"
|
||||
"327";"INLOCUIT COMUTATOR MI";"1"
|
||||
"329";"INLOCUIT CONDUCT RACIRE VAS - RADIATOR";"1"
|
||||
"334";"INLOCUIT CONDUCTA INJECTOR";"1"
|
||||
"333";"INLOCUIT CONDUCTA EGR";"1"
|
||||
|
388
docs/prd/prd-5.14-mapare-llm-distilata.md
Normal file
388
docs/prd/prd-5.14-mapare-llm-distilata.md
Normal file
@@ -0,0 +1,388 @@
|
||||
<!-- 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`
|
||||
+ `denumire` in loc de `cod_prestatie` RAR. Daca nu exista mapare, submission-ul
|
||||
intra in `needs_mapping` si asteapta confirmare umana. Service-urile reale au
|
||||
**volume mari de denumiri particulare** (masurat: 17.435 denumiri DISTINCTE in 4
|
||||
CSV-uri de clienti reali — `automotive` 13.170, `sigma` 3.743, `clever` 1.668,
|
||||
`south` 875). Maparea manuala a acestora, prin editorul `needs_mapping`, e
|
||||
prohibitiva: zeci de mii de operatii × confirmare umana.
|
||||
|
||||
Nomenclatorul RAR are doar **18 coduri** foarte largi (REPARATIE, INTRETINERE,
|
||||
REVIZIE PERIODICA, etc. — `nomenclator_seed.py`). Deci problema nu e potrivire de
|
||||
sinonime, ci **clasificare** a mii de operatii granulare in 18 categorii abstracte
|
||||
+ detectare de „gunoi" (linii care nu sunt operatii: `ITP CT 12 ABC`, `DISCOUNT
|
||||
MATERIALE 5%`, `MANOPERA`, nr. inmatriculare).
|
||||
|
||||
## Viziune (pivot 2026-06-28)
|
||||
|
||||
LLM-ul **NU ruleaza la runtime**. Rol unic: **etichetator offline** care
|
||||
construieste un set de date (denumire -> cod). La runtime ruleaza un **clasificator
|
||||
local mic, fara API** (similaritate / fuzzy / embeddings), „distilat" din etichetele
|
||||
LLM + maparile validate de oameni. Trei straturi:
|
||||
|
||||
1. **Etichetare offline (LLM, periodic):** acopera denumirile cu cele mai multe
|
||||
aparitii (frecventa) si grupeaza denumirile asemanatoare ca sa eticheteze ieftin.
|
||||
2. **Clasificator runtime (fara AI):** exact -> fuzzy/substring -> similaritate
|
||||
semantica (embeddings) peste baza de cunostinte. Zero cost per cerere, ruleaza pe
|
||||
LXC.
|
||||
3. **Baza de cunostinte PARTAJATA:** maparile validate de oameni din TOATE conturile
|
||||
de service contribuie la clasificare (strat „gold" comun), peste etichetele LLM
|
||||
(„silver" bootstrap). Munca de validare a unui service ajuta toate service-urile.
|
||||
|
||||
Viitor (nu acum): un LLM generativ local pe LXC. Pasul curent foloseste un model de
|
||||
**embedding** (nu generativ): mic, CPU, milisecunde/text.
|
||||
|
||||
## Premise
|
||||
|
||||
1. **Volumul de denumiri distincte e finit si se schimba lent.** Odata etichetate,
|
||||
90%+ din traficul viitor sunt repetari ale acelorasi denumiri (service-ul
|
||||
refoloseste propriul vocabular). Lege Zipf: top 100 denumiri = 43.6% volum,
|
||||
top 500 = 67.7%, top 1000 = 76.2% (din 155.195 operatii totale).
|
||||
2. **RAR accepta NUMAI coduri din nomenclator.** Un cod necunoscut -> HTTP 500
|
||||
(`ORA-12899`) + record PARTIAL FINALIZATA (terminal). Deci orice cod propus de
|
||||
un sistem automat TREBUIE validat fata de nomenclator inainte de enqueue
|
||||
(invariant existent in `resolve_prestatii(..., valid_codes)`).
|
||||
3. **Maparea gresita are cost asimetric:** un cod gresit trimis = FINALIZATA
|
||||
ireversibil la RAR. Deci pragul de auto-trimitere ramane conservator; incertul
|
||||
ramane `needs_mapping` cu om in bucla. Etichetele LLM NEVALIDATE = sugestie, nu
|
||||
auto-trimitere (vezi scara de incredere).
|
||||
4. **Hardware LLM local generativ e prea lent acum** (masurat: Ollama LXC 104
|
||||
generativ 180-320s/op). Embeddings locale insa sunt rapide pe CPU si suficiente
|
||||
pentru similaritate la runtime.
|
||||
5. **Datele nu sunt sensibile** (confirmat utilizator): denumirile de operatii pot
|
||||
merge la API-uri cloud pentru etichetare. PII incidental (nr. inmatriculare/VIN)
|
||||
se face scrub inainte de trimitere (F3).
|
||||
|
||||
## Masuratori
|
||||
|
||||
### Bootstrap (anterior, Groq)
|
||||
- Groq `llama-3.3-70b`: 28ms/op, acord 94% cu heuristica pe cazuri clare, detectare
|
||||
gunoi excelenta (`NUL`). Abandonat ca furnizor: cap zilnic free atins + cheie expusa.
|
||||
|
||||
### OpenRouter free — NVIDIA Nemotron (masurat 2026-06-28)
|
||||
Furnizor nou pentru etichetare: cheie utilizator, modele GRATUITE, date ne-sensibile.
|
||||
|
||||
- **Capcane de cont (rezolvate):** modelele free dau initial `404 No allowed
|
||||
providers` din cauza unui allowlist de provideri pe cont (venice/together/fireworks/
|
||||
atlas-cloud) — `open-inference`/`google-ai-studio`/`nvidia` erau excluse. Fix:
|
||||
eliminat restrictia in Settings -> Preferences + activat toggle-ul de privacy
|
||||
„free endpoints may publish/train". WAF: User-Agent `Mozilla/5.0` obligatoriu.
|
||||
- **Set fiabil = familia NVIDIA Nemotron.** Restul modelelor sunt 429 (rate-limited
|
||||
upstream, partajat global: llama-3.3-70b, qwen3-next, gemma, hermes, dolphin) sau
|
||||
404 (gpt-oss). Cap free tier ~50 cereri/zi fara credit.
|
||||
- **Test ensemble pe top 120 dupa frecventa (46.4% din volum), 2026-06-28:**
|
||||
|
||||
| Model | ms/op | parse-fail | acord vs Groq (overlap) |
|
||||
|---|---|---|---|
|
||||
| nemotron-3-super-120b | 1463 | 0 | 100% |
|
||||
| nemotron-nano-9b-v2 | 1248 | 0 | 100% |
|
||||
| nemotron-3-ultra-550b | 6450 | 0 | 100% |
|
||||
|
||||
Acord ensemble ponderat pe volum: **3/3 unanim = 87% volum**, 2/3 = 13%, dezacord
|
||||
total = **0%**. Din unanim: 7 NUL (gunoi), 100 coduri reale.
|
||||
- **Decizie model:** pastram `super-120b` + `nano-9b`; **aruncam `ultra-550b`**
|
||||
(4-5x mai lent, zero castig de acuratete). Caveat: ensemble din aceeasi familie
|
||||
NVIDIA -> acordul supraestimeaza increderea fata de un ensemble cross-family.
|
||||
- **Dezacordurile (13%) sunt cazuri de granita taxonomica reala**, nu zgomot:
|
||||
`REGLAT DIRECTIE/FARURI` (OE-2 intretinere vs OE-4 reglare), `MANOPERA
|
||||
TINICHIGERIE` (NUL vs OE-1), `DEZECHIPAT usa/bara` (pas de demontare), `INLOCUIT
|
||||
FILTRU AER` (OE-1 vs OE-3). Astea trebuie sa cada in `needs_mapping`.
|
||||
|
||||
## Solutia
|
||||
|
||||
### Stratul 1 — Etichetare offline (LLM, fara cod runtime)
|
||||
|
||||
Tool CLI (`tools/mapare-llm/`, stil `tools/apikey`). Etichetatorul OpenRouter
|
||||
(`or_common.py` + `or_label.py`) clasifica denumirile in cele 18 coduri RAR + `NUL`:
|
||||
|
||||
1. **Prioritizare pe FRECVENTA (NR), nu alfabetic.** Etichetam intai denumirile cu
|
||||
cele mai multe aparitii (acopera cel mai mult volum per apel).
|
||||
2. **Grupare pe similaritate inainte de etichetare.** Denumirile aproape identice
|
||||
(`REGLAT DIRECTIE` / `REGLAT DIRECTIA` / `REGLARE DIRECTIE`) se grupeaza; LLM
|
||||
eticheteaza doar **reprezentantul grupului**, codul se propaga la tot grupul.
|
||||
Maximizeaza acoperirea per apel LLM (critic pe cap free de ~50 cereri/zi).
|
||||
3. **Ensemble NVIDIA** (`super-120b` + `nano-9b`): acord -> incredere mai mare;
|
||||
dezacord -> ramane pentru `needs_mapping`. Vot pe coduri, nu self-confidence.
|
||||
4. **Scrub PII** (regex nr. inmatriculare/VIN) inainte de trimitere (F3, exista).
|
||||
5. Output: dataset etichetat cu `denumire, cod, sursa, confidence` (provenienta).
|
||||
`NUL` marcat separat (ancore negative + supresie), NU se promoveaza la cod RAR.
|
||||
|
||||
Prompt cu reguli explicite (avarii grave DOAR la accident; vopsire = reparatie;
|
||||
ulei+filtru = revizie; gunoi -> NUL). Batch mare (cap free tier), retry/backoff pe
|
||||
429, respecta `Retry-After`.
|
||||
|
||||
### Stratul 2 — Clasificator runtime (FARA AI, fara API)
|
||||
|
||||
Pentru o denumire din prezentare (canal API sau import), in `app/mapping.py`:
|
||||
|
||||
1. **Exact** in baza de cunostinte (`operations_mapping` + strat partajat) -> cod direct.
|
||||
2. **Fuzzy/substring** (`operation_text_rules`, `rapidfuzz`) — exista deja.
|
||||
3. **Similaritate semantica (embeddings)** — NOU: model multilingv mic (ex.
|
||||
`intfloat/multilingual-e5-small` sau `paraphrase-multilingual-MiniLM`), CPU.
|
||||
Vectorizam baza etichetata o data; la runtime vectorizam denumirea noua si luam
|
||||
cel mai apropiat vecin (sau top-k cu vot). Optional: clasificator `scikit-learn`
|
||||
(regresie logistica / kNN) antrenat pe (embedding -> cod) pentru generalizare
|
||||
dincolo de vecinul exact. „Antrenarea pe datele de test" = acest pas, secunde,
|
||||
ruleaza oriunde.
|
||||
4. Cod propus -> validat OBLIGATORIU `valid_codes` (garda ORA-12899). Peste pragul
|
||||
de incredere -> conform scarii; altfel `needs_mapping`.
|
||||
|
||||
Decizie de gazduire runtime: ramane deschisa pentru reviziile plan (in-proces in
|
||||
gateway vs microserviciu pe LXC/Flowise). Default propus: in-proces (cel mai simplu).
|
||||
|
||||
### Stratul 3 — Baza de cunostinte PARTAJATA cross-account
|
||||
|
||||
**Schimbare fata de versiunea anterioara** (care izola corpusul per cont):
|
||||
|
||||
- **Strat GOLD partajat:** maparile **validate de oameni** (din `needs_mapping`, in
|
||||
ORICE cont) intra intr-un store partajat `denumire_normalizata -> cod`. Astfel
|
||||
validarea facuta de un service ridica increderea pentru toate. Cheia = denumire
|
||||
normalizata (scrub PII, lower, strip), nu textul brut.
|
||||
- **Strat SILVER:** etichetele LLM (bootstrap) — sugestii, NU auto-trimitere.
|
||||
- **Override per-cont:** daca un cont mapeaza explicit o denumire la alt cod decat
|
||||
cel partajat (conflict legitim de vocabular), override-ul contului castiga pentru
|
||||
acel cont. Conflictele inter-cont se rezolva cu provenienta + (optional) majoritate.
|
||||
|
||||
Confirmarile umane curg organic prin folosirea normala a editorului `needs_mapping`
|
||||
— FARA sesiune separata de adjudecare manuala (cerinta utilizator).
|
||||
|
||||
### Scara de incredere (runtime, per operatie din prezentare)
|
||||
|
||||
| Treapta | Sursa | Actiune | Frictiune |
|
||||
|---|---|---|---|
|
||||
| Certa | exact in stratul GOLD (validat de om, orice cont) sau override cont | auto-trimite | zero |
|
||||
| Inalta | embedding NN cu similaritate FOARTE inalta la o mapare GOLD + ensemble LLM unanim | auto-trimite (prag calibrat) | zero |
|
||||
| Medie | LLM silver / similaritate medie | `needs_mapping` cu sugestie pre-completata -> 1 click | minima |
|
||||
| Joasa | similaritate slaba / coduri apropiate | `needs_mapping` manual | normala |
|
||||
| NUL | non-operatie (ITP, discount, nr. inmatriculare) | marcat „nu e operatie", suprimat | — |
|
||||
|
||||
**Invariant F1 (pastrat):** o eticheta pur-LLM NEVALIDATA nu auto-trimite singura;
|
||||
auto-send cere ori GOLD (validat de om), ori treapta „inalta" calibrata. Tensiunea
|
||||
centrala (utilizatorul se bazeaza pe LLM, dar FINALIZATA e ireversibil) = intrebarea
|
||||
cheie pentru reviziile plan: unde fix se aseaza bara treptei „inalta".
|
||||
|
||||
## Integrare
|
||||
|
||||
- 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: 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).
|
||||
|
||||
## Non-obiective
|
||||
|
||||
- Nu inlocuim confirmarea umana pentru cazuri incerte.
|
||||
- Nu trimitem automat coduri sub prag / etichete LLM nevalidate.
|
||||
- Nu adaugam dependenta cloud la RUNTIME (LLM doar offline pentru etichetare).
|
||||
- Nu antrenam un LLM generativ local acum (viitor).
|
||||
|
||||
## Riscuri
|
||||
|
||||
- Etichete LLM gresite tratate ca adevar daca scapa garda F1 (seed direct in GOLD).
|
||||
- Ensemble aceeasi familie (NVIDIA) -> acord corelat-gresit; supraestimare incredere.
|
||||
- Strat partajat cross-account: o denumire poate insemna lucruri diferite la
|
||||
service-uri diferite -> conflict; mitigat prin override per-cont + provenienta.
|
||||
- Drift: denumiri noi neacoperite; embeddings ajuta dar nu elimina.
|
||||
- Free tier OpenRouter flaky (429/404, cap 50/zi) -> etichetarea bulk e lenta;
|
||||
e offline, deci tolerabil, dar nu pe calea critica de productie.
|
||||
- Model embedding ales: calitate pe limba romana de verificat empiric.
|
||||
|
||||
<!-- AUTONOMOUS DECISION LOG -->
|
||||
## Decision Audit Trail
|
||||
|
||||
| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins |
|
||||
|---|------|---------|-------------|-----------|-------------|---------|
|
||||
| 1 | Eng | Seed-ul NU intra direct in stratul auto-send; etichetele LLM = strat SILVER (sugestii). Auto-send cere GOLD (validat de om) sau treapta inalta calibrata | TASTE (critic) | P1, P5 | `resolve_prestatii`->`queued` direct => seed auto = AUTO-TRIMITERE ghiciri la FINALIZATA ireversibil (Premisa 3) | seed direct in auto-send |
|
||||
| 2 | Eng | Seeder = `INSERT OR IGNORE` / refuza overwrite pe randuri validate de om | MECHANICAL | P1 | re-rularea ar clobber-ui maparile umane cu ghiciri LLM | ON CONFLICT UPDATE |
|
||||
| 3 | Eng | Scrub regex (nr. inmatriculare/VIN) inainte de trimitere la LLM | TASTE | P1 | gunoiul contine `ITP CT 12 ABC` = nr. inmatriculare = PII | trimitere text brut |
|
||||
| 4 | Eng | NUL = ancore negative in corpus + lista supresie | MECHANICAL | P1 | altfel gunoiul recurent reintra mereu in needs_mapping si fuzzy ii da cod gresit | exclude NUL |
|
||||
| 5 | Eng | Coloana `source`/`confidence` (provenienta) pe baza de cunostinte | MECHANICAL | P1 | audit + rollback batch model prost + safe re-seed | fara provenienta |
|
||||
| 6 | Eng | Runtime = embeddings + clasificator mic (sklearn), NU LLM generativ | TASTE | P3, P5 | LLM generativ local prea lent (Premisa 4); embeddings CPU suficiente + rapide | LLM la runtime |
|
||||
| 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)
|
||||
|
||||
Versiunea anterioara a trecut prin `/autoplan` (mod SELECTIVE EXPANSION, subagent-only,
|
||||
Codex indisponibil). Constatari portante atunci: **F1 CRITIC** (seed=auto-send),
|
||||
F2/F3/F4 HIGH (idempotenta seed, scrub PII, ancore NUL), F5/F6/F7/F8 MEDIUM. Acele
|
||||
decizii sunt incorporate in Decision Audit Trail de mai sus. Pivotul 2026-06-28
|
||||
(LLM offline-only + runtime embeddings + strat partajat cross-account) NECESITA o
|
||||
noua rulare de review (CEO / Eng / Design) — de aceea sectiunea GSTACK REVIEW REPORT
|
||||
e goala momentan si se completeaza la urmatoarea rulare.
|
||||
|
||||
## GSTACK REVIEW REPORT
|
||||
|
||||
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).
|
||||
611
docs/prd/prd-5.15-propagare-design-dashboard-editare.md
Normal file
611
docs/prd/prd-5.15-propagare-design-dashboard-editare.md
Normal file
@@ -0,0 +1,611 @@
|
||||
# PRD 5.15 — Propagare design landing in aplicatie (dashboard compact + editare slim, VIN unic, prestatii multi-select)
|
||||
|
||||
**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`.
|
||||
> Mockup-uri piese fara design (REFERINTA VIZUALA OBLIGATORIE): `docs/mockups/prd-5.15-mockups.html`
|
||||
> — strip sanatate D6 (stari rosu/verde), picker prestatii E4 (op<->cod), reveal odometru initial.
|
||||
> Acopera exact piesele pe care mockup-urile landing nu le aratau si corecteaza contradictiile
|
||||
> mockup<->PRD (VIN unic, contor all-time, culori prin tokeni).
|
||||
> Starea trece: `draft -> aprobat -> in-executie -> verify-pass -> inchis`.
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Propagam sistemul de design al landing-ului comercial (carduri/liste/formulare compacte,
|
||||
slim, si cele 4 teme grafit/cobalt/cupru/hartie) in aplicatia reala. Concret: dashboard-ul
|
||||
Acasa primeste cardurile-contor + lista de trimiteri slim din mockup-ul hero, iar formularul
|
||||
de editare trimitere primeste designul compact din mockup-ul "prestatie noua", cu **un singur
|
||||
camp VIN**, **Observatii** ca text liber pentru operatiile de service si **prestatii ca chips
|
||||
multi-select** de coduri RAR. Userul a cerut explicit replicarea acestor doua mockup-uri pentru
|
||||
ca ii place cat de compacte/slim sunt.
|
||||
|
||||
Decizii de produs confirmate cu userul (poarta de aprobare a acestui PRD):
|
||||
- **D1**: cardurile-contor INLOCUIESC bara de status actuala (`_status.html`); pastram doar
|
||||
indicatorii de sanatate worker/RAR intr-o forma compacta.
|
||||
- **D2**: temele sunt ADITIVE — pastram light/dark/petrol + Auto SI adaugam cele 4 din landing
|
||||
(grafit/cobalt/cupru/hartie). Selectorul ciclic le parcurge pe toate. (grafit ~ dark si
|
||||
hartie ~ light raman optiuni separate, la cererea userului.)
|
||||
- **D3**: prestatiile sunt chips reale multi-select — utilizatorul poate adauga mai multe coduri
|
||||
din nomenclatorul RAR si poate sterge oricare; se trimite lista `prestatii` completa (RAR
|
||||
accepta lista `{codPrestatie, idPrezentare:null}` — `docs/api-rar-contract.md` §payload).
|
||||
- **D4** (contor Trimise): cardul "Trimise" arata trei valori temporale — **all-time** (principal)
|
||||
+ **luna asta** + **azi** (secundar). Necesita extinderea numaratorilor cu `sent_today`/`sent_month`.
|
||||
- **D5** (Observatii = operatii service): in API-ul RAR, campul `obs` e DE FAPT denumirea operatiilor
|
||||
din service. Deci `obs` = text liber cu operatiile efectuate; la import, daca fisierul nu are
|
||||
coloana Observatii, **concatenam denumirea operatiei de service in `obs`**. `obs` ramane in
|
||||
`payload_json` (camp din contractul RAR), fara coloana noua.
|
||||
|
||||
Decizii din /plan-ceo-review (2026-06-28, mod SELECTIVE EXPANSION):
|
||||
- **D6** (sanatate mereu-vizibila): cardurile-contor inlocuiesc bara de status, DAR sanatatea
|
||||
(worker viu? RAR accesibil? ultima autentificare) ramane intr-un **strip mereu-vizibil, colorat,
|
||||
deasupra contoarelor** (verde "declaratiile curg" / rosu "blocat: worker oprit / RAR inaccesibil").
|
||||
Invariant: zero-silent-failures — semnalul critic NU se ingroapa sub volum. (Rafineaza D1.)
|
||||
- **D7** (operatie -> obs, fara regresie de mapare): la import, denumirea operatiei RAMANE in
|
||||
`op_service` (sursa pentru maparea op->cod) SI se COPIAZA in `obs`. `obs` e sink aditional, nu
|
||||
mutare; fluxul needs_mapping ramane neatins. (Rafineaza D5.)
|
||||
- **D8** (idempotenta obs): `obs` e EXCLUS din cheia de idempotenta (`idempotency.py:98`). Deci
|
||||
editarea `obs` NU schimba cheia si NU poate crea duplicate — corecteaza AC-ul gresit din US-005.
|
||||
`prestatii` ESTE in cheie (sortat dupa cod) — multi-select re-cheieaza randul (US-006).
|
||||
- **D9** (secventiere): 5.15 INAINTE de 5.14 (mapare LLM). Editorul manual defineste forma listei
|
||||
`prestatii` si UX-ul de confirmare; 5.14 umple codurile peste aceeasi forma.
|
||||
- **D10** (extinderi acceptate, SELECTIVE EXPANSION): toate 4 intra in scope — (a) salvare mapare
|
||||
din chip (US-009), (b) bulk-fix din lista (US-010), (c) require dinamic odometruInitial la chip
|
||||
R-ODO/I-ODO (US-007), (d) editare keyboard-first in form slim (US-007).
|
||||
|
||||
Decizii din /plan-eng-review (2026-06-28, model claude/opus; outside-voice = Claude subagent,
|
||||
Codex a atins usage-limit). Fiecare confirmata cu userul:
|
||||
- **E1** (ARCH, /repune nu mai sterge operatia): `/repune` face azi `p0.pop("cod_op_service")`
|
||||
la `routes.py:1326` — sterge operatia cand se seteaza un cod direct, rupand D7 si US-009.
|
||||
US-006 ELIMINA acel `pop` si pastreaza `cod_op_service`; test de regresie obligatoriu
|
||||
(op_service supravietuieste unui /repune cu cod). (Rafineaza US-006.)
|
||||
- **E2** (DRY teme, fisier fierbinte): config-ul de teme e duplicat in ~7 locuri in base.html
|
||||
(anti-FOUC `VALID` la :22 + cinci literali paraleli `CYCLE`/`VALID`/`ICONS`/`LABELS`/`NEXT`
|
||||
la :758-765). US-001 CONSOLIDEAZA intr-o singura structura sursa-de-adevar (`THEMES`
|
||||
ordonata) din care se DERIVA ciclul/etichetele/iconitele + setul anti-FOUC. Adaugarea unei
|
||||
teme = o intrare. (Rafineaza US-001.)
|
||||
- **E3** (obs concat idempotent): la import, copierea denumirii operatiei in `obs` se face
|
||||
DERIVE-ON-EMPTY (doar cand `obs` e gol) ca sa fie idempotenta la re-import/re-editare. Test
|
||||
dedicat anti-dublu-concat ("Schimb ulei; Schimb ulei"). (Rafineaza US-005.)
|
||||
- **E4** (binding operatie<->cod in chips — HIGH): chip-urile NU sunt o lista plata de coduri.
|
||||
Cand exista operatii (`cod_op_service`), UI-ul randeaza UN picker PE operatie (eticheta op +
|
||||
chip-ul ei de cod), pastrand perechea per-item pe care modelul o are deja; lista plata de
|
||||
coduri libere DOAR pentru cazul fara operatie (corectie pura). Astfel US-009 citeste perechea
|
||||
direct, iar deduparea e PER-ITEM (nu "dupa cod" — doua operatii distincte pot mapa legitim la
|
||||
acelasi cod RAR). (Rafineaza US-006 AC2 + US-007 AC3 + US-009.)
|
||||
- **E5** (serializare Val 3 pe routes.py): US-005 si US-006 rescriu ACEEASI functie
|
||||
`post_corecteaza` (`routes.py:1120-1262`). Regula "un singur autor pe fisier fierbinte" se
|
||||
EXTINDE la routes.py in Val 3: US-005 INAINTE de US-006 (secvential, nu paralel). (Rafineaza §6.)
|
||||
- **E6** (US-007 HTMX server-driven PRIMARY): inversam abordarea — chips add/remove via `hx-post`
|
||||
care re-randeaza partial-ul chips+form; reveal-ul conditional `odometruInitial` rezulta GRATIS
|
||||
din re-randarea server; navigare tastatura = `<select>`/`<datalist>` nativ. JS custom DOAR ca
|
||||
progressive enhancement (snappiness), nu calea principala. Elimina path-ul dublu JS/no-JS.
|
||||
(Rafineaza US-007.)
|
||||
- **E7** (contoare in timp local RO): `azi`/`luna asta` se bucketeaza in timp local RO (UTC+2/+3),
|
||||
nu UTC — `updated_at` e `datetime('now')` UTC, deci `date(updated_at)` pur ar numara gresit
|
||||
trimiterile dintre miezul noptii local si ~03:00. Folosim offset RO (ex. `date(updated_at,'+3 hours')`
|
||||
cu aceeasi baza `now`) + test la granita de miez de noapte local. (Rafineaza US-003.)
|
||||
- **E8** (interleave fix authz GET-listari — securitate): CLAUDE.md noteaza scurgere cross-cont
|
||||
deschisa ("GET-urile de listare sunt globale + neprotejate"). Userul a ales sa INTERLEAVE
|
||||
remedierea in 5.15 -> story noua **US-011** (account-scope pe GET-urile de listare + teste),
|
||||
nu queue dupa polish-ul de teme.
|
||||
|
||||
Fapte verificate care fundamenteaza scope-ul (nu presupuneri):
|
||||
- `vin` la RAR e **un singur camp** (17 car., MAJUSCULE, fara O/I/Q) — cerinta "fara 2 campuri
|
||||
VIN" e deja respectata azi (`_form_editare.html` are un singur `vin`); ramane sa NU regresam.
|
||||
- `prestatii` e deja **lista** in modelul intern (`mapping.resolve_prestatii(prestatii: list[dict])`)
|
||||
si in contractul RAR — multi-select nu cere model nou, ci editor nou.
|
||||
- `obs` exista deja ca alias de coloana la import (`import_router.py:71` — Observatii/Obs/Mentiuni/Note)
|
||||
si ca text liber optional in contractul RAR (`obs`); azi NU e editabil in formular.
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- Fara modificari pe backend-ul de trimitere: worker, masina de stari, idempotenta-logica
|
||||
(`build_key`), reconciliere, contract RAR. Recalcularea idempotentei la editare foloseste
|
||||
mecanismul EXISTENT (ca la 3.5/5.10), nu unul nou.
|
||||
- Fara migrare de schema decat daca strict necesar. `obs` si `prestatii` traiesc in
|
||||
`submissions.payload_json` (de confirmat la US-005) — fara coloane noi daca payload-ul le poarta.
|
||||
- Fara stergerea functionalitatii listei de trimiteri: filtre (data/vehicul/stare), paginare,
|
||||
bulk-delete pe randuri blocate, click->detaliu raman; se schimba DOAR aspectul randului (slim).
|
||||
- Fara schimbarea regulilor de mapare operatie->cod sau a validarii nomenclatorului RAR
|
||||
(`mapping.py`, `validation.py` raman ca atare; doar callsite-urile de editare le folosesc cu lista).
|
||||
- Fara redesign al landing-ului (deja livrat in 5.x); aici doar IMPORTAM stilul lui in app.
|
||||
|
||||
## 3. Stories atomice
|
||||
|
||||
> Backend + UI pentru acelasi comportament = 2 stories. `base.html` e fisier FIERBINTE
|
||||
> (serializat intre valuri — un singur autor pe val). Toate UI verificate pe un esantion de teme (o tema luminoasa + una intunecata).
|
||||
|
||||
### US-001: Teme aditive (light/dark/petrol + grafit/cobalt/cupru/hartie) + tokeni `--card2`/`--line2`
|
||||
**Ca** operator de service **vreau** aceleasi teme ca pe landing **pentru ca** aplicatia sa para
|
||||
acelasi produs, coerent vizual.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **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**:
|
||||
- [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`.
|
||||
- [x] Selectorul ciclic parcurge TOATE: light -> dark -> petrol -> grafit -> cobalt -> cupru ->
|
||||
hartie -> Auto, afiseaza eticheta temei curente, persistenta `localStorage` (D2).
|
||||
- [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).
|
||||
- [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).
|
||||
- [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).
|
||||
- [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.
|
||||
|
||||
### US-002: Componente de design slim in `base.html` (CSS, fara consumatori inca)
|
||||
**Ca** dezvoltator **vreau** clase reutilizabile pentru carduri-contor, lista slim, campuri slim si
|
||||
chips **pentru ca** dashboard-ul si formularul sa le consume DRY, identic cu mockup-ul.
|
||||
|
||||
- **Depinde de**: US-001 (foloseste `--card2`/`--line2`)
|
||||
- **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**:
|
||||
- [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).
|
||||
- [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.
|
||||
- [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).
|
||||
- [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).
|
||||
- [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`.
|
||||
- [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
|
||||
**Ca** operator **vreau** cele 3 carduri-contor compacte (Trimise / In coada / De corectat)
|
||||
**pentru ca** sa vad starea dintr-o privire, ca in mockup.
|
||||
|
||||
- **Depinde de**: US-002
|
||||
- **Fisiere**: `app/web/templates/_status.html`, `app/web/templates/_acasa.html`,
|
||||
`app/web/routes.py` (`_status_counts` extins cu `sent_today`/`sent_month`), `tests/test_web_status.py`,
|
||||
`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**:
|
||||
- [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:
|
||||
semnalul "declaratiile NU pleaca" e imposibil de ratat, NU ingropat sub volum.
|
||||
**Layout exact (din mockup)**: strip full-width sus; glifa (✗ rosu / ✓ verde) + text bold la
|
||||
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`.
|
||||
- [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).
|
||||
- [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`.
|
||||
- [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
|
||||
la +90z (`purge_after` se seteaza in ACEEASI scriere care marcheaza `sent`), deci `updated_at`
|
||||
== momentul trimiterii pentru randurile `sent` -> fara migrare de coloana (respecta Non-Goal).
|
||||
Daca pe viitor apar scrieri post-`sent`, reevalueaza o coloana `sent_at` dedicata.
|
||||
**Timezone (E7)**: `updated_at` e `datetime('now')` = UTC; bucketarea `azi`/`luna asta`
|
||||
se face in TIMP LOCAL RO (ex. `date(updated_at,'+3 hours')`, aceeasi baza `now`), altfel
|
||||
trimiterile dintre miezul noptii local si ~03:00 cad pe ziua precedenta si "luna asta" e
|
||||
gresita in primele ore ale zilei de 1. Test la granita de miez de noapte local.
|
||||
**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.
|
||||
- [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.
|
||||
- [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.
|
||||
|
||||
### US-004: Lista de trimiteri — rand slim (VIN + operatie·ora + pill)
|
||||
**Ca** operator **vreau** lista de trimiteri in stil slim ca in mockup **pentru ca** e mai compacta
|
||||
si mai usor de scanat, pastrand filtrele si actiunile.
|
||||
|
||||
- **Depinde de**: US-002
|
||||
- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/templates/_coada.html` (filtre raman),
|
||||
`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**:
|
||||
- [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).
|
||||
- [x] Filtre (data/vehicul/stare — `_coada.html`), paginarea numerotata si bulk-delete pe randuri
|
||||
blocate (checkbox doar pe `gestionabil`) raman FUNCTIONALE.
|
||||
- [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.
|
||||
|
||||
### US-005: Backend — `obs` (Observatii) editabil si persistat
|
||||
**Ca** operator **vreau** sa editez Observatiile (operatiile de service in text liber) **pentru ca**
|
||||
sa corectez/completez ce s-a facut, separat de codurile RAR.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/routes.py` (`/trimitere/{id}/corecteaza`),
|
||||
`app/api/v1/import_router.py` (`/_import/{id}/rand/{row}/editeaza`, `EDIT_FIELDS`),
|
||||
`app/validation.py` (obs optional), `app/payload_view.py` (echo obs),
|
||||
`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**:
|
||||
- [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.)
|
||||
- [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.
|
||||
- [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.
|
||||
- [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.
|
||||
|
||||
### US-006: Backend — prestatii multi-cod (lista) la editare/corectie
|
||||
**Ca** operator **vreau** sa adaug/sterg mai multe coduri RAR pe o trimitere **pentru ca** o
|
||||
comanda poate avea mai multe prestatii, asa cum accepta RAR.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/routes.py` (`/corecteaza`, `/repune` — **rescrie logica single-`prestatii[0]`**
|
||||
de azi: `cod_prestatie_curent` la `routes.py:977-982` + injectia la `1146-1164`/`1288-1324`
|
||||
presupun UN cod; multi-select cere pre-fill din lista intreaga + scriere pe toti itemii),
|
||||
`app/api/v1/import_router.py` (`/editeaza`, idem), `app/mapping.py` (NEATINS — deja accepta lista),
|
||||
`app/validation.py` (fiecare cod in nomenclator), `tests/test_web_corectie*.py`,
|
||||
`tests/test_mapping*.py` (~6 fisiere). Nota: `mapping.py` e neatins, dar call-site-urile din
|
||||
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**:
|
||||
- [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.
|
||||
`idPrezentare:null` se adauga in `payload.py` la construirea payload-ului, NU in itemul intern.
|
||||
**E1 (CRITIC)**: `/repune` face azi `p0.pop("cod_op_service", None)` la `routes.py:1326` —
|
||||
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.
|
||||
- [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).
|
||||
- [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).
|
||||
- [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.
|
||||
- [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.
|
||||
|
||||
### US-007: UI — formular editare slim (VIN unic, Observatii, chips prestatii)
|
||||
**Ca** operator **vreau** formularul de editare in design slim cu chips de prestatii **pentru ca**
|
||||
e compact si imi arata clar codurile RAR si observatiile, ca in mockup.
|
||||
|
||||
- **Depinde de**: US-002, US-005, US-006
|
||||
- **Fisiere**: `app/web/templates/_form_editare.html`, `app/web/templates/_macros.html`,
|
||||
`app/web/templates/_trimitere_detaliu.html`, `app/web/templates/_editare_preview_modal.html`,
|
||||
`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**:
|
||||
- [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").
|
||||
- [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).
|
||||
- [x] Acelasi `_form_editare.html` slujeste ambele modale (detaliu `/corecteaza` si preview
|
||||
`/editeaza`), fara duplicare; degradare fara JS rezonabila (chips ca lista, picker = select).
|
||||
- [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.
|
||||
- [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.
|
||||
- [x] Stilizare fidela mockup-ului pe toate temele; tinte 44px pe mobil; a11y (label-uri, aria,
|
||||
anunt de chip adaugat/sters pentru screen-reader).
|
||||
- [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.
|
||||
- [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
|
||||
"alege cod RAR" cu eticheta "lipsa cod". OdometruInitial: ascuns implicit (doar hint discret
|
||||
"se cere doar pentru R-ODO/I-ODO") si DEZVALUIT cu bordura-stanga galbena + label "necesar pentru
|
||||
R-ODO" cand lista de chips contine R-ODO/I-ODO.
|
||||
- **Verificare E2E**: browser — editare trimitere needs_data: schimb VIN + scriu Observatii + adaug
|
||||
2 coduri RAR (chips, cu tastatura) + adaug R-ODO (apare odometruInitial) + sterg un chip -> salvare
|
||||
-> persistat; identic in preview import.
|
||||
|
||||
### US-008: Teste de regresie + E2E final pe cele 4 teme
|
||||
**Ca** dezvoltator **vreau** acoperire si o trecere E2E completa **pentru ca** redesign-ul atinge
|
||||
fisiere fierbinti (base.html) si nu vreau regresii pe teme/liste/formular.
|
||||
|
||||
- **Depinde de**: US-003, US-004, US-007
|
||||
- **Fisiere**: `tests/test_web_responsive.py`, `tests/test_tema.py`, `tests/test_web_submissions.py`
|
||||
(~3 fisiere)
|
||||
- **Test intai (RED)**: completare scenarii lipsa (componente noi pe TOATE temele; slim list desktop+mobil)
|
||||
- **Acceptance criteria**:
|
||||
- [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.
|
||||
- [ ] E2E Playwright pe 390/820/1280, pe un dark (grafit) + un light (hartie) + petrol (verificare
|
||||
ca temele vechi nu au regresat): strip sanatate, dashboard contoare, lista slim cu
|
||||
filtre/paginare/bulk, formular slim cu chips, fara overflow orizontal.
|
||||
|
||||
### US-009: Salvare mapare din chip (compounding cu fluxul de mapare)
|
||||
**Ca** operator **vreau** ca atunci cand adaug un cod RAR la o operatie sa-l pot salva ca regula
|
||||
**pentru ca** data viitoare operatia sa se auto-rezolve, fara sa re-mapez manual.
|
||||
|
||||
- **Depinde de**: US-006, US-007
|
||||
- **Fisiere**: `app/web/templates/_form_editare.html`, `app/web/routes.py` (reuse `save_mapping` +
|
||||
`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**:
|
||||
- [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.
|
||||
- [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.
|
||||
|
||||
### US-010: Bulk-fix din lista (selectie multipla -> actiune unica)
|
||||
**Ca** operator **vreau** sa corectez mai multe randuri blocate dintr-o data **pentru ca** la 2-20
|
||||
de corectat/zi nu vreau sa intru in fiecare individual.
|
||||
|
||||
- **Depinde de**: US-004, US-006
|
||||
- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/routes.py` (reuse infra bulk
|
||||
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**:
|
||||
- [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`).
|
||||
- [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.
|
||||
|
||||
### US-011: Securitate — account-scope pe GET-urile de listare (interleave, E8)
|
||||
**Ca** operator **vreau** ca listarile sa-mi arate DOAR trimiterile contului meu **pentru ca**
|
||||
azi GET-urile de listare sunt globale + neprotejate (scurgere VIN/PII cross-cont, notata in CLAUDE.md).
|
||||
|
||||
- **Depinde de**: — (backend pur, independent de UI; ruleaza in paralel cu valurile de design)
|
||||
- **Fisiere**: `app/web/routes.py` (GET-urile de listare trimiteri), `app/api/v1/router.py`
|
||||
(GET-urile API de listare daca sunt globale), `app/auth.py` (refolosire scope existent),
|
||||
`tests/test_web_scope.py`, `tests/test_api_scope.py` (~5 fisiere)
|
||||
- **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**:
|
||||
- [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).
|
||||
- [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.
|
||||
- [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.
|
||||
|
||||
### US-012: Analytics device-mix (validare premisa mobil, in-PR)
|
||||
**Ca** owner **vreau** sa stiu raportul desktop/mobil al operatorilor **pentru ca** sa decid daca
|
||||
rafinarile mobil (390px) viitoare merita efortul (premisa nevalidata din TODOS 5.13/CEO-F1).
|
||||
|
||||
- **Depinde de**: — (instrumentare backend, independenta de UI)
|
||||
- **Fisiere**: `app/web/routes.py` (sau middleware existent), `app/schema.sql` SAU `app_events`
|
||||
(reuse tabela de evenimente existenta — fara coloana noua daca `app_events` poarta semnalul),
|
||||
`tests/test_device_mix.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `test_device_mix_inregistrat`, `test_device_mix_fara_pii`.
|
||||
- **Acceptance criteria**:
|
||||
- [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.
|
||||
- [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
|
||||
|
||||
- **base.html fisier fierbinte**: US-001/US-002 il ating amandoua + US-003/004/007 il citesc.
|
||||
Serializeaza pe valuri (un singur autor pe val pe base.html), ca la 5.12/5.13.
|
||||
- **Migrare teme legacy**: useri cu `localStorage.theme` = light/dark/petrol. Mitigare: maparea
|
||||
grafioasa din US-001 (light->hartie, dark->grafit, petrol->grafit) + test dedicat.
|
||||
- **Restyle lista = pierdere de functii**: filtre/paginare/bulk pot fi sparte de schimbarea de
|
||||
markup. Mitigare: US-004 are AC explicite pentru pastrarea lor + teste lock.
|
||||
- **Idempotenta la prestatii multiple**: schimbarea listei schimba cheia canonica. Mitigare:
|
||||
refolosim mecanismul existent de recalcul + prindere coliziune (3.5/5.10), zero logica noua.
|
||||
- **Densitate vizuala pe mobil**: randul slim cu 2 linii + pill poate aglomera. Mitigare: tinte
|
||||
44px + verificare 390px in US-004/008.
|
||||
- **Premisa mobil nevalidata** (din TODOS 5.13, CEO F1): valoarea slim/compact pe mobil presupune
|
||||
utilizare reala pe mobil. Daca device-mix-ul e ~95% desktop, partea responsive e efort irosit.
|
||||
Mitigare: nu blocheaza (designul e bun si pe desktop), dar confirma analytics inainte de a investi
|
||||
in rafinari mobil viitoare.
|
||||
- **7 teme = suprafata de test/intretinere** pe fisierul cel mai fierbinte: fiecare componenta noua
|
||||
trebuie corecta in 7+1 stari. Istoricul (5.13) arata ca testele de tema au dat false-green o data.
|
||||
Mitigare: US-008 cere test parametrizat ancorat pe SENTINEL (nu felii fixe); deduparea
|
||||
grafit~dark / hartie~light ramana optiune de simplificare (reziduala, non-blocanta).
|
||||
- **Secventiere cu 5.14** (D9): 5.15 defineste forma listei `prestatii`; daca 5.14 (mapare LLM)
|
||||
porneste in paralel, sincronizeaza forma listei. Mitigare: 5.15 INAINTE de 5.14.
|
||||
|
||||
## 5. Intrebari deschise
|
||||
|
||||
> Toate intrebarile au fost REZOLVATE cu userul (vezi D1-D5 §1). Pastrate aici ca istoric al deciziei.
|
||||
|
||||
- **I1 — contor Trimise** [REZOLVAT]: arata all-time + luna asta + azi (D4). `_status_counts` extins.
|
||||
- **I2 — teme** [REZOLVAT]: aditiv — light/dark/petrol + Auto + grafit/cobalt/cupru/hartie (D2).
|
||||
- **I3 — stocare obs** [REZOLVAT]: in `payload_json`, fara coloana noua (D5).
|
||||
- **I4 — operatii la import -> obs** [REZOLVAT]: concatenam denumirea operatiei in `obs` cand
|
||||
fisierul nu are coloana Observatii (D5).
|
||||
- Reziduale minore (de decis la executie, non-blocante): formatul exact de concatenare a denumirilor
|
||||
in `obs`; rezolvarea "Auto" la light vs hartie; eventuala deduplicare grafit~dark / hartie~light
|
||||
in eticheta selectorului.
|
||||
|
||||
## 6. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
Val 0: [US-011] authz GET-listari (backend pur; ruleaza in paralel cu orice val) ||
|
||||
Val 1: [US-001] base.html teme + tokeni (autor unic pe base.html)
|
||||
Val 2: [US-002] base.html componente (dupa US-001, autor unic pe base.html)
|
||||
Val 3: [US-003] [US-004] dashboard + strip sanatate + lista (consuma US-002; disjuncte) ||
|
||||
[US-005] -> [US-006] backend obs APOI prestatii — SECVENTIAL (E5): ambele rescriu
|
||||
ACEEASI functie post_corecteaza (routes.py:1120-1262), autor unic
|
||||
Val 4: [US-007] formular slim cu chips (dupa US-002+US-005+US-006)
|
||||
Val 5: [US-009] [US-010] salvare mapare din chip || bulk-fix (dupa US-006/007 resp. US-004)
|
||||
— disjuncte la nivel de template (_form_editare vs _submissions)
|
||||
Val 6: [US-008] regresie + E2E final (dupa toate)
|
||||
```
|
||||
|
||||
> **Regula autor-unic extinsa (E5)**: pe langa base.html, `routes.py` are autor unic in Val 3:
|
||||
> US-005 INAINTE de US-006 (ambele in `post_corecteaza`). US-009/US-010 in Val 5 sunt disjuncte
|
||||
> la nivel de template; adauga rute noi separate in routes.py (regiuni diferite, mergeabile).
|
||||
|
||||
> Secventiere fata de alte PRD-uri (D9): **5.15 INAINTE de 5.14** (mapare LLM) — 5.15 fixeaza forma
|
||||
> listei `prestatii` si UX-ul de confirmare; 5.14 umple codurile peste aceeasi forma.
|
||||
|
||||
---
|
||||
|
||||
## Raport 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.
|
||||
|
||||
---
|
||||
|
||||
## GSTACK REVIEW REPORT
|
||||
|
||||
Review: `/plan-ceo-review` — 2026-06-28. Mod: **SELECTIVE EXPANSION**. Model: claude (opus).
|
||||
Abordare aleasa de user: tot PRD-ul (8 stories) + 4 extinderi acceptate -> **10 stories**.
|
||||
|
||||
| Pasaj | Status | Constatari materiale |
|
||||
|-------|--------|----------------------|
|
||||
| Audit sistem | OK | base.html cel mai fierbinte fisier (31x/30z); 5.15 = a 5-a iteratie pe acelasi UI (smell recurent); 5.14 in flight pe acelasi seam |
|
||||
| S1 Arhitectura | OK | Fara componente noi; fara migrare; rollback = revert template. Concentrare de risc pe base.html, nu coupling |
|
||||
| S2 Eroare/Rescue | 2 GAP | (a) coduri duplicate in chips nedefinit -> US-006 dedupe; (b) cod necunoscut: invariant ORA-12899 pastrat |
|
||||
| S4 Edge cases | 1 GAP HIGH | R-ODO/I-ODO cere odometruInitial; formularul nu il forta -> US-007 require dinamic (D10c) |
|
||||
| S2/Idempotenta | 1 FIX | `obs` EXCLUS din cheie (`idempotency.py:98`) -> AC US-005 corectat (D8); `prestatii` in cheie -> re-cheiere OK |
|
||||
| S6 Test | 1 GAP | 7 teme x componente pe fisier fierbinte; "esantion" prea lax -> US-008 test parametrizat ancorat pe SENTINEL |
|
||||
| S8/S11 Trust | 1 HIGH | carduri-contor ascundeau sanatatea -> strip mereu-vizibil deasupra contoarelor (D6) |
|
||||
| S9 Deploy | OK | Fara migrare; doar sent_today/sent_month (scoped). Rollback ieftin |
|
||||
| S10 Trajectorie | 1 DECIZIE | secventiere 5.15 inainte de 5.14 (D9) |
|
||||
| S11 Design/UX | OK + 4 EXT | strip trust; extinderi: salvare mapare din chip, bulk-fix, require dinamic odo, keyboard-first |
|
||||
|
||||
**Decizii incorporate (D6-D10):** strip sanatate mereu-vizibil (D6); operatie ramane in op_service +
|
||||
copiata in obs (D7); obs exclus din idempotenta, AC corectat (D8); 5.15 inainte de 5.14 (D9); cele 4
|
||||
extinderi acceptate (D10) -> US-007 imbogatit + US-009 (salvare mapare din chip) + US-010 (bulk-fix).
|
||||
|
||||
**Risc rezidual notat (non-blocant):** premisa "utilizare mobil reala" nevalidata (TODOS 5.13 F1);
|
||||
7 teme = suprafata de test pe fisier fierbinte (deduparea grafit~dark/hartie~light ramana optiune).
|
||||
|
||||
**Spec-review loop (reviewer independent, context curat) — scor 7/10, verdict ISSUES -> remediat:**
|
||||
- #1 HIGH (contradictie): US-006 spunea "reconstruieste prestatii ca itemi goi `{cod_prestatie}`",
|
||||
ceea ce ar fi sters `cod_op_service`/`denumire` -> rupea D7 si US-009. **Remediat**: US-006 pastreaza
|
||||
itemii existenti, seteaza doar `cod_prestatie`; pereche operatie<->cod definita; `idPrezentare` se
|
||||
adauga in `payload.py`, nu in itemul intern.
|
||||
- #2 MEDIUM: `sent_today`/`sent_month` nu aveau sursa de timp (nu exista `sent_at`). **Remediat**:
|
||||
US-003 foloseste `status='sent' AND date(updated_at)` cu justificare (randul `sent` nu mai e scris
|
||||
pana la purge la +90z) -> fara migrare.
|
||||
- #3 MEDIUM: US-006 subestima rewrite-ul handler-elor (logica single-`prestatii[0]`). **Remediat**:
|
||||
Fisierele US-006 numesc liniile exacte de rescris.
|
||||
- #5/#6 LOW: suprafata JS reala (US-007) + tinta de click "De corectat" (US-003). **Remediat** (note adaugate).
|
||||
- #4 LOW (scope): US-009/US-010 sunt adiacente FUNCTIONALE (din SELECTIVE EXPANSION), dincolo de
|
||||
obiectivul pur de propagare design. **Acceptat constient** (alegerea userului); ramane optiunea de
|
||||
a le scoate intr-un PRD separat daca propagarea design e ce e urgent.
|
||||
|
||||
### /plan-eng-review — 2026-06-28 (model claude/opus; outside-voice = Claude subagent, Codex usage-limit)
|
||||
|
||||
| Review | Trigger | Why | Runs | Status | Findings |
|
||||
|--------|---------|-----|------|--------|----------|
|
||||
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | issues_open->remediat | 10 stories, 4 ext acceptate, spec-review remediat |
|
||||
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | issues_open->remediat | 7 issues (2 HIGH), 1 regresie IRON-RULE, +2 stories noi |
|
||||
| Outside Voice | Claude subagent | Independent 2nd opinion | 1 | issues_found | 10 findings; 2 HIGH absorbite, restul foldate |
|
||||
|
||||
**Step 0 scope:** acceptat ca-atare (10 stories). Gate de complexitate = breadth, nu depth (zero clase/servicii noi). User a confirmat pastrarea US-009/US-010.
|
||||
|
||||
**Constatari eng-review (toate confirmate cu userul si foldate in AC):**
|
||||
- **E1 (ARCH, HIGH, conf 9/10)** `routes.py:1326` `/repune` face `p0.pop("cod_op_service")` — sterge operatia, rupe D7+US-009. US-006: elimina pop + test regresie (IRON RULE).
|
||||
- **E2 (Code-quality, conf 9/10)** config teme duplicat ~7 locuri pe base.html (anti-FOUC + 5 literali). US-001: o singura structura `THEMES`, restul derivat.
|
||||
- **E3 (Test, conf 7/10)** obs concat la import poate dubla textul la re-import. US-005: derive-on-empty + test anti-dublu.
|
||||
- **E7 (Perf/Correctness, conf 8/10 — outside-voice)** `date(updated_at)` UTC numara gresit `azi`/`luna` peste granita local RO. US-003: bucketare timp local + test granita.
|
||||
|
||||
**Outside-voice (Claude subagent) — material absorbit:**
|
||||
- **OV/E4 (HIGH, conf 9/10)** chip-uri lista plata fara binding op<->cod -> rupe US-009; dedup-dupa-cod sterge operatie legala. US-006/007: picker PE operatie cand exista op; flat doar fara op; dedup per-item.
|
||||
- **OV/E5 (HIGH, conf 9/10)** Val 3 conflict same-function: US-005+US-006 rescriu `post_corecteaza`. §6: serializare US-005 -> US-006 pe routes.py.
|
||||
- **OV/E6 (MED, conf 8/10)** US-007 supraestimeaza JS custom intr-un app HTMX. US-007: hx-post server-driven primary; reveal odo gratis; select/datalist nativ.
|
||||
- **OV/E8 (MED securitate, conf 7/10)** GET-uri de listare globale neprotejate (scurgere VIN/PII cross-cont, CLAUDE.md). User a ales INTERLEAVE -> **US-011** (account-scope + teste).
|
||||
- **OV minore foldate:** reconcile drift pe `updated_at` (caveat US-003); cost poll non-sargabil (notat, non-blocant); cuplaj `EDIT_FIELDS` pentru obs preview (US-005 AC).
|
||||
|
||||
**TODO (decizie user):** premisa mobil nevalidata -> user a ales BUILD-IN-PR -> **US-012** (analytics device-mix, fara PII, reuse `app_events`).
|
||||
|
||||
**Scope actualizat:** 10 -> **12 stories** (+US-011 authz, +US-012 analytics). Fara migrare de schema. Outside-voice a confirmat "no migration" TRUE.
|
||||
|
||||
**Failure modes — gap critic:** niciun gap critic ramas silent. Cel mai aproape: E1 (regresie tacuta op_service pe /repune) — acum acoperit de test obligatoriu. E7 (off-by-a-day tacut) — acum cu test de granita.
|
||||
|
||||
**VERDICT:** CEO + ENG CLEARED — gata de executie. 12 stories. Outside-voice absorbit (2 HIGH foldate). Fara migrare de schema.
|
||||
|
||||
NO UNRESOLVED DECISIONS
|
||||
1064
docs/prd/prd-5.16-tipografie-uniforma-bugfix-editare.md
Normal file
1064
docs/prd/prd-5.16-tipografie-uniforma-bugfix-editare.md
Normal file
File diff suppressed because it is too large
Load Diff
960
docs/prd/prd-5.17-tipuri-cont-planuri-trial.md
Normal file
960
docs/prd/prd-5.17-tipuri-cont-planuri-trial.md
Normal file
@@ -0,0 +1,960 @@
|
||||
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/docs-prd-5.16-5.17-design-tiers-autoplan-restore-20260628-212453.md -->
|
||||
# PRD 5.17 — Tipuri de cont (planuri) + trial Pro 30 zile + enforcement
|
||||
|
||||
**Stare**: draft
|
||||
|
||||
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> Landing comercial cu planurile: `app/web/templates/landing.html` (sectiunea PRICING).
|
||||
> Lifecycle cont existent: `app/accounts.py`, `app/schema.sql` (tabela `accounts`, coloana `status`).
|
||||
> Signup: `app/web/auth_routes.py` (`signup_post`, butoanele landing trimit `data-plan`).
|
||||
> Starea trece: `draft -> aprobat -> in-executie -> verify-pass -> inchis`.
|
||||
|
||||
## 1. Introducere
|
||||
|
||||
Landing-ul comercial promite patru planuri — **Gratuit**, **Standard (39 lei)**, **Pro (59 lei, cu
|
||||
API)**, **Premium (la cerere)** — si afirma ca **fiecare cont incepe cu acces gratuit 30 de zile** la
|
||||
un plan superior. In aplicatie insa **nu exista deloc conceptul de tip de cont**: tabela `accounts`
|
||||
are doar `status` (pending/active/blocked/archived/deleted) si `on_unmapped_error_default`. Nimic nu
|
||||
diferentiaza un cont gratuit de unul platit, nimic nu aplica limita de volum sau gate-ul de API, si nu
|
||||
exista niciun trial.
|
||||
|
||||
In plus, userul a decis doua corectii fata de landing-ul actual:
|
||||
1. Trial-ul de 30 de zile e pe **Pro**, NU pe Premium (landing-ul scrie azi "Premium gratuit 30 de
|
||||
zile" — gresit; trebuie "Pro 30 de zile").
|
||||
2. Limita planului **Gratuit** scade de la **100** la **60 de prestatii/luna** — actualizata si in
|
||||
landing si in aplicatie.
|
||||
|
||||
5.17 introduce modelul de tipuri de cont, trial-ul Pro de 30 de zile, **enforcement DUR** al
|
||||
diferentelor (volum lunar + acces API), si downgrade automat la expirarea trial-ului. NU include
|
||||
integrare de plata (nu exista inca sistem de facturare) — alocarea planului platit ramane manuala
|
||||
(admin), iar trial-ul porneste automat la creare cont.
|
||||
|
||||
## 2. Obiective
|
||||
|
||||
### Obiectiv principal
|
||||
Aplicatia sa sustina real diferentele dintre planuri pe care landing-ul le promite: cont nou →
|
||||
trial Pro 30 zile → la expirare downgrade pe Gratuit (60/luna, fara API), cu enforcement efectiv.
|
||||
|
||||
### Obiective secundare
|
||||
- Sursa unica de adevar pentru definitia planurilor (limite + capabilitati), consumata de backend si UI.
|
||||
- Mesaje oneste cand un cont atinge limita sau cere o capabilitate neinclusa (3 niveluri, ca 5.4).
|
||||
- Vizibilitate in dashboard: planul curent + zile ramase din trial + consum lunar.
|
||||
|
||||
### Metrici de succes
|
||||
- Un cont Gratuit care depaseste 60 prestatii/luna primeste un raspuns clar de respingere (API + web),
|
||||
iar contoarele lunare se reseteaza corect la inceput de luna (timp local RO).
|
||||
- Un cont fara plan Pro+ primeste 403 onest pe `/v1/*` de import API.
|
||||
- Un cont nou are trial Pro activ; dupa 30 zile (sau setand `trial_until` in trecut in test) trece
|
||||
automat pe Gratuit, cu enforcement-ul aferent.
|
||||
- Landing + app afiseaza coerent "60 prestatii/luna" si "Pro gratuit 30 de zile".
|
||||
|
||||
## 3. User Stories
|
||||
|
||||
> Database → backend → API → UI (ordinea dependentelor). Un singur autor pe `accounts.py`/`schema.sql`
|
||||
> in valul de model.
|
||||
|
||||
### US-001: Schema — `accounts.tier` + `trial_until` + definitia planurilor
|
||||
**Ca** sistem **vreau** sa stiu planul fiecarui cont si pana cand e in trial **pentru ca** restul
|
||||
logicii depinde de asta.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/schema.sql` (coloane noi + migrare defensiva), `app/accounts.py` (helperi),
|
||||
`app/plans.py` (NOU — definitia planurilor, sursa de adevar), `tests/test_accounts.py` /
|
||||
`tests/test_plans.py` (~4 fisiere)
|
||||
- **Test intai (RED)**: `test_migrare_tier_trial_defensiva`, `test_plan_definitii`,
|
||||
`test_cont_nou_trial_pro_30z`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `accounts` capata (migrare aditiva defensiva, ca `email`/`status` in 5.5/5.12):
|
||||
`tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free','standard','pro','premium'))`
|
||||
si `trial_until TEXT` (nullable; ISO datetime UTC sau NULL daca nu e in trial).
|
||||
- [ ] `app/plans.py` = SINGURA sursa de adevar: dict `PLANS` cu, per plan,
|
||||
`{label, monthly_limit, api_access, ...}`. Valori: `free` → `monthly_limit=60`, `api_access=False`;
|
||||
`standard` → `monthly_limit=None` (nelimitat), `api_access=False`; `pro` → `monthly_limit=None`,
|
||||
`api_access=True`; `premium` → `monthly_limit=None`, `api_access=True`. (Aliniat landing-ului,
|
||||
cu limita Gratuit 60.)
|
||||
- [ ] Helper `effective_tier(account)`: daca `trial_until` e in viitor → randeaza ca `pro`
|
||||
(trial); altfel `tier`. (Trial-ul = acces Pro temporar peste tier-ul de baza `free`.)
|
||||
- [ ] `create_account` seteaza `tier='free'` si `trial_until = now + 30 zile` (trial Pro automat la
|
||||
creare). Contul implicit id=1 (dev) e exceptat / setat coerent (nu blocheaza dev-ul).
|
||||
- [ ] Migrare idempotenta (re-rulabila); conturile legacy fara `tier` primesc `free` + fara trial
|
||||
(sau trial calculat din `created_at` — decizie la executie; implicit: legacy → free fara trial).
|
||||
- **Verificare E2E**: creez cont nou → `tier=free`, `trial_until ≈ now+30z`, `effective_tier=pro`.
|
||||
|
||||
### US-002: Numarator de consum lunar (prestatii/luna pe cont)
|
||||
**Ca** sistem **vreau** sa stiu cate prestatii a trimis un cont in luna curenta **pentru ca** limita
|
||||
Gratuit (60/luna) se aplica pe acest numar.
|
||||
|
||||
- **Depinde de**: US-001
|
||||
- **Fisiere**: `app/accounts.py` SAU `app/plans.py` (`monthly_usage(conn, account_id)`),
|
||||
`tests/test_plans.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `test_consum_lunar_numara_sent_si_queued`, `test_consum_lunar_timp_local_ro`,
|
||||
`test_consum_lunar_resetare_luna_noua`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `monthly_usage(conn, account_id)` numara prestatiile contului in luna calendaristica curenta.
|
||||
**Definitia "prestatie consumata"** (de fixat la executie, propus): randuri `submissions` ale
|
||||
contului cu `status` in (`queued`,`sending`,`sent`) cu `created_at` in luna curenta — adica
|
||||
prestatiile ACCEPTATE in coada, nu cele respinse/blocate. (Justificare: limita e pe ce trimitem
|
||||
la RAR, nu pe incercari esuate.) Alternativ doar `sent` — de decis; implicit: acceptate-in-coada.
|
||||
- [ ] **Timp local RO** (ca E7 din 5.15): bucketarea lunii foloseste offset RO (`created_at,'+3 hours'`
|
||||
sau echivalent), nu UTC pur, ca prestatiile de la granita de luna sa cada corect. Test la granita.
|
||||
- [ ] Scoped strict pe cont (nu numara cross-account).
|
||||
- [ ] Fara coloana noua daca `submissions.created_at` ajunge (respecta non-goal migrare minima).
|
||||
- **Verificare E2E**: cont cu N trimiteri in luna → `monthly_usage == N`; luna urmatoare → reset la 0.
|
||||
|
||||
### US-003: Enforcement DUR — limita lunara Gratuit (60) pe ambele canale
|
||||
**Ca** owner **vreau** ca un cont Gratuit care depaseste 60 prestatii/luna sa fie oprit **pentru ca**
|
||||
asa sustinem diferenta de plan promisa.
|
||||
|
||||
- **Depinde de**: US-001, US-002
|
||||
- **Fisiere**: `app/api/v1/router.py` (`create_prezentari`), `app/api/v1/import_router.py`
|
||||
(commit import), `app/errors.py` (cod nou `PLAN_LIMITA_LUNARA`), `app/web/routes.py` (commit web),
|
||||
`tests/test_api_scope.py` / `tests/test_web_*` / `tests/test_plans.py` (~6 fisiere)
|
||||
- **Test intai (RED)**: `test_free_peste_60_respins_api`, `test_free_peste_60_respins_import_web`,
|
||||
`test_pro_si_trial_nelimitat`, `test_eroare_3_niveluri_plan_limita`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] La enqueue (API `POST /v1/prezentari` + commit import web + commit import API), daca
|
||||
`effective_tier` are `monthly_limit` si `monthly_usage + nr_cerut > monthly_limit` → cererea
|
||||
e respinsa (sau respinsa partial, la limita) cu eroare 3 niveluri (`app/errors.py`, cod
|
||||
`PLAN_LIMITA_LUNARA`: problema "Ai atins limita planului Gratuit (60/luna)", cauza, fix
|
||||
"Treci pe Standard/Pro sau astepti luna viitoare"). NU se face enqueue peste limita.
|
||||
- [ ] `standard`/`pro`/`premium` si conturile in **trial Pro** → fara limita de volum.
|
||||
- [ ] Comportament la cerere de lot care depaseste partial limita (ex. 50 folosite, vin 20):
|
||||
decizie la executie — implicit RESPINGERE clara a intregului lot cu mesaj cat mai e disponibil
|
||||
("mai poti trimite 10 luna asta"), NU enqueue partial tacut (evita surprize). De confirmat.
|
||||
- [ ] Enforcement aliniat cu `AUTOPASS_REQUIRE_API_KEY` (dev vs prod): in dev, contul id=1 nu e
|
||||
blocat artificial (trial/standard coerent), ca dogfooding-ul sa nu se loveasca de limita.
|
||||
- [ ] **Idempotenta neatinsa**: respingerea pe limita se face INAINTE de `build_key`/enqueue; un
|
||||
retry idempotent al unei prestatii deja acceptate nu consuma din nou cota.
|
||||
- **Verificare E2E**: cont free cu 60 trimise → a 61-a respinsa cu mesaj 3 niveluri (API si import web);
|
||||
cont pro → trece.
|
||||
|
||||
### US-004: Enforcement DUR — gate API doar pe Pro/Premium
|
||||
**Ca** owner **vreau** ca importul prin API sa fie disponibil doar pe Pro+ **pentru ca** landing-ul
|
||||
spune ca API-ul e o capabilitate Pro.
|
||||
|
||||
- **Depinde de**: US-001
|
||||
- **Fisiere**: `app/auth.py` (sau dependinta de ruta), `app/api/v1/router.py`,
|
||||
`app/api/v1/import_router.py`, `app/errors.py` (cod `PLAN_FARA_API`), `tests/test_api_scope.py`
|
||||
(~5 fisiere)
|
||||
- **Test intai (RED)**: `test_free_fara_api_403`, `test_standard_fara_api_403`, `test_pro_api_ok`,
|
||||
`test_trial_pro_api_ok`, `test_dry_run_valideaza_ramane_permis`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Rutele de **import/ingestie prin API** (`POST /v1/prezentari`, `POST /v1/import`, etc.)
|
||||
cer `effective_tier.api_access == True` (pro/premium sau trial Pro). Altfel 403 cu eroare
|
||||
3 niveluri (`PLAN_FARA_API`: "Importul prin API e disponibil pe planul Pro", fix).
|
||||
- [ ] **Canalul web ramane neafectat** — operatorii pe plan gratuit pot folosi import xlsx/csv prin
|
||||
dashboard (asa promite landing-ul: Gratuit are import manual, NU API). Doar suprafata API e gated.
|
||||
- [ ] `GET /v1/nomenclator` ramane public (coduri RAR, fara PII) — invariant CLAUDE.md.
|
||||
- [ ] `POST /v1/prezentari/valideaza` (dry-run) — decizie: ramane permis pe orice plan (read-only,
|
||||
ajuta integrarea inainte de upgrade) SAU gated ca restul API. Implicit: PERMIS (read-only,
|
||||
fara enqueue). De confirmat.
|
||||
- [ ] In dev (`AUTOPASS_REQUIRE_API_KEY=false`), contul id=1 are acces API (tier coerent), ca testele
|
||||
API existente sa nu pice.
|
||||
- **Verificare E2E**: cheie API pe cont free → 403 onest pe import; cheie pe cont pro/trial → 200.
|
||||
|
||||
### US-005: Downgrade automat la expirarea trial-ului
|
||||
**Ca** owner **vreau** ca la expirarea celor 30 de zile contul sa treaca automat pe Gratuit **pentru ca**
|
||||
landing-ul spune "apoi trece automat pe Gratuit, fara plata".
|
||||
|
||||
- **Depinde de**: US-001, US-003, US-004
|
||||
- **Fisiere**: `app/plans.py` (`effective_tier` deja trateaza expirarea — lazy), optional
|
||||
`app/worker/__main__.py` SAU un job de intretinere (eager), `tests/test_plans.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `test_trial_expirat_efective_free`, `test_trial_expirat_aplica_limita_60`,
|
||||
`test_trial_expirat_pierde_api`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] **Lazy-first**: `effective_tier` returneaza `tier` de baza (`free`) imediat ce
|
||||
`trial_until <= now` — fara job necesar pentru corectitudine (enforcement-ul US-003/004 se
|
||||
bazeaza pe `effective_tier`, deci downgrade-ul e automat la prima cerere dupa expirare).
|
||||
- [ ] Optional (eager, non-blocant): un pas in purjarea orara a worker-ului (T16 existent) poate
|
||||
normaliza `trial_until` expirat → NULL pentru igiena (NU obligatoriu pentru corectitudine).
|
||||
- [ ] Un cont cu `tier='standard'/'pro'/'premium'` setat de admin NU e downgradat de expirarea
|
||||
trial-ului (trial-ul e un BONUS peste `free`; un plan platit alocat persista).
|
||||
- [ ] Mesajele de limita/API dupa expirare sunt cele 3-niveluri din US-003/004.
|
||||
- **Verificare E2E**: setez `trial_until` in trecut → contul aplica limita 60 + pierde API, fara restart.
|
||||
|
||||
### US-006: UI dashboard — plan curent + zile ramase din trial + consum lunar
|
||||
**Ca** operator **vreau** sa vad pe ce plan sunt, cat mi-a mai ramas din trial si cat am consumat
|
||||
luna asta **pentru ca** vreau sa stiu cand ma apropii de limita.
|
||||
|
||||
- **Depinde de**: US-001, US-002
|
||||
- **Fisiere**: `app/web/routes.py` (context), `app/web/templates/_status.html` SAU `_cont.html`
|
||||
(afisaj plan), `tests/test_web_status.py` / `tests/test_dashboard.py` (~4 fisiere)
|
||||
- **Test intai (RED)**: `test_afisaj_plan_si_zile_trial`, `test_afisaj_consum_lunar`,
|
||||
`test_avertizare_aproape_de_limita`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Dashboard-ul afiseaza discret planul curent (ex. "Plan: Pro · trial 18 zile ramase" sau
|
||||
"Plan: Gratuit · 47/60 luna asta"). In trial → eticheta "trial" + zile ramase; pe Gratuit →
|
||||
consum `N/60`.
|
||||
- [ ] **Plasare (aliniat cu PRD 5.16)**: planul apare ca **badge in titlul din antet**
|
||||
(`Gratuit`/`Standard`/`Pro`/`Premium`) SI ca linie in **meniul burger** ("Plan: <tier> [· trial
|
||||
N zile]"), nu doar intr-un card pe Acasa. Vezi mockup-urile 5.16
|
||||
(`docs/mockups/prd-5.16-dashboard.html` / `...-mobil.html`). 5.16 furnizeaza locul de afisare
|
||||
(antet + meniu); 5.17 furnizeaza datele (tier, trial, consum).
|
||||
- [ ] Avertizare vizuala cand consumul Gratuit se apropie de limita (ex. ≥80% → ton warn), fara a
|
||||
ingropa stripul de sanatate (zero-silent-failures pastrat).
|
||||
- [ ] Scoped pe cont; design conform 5.15/5.16 (tokeni, fonturi system, fara hex hardcodat).
|
||||
- [ ] Pagina "Cont" arata planul + (daca exista) o explicatie "cum trec pe alt plan" (contact, ca
|
||||
nu exista plata self-service inca).
|
||||
- **Verificare E2E**: cont trial → "trial N zile"; cont free aproape de 60 → avertizare; cont pro →
|
||||
fara contor de limita.
|
||||
|
||||
### US-007: Aliniere landing — limita 60 + trial pe Pro (nu Premium)
|
||||
**Ca** vizitator **vreau** ca landing-ul sa spuna adevarul **pentru ca** azi promite "100/luna" si
|
||||
"Premium gratuit 30 zile", dar realitatea va fi 60/luna si trial pe Pro.
|
||||
|
||||
- **Depinde de**: — (copy-only; aliniaza cu modelul din US-001)
|
||||
- **Fisiere**: `app/web/templates/landing.html`, `tests/test_web_*` (~2 fisiere)
|
||||
- **Test intai (RED)**: `test_landing_limita_60`, `test_landing_trial_pro_nu_premium`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Toate aparitiile "100 de prestatii/luna" / "100/luna" / `meta description`
|
||||
(`landing.html:7,65,266` + oriunde apar) → **60**. Inclusiv cardul Gratuit din sectiunea PRICING.
|
||||
- [ ] Textul "Fiecare cont incepe cu **Premium gratuit 30 de zile**" (`landing.html:256`) →
|
||||
"**Pro gratuit 30 de zile**" (planul corect). Restul frazei ("Apoi trece automat pe Gratuit…")
|
||||
ramane.
|
||||
- [ ] Coerenta: orice alt loc care implica trial/limita reflecta 60 + Pro.
|
||||
- [ ] Fara alte schimbari de pret/continut (39/59 lei raman).
|
||||
- **Verificare E2E**: landing in browser — "60 prestatii/luna" peste tot, "Pro gratuit 30 de zile".
|
||||
|
||||
### US-008: Admin — alocare manuala de plan (fara plata self-service)
|
||||
**Ca** admin **vreau** sa pot seta planul unui cont **pentru ca** nu exista inca facturare automata,
|
||||
dar trebuie sa pot acorda Standard/Pro/Premium.
|
||||
|
||||
- **Depinde de**: US-001
|
||||
- **Fisiere**: `tools/account.py` (CLI `set-tier`), optional `app/web/routes.py` (`/admin` actiune),
|
||||
`tests/test_accounts.py` / `tests/test_web_admin*.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `test_cli_set_tier`, `test_admin_set_tier_scoped`, `test_tier_invalid_respins`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] CLI `python3 -m tools.account set-tier --account N --tier pro [--trial-days 30|--no-trial]`
|
||||
seteaza `tier`/`trial_until`. Tier invalid → eroare clara.
|
||||
- [ ] Optional (la executie): actiune in panoul `/admin` pentru a seta planul unui cont (scoped,
|
||||
CSRF, ca bulk-ul de status din 5.5). Daca nu intra in 5.17, CLI e suficient (admin-only).
|
||||
- [ ] Alocarea unui plan platit de catre admin NU e suprascrisa de expirarea trial-ului (US-005).
|
||||
- [ ] Audit: schimbarea de plan se logheaza in `app_events` (reuse jurnalul din 5.6), fara PII nou.
|
||||
- **Verificare E2E**: `set-tier --account 2 --tier pro` → contul 2 are API + volum nelimitat.
|
||||
|
||||
### US-009: Teste de regresie + E2E plan/trial/enforcement
|
||||
**Ca** dezvoltator **vreau** acoperire completa **pentru ca** enforcement-ul atinge ambele canale de
|
||||
ingestie si nu vreau sa blochez gresit conturi legitime.
|
||||
|
||||
- **Depinde de**: US-003, US-004, US-005, US-006, US-007
|
||||
- **Fisiere**: `tests/test_plans.py`, `tests/test_api_scope.py`, `tests/test_web_*` (~3 fisiere)
|
||||
- **Test intai (RED)**: matricea plan × capabilitate (volum, API) × canal (API, web) × trial activ/expirat.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `python3 -m pytest -q -m "not live"` verde; regresia de aur (`POST /v1/prezentari` → queued
|
||||
pe un cont cu drept) ramane verde.
|
||||
- [ ] Matrice testata: free(volum-blocat/API-blocat), standard(volum-ok/API-blocat),
|
||||
pro(ok/ok), trial-pro(ok/ok), trial-expirat(=free).
|
||||
- [ ] Contoarele lunare resetate la luna noua (test la granita timp local RO).
|
||||
- [ ] Dev (id=1) nu e blocat de enforcement (dogfooding).
|
||||
- **Verificare E2E**: rulare completa documentata in Raportul VERIFY.
|
||||
|
||||
## 4. Cerinte functionale (rezumat)
|
||||
1. [REQ-001] `accounts.tier` ∈ {free,standard,pro,premium} + `trial_until`; migrare aditiva defensiva.
|
||||
2. [REQ-002] `app/plans.py` = sursa unica: limite (free=60/luna) + capabilitati (API doar Pro+).
|
||||
3. [REQ-003] Cont nou → trial Pro 30 zile automat; `effective_tier` randeaza Pro in trial, free dupa.
|
||||
4. [REQ-004] Enforcement DUR: free peste 60/luna respins (API + import web) cu eroare 3 niveluri.
|
||||
5. [REQ-005] Enforcement DUR: import API gated pe Pro+ (403 onest); canalul web ramane liber.
|
||||
6. [REQ-006] Downgrade automat la expirare trial (lazy via `effective_tier`).
|
||||
7. [REQ-007] Dashboard arata plan + zile trial + consum lunar; landing aliniat (60, Pro).
|
||||
8. [REQ-008] Admin aloca planuri manual (CLI `set-tier`), audit in `app_events`.
|
||||
|
||||
## 5. Non-Goals (anti scope-creep)
|
||||
- **Fara integrare de plata / facturare / abonamente** (Stripe etc.) — alocarea platita = manuala (admin).
|
||||
- Fara self-service upgrade din UI (doar afisare plan + "contacteaza-ne"); plata vine intr-un PRD viitor.
|
||||
- Fara modificari pe backend-ul de trimitere (worker, masina de stari, idempotenta `build_key`,
|
||||
reconciliere, contract RAR). Enforcement-ul se face la ingestie/enqueue, INAINTE de coada.
|
||||
- Fara schimbarea capabilitatilor de produs in sine (sugestii/mapare exista deja pe toate planurile in
|
||||
cod; diferentierea 5.17 e pe VOLUM + ACCES API, exact ce promite landing-ul ca diferentiator hard).
|
||||
- Fara modificari de design (tipografia/temele sunt 5.16/5.15); doar reuse-ul stilurilor existente.
|
||||
|
||||
## 6. Consideratii tehnice
|
||||
- **Stack**: SQLite (migrare aditiva defensiva ca 5.5/5.12), FastAPI, Jinja2/HTMX.
|
||||
- **Patterns de urmat**: sursa unica (`app/plans.py` ca `app/errors.py`); eroare 3 niveluri (5.4);
|
||||
scope pe cont (5.15/US-011); timp local RO la bucketare (5.15/E7); audit `app_events` (5.6).
|
||||
- **Riscuri**:
|
||||
- **Blocare gresita a unui cont legitim** (enforcement prea agresiv) — risc de business. Mitigare:
|
||||
dev id=1 exceptat; teste matrice; mesaje 3 niveluri cu cale de iesire; respingere INAINTE de enqueue
|
||||
(nu pierde date).
|
||||
- **Definitia "prestatie consumata"** (acceptate-in-coada vs sent) schimba cand musca limita.
|
||||
Mitigare: o decidem explicit (US-002 AC) + test; documentam.
|
||||
- **Granita de luna / fus orar** — off-by-a-day la reset. Mitigare: timp local RO + test la granita
|
||||
(lectia E7 din 5.15).
|
||||
- **Idempotenta vs cota** — un retry idempotent nu trebuie sa consume cota de doua ori. Mitigare:
|
||||
enforce inainte de `build_key`; testul de retry.
|
||||
- **Conturi legacy fara tier** — migrare le pune `free`; un cont real activ ar putea fi limitat brusc
|
||||
la 60. Mitigare: decizie de migrare (legacy activ → ce plan?) confirmata cu user inainte de deploy.
|
||||
|
||||
## 7. Consideratii UI/UX
|
||||
- Afisaj plan discret, conform 5.16 (fonturi system, tokeni `--fs-*`, fara hex).
|
||||
- Stari: trial activ (zile ramase) / free (consum N/60, warn la ≥80%) / platit (fara contor limita).
|
||||
- Mesaje de respingere oneste, actionabile (cum trec pe alt plan), nu doar "403".
|
||||
|
||||
## 8. Open Questions
|
||||
- [ ] "Prestatie consumata" = acceptate-in-coada (queued+sending+sent) sau doar `sent`? (implicit: acceptate)
|
||||
- [ ] Lot care depaseste partial limita → respingere totala sau enqueue partial? (implicit: respingere totala clara)
|
||||
- [ ] `POST /v1/prezentari/valideaza` (dry-run) — gated pe Pro sau permis tuturor? (implicit: permis)
|
||||
- [x] ~~Migrare conturi legacy active: raman `free` sau primesc un trial/plan?~~ **REZOLVAT (user, 2026-06-28): NU exista conturi legacy (produs in TESTE, pre-productie) -> intrebare moot; enforcement DUR direct de la deploy.**
|
||||
- [ ] Standard (39 lei) si Premium difera de Pro doar prin API + suport in landing — pastram exact maparea
|
||||
de capabilitati din landing in `plans.py`? (implicit: da)
|
||||
|
||||
## 9. Valuri de executie
|
||||
|
||||
```
|
||||
Val 1: [US-001] schema tier+trial + app/plans.py (autor unic schema/accounts)
|
||||
Val 2: [US-002] numarator consum lunar (dupa model) ||
|
||||
[US-007] landing copy 60 + Pro (independent, copy-only)
|
||||
Val 3: [US-003] [US-004] [US-005] enforcement volum + API + downgrade (consuma plans.py)
|
||||
Val 4: [US-006] [US-008] UI dashboard plan/consum || admin set-tier
|
||||
Val 5: [US-009] regresie + E2E matrice (dupa toate)
|
||||
```
|
||||
|
||||
> Secventiere fata de 5.16: independent (5.16 = design/tipografie; 5.17 = model de cont). Pot rula in
|
||||
> paralel; doar US-006 (afisaj plan in `_status.html`) atinge un fisier pe care 5.16/US-003 il modifica
|
||||
> (dot RAR) — serializeaza acel template daca ambele PRD-uri sunt in executie simultan.
|
||||
|
||||
---
|
||||
|
||||
> Acest PRD nu a fost inca trecut prin `/plan-ceo-review` / `/plan-eng-review`. Recomandat inainte de
|
||||
> executie (enforcement de business cu risc de blocare gresita + decizia de migrare a conturilor legacy).
|
||||
|
||||
---
|
||||
|
||||
# REVIZIE /autoplan (2026-06-28)
|
||||
|
||||
> Pipeline complet rulat: CEO -> Design -> Eng -> DX. Mod: **SELECTIVE EXPANSION**.
|
||||
> Sesiune spawned (non-interactiva): fiecare AskUserQuestion intermediar a fost auto-decis cu cele
|
||||
> 6 principii; deciziile "taste" si "user challenges" sunt colectate la poarta finala (Faza 4).
|
||||
> **Codex INDISPONIBIL** (limita de utilizare atinsa pana la 2026-07-18) -> toate vocile duale
|
||||
> ruleaza `[codex-unavailable] / [subagent-only]` cu vocea analitica independenta Claude ca model unic.
|
||||
> Restore point: vezi comentariul HTML din capul fisierului.
|
||||
|
||||
## Faza 0 — Intake
|
||||
|
||||
- **Scop UI detectat: DA** (dashboard, badge antet, meniu burger, `_status.html`/`_cont.html`,
|
||||
avertizare vizuala, mockup-uri 5.16) -> Faza 2 (Design) ruleaza.
|
||||
- **Scop DX detectat: DA** (endpointuri `/v1/*`, 403/erori 3-niveluri, CLI `tools.account set-tier`,
|
||||
cheie API, mesaje pentru integratori) -> Faza 3.5 (DX) ruleaza.
|
||||
- Cod citit: `app/accounts.py`, `app/schema.sql` (accounts/submissions/app_events), `app/errors.py`,
|
||||
`app/auth.py` (`resolve_account_id`), `app/api/v1/router.py` (`create_prezentari`/`valideaza`),
|
||||
`app/api/v1/import_router.py` (`commit_import`), `tools/account.py`, `app/web/templates/landing.html`.
|
||||
|
||||
## Faza 1 — CEO Review (Strategie & Scop) [subagent-only]
|
||||
|
||||
### 0B. Ce exista deja (leverage map)
|
||||
| Sub-problema 5.17 | Cod existent reutilizabil | Reuse? |
|
||||
|---|---|---|
|
||||
| Sursa unica de adevar (definitii) | `app/errors.py` (pattern CATALOG + `eroare()`) | DA — `plans.py` copiaza pattern-ul |
|
||||
| Eroare 3 niveluri | `app/errors.py::eroare()` (problema/cauza/fix) | DA — adauga `PLAN_LIMITA_LUNARA`, `PLAN_FARA_API` in CATALOG |
|
||||
| Migrare aditiva defensiva | `_migrate` in `db.py` (ALTER ca `email`/`status` 5.5/5.12) | DA |
|
||||
| Scope pe cont la ingestie | `auth.py::resolve_account_id` (Depends) | DA — gate API se ataseaza aici/ruta |
|
||||
| Lifecycle cont + protectie id=1 | `accounts.py` (`set_status`, `_PROTECTED_ACCOUNT_ID`) | DA — `set_tier` urmeaza acelasi tipar |
|
||||
| Audit fara PII | `observ.py::log_event` -> `app_events` (5.6) | DA — log schimbare plan |
|
||||
| CLI admin | `tools/account.py` (argparse) | DA — subcomanda `set-tier` |
|
||||
| Consum lunar | `submissions.created_at` + `idx_submissions_account_status` | DA — fara coloana noua |
|
||||
|
||||
### 0C. Dream state
|
||||
```
|
||||
CURENT 5.17 IDEAL 12 LUNI
|
||||
landing promite 4 planuri, -> model de cont real (tier+trial), -> facturare self-service
|
||||
app nu stie de tipuri; enforcement volum+API, (Stripe), upgrade din UI,
|
||||
trial inexistent; downgrade lazy la expirare, dunning, conversie masurata,
|
||||
limita 100 doar pe hartie admin manual aloca plan platit re-trial/nurture automat
|
||||
```
|
||||
Delta: 5.17 aliniaza app-ul cu promisiunea landing-ului, DAR ramane fara calea de conversie
|
||||
(plata self-service) — enforcement-ul musca inainte sa existe un buton de upgrade.
|
||||
|
||||
### 0C-bis. Alternative de implementare
|
||||
```
|
||||
APROACH A: Enforcement DUR (planul actual)
|
||||
Rezumat: respinge la enqueue free>60 + 403 API non-Pro; downgrade lazy.
|
||||
Efort: M (human ~2-3z / CC ~45min) Risc: Mediu-Inalt (blocare gresita fara cale de upgrade)
|
||||
Pro: aliniere completa cu landing; diferentiator hard real.
|
||||
Contra: friction fara conversie self-service; risc fals-block legacy.
|
||||
Reuse: errors.py, auth.py, app_events.
|
||||
|
||||
APROACH B: Soft-first (warn + overgrace + flag admin) [recomandat de revizie]
|
||||
Rezumat: la depasire limita -> avertizare clara + enqueue permis cu marcaj, alerta admin;
|
||||
API gate ramane DUR (capability, nu volum). Hard-block volum activabil ulterior prin flag.
|
||||
Efort: M (human ~2-3z / CC ~45min) Risc: Scazut.
|
||||
Pro: zero fals-block; conversie prin contact, nu prin churn; deploy mai sigur.
|
||||
Contra: nu "forteaza" upgrade; cota e mai degraba un semnal decat un zid.
|
||||
Reuse: identic cu A.
|
||||
|
||||
APROACH C: Model + copy now, enforcement sub feature flag (deferat)
|
||||
Rezumat: adauga tier/trial + plans.py + fix landing; enforcement scris dar OFF (flag),
|
||||
pornit dupa migrare legacy confirmata.
|
||||
Efort: S-M Risc: Foarte scazut.
|
||||
Pro: deploy incremental, decuplaza copy-fix (banal) de enforcement (riscant).
|
||||
Contra: promisiunea landing nu e inca "reala" la deploy.
|
||||
```
|
||||
**RECOMANDARE revizie:** combina **C (feature flag de enforcement) + B (soft-first pe VOLUM)**,
|
||||
pastrand **A pe gate-ul API** (capability, risc mic). Principii P1 (completeness pe model) + P6
|
||||
(bias to action: deploy incremental). Vezi TASTE DECISION T-CEO-1 si T-CEO-2 la poarta.
|
||||
|
||||
### 0E. Interogare temporala
|
||||
- HOUR 1 (foundations): valorile exacte ale planurilor (sursa unica `plans.py`); valoarea `60` ca
|
||||
CONSTANTA unica; politica legacy (free fara trial vs trial calculat din `created_at`).
|
||||
- HOUR 2-3 (core): definitia "prestatie consumata" (acceptate-in-coada vs sent); bucketare luna
|
||||
timp local RO (lectia E7/5.15); interactiunea enforce-inainte-de-`build_key` (idempotenta).
|
||||
- HOUR 4-5 (integrare): unde se ataseaza gate-ul API (dependinta de ruta vs in `resolve_account_id`);
|
||||
lot care depaseste partial limita (respingere totala vs partial); `valideaza` dry-run gated sau nu.
|
||||
- HOUR 6+ (polish/teste): matrice plan x capabilitate x canal x trial; granita de luna; dev id=1 exceptat.
|
||||
|
||||
### 0F. Mod: SELECTIVE EXPANSION (default pentru iteratie pe sistem existent). Approach: B+C pe volum, A pe API.
|
||||
|
||||
### Voci duale (CEO)
|
||||
**CODEX SAYS (CEO — strategy challenge):** `[codex-unavailable]` — limita de utilizare (pana 2026-07-18).
|
||||
Voce omisa; consensul se calculeaza N/A pe coloana Codex.
|
||||
|
||||
**CLAUDE SUBAGENT (CEO — strategic independence)** (voce analitica independenta, inainte de orice Codex):
|
||||
1. **Problema corecta?** Gap real: landing-ul promite planuri pe care app-ul nu le sustine. DAR
|
||||
enforcement-ul DUR pe volum apare INAINTEA oricarei cai de plata. Reframe: "onestitate landing +
|
||||
diferentiere capability" se poate atinge fara a ZIDI free-ul la 60. (HIGH)
|
||||
2. **Premise asumate:** (a) "promisiunile trebuie impuse DUR acum" — asumata; un fix de copy + gate API
|
||||
ar inchide 80% din gap cu 20% din risc. (b) "60 in loc de 100" — decizie user, dar fara rationament;
|
||||
scade atractivitatea free-ului exact cand nu exista upgrade self-service. (MEDIUM)
|
||||
3. **Regret la 6 luni:** un cont free real face 80/luna, e migrat la free si blocat brusc la 60 ->
|
||||
churn in loc de conversie (nu exista buton de upgrade, doar "contacteaza-ne"). (HIGH, deploy-blocker
|
||||
pe migrarea legacy.)
|
||||
4. **Alternative neexplorate:** soft-enforcement (warn+overgrace) vs hard-block; planul sare direct la hard.
|
||||
5. **Risc competitiv:** nisa B2B reglementata (RAR), switching cost real -> risc competitiv scazut;
|
||||
riscul dominant e INTERN (friction fara conversie).
|
||||
|
||||
```
|
||||
CEO DUAL VOICES — CONSENSUS TABLE:
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Dimensiune Claude Codex Consensus
|
||||
───────────────────────────────────── ─────── ─────── ─────────
|
||||
1. Premise valide? Partial N/A N/A (Codex indisp.)
|
||||
2. Problema corecta? Da* N/A N/A
|
||||
3. Calibrare scop corecta? Nu** N/A N/A
|
||||
4. Alternative explorate suficient? Nu N/A N/A
|
||||
5. Riscuri piata acoperite? Da N/A N/A
|
||||
6. Traiectorie 6 luni sanatoasa? Partial N/A N/A
|
||||
═══════════════════════════════════════════════════════════════
|
||||
* problema reala, dar solutia (hard enforce) e mai agresiva decat o cere problema.
|
||||
** scop corect ca model; enforcement-ul DUR pe volum e calibrat prea agresiv pentru un produs fara plata.
|
||||
Single-model: niciun consens incrucisat; constatarile critice ale vocii Claude sunt semnalate oricum.
|
||||
```
|
||||
|
||||
### Sectiunile 1-11 (CEO)
|
||||
|
||||
**S1 Arhitectura.** Componenta noua `plans.py` = modul PUR (ca `errors.py`), fara import DB/HTTP, dict
|
||||
`PLANS` + `effective_tier(account_row, now)` + `monthly_usage(conn, account_id, now)`. Cuplare noua:
|
||||
rutele de ingestie (`router.py`, `import_router.py`, `routes.py` commit) depind de `plans.py` + citesc
|
||||
`accounts.tier/trial_until` -> cuplare justificata (un singur punct de adevar). Diagrama: vezi Faza 3 (Eng).
|
||||
Constatare CEO-S1-1 (MEDIUM): `effective_tier` are nevoie de `now` injectabil (nu `datetime.now()` intern)
|
||||
ca testele de granita trial/luna sa fie deterministe. Auto-decis (P5 explicit): semnatura cu `now` parametru.
|
||||
|
||||
**S2 Error & Rescue (registry mai jos).** Coduri noi: `PLAN_LIMITA_LUNARA`, `PLAN_FARA_API`. Ambele
|
||||
sunt erori de business (nu exceptii) -> 3 niveluri din `errors.py`, returnate ca raspuns structurat
|
||||
(nu 500). Fara catch-all. Constatare CEO-S2-1 (LOW): trial expirat NU e o eroare — e o stare; nu necesita
|
||||
cod de eroare, doar `effective_tier` care vede `free`.
|
||||
|
||||
**S3 Securitate (detaliu in Eng S3).** Suprafata: gate API (autorizare pe capability) + enforce volum.
|
||||
DOR (direct object reference) la `set-tier` admin: trebuie scoped + protejat id=1 (ca `set_status`).
|
||||
Risc privilege: un cont free NU trebuie sa-si poata seta singur tier (doar admin CLI / panou admin CSRF).
|
||||
Constatare CEO-S3-1 (HIGH): enforce pe volum/API trebuie sa ruleze DUPA `resolve_account_id` (cont
|
||||
autenticat), niciodata pe baza unui camp din body. Auto-decis (P1): gate ca dependinta server-side.
|
||||
|
||||
**S4 Data flow & edge cases.** Granita de luna (timp local RO), idempotenta vs cota (retry nu consuma
|
||||
de 2x), lot care depaseste partial. Vezi Failure Modes Registry. Edge: 2 cereri concurente la 59/60 ->
|
||||
race pe cota (ambele trec checkul, ajung la 61). Constatare CEO-S4-1 (MEDIUM): cota nu e tranzactionala
|
||||
cu enqueue -> mic overshoot posibil sub concurenta. Auto-decis (P3 pragmatic): accepta overshoot mic
|
||||
(±lot) documentat; un lock per-cont ar fi over-engineering pentru un cap soft. (Daca se alege hard-block,
|
||||
re-evalueaza.)
|
||||
|
||||
**S5 Code quality.** `plans.py` sursa unica evita DRY-violation intre backend si UI. Risc: valoarea `60`
|
||||
sa fie hardcodata in 3 locuri (router, import, web). Auto-decis (P4 DRY): O singura definitie in `PLANS`,
|
||||
consumata peste tot; templating UI primeste `monthly_limit` din context, nu literal.
|
||||
|
||||
**S6 Teste (diagrama in Eng S3).** Matrice plan x capabilitate x canal x trial. Gap-uri critice: granita
|
||||
luna timp local RO; retry idempotent; dev id=1 ne-blocat. Toate cerute in US-009.
|
||||
|
||||
**S7 Performanta.** `monthly_usage` = un COUNT cu `WHERE account_id=? AND status IN (...) AND created_at>=...`.
|
||||
Exista `idx_submissions_account_status(account_id,status)` dar NU acopera `created_at`. Constatare CEO-S7-1
|
||||
(MEDIUM): la volume mari un COUNT pe luna per-cerere e O(randuri luna); acceptabil la scara curenta, dar
|
||||
indexul nu acopera intervalul de timp. Auto-decis (P3): acceptabil acum (SQLite, volume mici); TODO index
|
||||
`(account_id, created_at)` daca apar conturi cu mii/luna. -> TODOS.
|
||||
|
||||
**S8 Observabilitate.** Fiecare respingere pe plan (volum/API) trebuie sa emita `app_events`
|
||||
(cod + cont + count), nu doar sa intoarca 4xx. Altfel "de ce a fost blocat clientul X?" e invizibil.
|
||||
Auto-decis (P_prime zero-silent-failures): log_event pe fiecare respingere de plan. (Adaugat ca AC.)
|
||||
|
||||
**S9 Deploy.** Migrare aditiva defensiva (idempotenta). **REZOLVAT (decizie user 2026-06-28):**
|
||||
enforcement DUR direct de la deploy — fara conturi legacy, produs in TESTE (pre-productie), deci riscul
|
||||
de fals-block e moot. Feature-flag `AUTOPASS_ENFORCE_PLANS` ramane **OPTIONAL** (nice-to-have de operare,
|
||||
kill-switch), NU blocant pentru deploy. Vezi T-CEO-1 (rezolvat).
|
||||
|
||||
**S10 Traiectorie.** Reversibilitate 4/5 (model aditiv; enforcement sub flag = usor de oprit). Path
|
||||
dependency: fara billing, `set-tier` manual devine gatuire daca adoptia creste -> Phase 2 = plata
|
||||
self-service. Datorie: cuplarea enforcement de ingestie e curata; datoria reala e "lipsa caii de upgrade".
|
||||
|
||||
**S11 Design & UX (deep in Faza 2).** Plasare badge plan in antet + meniu burger (aliniat 5.16),
|
||||
avertizare la >=80%, mesaje oneste cu cale de iesire. Recomand /plan-design-review (rulat ca Faza 2).
|
||||
|
||||
### Iesiri obligatorii CEO
|
||||
|
||||
**NOT in scope (deferat, cu rationament):**
|
||||
- Integrare plata/facturare (Stripe) — non-goal explicit; Phase 2.
|
||||
- Upgrade self-service din UI — depinde de billing; doar afisaj + "contacteaza-ne".
|
||||
- Index `(account_id, created_at)` — deferat pana apar conturi de volum mare (TODO P3).
|
||||
- Job eager de normalizare `trial_until` expirat -> NULL — optional, igiena; lazy acopera corectitudinea.
|
||||
- Diferentiere capability de produs (sugestii/mapare) pe planuri — non-goal; diferentierea e volum+API.
|
||||
|
||||
**What already exists:** vezi tabelul 0B (errors.py, auth.py, accounts.py, observ/app_events, db._migrate,
|
||||
submissions.created_at + index, tools/account.py — toate reutilizate; 5.17 nu reconstruieste nimic).
|
||||
|
||||
**Dream state delta:** 5.17 face promisiunea landing-ului REALA in app, dar lasa golul "conversie
|
||||
self-service"; urmatorul pas logic e billing (Phase 2). Enforcement-ul fara upgrade self-service e
|
||||
delta-ul de risc.
|
||||
|
||||
### Error & Rescue Registry (S2)
|
||||
```
|
||||
CODEPATH | CE POATE ESUA | COD / EXCEPTIE
|
||||
---------------------------------|--------------------------------|------------------------
|
||||
create_prezentari (enqueue) | free peste 60/luna | PLAN_LIMITA_LUNARA (business)
|
||||
commit_import (web+API) | free peste 60/luna | PLAN_LIMITA_LUNARA (business)
|
||||
import API / POST /v1/prezentari | cont fara api_access (non-Pro) | PLAN_FARA_API (403, business)
|
||||
effective_tier(account, now) | trial_until malformat/NULL | trateaza ca free (fallback)
|
||||
monthly_usage(conn, acct, now) | created_at NULL/malformat | exclus din count (defensiv)
|
||||
set-tier (CLI/admin) | tier invalid | ValueError -> mesaj clar
|
||||
set-tier pe id=1 | mutare cont sistem | protejat (ca set_status)
|
||||
|
||||
COD / STARE | RESCUED? | ACTIUNE | USER VEDE
|
||||
------------------------|----------|----------------------------------|---------------------------
|
||||
PLAN_LIMITA_LUNARA | Y | respinge inainte de build_key | "Ai atins limita Gratuit (60/luna)" + fix
|
||||
PLAN_FARA_API | Y | 403 inainte de procesare | "Importul API e pe Pro" + fix
|
||||
trial_until malformat | Y | fallback free, log WARNING | comportament free (fara crash)
|
||||
created_at malformat | Y | exclus din count, log WARNING | nimic (transparent)
|
||||
tier invalid (set-tier) | Y | ValueError, exit!=0 | "tier invalid: X"
|
||||
```
|
||||
|
||||
### Failure Modes Registry
|
||||
```
|
||||
CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER VEDE | LOGGED?
|
||||
--------------------------|--------------------------|----------|-------|------------------|--------
|
||||
enforce volum (enqueue) | free peste 60 | Y | Y | eroare 3 niveluri| Y (app_events)
|
||||
enforce volum | race concurent la 59/60 | Partial | Y(*) | overshoot mic | Y
|
||||
gate API | non-Pro pe /v1 import | Y | Y | 403 onest | Y
|
||||
downgrade lazy | trial expirat | Y | Y | aplica free | N (stare, nu eveniment)
|
||||
migrare legacy | cont activ -> free brusc | N/A(MOOT)| n/a | n/a | n/a
|
||||
bucketare luna | granita timp local RO | Y | Y | reset corect | n/a
|
||||
idempotenta vs cota | retry consuma cota 2x | Y | Y | nimic | n/a
|
||||
```
|
||||
**~~CRITICAL GAP~~ REZOLVAT (MOOT, 2026-06-28):** decizia userului — NU exista conturi legacy, produsul
|
||||
e in TESTE (pre-productie). Migrarea unui cont activ -> free brusc nu se poate produce (nu exista conturi
|
||||
reale de migrat). Gap inchis ca N/A. Enforcement DUR de la deploy, fara mitigare necesara.
|
||||
|
||||
### Completion Summary (CEO)
|
||||
```
|
||||
+====================================================================+
|
||||
| MEGA PLAN REVIEW — COMPLETION SUMMARY (CEO) |
|
||||
+====================================================================+
|
||||
| Mode | SELECTIVE EXPANSION |
|
||||
| Approach ales | B+C pe volum, A pe gate API |
|
||||
| S1 Arhitectura | 1 (now injectabil) |
|
||||
| S2 Errors | 2 coduri noi, 0 GAP-uri rescue |
|
||||
| S3 Securitate | 1 HIGH (gate server-side), DOR set-tier |
|
||||
| S4 Data/UX | 1 race cota (overshoot mic acceptat) |
|
||||
| S5 Quality | 1 (DRY pe valoarea 60) |
|
||||
| S6 Teste | matrice ceruta, 3 gap-uri acoperite US-009 |
|
||||
| S7 Perf | 1 (index timp) -> TODO |
|
||||
| S8 Observ | 1 (log pe respingere plan) -> AC nou |
|
||||
| S9 Deploy | enforcement DUR direct (user); flag optional |
|
||||
| S10 Future | Reversibilitate 4/5; datorie = lipsa billing|
|
||||
| S11 Design | -> Faza 2 |
|
||||
| NOT in scope | scris (5 items) |
|
||||
| Failure modes | 7 total, 0 CRITICAL GAP (legacy REZOLVAT moot)|
|
||||
| Outside voice | codex indisponibil (subagent-only) |
|
||||
| Unresolved decisions | 0 (toate inchise 2026-06-28: challenge + 3 taste)|
|
||||
+====================================================================+
|
||||
```
|
||||
|
||||
**Phase 1 complete.** Codex: indisponibil. Claude subagent: 9 constatari (2 HIGH, 5 MEDIUM, 2 LOW) +
|
||||
1 USER CHALLENGE + 2 TASTE. Consens: N/A (single-model). Trec la Faza 2.
|
||||
|
||||
## Faza 2 — Design Review [subagent-only]
|
||||
|
||||
> Scop UI confirmat. 5.17 aduce DATELE (tier/trial/consum); 5.16 aduce LOCUL (antet + meniu burger).
|
||||
> Aceasta revizie e la nivel de plan (intentionalitate de design), nu audit de pixeli.
|
||||
> Completitudine design initiala: **6/10** (plasare numita, dar stari incomplete + copy nespecificat).
|
||||
|
||||
**CODEX SAYS (design — UX challenge):** `[codex-unavailable]`.
|
||||
|
||||
**CLAUDE SUBAGENT (design — independent review):**
|
||||
1. **Ierarhie informatie:** badge plan in antet e corect (status, nu actiune); consumul `N/60` apartine
|
||||
contextului secundar (meniu/Cont), NU trebuie sa concureze cu stripul de sanatate. OK.
|
||||
2. **Stari lipsa:** planul numeste "trial activ / free consum / platit fara contor" dar NU specifica:
|
||||
(a) ULTIMA zi de trial ("expira azi" vs "1 zi"), (b) starea "limita ATINSA" (60/60, nu doar >=80%),
|
||||
(c) ce vede operatorul in MOMENTUL respingerii (toast? banner persistent?). GAP (HIGH).
|
||||
3. **Arc emotional:** trial -> "ai Pro 18 zile" (pozitiv) -> ziua 30 trecere tacuta pe free -> prima
|
||||
respingere la 61 = surpriza negativa daca nu a existat avertizare progresiva. Avertizarea >=80% e
|
||||
buna; lipseste un semnal la trecerea trial->free (ziua 0). GAP (MEDIUM).
|
||||
4. **Specificitate vs generic:** "afiseaza discret planul" e generic; mockup-urile 5.16 dau forma, dar
|
||||
copy-ul exact al badge-ului ("Pro · trial 18 zile" / "Gratuit · 47/60") trebuie fixat ca string-uri,
|
||||
nu lasat implementatorului. GAP (MEDIUM).
|
||||
5. **Decizii care vor bantui implementatorul:** prag exact warn (>=80% = 48/60?), pluralizare RO
|
||||
("1 zi" vs "18 zile", "1 zile" e gresit), ce se intampla la 0 zile ramase in trial in aceeasi zi.
|
||||
|
||||
```
|
||||
DESIGN LITMUS SCORECARD (0-10):
|
||||
Dimensiune Claude Codex Consensus
|
||||
────────────────────────────────── ─────── ─────── ─────────
|
||||
1. Ierarhie informatie 8 N/A N/A
|
||||
2. Acoperire stari (load/empty/err) 5 N/A N/A <- gap
|
||||
3. Coerenta user journey 6 N/A N/A
|
||||
4. Specificitate (nu generic) 5 N/A N/A <- gap
|
||||
5. Aliniere design system (5.15/16) 8 N/A N/A
|
||||
6. Intentie responsive 7 N/A N/A
|
||||
7. Accesibilitate (contrast/kbd) 6 N/A N/A
|
||||
────────────────────────────────── ─────── ─────── ─────────
|
||||
Overall design (plan-level) ~6.4/10
|
||||
```
|
||||
|
||||
### Pass-uri 1-7 (constatari + auto-decizii)
|
||||
- **P1 Ierarhie:** badge in antet (status), consum in meniu/Cont. OK, fara modificare.
|
||||
- **P2 Stari (CRITIC):** adauga stari explicite: `trial-activ(N zile)`, `trial-ultima-zi`,
|
||||
`free-sub-prag`, `free-warn(>=80%)`, `free-limita-atinsa(60/60)`, `platit(fara contor)`. Auto-decis
|
||||
(P1 completeness): toate 6 stari intra ca AC in US-006. Matrice stare->afisaj in plan.
|
||||
- **P3 Journey:** adauga un semnal one-time la trecerea trial->free (banner discret "Trial Pro
|
||||
expirat — esti pe Gratuit, 60/luna"). Auto-decis (P1): adaugat ca AC optional in US-006 (non-blocant
|
||||
daca lazy; afisat la prima incarcare dupa expirare). TASTE T-DES-1 (banner one-time vs doar badge).
|
||||
- **P4 Specificitate:** fixeaza string-urile de copy exact (RO, cu pluralizare corecta) in US-006.
|
||||
Auto-decis (P5 explicit): tabel de copy in plan (vezi mai jos).
|
||||
- **P5 Design system:** tokeni `--fs-*`, fonturi system, fara hex hardcodat (5.16). OK; reuse `_status.html`.
|
||||
- **P6 Responsive:** badge in antet + linie in burger acopera desktop+mobil (mockup-uri 5.16). OK.
|
||||
- **P7 Accesibilitate:** tonul "warn" NU doar prin culoare (adauga text/icon); contrast pe badge;
|
||||
badge-ul nu e buton (status) -> fara rol interactiv inselator. Auto-decis (P1): warn = culoare + text.
|
||||
|
||||
**Copy fix (RO, propus, auto-decis P5):**
|
||||
```
|
||||
trial activ: "Plan: Pro · trial {n} {zi|zile} ramase" (1->"zi", 2+->"zile")
|
||||
trial ultima zi: "Plan: Pro · trial expira azi"
|
||||
free sub prag: "Plan: Gratuit · {u}/60 luna asta"
|
||||
free warn (>=80%): "Plan: Gratuit · {u}/60 — aproape de limita"
|
||||
free limita atinsa: "Plan: Gratuit · 60/60 — limita atinsa"
|
||||
platit: "Plan: {Standard|Pro|Premium}"
|
||||
```
|
||||
|
||||
**Required: user flow ASCII (stari + tranzitii)**
|
||||
```
|
||||
[cont nou] --create--> (TRIAL Pro: badge "trial N zile") --N scade zilnic-->
|
||||
(trial ultima zi) --trial_until<=now (lazy)--> (FREE sub prag: "u/60")
|
||||
--u>=48--> (FREE warn ">=80%") --u==60--> (FREE limita atinsa "60/60")
|
||||
|
|
||||
a 61-a cerere -> RESPINS (eroare 3 niveluri / toast)
|
||||
(admin set-tier pro) --------------------------------> (PLATIT: fara contor)
|
||||
```
|
||||
|
||||
**Phase 2 complete.** Codex: indisponibil. Claude subagent: 4 constatari design (1 HIGH stari, 2 MEDIUM,
|
||||
1 accesibilitate) + 1 TASTE (T-DES-1). Overall ~6.4/10 -> tinta dupa AC-uri ~8.5/10. Trec la Faza 3.
|
||||
|
||||
## Faza 3 — Eng Review (Arhitectura & Teste) [subagent-only]
|
||||
|
||||
### Step 0 — Scope challenge (cod citit)
|
||||
- `app/errors.py`: CATALOG + `eroare(cod, field, cauza)` -> pattern de copiat exact pentru coduri noi.
|
||||
- `app/auth.py`: `resolve_account_id` (Depends) intoarce `account_id`; gate-ul API se ataseaza ca a doua
|
||||
dependinta (`require_api_access`) care reuseaza `account_id` -> nu reimplementa auth.
|
||||
- `app/api/v1/router.py`: `create_prezentari` itereaza prestatiile, face `canonicalize_row` -> `build_key`
|
||||
-> enqueue. Gate-ul de VOLUM trebuie INAINTE de bucla de `build_key`/enqueue (idempotenta intacta).
|
||||
- `app/api/v1/import_router.py`: `commit_import` face enqueue per-rand cu ON CONFLICT DO NOTHING; gate
|
||||
volum la inceputul commit-ului (nr randuri `ok` vs cota ramasa).
|
||||
- `app/accounts.py`: `set_status` + `_PROTECTED_ACCOUNT_ID=1` -> `set_tier` urmeaza acelasi tipar (validare
|
||||
tier, protectie id=1, update). `create_account` adauga `tier='free'` + `trial_until=now+30z`.
|
||||
- `tools/account.py`: argparse; adauga subparser `set-tier`.
|
||||
- Complexitate: ramane sub 8 fisiere de logica + `plans.py` nou. Sub pragul de smell. OK.
|
||||
|
||||
**CLAUDE SUBAGENT (eng — independent review):**
|
||||
1. **Arhitectura:** `plans.py` PUR + consum din rute = curat. Singura cuplare noua justificata.
|
||||
2. **Edge:** race pe cota sub concurenta (overshoot ±lot); `now` trebuie injectabil pentru teste de granita.
|
||||
3. **Teste:** matricea e ceruta, dar lipsesc explicit: testul de retry idempotent care NU re-consuma cota,
|
||||
si testul ca `valideaza` dry-run NU consuma cota. (HIGH — sunt invariante usor de stricat.)
|
||||
4. **Securitate:** gate API server-side (nu din body); `set-tier` scoped + protejat id=1.
|
||||
5. **Complexitate ascunsa:** definitia "prestatie consumata" + bucketarea lunii timp local RO sunt sursa
|
||||
reala de bug-uri (off-by-a-day, status care iese din count cand un rand devine `error`).
|
||||
|
||||
```
|
||||
ENG DUAL VOICES — CONSENSUS TABLE:
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Dimensiune Claude Codex Consensus
|
||||
───────────────────────────────────── ─────── ─────── ─────────
|
||||
1. Arhitectura sanatoasa? Da N/A N/A
|
||||
2. Acoperire teste suficienta? Partial N/A N/A
|
||||
3. Riscuri performanta tratate? Partial N/A N/A
|
||||
4. Amenintari securitate acoperite? Da N/A N/A
|
||||
5. Cai de eroare tratate? Da N/A N/A
|
||||
6. Risc deploy gestionabil? Partial N/A N/A (flag + legacy)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Single-model (codex indisponibil).
|
||||
```
|
||||
|
||||
### Section 1 — Architecture (ASCII)
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ app/plans.py (NOU) │ modul PUR (ca errors.py)
|
||||
│ PLANS{tier->limite} │ effective_tier(acct,now)
|
||||
│ api_access, limita │ monthly_usage(conn,acct,now)
|
||||
└──────────┬──────────┘
|
||||
┌───────────────┬───────┼───────────────┬──────────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
api/v1/router.py import_router web/routes.py auth.py web/templates
|
||||
create_prezentari commit_import commit web require_api_access _status/_cont.html
|
||||
│ gate VOLUM │ gate VOLUM │ gate VOLUM │ gate API (403) badge plan
|
||||
▼ ▼ ▼ ▼
|
||||
errors.eroare(PLAN_LIMITA_LUNARA / PLAN_FARA_API) observ.log_event(app_events)
|
||||
│
|
||||
▼ (daca trece)
|
||||
canonicalize_row -> build_key -> enqueue submissions <-- NESCHIMBAT (worker/idempotenta/reconcile)
|
||||
|
||||
accounts.py: create_account(tier='free', trial_until=now+30z) ; set_tier(acct,tier,trial)
|
||||
db._migrate: ALTER accounts ADD tier / trial_until (aditiv defensiv, idempotent)
|
||||
tools/account.py: subcomanda set-tier
|
||||
config.py: AUTOPASS_ENFORCE_PLANS (flag, vezi T-CEO-1)
|
||||
```
|
||||
Cuplare before/after: inainte rutele depind doar de auth+idempotency+validation; dupa adauga o dependinta
|
||||
catre `plans.py` (pur, fara cicluri). Single point of failure: niciunul nou (modul pur, fara IO).
|
||||
Rollback: revert + flag OFF; migrarea e aditiva (coloanele raman, inofensive).
|
||||
|
||||
### Section 2 — Code quality
|
||||
- DRY: valoarea 60 + maparea capability EXCLUSIV in `PLANS`. Constatare ENG-S2-1: nu duplica `status IN
|
||||
(...)` (definitia consumului) intre `monthly_usage` si teste — exporta o constanta `CONSUMED_STATUSES`.
|
||||
- Naming: `effective_tier`, `monthly_usage`, `api_access`, `monthly_limit` — clare.
|
||||
- Over/under-engineering: NU adauga tabela `plan_usage` (coloana noua) — `submissions.created_at` ajunge
|
||||
(respecta non-goal migrare minima). Lock per-cont pe cota = over-engineering pentru cap soft.
|
||||
|
||||
### Section 3 — Test Review (diagrama completa — NU se sare)
|
||||
```
|
||||
NEW DATA FLOWS:
|
||||
- cerere ingestie -> citeste effective_tier -> compara monthly_usage+nr vs limita -> permite/respinge
|
||||
- cont nou -> create_account seteaza trial_until
|
||||
- trial_until <= now -> effective_tier randeaza free (lazy)
|
||||
NEW CODEPATHS / BRANCHES:
|
||||
- tier in {free,standard,pro,premium}; api_access T/F; monthly_limit None/60
|
||||
- effective_tier: trial activ vs expirat vs plan platit (nu downgrada)
|
||||
- enforce volum: sub limita / la limita / peste / lot care depaseste partial
|
||||
- gate API: free/standard -> 403 ; pro/premium/trial -> ok ; nomenclator public ; valideaza permis
|
||||
- dev id=1: ne-blocat (AUTOPASS_REQUIRE_API_KEY=false)
|
||||
NEW INTEGRATIONS/EXTERNAL: niciuna (totul intern; worker/RAR neatins)
|
||||
NEW ERROR/RESCUE: PLAN_LIMITA_LUNARA, PLAN_FARA_API (+ log_event)
|
||||
|
||||
ITEM | TIP TEST | EXISTA? | HAPPY / FAIL / EDGE
|
||||
--------------------------------------|--------------|---------|---------------------------------
|
||||
migrare tier+trial defensiva | unit (db) | NOU | re-rulare idempotenta; legacy->free
|
||||
PLANS definitii + capability map | unit | NOU | free=60/noAPI; pro=None/API
|
||||
effective_tier trial activ/expirat | unit (now inj)| NOU | viitor->pro; trecut->free; platit persista
|
||||
monthly_usage count | unit | NOU | numara queued+sending+sent; reset luna noua
|
||||
monthly_usage granita timp local RO | unit | NOU | rand la 23:30 UTC ultima zi -> luna RO corecta
|
||||
enforce volum free>60 API | integration | NOU | a 61-a respinsa 3 niveluri
|
||||
enforce volum free>60 import web | integration | NOU | commit respins peste cota
|
||||
enforce volum lot partial | integration | NOU | 50 folosite + lot 20 -> respingere totala (default)
|
||||
retry idempotent NU re-consuma cota | integration | NOU | <-INVARIANT critic
|
||||
valideaza dry-run NU consuma cota | integration | NOU | <-INVARIANT critic
|
||||
gate API free/standard 403 | integration | NOU | 403 onest
|
||||
gate API pro/trial 200 | integration | NOU | trece
|
||||
nomenclator public ramane | integration | reuse | fara cheie -> 200
|
||||
dev id=1 ne-blocat | integration | NOU | dogfooding nu pica
|
||||
set-tier CLI + invalid + id=1 protejat| unit | NOU | tier ok; invalid err; id=1 respins
|
||||
regresie aur (POST -> queued) | integration | reuse | ramane verde
|
||||
```
|
||||
Test 2am-Friday: "un cont Pro NU e blocat niciodata pe volum, indiferent de consum". Test ostil:
|
||||
"trimit 100 cereri concurente la 59/60 pe free" -> verifica overshoot marginit + log. Flakiness: testele
|
||||
de granita luna/trial trebuie sa injecteze `now` (fara `datetime.now()` intern) — altfel flaky.
|
||||
LLM/eval: 5.17 NU atinge prompturi/mapare LLM -> fara eval suites (confirmat: non-goal pe backend trimitere).
|
||||
|
||||
### Section 4 — Performance
|
||||
- `monthly_usage`: COUNT per-cerere; index `(account_id,status)` exista, NU acopera `created_at`.
|
||||
ENG-S4-1 (MEDIUM): la conturi de volum mare scaneaza randurile lunii. Auto-decis (P3): acceptabil acum;
|
||||
TODO index `(account_id, created_at)` (P3) cand apar conturi cu mii/luna.
|
||||
- Fara N+1, fara conexiuni noi, fara job nou (downgrade = lazy).
|
||||
|
||||
### Iesiri obligatorii Eng
|
||||
**NOT in scope (eng):** tabela `plan_usage` dedicata (nu necesara); lock tranzactional pe cota (overshoot
|
||||
mic acceptat); job eager downgrade (lazy ajunge); index timp (TODO).
|
||||
**What already exists (eng):** errors.eroare, auth.resolve_account_id, accounts.set_status pattern,
|
||||
db._migrate, observ.log_event, idempotency.build_key/canonicalize_row, submissions index — toate reutilizate.
|
||||
**Failure modes (eng) cu gap critic:** vezi Failure Modes Registry (CEO) — singurul CRITICAL GAP =
|
||||
migrare legacy active (acoperit de flag + decizie user T-CEO-1).
|
||||
|
||||
### Completion Summary (Eng)
|
||||
```
|
||||
| S1 Arhitectura | curata, 1 cuplare justificata, diagrama produsa |
|
||||
| S2 Quality | 1 (CONSUMED_STATUSES constanta) |
|
||||
| S3 Teste | diagrama produsa; 2 invariante critice (retry, dry-run) |
|
||||
| S4 Perf | 1 (index timp -> TODO P3) |
|
||||
| Artifact teste | scris in ~/.gstack/projects/romfast-rar-autopass/ |
|
||||
| Critical gaps | 1 (legacy) -> flag + decizie user |
|
||||
| Outside voice | codex indisponibil (subagent-only) |
|
||||
```
|
||||
|
||||
**Phase 3 complete.** Codex: indisponibil. Claude subagent: 4 constatari (1 HIGH teste-invariante,
|
||||
3 MEDIUM). Artifact test-plan scris pe disc. Trec la Faza 3.5 (DX).
|
||||
|
||||
## Faza 3.5 — DX Review [subagent-only]
|
||||
|
||||
> Scop DX confirmat: integratorul ROAAUTO/soft propriu foloseste `/v1/*` cu cheie API; adminul foloseste
|
||||
> CLI `tools.account`. Tip produs: **gateway API B2B + CLI admin**. Persona: dezvoltator integrator RO
|
||||
> (consuma `POST /v1/prezentari`) + admin gateway.
|
||||
|
||||
**CODEX SAYS (DX — developer experience challenge):** `[codex-unavailable]`.
|
||||
|
||||
**CLAUDE SUBAGENT (DX — independent review):**
|
||||
1. **Time-to-hello-world:** neschimbat de 5.17 pentru cont cu drept; DAR un integrator pe cont free care
|
||||
incearca `POST /v1/prezentari` va primi acum 403 (PLAN_FARA_API) la primul apel. Daca mesajul nu spune
|
||||
clar "API e pe Pro, dar `valideaza` merge", dezvoltatorul crede ca integrarea e stricata. (HIGH)
|
||||
2. **Mesaje de eroare:** `PLAN_FARA_API` si `PLAN_LIMITA_LUNARA` trebuie problema+cauza+fix (au structura
|
||||
din errors.py). Fix-ul trebuie sa fie actionabil ("Treci pe Pro: contacteaza-ne / set-tier"), nu doar 403.
|
||||
3. **API/CLI naming:** `set-tier --tier pro --trial-days 30|--no-trial` e consistent cu `tools.account`
|
||||
existent (create/activate/deactivate). OK. Sugestie: si `--account` (deja folosit).
|
||||
4. **Docs:** `/v1/nomenclator` ramane public (bun pentru explorare pre-upgrade). `valideaza` permis pe orice
|
||||
plan = excelent DX (integrezi+testezi inainte de a plati). Trebuie documentat explicit ca "poti dezvolta
|
||||
pe free cu valideaza, dar trimiterea reala cere Pro".
|
||||
5. **Upgrade path:** fara self-service -> 403 zice "contacteaza-ne"; un dezvoltator vrea un link/email
|
||||
concret, nu "contact". (MEDIUM)
|
||||
|
||||
```
|
||||
DX DUAL VOICES — CONSENSUS TABLE:
|
||||
Dimensiune Claude Codex Consensus
|
||||
───────────────────────────────────── ─────── ─────── ─────────
|
||||
1. Getting started < 5 min? Da* N/A N/A (*free->403 surprinde)
|
||||
2. Naming API/CLI ghicibil? Da N/A N/A
|
||||
3. Mesaje de eroare actionabile? Partial N/A N/A
|
||||
4. Docs gasibile & complete? Partial N/A N/A
|
||||
5. Upgrade path sigur? Partial N/A N/A (fara self-service)
|
||||
6. Mediu dev fara friction? Da N/A N/A (valideaza permis)
|
||||
```
|
||||
|
||||
### Developer journey map (9 etape)
|
||||
| Etapa | Azi | Cu 5.17 | Friction |
|
||||
|---|---|---|---|
|
||||
| 1 Descoperire | landing | landing aliniat (60, Pro) | — |
|
||||
| 2 Signup | cont + trial Pro | trial Pro 30z automat | — |
|
||||
| 3 Cheie API | CLI apikey | idem | — |
|
||||
| 4 Primul apel | 200 | 200 in trial; 403 pe free dupa trial | mesaj clar necesar |
|
||||
| 5 Dezvoltare | — | `valideaza` permis pe orice plan | excelent |
|
||||
| 6 Trimitere reala | 200 | gated pe Pro+ | upgrade path |
|
||||
| 7 Atingere limita | — | free 60/luna -> respins | mesaj 3 niveluri |
|
||||
| 8 Upgrade | — | contact admin (fara self-service) | link concret |
|
||||
| 9 Operare | dashboard | + badge plan/consum | — |
|
||||
|
||||
### Developer empathy narrative (persoana intai)
|
||||
"Mi-am facut cont, am cheia, trimit prima prestatie — merge (sunt in trial). Construiesc integrarea,
|
||||
folosesc `valideaza` ca sa testez fara sa consum nimic — perfect. Peste o luna, trial-ul expira; brusc
|
||||
`POST /v1/prezentari` da 403. Daca mesajul zice doar '403 Forbidden', cred ca mi-am stricat cheia si pierd
|
||||
o ora. Daca zice 'Importul prin API e pe planul Pro — scrie-ne la X ca sa activam', stiu exact ce sa fac."
|
||||
|
||||
### DX Scorecard (8 dimensiuni, 0-10)
|
||||
```
|
||||
1. TTHW 7 (free->403 dupa trial surprinde fara mesaj clar)
|
||||
2. Naming consistency 9
|
||||
3. Error actionability 6 -> tinta 9 dupa copy fix
|
||||
4. Docs/exemple 6 -> documenteaza valideaza-pe-free + upgrade
|
||||
5. Progressive disclosure 8 (nomenclator+valideaza publice/permise)
|
||||
6. Escape hatches 7 (dev id=1; flag enforcement)
|
||||
7. Upgrade safety 6 (manual; link concret lipseste)
|
||||
8. Consistency cross-canal 8
|
||||
--------------------------------
|
||||
Overall DX ~7.1/10 (TTHW: ~5 min ramane; tinta erori/docs ~8.5)
|
||||
```
|
||||
|
||||
### DX Implementation Checklist
|
||||
- [ ] `PLAN_FARA_API`: fix actionabil cu canal de contact concret (email/telefon), mentioneaza `valideaza`.
|
||||
- [ ] `PLAN_LIMITA_LUNARA`: fix cu "mai poti trimite N luna asta" + cum treci pe alt plan.
|
||||
- [ ] Doc scurt pentru integratori: "dezvolta pe free cu `valideaza`; trimiterea reala cere Pro".
|
||||
- [ ] `set-tier` help text clar (CLI) + audit in app_events.
|
||||
- [ ] Confirma `valideaza` ramane permis pe orice plan (decizie -> default PERMIS).
|
||||
|
||||
**Phase 3.5 complete.** DX overall ~7.1/10. TTHW ~5 min (neschimbat pentru cont cu drept). Codex:
|
||||
indisponibil. Claude subagent: 3 constatari (1 HIGH mesaj-403, 2 MEDIUM docs/upgrade-link) + leaga
|
||||
T-CEO-3 (valideaza gated vs permis). Trec la Faza 4.
|
||||
|
||||
<!-- AUTONOMOUS DECISION LOG -->
|
||||
## Decision Audit Trail
|
||||
|
||||
| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins |
|
||||
|---|------|---------|-------------|-----------|-------------|---------|
|
||||
| 1 | CEO | Mod = SELECTIVE EXPANSION | Mechanical | override autoplan | iteratie pe sistem existent | EXPANSION/HOLD/REDUCTION |
|
||||
| 2 | CEO | `effective_tier(acct, now)` cu `now` injectabil | Mechanical | P5 explicit | teste de granita deterministe | now intern (flaky) |
|
||||
| 3 | CEO | Coduri noi ca erori business 3-niveluri (nu 500) | Mechanical | P4 DRY/errors.py | reuse pattern existent | exceptii/catch-all |
|
||||
| 4 | CEO | Gate volum/API server-side dupa resolve_account_id | Mechanical | P1 completeness/sec | nu pe camp din body | gate din body (nesigur) |
|
||||
| 5 | CEO | Accepta overshoot mic cota sub concurenta | Taste->auto | P3 pragmatic | lock per-cont = over-eng pt cap soft | lock tranzactional |
|
||||
| 6 | CEO | Valoarea 60 + capability EXCLUSIV in PLANS | Mechanical | P4 DRY | o singura sursa | hardcodare in 3 locuri |
|
||||
| 7 | CEO | log_event pe fiecare respingere de plan | Mechanical | zero-silent-failures | "de ce blocat X?" vizibil | doar 4xx tacut |
|
||||
| 8 | CEO | Index `(account_id,created_at)` deferat -> TODO | Mechanical | P3 | volume mici acum | index acum (premature) |
|
||||
| 9 | CEO | T-CEO-1: enforcement sub flag + soft-first volum | **USER CHALLENGE -> REZOLVAT** | decizie user (2026-06-28) | **enforcement DUR direct de la deploy**; fara conturi legacy, pre-productie -> riscul de fals-block e moot | soft-first / flag-OFF respinse |
|
||||
| 10 | CEO | T-CEO-2: limita 60 ca o constanta config | Taste | P5 | tunabila fara cod | hardcodat |
|
||||
| 11 | Design | 6 stari explicite afisaj in US-006 | Mechanical | P1 completeness | acoperire stari | doar 3 stari |
|
||||
| 12 | Design | Copy RO fix cu pluralizare (zi/zile) | Mechanical | P5 explicit | nu lasa implementatorului | generic |
|
||||
| 13 | Design | T-DES-1: banner one-time la trial->free | Taste | P1 | semnal la trecere | doar badge tacut |
|
||||
| 14 | Design | warn = culoare + text (nu doar culoare) | Mechanical | P1 a11y | accesibilitate | doar culoare |
|
||||
| 15 | Eng | `CONSUMED_STATUSES` constanta exportata | Mechanical | P4 DRY | nu duplica definitia consum | duplicare in teste |
|
||||
| 16 | Eng | Fara tabela `plan_usage` (foloseste created_at) | Mechanical | P3/non-goal | migrare minima | coloana/tabela noua |
|
||||
| 17 | Eng | 2 invariante critice ca teste (retry, dry-run) | Mechanical | P1 completeness | usor de stricat | a le omite |
|
||||
| 18 | DX | `valideaza` ramane PERMIS pe orice plan (default) | Taste->auto | P1 DX | dezvolti pe free, trimiti pe Pro | gated ca restul API |
|
||||
| 19 | DX | Fix erori plan cu canal de contact concret | Mechanical | P1 completeness | actionabil | "contacteaza-ne" vag |
|
||||
| 20 | All | "prestatie consumata" = queued+sending+sent | Taste->auto | P1 | limita pe ce trimitem la RAR | doar sent |
|
||||
| 21 | All | Lot peste limita -> respingere totala clara | Taste->auto | P5 explicit | evita surprize enqueue partial | partial tacut |
|
||||
| 22 | All | **Enforcement DUR direct de la deploy** (rezolva T-CEO-1) | **USER DECISION (2026-06-28)** | user-stated | fara conturi legacy, produs in TESTE/pre-productie -> riscul de fals-block e moot; flag = optional kill-switch | soft-first / flag-OFF |
|
||||
| 23 | CEO | **T-CEO-2 REZOLVAT: limita 60 = constanta config tunabila** (o singura sursa in plans.py/config) | **USER DECISION (2026-06-28)** | user-stated (pe recomandare) | DRY/tunabil fara arheologie de cod | hardcodat |
|
||||
| 24 | Design | **T-DES-1 REZOLVAT: banner one-time la expirarea trial->Gratuit** | **USER DECISION (2026-06-28)** | user-stated (pe recomandare) | semnal clar la trecere, evita surpriza la prima respingere | doar badge |
|
||||
| 25 | DX | **T-DX-3 REZOLVAT: `valideaza` dry-run ramane PERMIS pe orice plan** | **USER DECISION (2026-06-28)** | user-stated (pe recomandare) | dezvolti pe free, trimiti pe Pro — DX excelent | gated ca restul API |
|
||||
|
||||
## Cross-Phase Themes
|
||||
- **Tema: enforcement fara cale de conversie** — semnalata in CEO (S9/S10) + DX (upgrade path). Semnal
|
||||
inalt: hard-block + lipsa self-service = friction. -> sustine T-CEO-1.
|
||||
- **Tema: mesaje oneste, actionabile** — CEO (S2/S8) + Design (P4 copy) + DX (erori). Convergent:
|
||||
fiecare respingere are problema+cauza+fix + canal de contact.
|
||||
- **Tema: determinism temporal** — CEO (S1 now injectabil) + Eng (S3 teste granita) + Design (pluralizare
|
||||
zile). `now` injectabil + timp local RO sunt fundatia testelor.
|
||||
|
||||
## TODOS.md (propuneri)
|
||||
- **[P3] Index `(account_id, created_at)` pe submissions** — cand apar conturi cu mii prestatii/luna,
|
||||
`monthly_usage` scaneaza randurile lunii. Efort S. Depinde de: aparitia volumului mare. (A: adauga la TODOS)
|
||||
- **[P2] Job eager downgrade `trial_until` expirat -> NULL** — igiena in purjarea orara T16; lazy acopera
|
||||
corectitudinea. Efort S. (A: adauga la TODOS, optional)
|
||||
- **[P1->Phase 2] Billing self-service (upgrade din UI)** — golul strategic; fara el enforcement-ul produce
|
||||
churn in loc de conversie. Efort XL. PRD separat. (A: adauga la TODOS ca Phase 2)
|
||||
- **[P3] Re-trial / nurture la expirare** — email "trial expirat, treci pe Pro". Efort M. (A: TODOS)
|
||||
|
||||
## Implementation Tasks (sintetizate)
|
||||
- [ ] **T1 (P1, human ~3h / CC ~25min) — schema/plans** — `accounts.tier`+`trial_until` (migrare aditiva
|
||||
defensiva) + `app/plans.py` (PLANS, `effective_tier(acct,now)`, `monthly_usage(conn,acct,now)`,
|
||||
`CONSUMED_STATUSES`). Surfaced by: CEO S1 / Eng S1-S2. Files: schema.sql, db.py, app/plans.py, accounts.py.
|
||||
Verify: test_migrare_*, test_plan_definitii, test_effective_tier_*.
|
||||
- [ ] **T2 (P1, human ~2h / CC ~15min) — accounts** — `create_account` seteaza trial Pro 30z; `set_tier`
|
||||
(protejat id=1); legacy -> free fara trial. Surfaced by: CEO 0B / Eng. Files: accounts.py, tools/account.py.
|
||||
- [ ] **T3 (P1, human ~3h / CC ~25min) — enforce volum** — gate INAINTE de build_key pe ambele canale +
|
||||
cod `PLAN_LIMITA_LUNARA` + log_event; lot peste limita -> respingere totala. Surfaced by: CEO S3/S4/S8.
|
||||
Files: api/v1/router.py, import_router.py, web/routes.py, errors.py. Verify: test_free_peste_60_*, retry.
|
||||
- [ ] **T4 (P1, human ~2h / CC ~15min) — gate API** — `require_api_access` (Pro+/trial) pe rutele de
|
||||
ingestie API; `valideaza`+`nomenclator` raman permise; dev id=1 exceptat; cod `PLAN_FARA_API` (403 actionabil).
|
||||
Files: auth.py, api/v1/router.py, import_router.py, errors.py. Verify: test_*_api_403/ok.
|
||||
- [ ] **T5 (P3 OPTIONAL, human ~30min / CC ~5min) — flag enforcement (kill-switch)** — `AUTOPASS_ENFORCE_PLANS`
|
||||
(config). NU blocant: enforcement DUR e activ implicit de la deploy (decizie user). Flag-ul = doar
|
||||
comoditate de operare. Files: config.py + gate-urile. Surfaced by: CEO S9 (rezolvat).
|
||||
- [ ] **T6 (P2, human ~3h / CC ~20min) — UI dashboard** — badge plan antet + linie burger + consum N/60 +
|
||||
warn>=80% + 6 stari + copy RO pluralizat + pagina Cont. Surfaced by: Design P2/P4. Files: web/routes.py,
|
||||
templates/_status.html,_cont.html. Verify: test_afisaj_*, test_copy_pluralizare.
|
||||
- [ ] **T7 (P1, human ~30min / CC ~5min) — landing copy** — 100->60 (linii 7,65,92,266,388);
|
||||
"Premium gratuit 30 zile"->"Pro gratuit 30 zile" (256,350). Files: landing.html. Verify: test_landing_*.
|
||||
- [ ] **T8 (P2, human ~1h / CC ~10min) — teste matrice E2E** — plan x capabilitate x canal x trial +
|
||||
granita luna RO + dev id=1. Files: tests/test_plans.py, test_api_scope.py, test_web_*. Verify: pytest -q.
|
||||
- [ ] **T9 (P2, human ~30min / CC ~5min) — docs integrator** — "dezvolta pe free cu valideaza, trimiterea
|
||||
reala cere Pro". Surfaced by: DX. Files: docs/ + integrare_examples.
|
||||
|
||||
## GSTACK REVIEW REPORT
|
||||
|
||||
| Review | Trigger | Why | Runs | Status | Findings |
|
||||
|--------|---------|-----|------|--------|----------|
|
||||
| CEO Review | `/plan-ceo-review` | Scop & strategie | 1 | issues_open | 9 constatari, CRITICAL GAP legacy REZOLVAT (moot), mode SELECTIVE_EXPANSION |
|
||||
| Codex Review | `/codex review` | A 2-a opinie | 0 | indisponibil | limita utilizare (pana 2026-07-18) |
|
||||
| Eng Review | `/plan-eng-review` | Arhitectura & teste (required) | 1 | issues_open | 4 constatari, gap legacy moot, 2 invariante critice teste |
|
||||
| Design Review | `/plan-design-review` | UI/UX | 1 | issues_open | 4 constatari, overall ~6.4/10 |
|
||||
| DX Review | `/plan-devex-review` | Developer experience | 1 | issues_open | 3 constatari, DX ~7.1/10 |
|
||||
|
||||
- **VERDICT:** CEO + Design + Eng + DX rulate (subagent-only, codex indisponibil). Toate deciziile inchise
|
||||
(2026-06-28): USER CHALLENGE rezolvat (enforcement DUR direct de la deploy; CRITICAL GAP migrare = moot,
|
||||
fara conturi legacy/pre-productie) + cele 3 taste decisions rezolvate pe recomandare (T-CEO-2 constanta
|
||||
config, T-DES-1 banner one-time trial->Gratuit, T-DX-3 `valideaza` permis pe orice plan). Plan gata de executie.
|
||||
|
||||
NO UNRESOLVED DECISIONS
|
||||
355
docs/prd/prd-5.18-corpus-knn-exemple-etichetate.md
Normal file
355
docs/prd/prd-5.18-corpus-knn-exemple-etichetate.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# PRD 5.18 — Corpus k-NN din exemple reale etichetate (mapare operatii service)
|
||||
|
||||
**Stare**: aprobat + revizuit /autoplan (2026-06-28; intrebari deschise rezolvate de user — vezi §5 Decizii;
|
||||
cerinte user D4/D5 + 10 constatari Eng incorporate — vezi GSTACK REVIEW REPORT la final)
|
||||
|
||||
> Proces: `docs/ROADMAP.md` §5. Contract RAR: `docs/api-rar-contract.md`. Construieste peste
|
||||
> infrastructura 5.14 (straturi GOLD/SILVER/embeddings, `app/embeddings.py`, `app/shared_store.py`,
|
||||
> `mapping_suggestions`). NU re-deschide deciziile 5.14 (#11-#19); le foloseste.
|
||||
|
||||
## 0. Context si motivatie (de ce acest PRD)
|
||||
|
||||
5.14 a livrat embeddings in-proces, dar corpusul indexat = **cele 18 denumiri RAR generice**
|
||||
din nomenclator (`nume_prestatie` -> `cod_prestatie`). O operatie reala ("inlocuit lubrifiant
|
||||
la propulsor") se potriveste semantic slab cu etichete generice scurte ("INTRETINERE",
|
||||
"REPARATIE"). In plus, stratul **SILVER (`mapping_suggestions`) e populat DOAR in teste** —
|
||||
in productie e gol, deci nu produce nicio sugestie (LLM-ul nu e chemat la runtime).
|
||||
|
||||
Acest PRD muta corpusul de la cele 18 categorii la **operatiile reale etichetate** (k-NN peste
|
||||
exemple): o operatie noua se potriveste semantic cu o operatie deja vazuta si MOSTENESTE codul ei.
|
||||
|
||||
**Masuratori care justifica directia** (vezi memorie `test-precizie-knn-embeddings`, rulat 2026-06-28):
|
||||
- k-NN peste exemple etichetate: **94.3% acord cu LLM pe operatii distincte** (baseline "mereu OE-1" = 86.2%).
|
||||
- Acoperire IEFTINA: pe volumul real total (155.195 aparitii, 17.181 operatii distincte):
|
||||
148 operatii = 50% volum, **1.380 = 80%**, 4.368 = 90%, 9.422 = 95%.
|
||||
- Punct slab masurat: **NUL recall 64%** (ITP/discount/plata scapa ca OE-1) -> de aici pre-filtrul (US-001).
|
||||
- Etichetarea offline cu **Qwen3-4B local (LM Studio, GPU RX 6600M)** + prompt procedural in 3 pasi:
|
||||
**91% pe batch greu, 20/20 pe batch de validare**, ambele NUL prinse. Debit ~1.5-2h pentru ~13.5k operatii.
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Inlocuieste corpusul embeddings (18 categorii generice) cu **corpusul de operatii reale etichetate**
|
||||
(exemplu -> cod RAR), populat dintr-un seed comis in repo, plus un **pre-filtru determinist** pentru
|
||||
non-operatii (NUL). Rezultat: sugestii de mapare semnificativ mai precise in editor, fara LLM la runtime.
|
||||
|
||||
**Pasul 1 (bootstrap offline, fundatia intregului PRD) = etichetare cu LLM via LM Studio local.**
|
||||
Tot restul (seeder, corpus embeddings, enrich) consuma artefactul produs aici. Pasul are doua garantii
|
||||
non-negociabile:
|
||||
1. **LM Studio = backend implicit aprobat pentru rularea v1** (Qwen3-4B local, GPU RX 6600M, `json_schema`
|
||||
strict — `json_object` e respins de LM Studio). Groq/OpenRouter raman fallback-uri interschimbabile, dar
|
||||
NU sunt calea aprobata pentru bootstrap-ul v1 (vezi D4).
|
||||
2. **Dedup INAINTE de orice apel LLM.** Cele 4 fisiere (`docs/operatii-service/*.csv`) contin **19.456 randuri
|
||||
brute -> 17.181 operatii distincte dupa `normalize_for_match`** (gain de doar 254 fata de dedup exact-string,
|
||||
pentru ca datele sunt deja majuscule, fara diacritice — `normalize_for_match` colapseaza spatii + scoate diacritice,
|
||||
**NU** scoate punctuatie). Din cele 17.181, **3.662 sunt deja etichetate** (in spatiu normalizat) in
|
||||
`labels-groq-partial.json`. Trimitem la LLM EXACT cele **13.519** operatii distincte ne-etichetate, niciodata un
|
||||
duplicat normalizat, o cheie normalizata vida sau o operatie deja etichetata (vezi D5). Economie: **31% mai putine
|
||||
apeluri** vs randuri brute. (Castigul real al pipeline-ului nu e atat normalizarea — 254 chei — cat **reuse-ul
|
||||
etichetelor existente** + agregarea frecventei; motivul principal pentru spatiul normalizat e **consistenta
|
||||
end-to-end cu cheia DB/k-NN**, vezi F1/F3 din review.)
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- **NU auto-send peste GOLD propriu.** Toate sursele (k-NN, exact, NUL pre-filtru) raman SUGGESTION-ONLY,
|
||||
niciodata in `resolve_prestatii`/`load_mapping` (invariant #13, #11 din 5.14). Singura cale spre `queued`
|
||||
ramane `operations_mapping` (GOLD propriu confirmat de om).
|
||||
- **NU LLM la runtime.** Etichetarea LLM se face O SINGURA DATA, offline; runtime = doar embeddings + exact + reguli.
|
||||
- **NU validare temporala / re-etichetare automata.** Seedul e static; reimprospatarea e un re-run manual al tool-ului.
|
||||
- **NU schimbare UI majora.** Editorul (`_mapari.html`) consuma deja `sugestie_principala`; doar sursa se schimba.
|
||||
(Un badge optional de sursa = US-007, jos.)
|
||||
- **NU eshantion etichetat de om in acest PRD** (doar mentionat la Riscuri ca recomandare — Decision #19).
|
||||
|
||||
## 3. Stories atomice
|
||||
|
||||
> Fiecare story = cea mai mica unitate care lasa sistemul functional. Refoloseste `mapping_suggestions`
|
||||
> (SILVER) ca tabela-corpus (are deja: `denumire_normalizata`, `cod_prestatie`, `is_nul`, `source`,
|
||||
> `confidence`) — populata acum si in productie, nu doar in teste.
|
||||
|
||||
### US-001: Pre-filtru determinist non-operatii (NUL)
|
||||
**Ca** operator **vreau** ca gunoiul evident (ITP, plata, discount, nr. inmatriculare, tractare) sa fie
|
||||
marcat NUL inainte de k-NN **pentru ca** masuratoarea arata recall NUL doar 64% (scapa ca OE-1).
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/mapping.py` (functie noua `prefiltru_nul(denumire) -> bool`), `tests/test_prefiltru_nul.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_prefiltru_nul.py` — `test_itp_e_nul`, `test_plata_discount_nul`, `test_nr_inmatriculare_nul`, `test_operatie_reala_nu_e_nul`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Reguli text/regex deterministe (ITP, ACHITAT/PLATA, DISCOUNT/REDUCERE, NR INMATRICULARE + pattern placuta, TRACTARE, TAXA)
|
||||
- [ ] `prefiltru_nul("13 X ITP")` / `("DISCOUNT FIDELITATE 10%")` -> True; `("INLOCUIT PLACUTE FRANA")` -> False
|
||||
- [ ] Zero fals-pozitiv pe un set de 20 operatii reale (din `docs/operatii-service`)
|
||||
- [ ] `python3 -m pytest tests/test_prefiltru_nul.py -q` verde
|
||||
- **Verificare E2E**: — (pur backend, acoperit de teste)
|
||||
|
||||
### US-002: Etichetator offline multi-backend cu prompt procedural
|
||||
**Ca** dezvoltator **vreau** un tool care eticheteaza operatii->coduri RAR via LM Studio local / Groq /
|
||||
OpenRouter, cu prompt procedural in 3 pasi si `json_schema` strict **pentru ca** LM Studio respinge
|
||||
`json_object` si promptul nou ridica precizia (91% vs 80%).
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `tools/mapare-llm/eticheteaza.py` (NOU, backend-uri interschimbabile), `tests/test_eticheteaza_tool.py` (mock HTTP) (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_eticheteaza_tool.py` — `test_construieste_prompt_3pasi`, `test_parseaza_json_schema`, `test_backend_selectabil_env`, `test_scrub_pii_inainte_de_request`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Backend selectabil prin env (`ETICHETARE_BACKEND=lmstudio|groq|openrouter`, endpoint+model configurabile);
|
||||
**default = `lmstudio`** (backend-ul aprobat pentru bootstrap v1, D4). Groq/OpenRouter = fallback.
|
||||
- [ ] `response_format` = `json_schema` strict cu **envelope complet** `{"type":"json_schema","json_schema":{"name":...,"strict":true,"schema":{...}}}`
|
||||
(NU `{"type":"json_object"}` ca `or_common.py:57`/`label_common.py:24`); `cod` = **enum** peste cele 19 `ALL_LABELS` (18 + NUL),
|
||||
cod invalid/lipsa -> `?` (F8 din review). Etichetatorul nou NU reutilizeaza request-ul vechi, doar promptul/codurile/scrub-ul.
|
||||
- [ ] **Dezactiveaza explicit "thinking"-ul Qwen3** (`/no_think` sau reasoning off) — altfel modelul emite `<think>` si
|
||||
umfla tokeni/latenta sub structured output strict (F8).
|
||||
- [ ] **Garda de truncare**: daca raspunsul are mai putine iteme decat batch-ul sau JSON invalid -> log + marcheaza `?`
|
||||
pe pozitiile lipsa, NU le ascunde tacit (la batch 40 + prompt 3 pasi, `n_ctx=4096` e stramt — F8).
|
||||
- [ ] Promptul = procedura 3 pasi + ancore (mapare parte caroserie->OE-C etc.), versionat in fisier
|
||||
- [ ] Scrub PII (nr. inmatriculare, VIN) inainte de orice request (refoloseste `or_common.scrub`, #3)
|
||||
- [ ] Setari conservatoare documentate in tool (batch 32-40, `n_parallel=1`, `n_ctx=4096`) — vezi Riscuri
|
||||
- [ ] `python3 -m pytest tests/test_eticheteaza_tool.py -q` verde (fara retea reala)
|
||||
- **Verificare E2E**: rulare manuala 1 batch pe LM Studio local (`http://<tailscale>:1234`), confirmare JSON valid
|
||||
|
||||
### US-003: Generare seed etichetat in faze pe frecventa
|
||||
**Ca** dezvoltator **vreau** sa generez un fisier seed `operatii-etichetate.json` (operatie->cod) pornind de la
|
||||
operatiile existente + cele deja etichetate, in ordinea frecventei **pentru ca** 1.380 operatii prind 80% din volum.
|
||||
|
||||
- **Depinde de**: US-002
|
||||
- **Fisiere**: `tools/mapare-llm/genereaza_seed.py` (NOU), `app/data/operatii-etichetate.json` (artefact comis), `tests/test_genereaza_seed.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_genereaza_seed.py` — `test_dedup_normalizat`, `test_zero_duplicate_trimis_la_llm`, `test_rerun_zero_apeluri_llm`, `test_reuse_conflict_determinist`, `test_skip_cheie_normalizata_vida`, `test_reuse_in_spatiu_normalizat`, `test_ordine_pe_frecventa`, `test_format_seed_valid`
|
||||
- **Pipeline dedup (ordinea e obligatorie, INAINTE de orice apel LLM):**
|
||||
1. Agrega cele 4 CSV-uri -> pentru fiecare rand `(denumire, NR)`. Parseaza NR tolerant (skip rand pe NR ne-numeric, nu zero-weight — F9).
|
||||
2. `cheie = normalize_for_match(denumire)` — ACEEASI functie ca DB/k-NN (`app/mapping.py:40`), NU `.strip()` exact.
|
||||
**Arunca randurile cu `cheie == ""`** (gunoi gen `"..."`, `" "`) inainte de dedup — altfel se bat pe slotul UNIQUE gol (F6).
|
||||
3. Dedup pe cheie: un singur reprezentant per cheie, `freq = suma NR` pe toate aparitiile/fisierele.
|
||||
4. Construieste **harta** `cheie_normalizata -> cod` (NU doar un set) din TOATE sursele de etichete deja existente:
|
||||
`labels-groq-partial.json` (cheiat pe text BRUT) **PLUS seedul comis anterior** `operatii-etichetate.json` (cheiat normalizat).
|
||||
Reuse + scaderea se fac in spatiu normalizat. **Rezolvare conflict determinista** cand acelasi `cheie` are coduri diferite
|
||||
pe variante raw (masurat: 1 azi — `CURATAT CATALIZATOR` OE-2 vs OE-1): castiga varianta cu `freq` (suma NR) maxima, tie-break pe `cod` sortat (F3).
|
||||
5. `de_etichetat = {cheie in corpus} - {cheie in harta etichete}`. Lista (distincta, ne-etichetata, sortata desc pe freq) = SINGURUL input catre LLM.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `test_zero_duplicate_trimis_la_llm` (within-run): backend LLM mock care inregistreaza fiecare denumire primita;
|
||||
input cu duplicate intentionate (spatii/case + cross-file) -> mock-ul nu vede NICIODATA doua chei normalizate egale,
|
||||
nicio cheie deja etichetata, nicio cheie vida.
|
||||
- [ ] `test_rerun_zero_apeluri_llm` (cross-run, **criteriul real de idempotenta**, F2/F7): ruleaza tool-ul de doua ori cu acelasi
|
||||
input; a doua rulare consuma seedul comis ca cache -> **0 apeluri LLM**, seed identic byte-cu-byte.
|
||||
- [ ] `test_reuse_conflict_determinist` (F3/F7): doua variante raw ale aceleiasi chei cu coduri diferite -> codul ales e determinist (freq-max, tie-break cod).
|
||||
- [ ] Dedup pe `normalize_for_match` (colapseaza spatii + diacritice, **NU** punctuatie; gain real ~254 chei vs exact-string —
|
||||
valoarea principala e consistenta cu cheia DB/k-NN, nu volumul); NU reutiliza `or_common.corpus_by_freq()` ca atare (dedup exact-string).
|
||||
- [ ] Eticheteaza DOAR ce lipseste, in ordine descrescatoare de frecventa, cu `--target-volum 0.9` (oprire la prag) sau `--all`
|
||||
- [ ] Seed format `[{denumire, denumire_normalizata, cod, is_nul, source, confidence}]`, UTF-8, comis in repo;
|
||||
`denumire_normalizata` unica + ne-vida in seed (oglindeste UNIQUE din `mapping_suggestions`; `test_format_seed_valid` asserta non-empty)
|
||||
- [ ] `python3 -m pytest tests/test_genereaza_seed.py -q` verde
|
||||
- **Verificare E2E**: rulare `--target-volum 0.5` pe date reale -> ~150 etichete noi, fisier valid; log-ul tool-ului
|
||||
raporteaza explicit "{brute} randuri -> {distincte} dupa normalizare -> {de_etichetat} trimise la LLM"
|
||||
|
||||
### US-004: Seeder corpus etichetat in DB (mapping_suggestions)
|
||||
**Ca** sistem **vreau** sa incarc seedul etichetat in `mapping_suggestions` la init (INSERT OR IGNORE)
|
||||
**pentru ca** SILVER e gol in productie si trebuie populat ca sa dea sugestii exact-match + corpus k-NN.
|
||||
|
||||
- **Depinde de**: US-003
|
||||
- **Fisiere**: `app/operatii_seed.py` (NOU, dupa modelul `nomenclator_seed.py`), `app/db.py` (apel la init), `tests/test_operatii_seed.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_operatii_seed.py` — `test_seed_populeaza_mapping_suggestions`, `test_insert_or_ignore_nu_clobber_uman`, `test_is_nul_din_seed`, `test_idempotent_la_reinit`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] La `init_db`, daca seedul exista si tabela permite, INSERT OR IGNORE randurile (idempotenta re-seed: nu dubla / nu
|
||||
clobber un rand seedat sau de embedding deja prezent). NB (F10): confirmarile UMANE stau in `shared_mappings`
|
||||
(`record_human_validation`), NU in `mapping_suggestions` — deci INSERT OR IGNORE pastreaza TACIT codul LLM vechi la re-seed;
|
||||
daca vrei refresh pe coduri LLM invechite, e decizie explicita upsert-vs-ignore (v1 = ignore)
|
||||
- [ ] `is_nul=1` -> `cod_prestatie=NULL` (respecta CHECK-ul existent); `source='llm_seed'`, `confidence` din seed
|
||||
- [ ] Idempotent: a doua initializare nu dubleaza si nu modifica randuri existente
|
||||
- [ ] `python3 -m pytest tests/test_operatii_seed.py -q` verde
|
||||
- **Verificare E2E**: pornire app pe DB gol -> `SELECT count(*) FROM mapping_suggestions` > 0
|
||||
|
||||
### US-005: Embeddings indexeaza corpusul etichetat (nu nomenclatorul)
|
||||
**Ca** sistem **vreau** ca `ensure_embeddings_corpus` sa indexeze operatiile etichetate (denumire->cod, cu is_nul)
|
||||
**pentru ca** k-NN peste exemple reale e net mai precis decat peste 18 categorii generice.
|
||||
|
||||
- **Depinde de**: US-004
|
||||
- **Fisiere**: `app/mapping.py` (`ensure_embeddings_corpus` schimba sursa), `app/embeddings.py` (`suggest_nearest` intoarce si `is_nul`), `tests/test_embeddings_corpus_etichetat.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_embeddings_corpus_etichetat.py` — `test_corpus_din_mapping_suggestions`, `test_suggest_nearest_intoarce_is_nul`, `test_semnatura_corpus_pe_seed`, `test_degradare_gratioasa_pastrata`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Corpusul = `mapping_suggestions` (denumire_normalizata -> cod, is_nul), NU `nomenclator_rar`
|
||||
- [ ] **Simetrie corpus/query (F1, HIGH)**: corpusul e text `denumire_normalizata`; deci `enrich_suggestions` trebuie
|
||||
sa interogheze `suggest_nearest(normalize_for_match(denumire), ...)`, NU `denumire` brut. Altfel corpus normalizat vs
|
||||
query brut degradeaza cosine si NU e configul sub care s-a masurat 94.3%. `test_query_normalizat_ca_si_corpusul` o asserta.
|
||||
- [ ] `suggest_nearest` intoarce `[{cod, is_nul, similaritate}]`; un vecin NUL -> semnal de supresie, nu cod
|
||||
- [ ] Re-index doar la schimbarea semnaturii corpusului (cache pastrat, #16b degradare gratioasa neschimbata)
|
||||
- [ ] Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (acum default True — vezi 5.14 CLOSE); off in teste (conftest)
|
||||
- [ ] `python3 -m pytest tests/test_embeddings_corpus_etichetat.py -q` verde
|
||||
- **Verificare E2E**: cu flag on + seed incarcat, `suggest_nearest("schimbat uleiul motor")` -> cod revizie/intretinere real
|
||||
|
||||
### US-006: enrich_suggestions = pre-filtru NUL + k-NN pe corpus etichetat
|
||||
**Ca** operator **vreau** ca editorul sa imbine pre-filtrul NUL, exact-match si k-NN semantic in ordinea de
|
||||
precedenta corecta **pentru ca** vreau sugestia cea mai buna fara junk.
|
||||
|
||||
- **Depinde de**: US-001, US-005
|
||||
- **Fisiere**: `app/mapping.py` (`enrich_suggestions`), `tests/test_enrich_corpus_etichetat.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_enrich_corpus_etichetat.py` — `test_prefiltru_nul_supreseaza_inainte_de_knn`, `test_precedenta_gold_exact_embedding`, `test_prag_similaritate`, `test_abtinere_sub_prag`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Ordine: pre-filtru NUL -> daca NUL, fara sugestie de cod (marcat non-operatie); altfel GOLD partajat > exact (SILVER) > k-NN embeddings
|
||||
- [ ] k-NN sub `EMB_MIN_SIMILARITATE` -> abtinere (`embedding=None`), nu sugestie incerta
|
||||
- [ ] Vecin k-NN cu `is_nul=1` -> tratat ca supresie, nu cod (consecventa cu pre-filtrul)
|
||||
- [ ] Invariant #13 pastrat: nimic din asta nu intra in `resolve_prestatii`/`load_mapping` (test de regresie)
|
||||
- [ ] `python3 -m pytest tests/test_enrich_corpus_etichetat.py -q` verde + suita 5.14 (`test_mapare_integrare_l14.py`) ramane verde
|
||||
- **Verificare E2E**: browser HTMX pe `/_fragments/mapari` — operatie parafraza primeste cod corect pre-selectat din k-NN
|
||||
|
||||
### US-007 (optional): Badge sursa sugestie in editor
|
||||
**Ca** operator **vreau** sa vad de unde vine sugestia (confirmat de om / exemplu similar / non-operatie)
|
||||
**pentru ca** acum nu pot distinge sursa si nu stiu cata incredere sa am.
|
||||
|
||||
- **Depinde de**: US-006
|
||||
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_badge_sursa.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_badge_sursa.py` — `test_badge_gold`, `test_badge_embedding`, `test_badge_nul`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Chip mic langa sugestie: "confirmat" (gold), "similar" (embedding/silver), "non-operatie" (NUL)
|
||||
- [ ] Fara sursa -> fara chip; nu rupe layoutul 5.15/5.16
|
||||
- [ ] `python3 -m pytest tests/test_web_badge_sursa.py -q` verde
|
||||
- **Verificare E2E**: browser — chip vizibil si corect colorat pe randul de mapare
|
||||
|
||||
## 4. Riscuri
|
||||
|
||||
- **Calitate etichetare model local (Qwen3-4B Q4) < model mare (Groq 70b).** Masurat: bun pe cap (frecvent,
|
||||
clar), mai slab pe coada rara/ambigua (ADAS calibrare, chei, "doar nume piesa"). Mitigare: pre-filtru NUL
|
||||
(US-001) + optiunea unui al doilea pas de verificare cloud DOAR pe esantionul cu cod rar/incert.
|
||||
- **Hardware GPU-box instabil sub sarcina (shutdown observat 2026-06-29).** La config-ul rulant erau ~4GB VRAM
|
||||
liberi -> cauza probabil termica/alimentare, NU memorie. Mitigare OBLIGATORIE pentru pasul de etichetare:
|
||||
`n_parallel=1`, `n_ctx=4096`, batch 32-40, monitorizare temperatura GPU. NU mari batch/context fara headroom termic.
|
||||
- **Ground-truth = eticheta LLM, nu om.** 94.3% e ACORD cu LLM, nu acuratete reala; LLM impinge 86% in OE-1
|
||||
(posibil prea agresiv). **Recomandare (Decision #19):** inainte de a creste increderea/orice auto-send, ruleaza
|
||||
`heldout_eval.py` cu un esantion etichetat de OM. Ramane in afara scope-ului acestui PRD, dar e poarta pentru orice 5.x viitor de auto-send.
|
||||
- **`mapping_suggestions` populat schimba comportamentul testelor** care presupuneau SILVER gol. Mitigare: seederul
|
||||
ruleaza doar daca seedul exista; conftest poate dezactiva seedul in testele care nu-l vor (ca la embeddings).
|
||||
- **Coada lunga ramane needs_mapping.** Chiar la 90% volum acoperit, 76% din operatiile DISTINCTE raman neetichetate
|
||||
(frecventa 1). Asteptare corecta: bootstrap-ul reduce mult volumul, dar editorul uman ramane necesar pe coada.
|
||||
- **(F1, review) Simetrie corpus/query la embeddings.** Corpusul k-NN devine text NORMALIZAT (`denumire_normalizata`),
|
||||
deci query-ul TREBUIE normalizat la fel inainte de embedding (US-005 AC). Daca raman asimetrice (corpus normalizat,
|
||||
query brut), similaritatea scade si nu mai e configul masurat (94.3%). Risc de regresie tacuta — acoperit de test in US-005.
|
||||
- **(F2, review) Idempotenta cross-run a etichetarii.** Etichetele noi produse de o rulare trebuie sa devina cache pentru
|
||||
urmatoarea (seedul comis = sursa de etichete, nu doar `labels-groq-partial.json`), altfel re-run-ul re-trimite tot la LLM.
|
||||
Acoperit de `test_rerun_zero_apeluri_llm` (US-003).
|
||||
|
||||
## 5. Decizii (intrebari deschise rezolvate la aprobare, 2026-06-28)
|
||||
|
||||
> Erau intrebari deschise; rezolvate de user la poarta de aprobare PRD. Devin constrangeri de executie.
|
||||
|
||||
- **D1 — Tinta de acoperire la etichetare: 90% din volum** (`--target-volum 0.9`, ~4.368 operatii distincte).
|
||||
Restul (coada lunga, 76% din operatiile distincte dar doar ~10% din volum) ramane pe editorul uman.
|
||||
US-003 implementeaza exact acest default; `--all` ramane disponibil dar NU e calea aprobata pentru v1.
|
||||
- **D2 — Verificare cloud pe esantionul incert: NU in acest PRD.** Toate sursele sunt suggestion-only (blast
|
||||
radius mic: o sugestie gresita = omul alege altceva in editor). Pre-filtrul NUL (US-001) acopera punctul slab
|
||||
masurat. Codurile rare/avarii grave sunt volum mic; un pas de verificare cloud adauga un backend in plus pentru
|
||||
castig marginal. Se reia DOAR daca esantionul uman (Decision #19, vezi Riscuri) arata ca erorile pe coduri rare
|
||||
sunt o problema reala. `source`/`confidence` din seed raman in DB pentru o eventuala flag-uire ulterioara.
|
||||
- **D3 — Pastram exact-match (SILVER) separat de k-NN.** Exact-match (`lookup_suggestion` pe text normalizat) =
|
||||
instant, 100% pe text identic; k-NN = generalizare semantica pentru texte nevazute. Precedenta confirmata:
|
||||
**GOLD partajat > exact (SILVER) > k-NN embedding** (US-006). k-NN NU inlocuieste exact-match.
|
||||
- **D4 — Bootstrap-ul v1 ruleaza pe LM Studio local** (Qwen3-4B, `json_schema` strict), nu pe Groq/OpenRouter.
|
||||
Motiv: zero cost per-token, date pe hardware propriu (PII service local), masurat 91% pe batch greu + 20/20 validare.
|
||||
Groq/OpenRouter raman in tool ca fallback interschimbabil (US-002), dar nu sunt calea aprobata pentru v1. Cerinta user, 2026-06-28.
|
||||
- **D5 — Dedup pe `normalize_for_match` INAINTE de orice apel LLM, cu reuse in spatiu normalizat.** Nu se trimite la LLM
|
||||
niciun duplicat normalizat si nicio operatie deja etichetata. Garantat de `test_zero_duplicate_trimis_la_llm` (within-run) +
|
||||
`test_rerun_zero_apeluri_llm` (cross-run, idempotenta) — US-003.
|
||||
Motiv: ~31% randuri redundante (19.456 brute -> 13.519 de etichetat: cross-file + variatii spatii + reuse labels existente);
|
||||
fara dedup-ul corect platim apeluri LLM inutile si riscam etichete inconsistente pe acelasi text logic. Cerinta user, 2026-06-28.
|
||||
|
||||
## 6. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
PASUL 1 — BOOTSTRAP ETICHETE OFFLINE (LM Studio LLM) — fundatia, ruleaza prima:
|
||||
Val 1: [US-002] [US-001] ← US-002 (etichetator LM Studio) = pasul 1; US-001 (pre-filtru NUL) paralel, fisiere disjuncte
|
||||
Val 2: [US-003] ← deblocat de US-002: dedup normalizat -> trimite la LLM -> seed comis
|
||||
PASUL 2 — CONSUM SEED (fara LLM):
|
||||
Val 3: [US-004] ← deblocat de US-003 (owns schema/seed loader)
|
||||
Val 4: [US-005] ← deblocat de US-004
|
||||
Val 5: [US-006] ← deblocat de US-001 + US-005
|
||||
Val 6: [US-007] (optional) ← deblocat de US-006
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raport VERIFY (2026-06-29) — PASS
|
||||
|
||||
> Faza VERIFY + CLOSE rulata pe `feat/5.18-corpus-knn-exemple-etichetate`, commit-uri
|
||||
> `756f777` (5.18 core + seed) + `308fee6` (fix lateral start-test ONNX). Seed-ul real produs
|
||||
> cu subagenti Haiku (decizie user 2026-06-29), NU LM Studio (GPU jos) si NU Groq — vezi
|
||||
> nota la "Seed real" mai jos. Abaterea de la D4 (LM Studio = backend bootstrap v1) e
|
||||
> documentata si justificata: motorul de etichetare s-a schimbat, garantiile de calitate
|
||||
> (validare 157 op Haiku vs Groq) sunt mai bune, restul pipeline-ului (US-003..006) e neatins.
|
||||
|
||||
### PASS/FAIL per story
|
||||
|
||||
| Story | Stare | Dovada |
|
||||
|-------|-------|--------|
|
||||
| US-001 pre-filtru NUL | PASS | `tests/test_prefiltru_nul.py` verde; seed contine 2200 NUL (`is_nul=1`, `cod=NULL`) |
|
||||
| US-002 etichetator offline | PASS | `tests/test_eticheteaza_tool.py` verde (json_schema envelope, enum cod, scrub PII, no_think) |
|
||||
| US-003 generare seed pe frecventa | PASS | `tests/test_genereaza_seed.py` verde (dedup normalizat, zero-duplicat, idempotenta cross-run, conflict determinist) |
|
||||
| US-004 seeder DB | PASS | `tests/test_operatii_seed.py` verde; smoke `init_db` pe DB gol -> `mapping_suggestions`=17181, NUL=2200, re-seed = 0 inserate (idempotent) |
|
||||
| US-005 embeddings pe corpus etichetat | PASS | `tests/test_embeddings_corpus_etichetat.py` verde (corpus din `mapping_suggestions`, query normalizat simetric, `is_nul` propagat) |
|
||||
| US-006 enrich = NUL + exact + k-NN | PASS | `tests/test_enrich_corpus_etichetat.py` verde (precedenta NUL>GOLD>exact>k-NN, abtinere sub prag, invariant #13 regresie) |
|
||||
| US-007 badge sursa (optional) | PASS | `tests/test_web_badge_sursa.py` verde (4 teste); E2E render live confirma chip confirmat/similar/non-operatie. Implementat la cererea user (2026-06-29) |
|
||||
|
||||
### Dovezi agregat
|
||||
|
||||
- **Suita completa**: `python3 -m pytest -q -m "not live"` -> **1387 passed, 1 deselected (live), 0 failed** (142.77s).
|
||||
- **Cele 6 fisiere de test 5.18** rulate izolat: **36 passed** (`test_prefiltru_nul`, `test_eticheteaza_tool`, `test_genereaza_seed`, `test_operatii_seed`, `test_embeddings_corpus_etichetat`, `test_enrich_corpus_etichetat`).
|
||||
- **Smoke seeder** (`init_db` pe DB gol, `AUTOPASS_SEED_OPERATII_ENABLED=true`): 17181 randuri in `mapping_suggestions`, 2200 NUL, `source='haiku_seed'`, re-seed idempotent (0 inserate).
|
||||
- **Validare nomenclator**: toate codurile distincte din seed (`OE-1`..`OE-8`, `OE-I/R`, `AITLV`, `R-ODO`) sunt in `FALLBACK_NOMENCLATOR` — zero cod gunoi care ar da HTTP 500 / `ORA-12899` la RAR.
|
||||
|
||||
### Seed real (abatere de la D4, aprobata de user)
|
||||
|
||||
Seed-ul `app/data/operatii-etichetate.json` rescris de la 3758 (Groq partial) la **17181** operatii
|
||||
distincte (toate, ordine frecventa), `source="haiku_seed"`, prin subagenti Haiku in Claude Code
|
||||
(blocantul GPU LM Studio rezolvat fara GPU). Validare la dezacorduri Haiku vs Groq pe 157 operatii:
|
||||
Haiku corect ~22/30, Groq ~0 (ex: CHIRIE ANVELOPE->NUL, ADAPTARE electronica->OE-7, INLOCUIT
|
||||
PLACUTE FRANA->OE-1). Distributie: OE-1=13764 (cap, asteptat), NUL=2200, restul sparse. Calitate
|
||||
estimata la scara ~95%; codurile rare (avarii grave OE-C/S/D/F/A, OE-5/6) sunt sparse si pot avea
|
||||
erori de margine ne-verificate uman — ramane recomandarea Decision #19 (esantion uman) inainte de
|
||||
orice crestere de incredere / auto-send.
|
||||
|
||||
### CLOSE — `/code-review high` (main..HEAD, 3 finder x 8 unghiuri)
|
||||
|
||||
Calea de runtime in productie = **curata**. Verificat intact:
|
||||
- **Invariant #13**: nimic din SILVER/k-NN/NUL nu intra in `resolve_prestatii`/`load_mapping` (suggestion-only).
|
||||
- `suggest_nearest`/`enrich_suggestions` semnatura noua (`is_nul`) consumata corect de unicul apelant.
|
||||
- Worker keepalive RAR (`308fee6`/`c05fa00`): fara race (worker single-thread), heartbeat actualizat doar pe login reusit.
|
||||
- Config `embeddings_enabled=True` + `seed_operatii_enabled=True` default: teste neafectate (conftest override).
|
||||
|
||||
Findings (toate low / cosmetic, niciun bug de runtime) — **REPARATE in faza CLOSE**:
|
||||
1. `tools/mapare-llm/genereaza_seed.py` (`_incarca_seed`/`construieste_harta_etichete`): `json.loads(open(...).read())` fara context manager -> FD leak in tool offline. **Fix**: `with open(...)`.
|
||||
2. `app/shared_store.py` `seed_suggestions`: `cod=" "` (whitespace) -> `''` in loc de NULL pe rand non-NUL. **Fix**: `str(...).strip().upper() or None` INAINTE de truthiness. Lock: `test_seed_suggestions_cod_whitespace_devine_null`.
|
||||
3. `app/embeddings.py` (2 docstring-uri): ziceau `[{cod, similaritate}]`, real `[{cod, is_nul, similaritate}]`. **Fix**: docstring-uri aliniate.
|
||||
|
||||
Concluzie VERIFY: **PASS**. US-001..006 livrate cu dovezi; zero bug de corectitudine in runtime; cele 3 findings de cleanup reparate + lock-uite.
|
||||
|
||||
### CLOSE — US-007 implementat (cerere user 2026-06-29)
|
||||
|
||||
User a cerut la poarta CLOSE sa includem badge-ul direct pe sugestiile sistemului fuzzy.
|
||||
Implementat: chip in coloana "Sugestii" din `_mapari.html`, mapat din `sugestie_principala.sursa`:
|
||||
**confirmat** (GOLD partajat) / **similar** (SILVER exact + k-NN embeddings) / **non-operatie**
|
||||
(pre-filtru NUL / vecin NUL). CSS `.sugg-sursa--{confirmat,similar,nul}` pe tokeni de tema
|
||||
(`--ok`/`--accent`/`--muted` cu `color-mix`), nu rupe layoutul. Suggestion-only (#13). Fix lateral:
|
||||
`surse_sugestie` default in `routes.py` a primit cheia `nul` (lipsea — finding cross-file). Teste:
|
||||
`tests/test_web_badge_sursa.py` (gold/silver/nul/fara-sursa). Render verificat in serverul real
|
||||
(`/_fragments/mapari`): OP-REV->confirmat, OP-REP->similar, OP-ITP->non-operatie, OP-XYZ->fara chip.
|
||||
Suita: **1392 passed, 1 deselected (live)**.
|
||||
|
||||
---
|
||||
|
||||
<!-- AUTONOMOUS DECISION LOG -->
|
||||
## GSTACK REVIEW REPORT (/autoplan — Eng focus, 2026-06-28)
|
||||
|
||||
Scope review: Eng (CEO premise gate + Eng dual-voice). Design/DX sarite (UI = doar badge optional US-007, tool intern mono-dezvoltator). Voce Eng: **subagent-only** — Codex a lovit limita de utilizare (degradare conform matricei).
|
||||
|
||||
**Premise confirmate** (poarta umana): (1) k-NN peste exemple reale > 18 categorii generice (94.3% vs 86.2% masurat); (2) etichetare LLM o singura data, offline, zero LLM la runtime; (3) SILVER populat in productie din seed comis; (4) pre-filtru NUL necesar (recall 64%); (5) LM Studio Qwen3-4B = calitate acceptabila pt bootstrap (91% batch greu / 20/20 validare).
|
||||
|
||||
**Cerinte user incorporate**: D4 (LM Studio = backend default v1), D5 (dedup pe `normalize_for_match` + reuse normalizat, INAINTE de LLM).
|
||||
|
||||
### Decision Audit Trail
|
||||
|
||||
| # | Faza | Decizie | Clasif. | Principiu | Rationament |
|
||||
|---|------|---------|---------|-----------|-------------|
|
||||
| 1 | CEO | Restructurare valuri: Pasul 1 = bootstrap LM Studio (US-002->US-003) | Mecanic | P1 | Cerinta user explicita; reflecta dependenta reala |
|
||||
| 2 | Eng | F1: query embedding normalizat ca si corpusul (US-005 AC + test) | Mecanic | P5 | Corectitudine; altfel 94.3% nereproductibil. Blast radius (US-005) |
|
||||
| 3 | Eng | F2: seed comis = cache de etichete cross-run (US-003 pipeline + `test_rerun_zero_apeluri_llm`) | Mecanic | P1 | Criteriul "0 apel LLM la re-run" altfel nesatisfiabil |
|
||||
| 4 | Eng | F3: harta normalizat->cod cu tie-break determinist (freq-max) | Mecanic | P5 | 1 conflict real azi (CURATAT CATALIZATOR); altfel cod nedeterminist |
|
||||
| 5 | Eng | F4/F5: corectie cifre (17.181 distinct, 13.519 de etichetat, 31%) + claim "fara punctuatie" | Mecanic | P5 | Cifre verificate cu `normalize_for_match` real |
|
||||
| 6 | Eng | F6: arunca cheie normalizata vida inainte de dedup | Mecanic | P1 | Coliziune pe slot UNIQUE gol |
|
||||
| 7 | Eng | F7: teste two-run + conflict adaugate | Mecanic | P1 | Testul single-run nu acopera idempotenta/determinismul |
|
||||
| 8 | Eng | F8: envelope json_schema strict + enum cod + dezactivare thinking Qwen3 + garda truncare | Mecanic | P1 | Realism integrare LM Studio (cerinta user #1) |
|
||||
| 9 | Eng | F9: parsare NR toleranta (skip, nu zero-weight) | Mecanic | P3 | Date curate azi; ieftina robustete |
|
||||
| 10 | Eng | F10: re-justificare INSERT OR IGNORE (confirmari umane = shared_mappings) | Mecanic | P5 | Evita inducerea in eroare a unui mentainer |
|
||||
|
||||
Zero decizii de gust (taste) si zero user-challenge: toate constatarile au intarit directia user, nu au contrazis-o.
|
||||
495
docs/prd/prd-5.19-auto-send-manual-coada.md
Normal file
495
docs/prd/prd-5.19-auto-send-manual-coada.md
Normal file
@@ -0,0 +1,495 @@
|
||||
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260629-150326.md -->
|
||||
# PRD 5.19 — Bifa "Auto": transmitere automata sau manuala din coada
|
||||
|
||||
> Status: DRAFT (asteapta aprobare). Sursa de contract: `docs/api-rar-contract.md`.
|
||||
> Limba: romana, fara emoji. Stil: aditiv, nedistructiv pe backend-ul de trimitere.
|
||||
|
||||
## 1. Introducere
|
||||
|
||||
Astazi transmiterea catre RAR e controlata de un singur comutator **global**
|
||||
(`AUTOPASS_WORKER_SEND_ENABLED`, env): cand e pornit, worker-ul ia ORICE rand `queued`
|
||||
al unui cont `active` si il trimite imediat. Nu exista un control **per-cont** care sa
|
||||
permita unui service sa-si tina prezentarile in coada pentru verificare umana inainte
|
||||
de a pleca la RAR.
|
||||
|
||||
Cazul concret care motiveaza feature-ul: utilizatorul testeaza canalul API din ROAAUTO
|
||||
(Visual FoxPro) direct in **productie** (`autopass.romfast.ro`), pe contul lui de test.
|
||||
Vrea ca prezentarile sa **apara in coada si sa astepte**, nu sa plece automat la RAR,
|
||||
pana cand le verifica si apasa explicit "Trimite". Reper vizual: bifa **"Auto"** din
|
||||
dashboard-ul gomag-vending (`image.png`).
|
||||
|
||||
## 2. Obiective
|
||||
|
||||
### Obiectiv principal
|
||||
- Un comutator **"Auto" per-cont**, persistat pe contul service-ului: cand e bifat,
|
||||
prezentarile pleaca automat la RAR (comportament actual); cand e debifat, randurile
|
||||
**asteapta vizibil in coada** pana cand un operator le trimite manual.
|
||||
|
||||
### Obiective secundare
|
||||
- Trimitere manuala **per rand** ("Trimite") si in **bloc** ("Trimite toate (N)",
|
||||
analogul "Start Sync" din gomag).
|
||||
- La activarea Auto (OFF -> ON), randurile deja tinute sunt **eliberate automat**
|
||||
spre transmitere.
|
||||
- Vizibilitate: randurile tinute apar in coada cu o stare umana clara
|
||||
("In asteptare (manual)"), separate de cele in curs de trimitere.
|
||||
|
||||
### Metrici de succes
|
||||
- Cu Auto OFF, un `POST /v1/prezentari` valid creeaza un rand care **NU** e trimis de
|
||||
worker (ramane vizibil in coada) pana la actiune umana.
|
||||
- Cu Auto ON, acelasi rand pleaca la RAR fara interventie (zero regresie fata de azi).
|
||||
- Bifa supravietuieste restartului (persistata in `accounts`), per-cont (un cont OFF nu
|
||||
afecteaza alt cont).
|
||||
|
||||
## 3. Design (decizii luate cu utilizatorul)
|
||||
|
||||
| # | Decizie | Alegere |
|
||||
|---|---------|---------|
|
||||
| D1 | Default bifa "Auto" pe conturi (inclusiv noi) | **OFF** (manual) — sigur, nimic nu pleaca fara confirmare |
|
||||
| D2 | La OFF -> ON, randurile deja tinute | **Eliberate automat** spre transmitere |
|
||||
| D3 | Plasare in UI | **Bara de status** (langa contoare, ca in mockup gomag) |
|
||||
| D4 | Trimitere manuala | **Per rand + buton "Trimite toate (N)"** |
|
||||
| D5 | Persistenta | Bifa salvata **pe contul service-ului** (`accounts`) |
|
||||
|
||||
### Mecanica aleasa: flag `held` pe submission (NU stare noua)
|
||||
|
||||
Randurile tinute raman in starea `queued` (sunt logic in coada, doar puse pe pauza),
|
||||
marcate cu o coloana booleana noua `submissions.held`. Motiv: evitam atingerea
|
||||
CHECK-ului de stari si a masinii de stari (`queued/sending/sent/needs_mapping/
|
||||
needs_data/error`), a pill-urilor, filtrelor si contoarelor — schimbare strict
|
||||
**aditiva**. Eticheta umana "In asteptare (manual)" se deriva din `status='queued'
|
||||
AND held=1` in stratul de afisaj (`labels.py`).
|
||||
|
||||
- **Comutatorul de cont** (`accounts.auto_send_enabled`) guverneaza DOAR:
|
||||
(a) valoarea implicita a lui `held` la ingestie; (b) eliberarea in bloc la OFF -> ON.
|
||||
- **Worker-ul** (`claim_one`) ia doar `status='queued' AND held=0`. Nu mai stie de
|
||||
comutatorul de cont — ramane simplu si robust.
|
||||
- **Trimiterea manuala** (per rand sau bulk) = `held: 1 -> 0`; worker-ul preia randul la
|
||||
urmatorul poll. Functioneaza chiar daca contul e pe Auto OFF (override uman per rand).
|
||||
|
||||
Comutatorul global `AUTOPASS_WORKER_SEND_ENABLED` ramane **kill-switch master** (productia
|
||||
il porneste). Feature-ul nou se aseaza DEASUPRA lui: held tine randul indiferent de env.
|
||||
|
||||
## 4. User Stories
|
||||
|
||||
### US-001: Schema — comutator cont + flag held
|
||||
**Ca** dezvoltator
|
||||
**Vreau** coloanele de persistenta pentru bifa Auto si pentru randurile tinute
|
||||
**Pentru ca** starea sa supravietuiasca restartului si sa fie per-cont.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `accounts.auto_send_enabled INTEGER NOT NULL DEFAULT 0 CHECK (auto_send_enabled IN (0,1))`
|
||||
adaugat in `app/schema.sql` + migrare defensiva in `app/db.py::_migrate` (ALTER
|
||||
idempotent, ca la `email`/`tier`).
|
||||
- [ ] `submissions.held INTEGER NOT NULL DEFAULT 0 CHECK (held IN (0,1))` adaugat +
|
||||
migrare defensiva. Index partial `idx_submissions_held ON submissions(held) WHERE held=1`.
|
||||
- [ ] **Index in `_migrate`, nu doar `schema.sql` (Eng MEDIUM):** `CREATE TABLE IF NOT EXISTS`
|
||||
nu se declanseaza pe DB existent -> indexul partial trebuie creat si in `_migrate`
|
||||
(ca `idx_submissions_batch` la `db.py:155`), altfel un DB prod upgradat capata coloana
|
||||
(ALTER) dar NU si indexul.
|
||||
- [ ] Contul implicit id=1 (dev) ramane pe default (0) — fara tratament special.
|
||||
- [ ] Helperi in `app/accounts.py`: `get_auto_send(conn, account_id) -> bool` si
|
||||
`set_auto_send(conn, account_id, enabled: bool)` (idempotent, scoped pe cont).
|
||||
- [ ] `python3 -m pytest -q` ramane verde (migrare aditiva, fara regresie pe schema).
|
||||
|
||||
### US-002: Ingestie respecta comutatorul de cont
|
||||
**Ca** operator de service cu Auto OFF
|
||||
**Vreau** ca prezentarile noi sa intre in coada tinute (held=1)
|
||||
**Pentru ca** sa le verific inainte sa plece la RAR.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] La INSERT-ul `status='queued'` pe canalul API (`app/api/v1/router.py`, ~l.282),
|
||||
`held` = `0 daca accounts.auto_send_enabled=1 altfel 1` (snapshot la ingestie).
|
||||
- [ ] Acelasi snapshot la commit-ul de import (`app/api/v1/import_router.py`, ~l.1193).
|
||||
- [ ] La reresolve (un `needs_mapping` rezolvat trece pe `queued`, `app/mapping.py` ~l.895),
|
||||
`held` se seteaza tot din comutatorul contului.
|
||||
- [ ] `held` NU intra in `payload_json`, NU in `build_key`/idempotenta, NU in payload-ul
|
||||
RAR — e pur control de coada (ca `reviewed` la import).
|
||||
- [ ] **DRY + acoperire COMPLETA (review CEO + Eng Finding A — HIGH):** calculul `held` e UN
|
||||
SINGUR helper `held_for_account(conn, account_id) -> int`, chokepoint pt. TOATE situri
|
||||
`SET status='queued'`, nu doar 3. Codebase-ul are ~8 scriitori de `queued`:
|
||||
`router.py:282` (enqueue), `import_router.py:1190` (commit), `mapping.py:895` (reresolve),
|
||||
**`router.py:237` (reactivare error->queued la re-POST — BUG real de bypass: randul pastra
|
||||
`held` VECHI -> se auto-trimite desi Auto OFF)**, si rutele web de operator
|
||||
`routes.py` mapeaza-inline / corecteaza / repune / bulk-fix.
|
||||
- [ ] **Politica rute operator:** pentru tranzitiile declansate de operator in panoul de
|
||||
detaliu (corecteaza/repune/mapeaza/bulk-fix), `held=0` (actiunea operatorului = intentie
|
||||
explicita de trimitere) — DAR e o DECIZIE documentata, nu o omisiune, si respecta UX-ul de
|
||||
confirmare cand contul e OFF. Canalele de ingestie (API/import/reresolve/reactivare) =
|
||||
`held_for_account`.
|
||||
- [ ] `requeue_with_backoff` (worker `:154`) NU atinge `held` (tranzitie interna worker).
|
||||
- [ ] **Echo pe dedup (Eng MEDIUM):** ramura de dedup (`router.py:264`, re-POST pe rand
|
||||
existent) intoarce si ea `held` (azi ar da un "queued" curat fals — vezi US-009).
|
||||
- [ ] Test: cont Auto OFF -> `POST /v1/prezentari` valid -> rand `queued, held=1`;
|
||||
cont Auto ON -> `queued, held=0`.
|
||||
- [ ] Test reresolve: cont Auto OFF, submission `needs_mapping` -> mapare salvata ->
|
||||
rand devine `queued, held=1` (nu pleaca automat).
|
||||
|
||||
### US-003: Worker nu trimite randurile tinute
|
||||
**Ca** sistem
|
||||
**Vreau** ca worker-ul sa sara peste randurile held=1
|
||||
**Pentru ca** transmiterea sa astepte decizia umana.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `claim_one` (`app/worker/__main__.py`) adauga `AND s.held = 0` la `WHERE`-ul de claim.
|
||||
- [ ] Test: rand `queued, held=1` cu cont `active` si send pornit -> `claim_one` intoarce
|
||||
`None` (nu il ia); acelasi rand cu `held=0` -> e luat (`sending`).
|
||||
- [ ] Recuperarea orfanilor / reconcilierea NU sunt afectate (held se aplica doar la claim
|
||||
din `queued`; un rand deja `sending` ramane gestionat normal).
|
||||
|
||||
### US-004: Bifa "Auto" in bara de status (toggle + persistenta + auto-release)
|
||||
**Ca** operator
|
||||
**Vreau** o bifa "Auto" in bara de status, salvata pe cont
|
||||
**Pentru ca** sa pornesc/opresc transmiterea automata dintr-un click.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Control checkbox HTMX cu eticheta vizibila **"Trimite automat la RAR"** (decizie user;
|
||||
NU "Auto" — eviti coliziunea cu "Trimitere automata" worker din `labels.py`) + helptext
|
||||
("Debifat: prezentarile asteapta confirmarea ta"), in clusterul de header langa
|
||||
`.rar-chip` SAU pe rand propriu in bara de status (vezi D1). Reflecta
|
||||
`accounts.auto_send_enabled` al contului din sesiune.
|
||||
- [ ] `POST /auto-send` (ruta web, sub `require_login` + scope cont + CSRF) comuta bifa
|
||||
si o **persista** pe cont; raspuns OOB care re-randeaza bara de status.
|
||||
- [ ] La trecerea OFF -> ON: toate randurile `queued AND held=1` ale contului devin
|
||||
`held=0` (eliberare in bloc), scoped strict pe contul curent. Eliberarea e o
|
||||
SINGURA instructiune SQL atomica (`UPDATE ... WHERE account_id=? AND status='queued'
|
||||
AND held=1`), NU un loop (review CEO: atomicitate + evita contention cu worker-ul).
|
||||
- [ ] **Garda de confirmare (review CEO F4):** daca exista N>0 randuri tinute la
|
||||
activarea Auto, comutatorul cere o confirmare explicita cu numarul si destinatia
|
||||
("Activarea Auto trimite imediat N prezentari catre RAR PRODUCTIE — FINALIZATA e
|
||||
ireversibila"). Fara confirmare, randurile tinute NU pleaca. Motiv: pe contul de
|
||||
test, un OFF->ON necugetat ar arunca toate prezentarile de proba in RAR real.
|
||||
- [ ] La trecerea ON -> OFF: randurile deja `queued held=0` NU sunt retrase (doar
|
||||
ingestiile NOI vor fi tinute); randurile in `sending`/`sent` neatinse.
|
||||
- [ ] Verify in browser: comuti bifa, se salveaza, ramane dupa refresh; cu OFF un rand nou
|
||||
apare tinut; comutand pe ON randurile tinute pleaca.
|
||||
|
||||
### US-005: Trimitere manuala — per rand + "Trimite toate (N)"
|
||||
**Ca** operator cu Auto OFF
|
||||
**Vreau** sa trimit un rand tinut sau toate odata
|
||||
**Pentru ca** sa eliberez selectiv sau in bloc spre RAR.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Buton "Trimite" pe fiecare rand `queued held=1` in lista de trimiteri/coada
|
||||
(`_submissions.html` / `_coada.html`), scoped + CSRF.
|
||||
- [ ] `POST /trimitere/{id}/trimite-acum`: 404-before-leak pe id strain; seteaza `held=0`
|
||||
DOAR daca randul e `queued held=1` (no-op sigur altfel); OOB refresh.
|
||||
- [ ] Buton bulk "Trimite toate (N)" (N = nr. randuri tinute ale contului) ->
|
||||
`POST /trimite-toate`: elibereaza toate `queued AND held=1` ale contului (held=0),
|
||||
cu confirmare tipata (count + "catre RAR PRODUCTIE", review CEO F5). Update atomic
|
||||
scoped pe cont (NU poate elibera randuri ale altui cont).
|
||||
- [ ] `POST /trimitere/{id}/trimite-acum` UPDATE include `AND status='queued'` ca un rand
|
||||
deja `sending` (luat de worker intre afisaj si click) sa fie no-op sigur (edge race).
|
||||
- [ ] Eliberarea seteaza doar `held=0`; worker-ul preia randul la urmatorul poll
|
||||
(trimitere asincrona, ca azi). Necesita worker pornit + send master ON + cont activ.
|
||||
- [ ] Butonul "Trimite toate (0)" e ascuns cand nu exista randuri tinute.
|
||||
- [ ] Test: rand tinut -> `trimite-acum` -> `held=0`; apoi `claim_one` il ia.
|
||||
|
||||
### US-006: Afisaj stare "In asteptare (manual)"
|
||||
**Ca** operator
|
||||
**Vreau** sa disting randurile tinute de cele in curs de trimitere
|
||||
**Pentru ca** sa stiu ce asteapta decizia mea.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `app/web/labels.py`: pentru `status='queued' AND held=1` -> eticheta umana
|
||||
"In asteptare (manual)" + clasa CSS de avertizare (ca `needs_*`); `held=0` ramane
|
||||
"In asteptare" (queued normal).
|
||||
- [ ] Bara de status arata un contor separat "In asteptare (manual): N" cand N > 0
|
||||
(derivat din `queued AND held=1`); contorul `queued` total ramane corect.
|
||||
- [ ] Lista de trimiteri marcheaza randurile tinute (badge/pill), butonul "Trimite" apare
|
||||
doar pe ele.
|
||||
- [ ] Verify in browser: un rand tinut afiseaza eticheta corecta si butonul; dupa trimitere
|
||||
trece la "In curs de trimitere" -> "Trimisa".
|
||||
|
||||
### US-007: Vizibilitate coada tinuta imbatranita (mitigare OBLIGATORIE pt. default OFF)
|
||||
**Ca** operator / admin
|
||||
**Vreau** un semnal vizibil cand prezentari raman tinute prea mult
|
||||
**Pentru ca** default OFF (decizie user, pana devine stabil) lasa altfel prezentari
|
||||
nedeclarate tacit — exact esecul silentios pe care L.142/2023 il face risc legal.
|
||||
|
||||
> Conditie: user a ales DELIBERAT default OFF "pana devine stabil" peste avertismentul de
|
||||
> conformitate (review CEO F1/F3). Aceasta US e atenuarea agreata si e BLOCANTA, nu optionala.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Bara de status: cand exista randuri `queued AND held=1` mai vechi de `N` zile
|
||||
(config `AUTOPASS_HELD_WARN_DAYS`, default 7), afiseaza un banner de avertizare
|
||||
("M prezentari tinute de >N zile — declarare obligatorie L.142") cu deep-link la lista
|
||||
filtrata pe tinute.
|
||||
- [ ] `/metrics` expune un gauge `autopass_held_submissions` (total randuri tinute) si
|
||||
`autopass_held_oldest_age_seconds` (varsta celui mai vechi rand tinut), scoped global
|
||||
(observabilitate ops, review CEO F3).
|
||||
- [ ] Bannerul + gauge sunt derivate (zero stare noua); contorul varstei foloseste
|
||||
`created_at` al randului.
|
||||
- [ ] Test: rand tinut cu `created_at` vechi -> bannerul apare; gauge raporteaza varsta.
|
||||
|
||||
### US-008: Retentie randuri tinute (inchide gaura GDPR/L.142, review CEO F6)
|
||||
**Ca** sistem
|
||||
**Vreau** ca randurile tinute la nesfarsit sa aiba o politica de expirare
|
||||
**Pentru ca** un `queued held=1` nu e nici `sent` nici blocat -> azi NU primeste
|
||||
`purge_after` -> PII criptat (si `rar_creds_enc` efemer pe canalul API) ar sta vesnic.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Worker-ul expira randurile `queued AND held=1` mai vechi de `held_retention_days`
|
||||
(config, default 90, aliniat T16): le trece la `error` cu mesaj `TINUT_EXPIRAT`
|
||||
(terminal) + **seteaza `purge_after` DIRECT la momentul expirarii** (NU lasa `mark()` sa
|
||||
aplice `blocked_retention_days`=30). Eng MEDIUM: altfel viata reala = 90 (held) + 30
|
||||
(error) = 120 zile, nu 90. Fie purge_after explicit la tranzitie, fie documenteaza 120.
|
||||
- [ ] La eliberarea manuala/auto a unui rand tinut, daca `rar_creds_enc` (canal API) e prea
|
||||
vechi, worker-ul cade pe `accounts.rar_creds_enc` (fallback re-login) ca azi — verificat
|
||||
ca creds efemere expirate nu blocheaza trimiterea.
|
||||
- [ ] Test: rand tinut vechi -> ciclul de purjare al worker-ului il expira + seteaza
|
||||
`purge_after`; PII devine purjabil.
|
||||
|
||||
### US-009: Fixturi teste + jurnal audit (review CEO F7 + observabilitate)
|
||||
**Ca** dezvoltator
|
||||
**Vreau** ca suita existenta sa nu se blocheze pe default OFF si ca actiunile sa fie auditate
|
||||
**Pentru ca** default OFF + `claim_one ... AND held=0` face ca lantul `POST -> claim -> sent`
|
||||
din testele existente (+ `test_live_rar`) sa stagneze tacit daca nu setam Auto ON.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `conftest`/factory de cont seteaza `auto_send_enabled=1` (sau `held=0`) pe conturile
|
||||
folosite de testele care exercita lantul de trimitere; `test_live_rar` seteaza explicit
|
||||
Auto ON. `pytest -q` ramane verde.
|
||||
- [ ] **Subtilitate id=1 (Eng HIGH/test):** contul implicit id=1 e creat de `schema.sql`
|
||||
(`INSERT OR IGNORE`), NU de `create_account` -> un fix care patcheaza doar factory-ul NU
|
||||
acopera contul folosit de majoritatea testelor (`test_import_e2e`, `test_creds_delivery`,
|
||||
`test_live_rar` ar stagna). Conftest face explicit `UPDATE accounts SET auto_send_enabled=1
|
||||
WHERE id=1` (autouse). E un fix de STARE DB, nu env var (coloana e per-rand in `accounts`).
|
||||
- [ ] Audit `app_events`: comutarea Auto (`auto_send_schimbat` cu valoarea + cont) si
|
||||
eliberarile manuale/bulk (`held_eliberat` cu count) sunt jurnalizate (redactat, scoped).
|
||||
- [ ] Echo onest pe canalul API (aliniat invariant 5.7): raspunsul `POST /v1/prezentari`
|
||||
pentru un rand tinut indica starea reala (`held=true` / nota umana "tinut pentru
|
||||
verificare"), nu un fals "queued" curat. Dev-ul ROAAUTO vede ca randul NU a plecat.
|
||||
- [ ] Test: eveniment audit scris la toggle + la eliberare; raspuns API reflecta `held`.
|
||||
|
||||
### US-010: Onestitate + observabilitate pe canalul API (review DX Faza 3.5)
|
||||
**Ca** dezvoltator ROAAUTO/VFP care integreaza prin `POST /v1/prezentari`
|
||||
**Vreau** sa vad clar ca un rand e tinut si NU a plecat la RAR
|
||||
**Pentru ca** azi un rand tinut intoarce byte-identic cu unul gata de auto-send
|
||||
(`status:queued, erori:[]`) -> reintroduce exact bug-ul de succes-fals 5.7.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] **Camp `held: bool = False` pe `SubmissionResult`** (`models.py`) + plumbing din
|
||||
`held_for_account` in `_rezultat_enqueue(..., held=...)` SI pe ramura de dedup
|
||||
(`router.py:264`). Cand `held and status=='queued'`, `motiv` devine NON-null
|
||||
(DX CRITICAL): mesaj uman "In asteptare — tinut pt verificare; NU trimis la RAR (Auto OFF)".
|
||||
- [ ] **`held` in proiectiile GET** (`_PREZENTARE_FIELDS` `router.py:398` + lista `cols`
|
||||
`router.py:369`): un dev care face `GET /v1/prezentari/{id}` vede `held=true`, nu un
|
||||
`queued` etern fara semnal (DX HIGH).
|
||||
- [ ] **Reutilizeaza vocabularul existent `AUTO_SEND_OPRIT`** (`errors.py:92`) pt. mesajul
|
||||
held — NU inventa al treilea vocabular "auto_send" (DX + R6). Mesaj 3-niveluri
|
||||
(problema/cauza/fix) pe `rar_error`/`motiv`.
|
||||
- [ ] **Documentatie hub `/integrare`** (`integrare_examples.py`/`_integrare.html`): tabel
|
||||
"De ce nu ajunge la RAR?" (held / needs_mapping / needs_data) + nota explicita "conturi
|
||||
noi pornesc cu Auto OFF, randurile asteapta eliberare manuala" + cum verifici/comuti
|
||||
(DX HIGH — altfel primul POST da 200/queued, dev-ul crede ca merge, nimic nu ajunge).
|
||||
- [ ] (Optional, paritate API) endpoint de eliberare API simetric cu `/repune`
|
||||
(`router.py:458`): `POST /v1/prezentari/{id}/trimite-acum`, scoped pe cont, 404-before,
|
||||
no-op daca nu `queued AND held=1` — ca integratorul API sa nu fie fortat in browser.
|
||||
- [ ] Test: held -> `held=true` + `motiv` non-null pe enqueue, dedup si GET (regresie ca
|
||||
`test_queued_fara_erori_nemapate`).
|
||||
|
||||
## 5. Cerinte functionale
|
||||
|
||||
1. [REQ-001] Comutatorul "Auto" e per-cont, persistat in `accounts.auto_send_enabled`,
|
||||
default 0 (OFF) inclusiv pentru conturi noi.
|
||||
2. [REQ-002] Cu Auto OFF, orice ingestie care ar produce `queued` produce `queued held=1`.
|
||||
3. [REQ-003] Worker-ul nu trimite niciodata un rand `held=1`.
|
||||
4. [REQ-004] OFF -> ON elibereaza in bloc randurile tinute ale contului (atomic, scoped),
|
||||
DAR cu confirmare tipata cand N>0 (count + "RAR PRODUCTIE"); ON -> OFF nu retrage
|
||||
randuri deja eliberate.
|
||||
5. [REQ-005] Operatorul poate elibera un rand tinut individual sau toate odata (bulk cu
|
||||
confirmare).
|
||||
6. [REQ-006] `held` nu influenteaza payload-ul RAR, idempotenta sau validarea — pur coada.
|
||||
7. [REQ-007] Toate rutele noi sunt scoped pe contul din sesiune, sub `require_login`,
|
||||
cu CSRF si 404-before-leak pe id strain. **`account_id` se deriva INTOTDEAUNA din sesiune,
|
||||
NICIODATA dintr-un camp de formular** (Eng security): altfel un operator pe contul A ar
|
||||
elibera in bloc randurile contului B postand `account_id=B`. Per-rand prin
|
||||
`_get_submission_scoped` (404 inainte de UPDATE).
|
||||
8. [REQ-008] Randurile tinute imbatranite sunt VIZIBILE (banner + `/metrics`) si au
|
||||
politica de retentie/expirare (nu raman PII vesnic). Comutarea + eliberarile sunt
|
||||
auditate in `app_events`.
|
||||
|
||||
## 6. Non-Goals (ce NU facem)
|
||||
|
||||
- **Fara interval/programare de sync** (dropdown "1 min" + buton "Start Sync" din gomag):
|
||||
worker-ul autopass e continuu, nu pe interval. "Trimite toate" e analogul lui "Start Sync".
|
||||
- **Fara stare noua de submission** (`held`/`tinut`): folosim flag boolean pe `queued`.
|
||||
- **Fara comutator per-operatie sau per-canal**: granularitatea e per-cont (decizie D5).
|
||||
(Nota: coloanele `auto_send` ramase pe `operations_mapping`/`operation_text_rules` sunt
|
||||
neutralizate din 5.11 si NU se reactiveaza aici.)
|
||||
- **Fara modificarea kill-switch-ului global** `AUTOPASS_WORKER_SEND_ENABLED`.
|
||||
- **Fara retragerea randurilor deja in `sending`/`sent`** (FINALIZATA e terminal la RAR).
|
||||
- **`held` NU e sandbox de testare** (avertisment de onestitate — tema cross-faza CEO F2 + DX4):
|
||||
eliberarea unui rand tinut declara REAL la RAR (FINALIZATA ireversibila). "Tinut" doar
|
||||
AMANA o trimitere reala. Ca sa testezi fara consecinte cu functia asta: tii randul si il
|
||||
STERGI (nu-l eliberezi). **Decizie user (poarta finala): 5.19 = doar tinut operational**;
|
||||
fara documentare `/valideaza` ca unealta de testare si fara rutare per-cont la RAR test
|
||||
(`rar_env`). Acestea raman posibile follow-up-uri (TODOS), neangajate in 5.19.
|
||||
|
||||
## 7. Consideratii tehnice
|
||||
|
||||
### Stack / fisiere atinse
|
||||
- Schema: `app/schema.sql` + `app/db.py::_migrate` (2 coloane aditive + 1 index).
|
||||
- Backend cont: `app/accounts.py` (get/set toggle).
|
||||
- Ingestie: `app/api/v1/router.py`, `app/api/v1/import_router.py`, `app/mapping.py`
|
||||
(reresolve) — set `held` din comutator.
|
||||
- Worker: `app/worker/__main__.py::claim_one` (+`AND s.held=0`).
|
||||
- Web: `app/web/routes.py` (rute `/auto-send`, `/trimite-toate`,
|
||||
`/trimitere/{id}/trimite-acum`), `app/web/labels.py`, template-uri
|
||||
`_status.html` / `_submissions.html` / `_coada.html`.
|
||||
|
||||
### Patterns de urmat
|
||||
- Migrare defensiva aditiva (model `accounts.email` / `accounts.tier` din 5.12/5.17).
|
||||
- Rute web scoped + CSRF + OOB HTMX (model `submissions_admin.py` / butoanele de lifecycle 5.6).
|
||||
- Strat de afisaj pur in `labels.py` (model 5.4) — fara logica de stare in template.
|
||||
|
||||
### Riscuri tehnice
|
||||
- **R1 (default OFF schimba comportamentul):** azi nu exista hold; cu default 0, conturile
|
||||
ar tine totul. Acceptabil — productia e pre-lansare, fara conturi legacy active
|
||||
(cf. 5.17), iar utilizatorul vrea explicit OFF pe contul de test. Documentat ca
|
||||
decizie constienta (D1).
|
||||
- **R2 (reresolve scapa snapshot-ul):** daca uitam `held` pe calea de reresolve
|
||||
(`mapping.py`), un rand deblocat din `needs_mapping` ar pleca automat desi contul e OFF.
|
||||
Acoperit explicit de US-002 AC.
|
||||
- **R3 (idempotenta):** `held` NU intra in cheie -> un re-`POST` al aceluiasi continut
|
||||
loveste randul existent (dedup), nu creeaza dublura. Confirmat de invariantul `build_key`.
|
||||
- **R4 (hazard de rollback — review CEO + Eng, HIGH operational):** daca se da revert pe cod
|
||||
DUPA ce randuri au `held=1`, worker-ul pierde filtrul `AND held=0` -> ar trimite TOATE
|
||||
randurile tinute la RAR (FINALIZATA ireversibila). Atenuare OBLIGATORIE: livreaza ODATA cu
|
||||
feature-ul un helper `tools/` care carantineaza randurile tinute
|
||||
(`UPDATE submissions SET status='error', rar_error='ROLLBACK_QUARANTINE' WHERE held=1`) +
|
||||
pas de runbook scris in §9 (copy-paste, nu improvizat sub presiune).
|
||||
- **R7 (eroziune creds efemere — Eng low-med):** la orice login reusit worker-ul NULL-eaza
|
||||
TOATE `submissions.rar_creds_enc` ale contului (`worker:382`), nu doar randul trimis. Un cont
|
||||
hibrid web+API cu keepalive-login poate sterge creds-urile efemere ale unui rand tinut API ->
|
||||
la eliberare se cade pe `accounts.rar_creds_enc` (fallback). Acoperit de US-008, dar triggerul
|
||||
e login-frate, nu varsta creds — de formulat corect.
|
||||
- **R5 (contention SQLite la bulk release):** `UPDATE` masiv pe "Trimite toate" concureaza
|
||||
cu `BEGIN IMMEDIATE` al worker-ului -> posibil `database is locked`. Update-ul atomic
|
||||
(o instructiune) + retry/backoff scurt; sau chunking daca N e mare.
|
||||
- **R6 (naming):** apare al TREILEA `auto_send` (cont `auto_send_enabled` vs
|
||||
`operations_mapping.auto_send` vs `operation_text_rules.auto_send`). Comentariu clar in
|
||||
`schema.sql` care le distinge, ca un viitor dezvoltator sa nu le confunde.
|
||||
|
||||
### Rafinari UI (review design Faza 2 — OBLIGATORII la implementare)
|
||||
- **D1 (container real):** RAR dot e in `base.html` (header `.rar-chip`), NU in `_status.html`.
|
||||
US-004 AC corectat: comutatorul Auto sta in clusterul de header langa `.rar-chip` (vizibilitate
|
||||
maxima, langa semnalul RAR real) SAU pe un rand propriu etichetat in bara de status — NU
|
||||
"langa dot" in `_status.html` (dot-ul nu e acolo).
|
||||
- **D2 (toggle non-optimist):** checkbox HTMX flip-uie vizual indiferent de raspuns. Necesita
|
||||
`hx-indicator` + revert-on-failure (la esec POST `/auto-send` -> bifa revine + toast eroare).
|
||||
Fara fals-sigur tacit pe un comutator de transmitere guvernamentala.
|
||||
- **D3 (poller nu inghite toggle-ul):** `#status-bar` are `hx-trigger="every 15s"` +
|
||||
`hx-swap="outerHTML"` -> ar inlocui comutatorul la fiecare 15s (pierdere focus tastatura +
|
||||
flicker). Exclude comutatorul din swap-ul periodic (container separat sau `hx-preserve`).
|
||||
- **D4 (modal de confirmare real):** confirmarea tipata (count + "RAR PRODUCTIE") NU se poate
|
||||
face cu `hx-confirm` (doar OK/Cancel nativ). Necesita un component modal (count, destinatie,
|
||||
type-to-confirm) — adaugat in lista de fisiere. Per-rand "Trimite" primeste si el o
|
||||
confirmare (1 linie + microcopy de ireversibilitate), nu doar bulk-ul.
|
||||
- **D5 (camp derivat, nu in template):** `held` NU e stare noua -> pill-ul existent ar randa
|
||||
"In coada" identic pt held si non-held. Calcul UN camp de afisaj derivat in `routes.py`
|
||||
(regula "display layer pur"), nu in template. Culoare `--warn` (amber), NU clasa `needs_*`
|
||||
(rosu/eroare) — held e asteptare benigna, nu eroare.
|
||||
- **D6 (mobil 390px):** per-rand actiune = afordanta dedicata pe `.trimitere-slim` cu
|
||||
`event.stopPropagation()` (randul e el insusi `role=button`), NU buton-copil nestat.
|
||||
Al 6-lea contor "In asteptare (manual)" se pliaza in celula "In coada" pe bara compacta (nu
|
||||
adauga a 6-a celula la 10px). Pill scurt ("Manual"/"Tinut") cu fraza completa in `title`.
|
||||
- **D7 (ordonare bannere):** `_status.html` poate avea deja 3 bannere (cont inactiv / trial /
|
||||
RAR jos) + al 4-lea (US-007 held). Regula de prioritate un-singur-banner ca sa nu impinga
|
||||
contoarele sub fold pe mobil.
|
||||
|
||||
### Dependente
|
||||
- Trimiterea manuala produce efect doar cu worker pornit + send master ON + cont `active`
|
||||
(mediul de productie real). In dev (send OFF) randul eliberat ramane `queued held=0`.
|
||||
|
||||
## 8. Open Questions
|
||||
|
||||
- [ ] Trimiterea manuala se face asincron (flip `held=0`, worker preia la poll). Acceptam
|
||||
latenta de un poll (cateva secunde) sau vrem feedback "in curs" imediat in UI?
|
||||
(Propunere: asincron + OOB refresh, fara sincron — consistent cu arhitectura.)
|
||||
- [ ] Pe mobil, butonul "Trimite" per rand + "Trimite toate" incap in layout-ul compact
|
||||
(5.13)? (Propunere: "Trimite toate" in bara sticky, "Trimite" iconita pe card.)
|
||||
|
||||
## 9. Plan de verificare
|
||||
|
||||
- Regresie `python3 -m pytest -q` verde (baseline curent ~1392) + teste noi per story.
|
||||
- E2E browser (Playwright, logat): comutare bifa persistenta dupa refresh; rand nou tinut
|
||||
cu Auto OFF; eliberare per rand si bulk; tranzitie OFF -> ON elibereaza in bloc.
|
||||
- Optional live RAR (`AUTOPASS_LIVE_RAR=1`): cont OFF -> rand tinut -> "Trimite" ->
|
||||
`sent idPrezentare=...` confirmat in finalizate.
|
||||
|
||||
## 10. Decizii /autoplan — audit trail
|
||||
|
||||
Pipeline: CEO -> Design -> Eng -> DX, voce unica (Codex indisponibil pana 2026-07-18, plafon
|
||||
utilizare). Deciziile intermediare auto-decise pe 6 principii; portile umane = premise + taste.
|
||||
|
||||
### Poarta de premise (decizia ta)
|
||||
- **Scop:** AMBELE — control operational permanent + ajutor de testare.
|
||||
- **Default Auto:** OFF, pastrat "pana devine stabil" (ales constient peste avertismentul de
|
||||
conformitate L.142). Inverseaza recomandarea CEO F1 (default ON). Acceptat ca decizie de
|
||||
domeniu; declanseaza atenuari OBLIGATORII (US-007/008/009).
|
||||
|
||||
### Decizii auto (6 principii)
|
||||
| # | Faza | Decizie | Clasif. | Principiu | Motiv |
|
||||
|---|------|---------|---------|-----------|-------|
|
||||
| 1 | CEO | Approach A (held boolean) ca baza, nu stare noua (B) sau enum mod (C) | Mecanica | P5+P3 | aditiv, reuse pattern `reviewed`; B atinge masina de stari pazita |
|
||||
| 2 | CEO | US-007 vizibilitate coada imbatranita OBLIGATORIE | Mecanica | P1+observ | atenuarea agreata pt default OFF; inchide esecul silentios F3 |
|
||||
| 3 | CEO | US-008 retentie randuri tinute | Mecanica | P1 | F6: held nu primeste `purge_after` -> PII vesnic (GDPR/L.142) |
|
||||
| 4 | CEO | US-009 fixturi teste Auto ON + audit + echo API held | Mecanica | P1 | F7: default OFF stagneaza testele; invariant 5.7 raspuns onest |
|
||||
| 5 | CEO | Garda de confirmare OFF->ON + bulk (count + RAR PRODUCTIE) | Mecanica | P1 | F4/F5: flush ireversibil de randuri test in RAR real |
|
||||
| 6 | CEO | `held_for_account()` helper unic (DRY) | Mecanica | P4 | calcul held inline de 3x = sit uitat trimite automat |
|
||||
| 7 | CEO | Enum mod cont (live/hold/test) -> TODOS | Mecanica | P3 | scope dincolo de cerere; dream-state, nu blocant |
|
||||
|
||||
### Decizii de taste / provocari -> poarta finala (Faza 4)
|
||||
- **T-EXP1 (reframe testare, CEO F2 + DX4) -> REZOLVAT: user a ales "doar tinut".** Nici
|
||||
`rar_env`, nici documentarea `/valideaza` ca unealta de testare in 5.19. Ambele -> TODOS
|
||||
(posibil follow-up). Pastrat doar avertismentul de onestitate ca eliberarea declara real.
|
||||
- **T-LABEL (eticheta toggle, Design HIGH) -> REZOLVAT: user a ales REDENUMIREA.** Eticheta
|
||||
vizibila = **"Trimite automat la RAR"** (nu "Auto"), ca sa nu se ciocneasca cu
|
||||
"Trimitere automata" (worker viu) din `labels.py`. Conceptul/coloana raman `auto_send_enabled`.
|
||||
|
||||
### Faze Design/Eng/DX (audit)
|
||||
| Faza | Decizie cheie | Clasif. | Motiv |
|
||||
|------|---------------|---------|-------|
|
||||
| Design | D1-D7 rafinari UI (non-optimist, poller, modal, mobil, camp derivat, --warn, bannere) | Mecanica | structural, P5 explicit |
|
||||
| Eng | held_for_account la TOATE ~8 situri queued (bug reactivare router:237) | Mecanica | P5; bypass real Auto OFF |
|
||||
| Eng | conftest UPDATE id=1; index in _migrate; purge_after direct; account_id din sesiune | Mecanica | corectitudine/securitate |
|
||||
| DX | held pe SubmissionResult+GET; reuse AUTO_SEND_OPRIT; hub docs | Mecanica | P1; invariant 5.7 |
|
||||
|
||||
### Sumar completare review
|
||||
```
|
||||
+====================================================================+
|
||||
| /autoplan — MEGA PLAN REVIEW — COMPLETION SUMMARY |
|
||||
+====================================================================+
|
||||
| Mod | SELECTIVE EXPANSION |
|
||||
| Voci | Claude subagent (CEO/Design/Eng/DX); |
|
||||
| | Codex INDISPONIBIL (plafon -> 2026-07-18) |
|
||||
| Poarta premise | scop=AMBELE; default OFF (user, time-boxed) |
|
||||
| CEO | 7 findings, 2 critice -> atenuate |
|
||||
| Design | 5->8/10; 13 findings, 3 critice -> D1-D7 |
|
||||
| Eng | 7 issues; 1 BUG real (reactivare bypass) |
|
||||
| DX | 5->8/10; onestitate API -> US-010 |
|
||||
| Stories | 6 -> 10 (US-007/008/009/010 adaugate) |
|
||||
| Taskuri | 26 (14 P1, 9 P2, 3 P3), agregate pe faze |
|
||||
| Tema cross-faza | hold != sandbox testare (CEO F2 + DX4) |
|
||||
| Taste rezolvate | T-EXP1=doar tinut; T-LABEL=redenumire |
|
||||
| Deferate (TODOS) | enum mod cont; rar_env; doc /valideaza |
|
||||
| Test plan | scris pe disc (~/.gstack/.../test-plan) |
|
||||
| Artefacte taskuri | 4 JSONL pe faza |
|
||||
| Decizii nerezolvate | 0 |
|
||||
+====================================================================+
|
||||
```
|
||||
|
||||
## GSTACK REVIEW REPORT
|
||||
|
||||
| Review | Trigger | Why | Runs | Status | Findings |
|
||||
|--------|---------|-----|------|--------|----------|
|
||||
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | issues_open->resolved | 5 propuneri, 4 acceptate, 2 deferate; 2 gap critice atenuate |
|
||||
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | issues_open->resolved | 7 issues (1 bug bypass reactivare), 0 gap critice ramase |
|
||||
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open->resolved | 5->8/10, 13 findings (3 critice) -> D1-D7 |
|
||||
| DX Review | `/plan-devex-review` | Developer experience gaps | 1 | issues_open->resolved | 5->8/10, onestitate API (US-010) |
|
||||
|
||||
- **CROSS-MODEL:** N/A — Codex indisponibil (plafon utilizare pana 2026-07-18); voce unica Claude subagent pe toate fazele.
|
||||
- **VERDICT:** CEO + DESIGN + ENG + DX CLEARED (voce unica) — PRD revizuit, gata de implementare. Toate deciziile portilor inchise cu user.
|
||||
|
||||
NO UNRESOLVED DECISIONS
|
||||
478
docs/prd/prd-5.20-target-rar-test-productie.md
Normal file
478
docs/prd/prd-5.20-target-rar-test-productie.md
Normal file
@@ -0,0 +1,478 @@
|
||||
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260629-184940.md -->
|
||||
# PRD 5.20 — Medii RAR per cont (Testare / Productie): activare, credentiale, selectie per trimitere
|
||||
|
||||
**Stare**: aprobat
|
||||
|
||||
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> Stare: `draft -> aprobat -> in-executie -> verify-pass -> inchis`.
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Trateaza **Testare** si **Productie** ca doua medii RAR configurabile **per cont**. Fiecare mediu are, independent:
|
||||
o **bifa de activare** si un **set propriu de credentiale**. Un mediu e *disponibil* pentru trimitere doar daca e
|
||||
activat SI are credentiale. Din disponibilitate decurge tot UX-ul: cand un singur mediu e disponibil totul merge
|
||||
acolo (fara selector); cand ambele sunt disponibile, apare selector la import + toggle in statusbar + alegere in
|
||||
API. Trimiterile arata mereu un **badge** cu mediul tinta. Scop: clientul declara real pe Productie, iar cine are
|
||||
si cont de test RAR isi poate testa integrarea pe Testare — fara redeploy si fara variabila globala de mediu.
|
||||
|
||||
**Premisa verificata (2026-06-29, doua seturi reale)**: test si prod sunt sisteme RAR **complet separate**; un set
|
||||
de credentiale se autentifica pe **exact unul** (creds dev: test 200 / prod 401; creds client real: test 401 /
|
||||
prod 200). Deci 2 seturi de creds per cont; un cont prod-only NU poate trimite la test fara cont de test emis de RAR.
|
||||
Detaliu memorat: vezi memoria de proiect "rar-test-prod-creds-separate".
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- NU eliminam `AUTOPASS_RAR_ENV` global: ramane **ancora de migrare** + fallback pentru actiuni de sistem fara cont
|
||||
(ex. keepalive login). Per-submission are precedenta cand exista.
|
||||
- NU configuram base_url-uri din UI (raman in `config.py`); NU adaugam un al treilea mediu.
|
||||
- NU gating pe plan/tier pentru Productie (decizie user: liber). „Guard-ul" e: Productie e tinta doar daca e
|
||||
activata + are creds, plus o confirmare unica la activarea Productie (constientizare L.142), NU per trimitere.
|
||||
- NU schimbam masina de stari, backoff-ul, sau payload-ul `postPrezentare`.
|
||||
- NU migram automat credentiale de prod ale clientilor — ei le introduc; migrarea doar muta creds-ul existent in
|
||||
slotul mediului sub care contul opera efectiv.
|
||||
|
||||
## 3. Cerinte transversale (reguli de derivare)
|
||||
|
||||
- **REQ-DISP**: `medii_disponibile(cont)` = mediile din {test, prod} cu `enabled=1` SI creds prezente. Sursa unica
|
||||
de adevar pentru vizibilitatea selector/toggle si pentru validarea unei tinte cerute.
|
||||
- **REQ-VIZ**: selector la import + toggle in statusbar apar DOAR cand `len(medii_disponibile) >= 2`. La 1 mediu,
|
||||
tinta e implicita (acel mediu), fara selector. La 0, trimiterea e blocata cu mesaj „configureaza credentiale RAR".
|
||||
- **REQ-BADGE**: orice trimitere afiseaza badge Test/Productie (chiar si la 1 mediu — claritate ca declari real).
|
||||
- **REQ-DEFAULT**: `rar_env_default(cont)` e mereu unul din mediile disponibile; cont client nou = `prod`. Daca
|
||||
default-ul nu mai e disponibil (mediu dezactivat), cade pe singurul disponibil; daca 0 disponibile -> nicio tinta.
|
||||
- **REQ-CONF**: trimiterea pe Productie nu cere confirmare per-rand; constientizarea vine din badge + o confirmare
|
||||
UNICA la activarea mediului Productie in configurare.
|
||||
|
||||
## 4. Stories atomice
|
||||
|
||||
> Backend + UI pentru acelasi comportament = stories separate. `Fisiere` + `Depinde de` complete.
|
||||
|
||||
### US-001: Schema — medii per cont (activare + creds) + env pe submission
|
||||
**Ca** sistem **vreau** sa stochez per cont activarea si credentialele fiecarui mediu, default-ul, si env-ul tinta
|
||||
pe fiecare submission **pentru ca** test si prod sunt sisteme separate cu credentiale separate.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/schema.sql`, `app/db.py` (migrare idempotenta), `tests/test_schema_migrate.py`
|
||||
- **Test intai (RED)**: `tests/test_schema_migrate.py` — `test_coloane_medii_pe_cont`,
|
||||
`test_default_client_prod_on_test_off`, `test_migrare_creds_in_slotul_env_global`, `test_submissions_rar_env`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `accounts`: `rar_test_enabled INTEGER NOT NULL DEFAULT 0`, `rar_prod_enabled INTEGER NOT NULL DEFAULT 1`
|
||||
(ambele CHECK IN (0,1)); `rar_creds_test_enc TEXT`, `rar_creds_prod_enc TEXT`;
|
||||
`rar_env_default TEXT NOT NULL DEFAULT 'prod' CHECK (rar_env_default IN ('test','prod'))`
|
||||
- [ ] `submissions.rar_env TEXT NOT NULL DEFAULT 'test' CHECK (rar_env IN ('test','prod'))`
|
||||
- [ ] **Migrare existenti (NU presupune env-ul)**: `rar_creds_enc` -> slotul `AUTOPASS_RAR_ENV` global de la
|
||||
migrare; seteaza `enabled=1` DOAR pe mediul cu creds; `rar_env_default` = acel mediu. Conturi fara creds:
|
||||
raman pe default-urile coloanei (prod on / test off). Coloana veche RAMANE acum (dropul e in US-013, dupa
|
||||
ce toate citirile trec pe per-env)
|
||||
- [ ] **(AUTO-FIX G — CRITIC, amendament AC) Backfill `submissions.rar_env` EXISTENT din `AUTOPASS_RAR_ENV`
|
||||
global**, NU lasa pe `DEFAULT 'test'`. Un rand prod pre-migrare etichetat 'test' -> US-006 reconciliaza
|
||||
contra endpoint TEST -> no-match -> re-send prod = DUPLICAT REAL IREVERSIBIL. `DEFAULT 'test'` ramane doar
|
||||
plasa pentru randuri net-noi (fiecare INSERT din US-004/005/009 seteaza `rar_env` explicit)
|
||||
- [ ] **(AUTO-FIX E4/3) Recompute `idempotency_key` pentru randurile existente** la forma env-aware
|
||||
(`build_key(account_id, canon, rar_env)` cu `rar_env`-ul backfill-at), ca lookup-urile de dedup (API +
|
||||
import) sa nu rateze randuri legacy -> altfel re-POST = duplicat
|
||||
- [ ] `test_submissions_rar_env` asserteaza ca un rand PRE-migrare ajunge cu env-ul global (NU 'test') si
|
||||
reconciliaza contra endpointului corect
|
||||
- [ ] migrare idempotenta pe DB existent, fara pierdere de date
|
||||
- [ ] `python3 -m pytest tests/test_schema_migrate.py -q` PASS
|
||||
- **Verificare E2E**: DB pre-migrare cu `AUTOPASS_RAR_ENV=test` -> creds aterizeaza in `rar_creds_test_enc`,
|
||||
`rar_test_enabled=1`, `rar_env_default='test'`.
|
||||
|
||||
### US-002: Logica de disponibilitate si default efectiv
|
||||
**Ca** sistem **vreau** un helper unic care intoarce mediile disponibile si default-ul efectiv al unui cont
|
||||
**pentru ca** vizibilitatea UI, API-ul si worker-ul sa decida identic (REQ-DISP/REQ-DEFAULT).
|
||||
|
||||
- **Depinde de**: US-001
|
||||
- **Fisiere**: `app/rar_env.py` (nou) sau `app/mapping.py`, `tests/test_rar_env_disponibil.py`
|
||||
- **Test intai (RED)**: `tests/test_rar_env_disponibil.py` — `test_doar_prod_cu_creds`, `test_ambele`,
|
||||
`test_zero_cand_lipsesc_creds`, `test_default_cade_pe_singurul_disponibil`, `test_enabled_fara_creds_nu_e_disponibil`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `medii_disponibile(account) -> list[str]` (subset din ['test','prod']) = enabled AND creds prezente
|
||||
- [ ] `rar_env_efectiv(account) -> 'test'|'prod'|None` aplica REQ-DEFAULT
|
||||
- [ ] `python3 -m pytest tests/test_rar_env_disponibil.py -q` PASS
|
||||
- **Verificare E2E**: —
|
||||
|
||||
### US-003: Idempotenta include rar_env
|
||||
**Ca** sistem **vreau** ca `build_key` sa incorporeze `rar_env` **pentru ca** aceeasi prezentare la test si apoi
|
||||
la prod sunt doua trimiteri reale distincte, nu un duplicat.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/idempotency.py`, `tests/test_idempotency.py`
|
||||
- **Test intai (RED)**: `tests/test_idempotency.py` — `test_key_difera_intre_test_si_prod`, `test_key_stabil_pe_env`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `build_key(account_id, canon, rar_env)` -> chei diferite test vs prod pe acelasi continut; stabil pe re-apel
|
||||
- [ ] toate apelurile (`router.py`, `import_router.py`) trec env-ul rezolvat
|
||||
- [ ] `python3 -m pytest tests/test_idempotency.py -q` PASS
|
||||
- **Verificare E2E**: —
|
||||
|
||||
### US-004: Rezolvare tinta la ingestie (cerere > default cont) + respinge tinta indisponibila
|
||||
**Ca** sistem **vreau** sa decid env-ul unui submission si sa resping tintele indisponibile **pentru ca** o tinta
|
||||
fara mediu activ/creds nu trebuie sa intre in coada.
|
||||
|
||||
- **Depinde de**: US-002
|
||||
- **Fisiere**: `app/validation.py`, `app/mapping.py`, `tests/test_rar_env_resolve.py`
|
||||
- **Test intai (RED)**: `tests/test_rar_env_resolve.py` — `test_cerere_castiga`, `test_fallback_default_cont`,
|
||||
`test_tinta_indisponibila_respinsa`, `test_valoare_invalida`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] precedenta: valoare ceruta (daca e in `medii_disponibile`) > `rar_env_efectiv(cont)`
|
||||
- [ ] tinta ceruta dar indisponibila -> eroare clara („mediul X nu e activat / fara credentiale"), fara enqueue
|
||||
- [ ] valoare invalida (≠ test/prod) -> eroare de validare, fara fallback silentios
|
||||
- [ ] `python3 -m pytest tests/test_rar_env_resolve.py -q` PASS
|
||||
- **Verificare E2E**: —
|
||||
|
||||
### US-005: API — camp `rar_target` pe POST /v1/prezentari si /valideaza
|
||||
**Ca** integrator ROAAUTO **vreau** sa pot preciza `rar_target`, cu default = default-ul contului meu **pentru ca**
|
||||
sa aleg unde declar fara sa stiu env-ul global.
|
||||
|
||||
- **Depinde de**: US-003, US-004
|
||||
- **Fisiere**: `app/api/v1/router.py`, `app/models.py`, `tests/test_api_rar_target.py`
|
||||
- **Test intai (RED)**: `tests/test_api_rar_target.py` — `test_default_din_cont_cand_lipseste`,
|
||||
`test_target_explicit`, `test_target_indisponibil_respins`, `test_get_ecou_rar_env`, `test_valoare_invalida_422`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] camp optional `rar_target: "test"|"prod"` pe `POST /v1/prezentari` si `/valideaza`
|
||||
- [ ] absent -> `rar_env_efectiv(cont)` (pt client prod-only = `prod`)
|
||||
- [ ] tinta indisponibila -> raspuns clar, fara enqueue; `SubmissionResult` + GET ecou-iesc `rar_env`
|
||||
- [ ] valoare invalida -> 422 fara echo de input (handler global pastrat)
|
||||
- [ ] `python3 -m pytest tests/test_api_rar_target.py -q` PASS
|
||||
- **Verificare E2E**: `POST /v1/prezentari` fara `rar_target` pe un cont prod-only -> submission env=prod.
|
||||
|
||||
### US-006: Worker — sesiuni si trimitere per (cont, env)
|
||||
**Ca** worker **vreau** login/JWT separat per `(account_id, rar_env)`, cu base_url + creds corecte per submission
|
||||
**pentru ca** test si prod sunt sisteme RAR diferite.
|
||||
|
||||
- **Depinde de**: US-001
|
||||
- **Fisiere**: `app/worker/__main__.py` (`AccountSessions`), `app/rar_client.py` (base_url per env),
|
||||
`app/reconcile.py`, `tests/test_worker_rar_env.py`
|
||||
- **Test intai (RED)**: `tests/test_worker_rar_env.py` — `test_sesiune_separata_per_env`,
|
||||
`test_base_url_dupa_submission`, `test_creds_din_slotul_env`, `test_reconcile_pe_env_corect`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] cheia cache sesiune = `(account_id, rar_env)`; JWT/keepalive/last_rar_login_ok per env
|
||||
- [ ] `RarClient` primeste env/base_url explicit (nu doar `settings.rar_base_url`)
|
||||
- [ ] creds alese: submission efemere -> `accounts.rar_creds_{env}_enc`; lipsa -> blocaj clar (nu trimite)
|
||||
- [ ] reconcilierea cauta in `finalizate` pe endpoint-ul `submission.rar_env`
|
||||
- [ ] purjarea atinge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_{env}_enc`
|
||||
- [ ] `python3 -m pytest tests/test_worker_rar_env.py -q` PASS
|
||||
- **Verificare E2E**: doua submission-uri (test + prod, creds prezente) -> doua login-uri distincte in jurnal.
|
||||
|
||||
### US-007: Validare login pe env-ul ales (signup / preview / test integrare)
|
||||
**Ca** sistem **vreau** ca validarea credentialelor sa loveasca mediul caruia ii apartin **pentru ca** o parola
|
||||
prod nu se valideaza contra RAR test si invers (confirmat: 401 incrucisat).
|
||||
|
||||
- **Depinde de**: US-002
|
||||
- **Fisiere**: `app/web/routes.py`, `app/rar_client.py`, `app/web/templates/_integrare.html`,
|
||||
`tests/test_validare_env.py`
|
||||
- **Test intai (RED)**: `tests/test_validare_env.py` — `test_valideaza_pe_env_creds`, `test_mesaj_distinge_env`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] validarea (signup, „testeaza integrarea", preview) foloseste env-ul setului de creds verificat
|
||||
- [ ] mesaj distinct „creds invalide pe TESTARE" vs „pe PRODUCTIE"
|
||||
- [ ] `python3 -m pytest tests/test_validare_env.py -q` PASS
|
||||
- **Verificare E2E**: in UI „testeaza integrarea" cu creds prod -> login pe endpoint prod.
|
||||
|
||||
### US-008: Configurare cont — doua medii (bifa activare + creds), default, confirmare prod
|
||||
**Ca** titular de cont **vreau** sa activez fiecare mediu, sa-i introduc credentialele si sa aleg default-ul
|
||||
**pentru ca** vreau sa controlez unde se poate trimite si unde merge implicit.
|
||||
|
||||
- **Depinde de**: US-001, US-007
|
||||
- **Fisiere**: `app/web/routes.py`, `app/web/templates/_cont.html`, `app/crypto.py` (refolosit),
|
||||
`tests/test_cont_medii.py`
|
||||
- **Test intai (RED)**: `tests/test_cont_medii.py` — `test_activeaza_si_salveaza_creds_per_env`,
|
||||
`test_default_doar_dintre_disponibile`, `test_activare_prod_cere_confirmare`, `test_creds_criptate_fara_echo`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] doua sectiuni „Testare" si „Productie": fiecare cu bifa Activeaza + campuri email/parola; default client =
|
||||
Productie bifat, Testare nebifat
|
||||
- [ ] la salvare, creds-ul fiecarui mediu activat e validat prin login pe acel env (US-007); invalid -> nu se
|
||||
marcheaza disponibil
|
||||
- [ ] selectorul de default ofera DOAR mediile disponibile; nu poti seta default un mediu indisponibil
|
||||
- [ ] activarea mediului Productie cere o confirmare unica „Inteleg ca trimiterile pe Productie sunt declaratii
|
||||
reale (L.142)"
|
||||
- [ ] creds criptate Fernet in `rar_creds_{env}_enc`, niciodata reflectate inapoi in pagina
|
||||
- [ ] `python3 -m pytest tests/test_cont_medii.py -q` PASS
|
||||
- **Verificare E2E**: activez Testare + creds valide si Productie + creds invalide -> doar Testare devine disponibil.
|
||||
|
||||
### US-009: Import web — selector mediu conditionat de disponibilitate
|
||||
**Ca** operator **vreau** sa aleg mediul la import doar cand am ≥2 disponibile, pre-bifat pe default **pentru ca**
|
||||
la un singur mediu alegerea e inutila.
|
||||
|
||||
- **Depinde de**: US-002, US-004
|
||||
- **Fisiere**: `app/import_router.py`, `app/import_parse.py`, `app/web/templates/_upload.html`,
|
||||
`_preview_import.html`, `tests/test_import_rar_env.py`
|
||||
- **Test intai (RED)**: `tests/test_import_rar_env.py` — `test_selector_ascuns_la_un_mediu`,
|
||||
`test_selector_prezent_si_prebifat_la_doua`, `test_commit_seteaza_env_pe_submissions`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] selector Test/Prod apare DOAR daca `len(medii_disponibile) >= 2`; initial = `rar_env_efectiv`
|
||||
- [ ] la 1 mediu: fara selector, toate randurile primesc acel mediu
|
||||
- [ ] la commit, toate submission-urile lotului primesc `rar_env` ales
|
||||
- [ ] `python3 -m pytest tests/test_import_rar_env.py -q` PASS
|
||||
- **Verificare E2E**: cont prod-only -> import fara selector, submissions env=prod; cont cu ambele -> selector pre-bifat.
|
||||
|
||||
### US-010: Badge mediu in liste, preview, jurnal, audit + ecou API
|
||||
**Ca** utilizator **vreau** sa vad pe fiecare trimitere mediul tinta **pentru ca** sa nu confund testul cu realul.
|
||||
|
||||
- **Depinde de**: US-001
|
||||
- **Fisiere**: `app/web/templates/_submissions.html`, `_coada.html`, `_trimitere_detaliu.html`,
|
||||
`_preview_rand.html`, `_jurnal.html`, `app/web/routes.py` (audit export), `app/api/v1/router.py` (GET),
|
||||
`tests/test_badge_rar_env.py`
|
||||
- **Test intai (RED)**: `tests/test_badge_rar_env.py` — `test_badge_in_lista`, `test_audit_contine_rar_env`,
|
||||
`test_get_ecou_rar_env`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] badge vizibil (Test vs Productie, culori distincte) in lista, preview rand, detaliu, jurnal
|
||||
- [ ] `rar_env` in audit export si in `GET /v1/prezentari(/{id})`
|
||||
- [ ] `python3 -m pytest tests/test_badge_rar_env.py -q` PASS
|
||||
- **Verificare E2E**: rand prod -> badge „Productie"; export audit contine coloana.
|
||||
|
||||
### US-011: Statusbar — indicator mediu + toggle conditionat
|
||||
**Ca** operator **vreau** sa vad in statusbar mediul default si sa-l pot schimba cand am ≥2 medii **pentru ca**
|
||||
sa stiu mereu unde trimit si sa comut rapid.
|
||||
|
||||
- **Depinde de**: US-002, US-008
|
||||
- **Fisiere**: `app/web/templates/_status.html`, `base.html`, `app/web/routes.py` (ruta toggle account-scoped),
|
||||
`tests/test_statusbar_env.py`
|
||||
- **Test intai (RED)**: `tests/test_statusbar_env.py` — `test_afiseaza_env_default`,
|
||||
`test_toggle_doar_la_doua_medii`, `test_toggle_schimba_default`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] statusbar afiseaza mediul default al contului logat (Test/Productie), distinct vizual
|
||||
- [ ] toggle apare DOAR la `len(medii_disponibile) >= 2`; comutarea schimba `rar_env_default` (HTMX, fara reload)
|
||||
- [ ] la 1 mediu: doar eticheta statica
|
||||
- [ ] `python3 -m pytest tests/test_statusbar_env.py -q` PASS
|
||||
- **Verificare E2E**: cont cu ambele -> click statusbar schimba default; cont prod-only -> eticheta fixa „Productie".
|
||||
|
||||
### US-012: Audit + e2e pe medii
|
||||
**Ca** lead **vreau** evenimente de audit la activare mediu / schimbare default / blocaj tinta, plus teste e2e
|
||||
**pentru ca** orice atingere a mediului Productie trebuie trasabila.
|
||||
|
||||
- **Depinde de**: US-005, US-006, US-009, US-011
|
||||
- **Fisiere**: `app/audit.py`/`log_event`, `tests/test_e2e_rar_env.py`
|
||||
- **Test intai (RED)**: `tests/test_e2e_rar_env.py` — `test_lant_import_pana_la_queued`, `test_activare_prod_logata`,
|
||||
`test_tinta_indisponibila_blocata_si_logata`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] audit la: activare/dezactivare mediu, schimbare `rar_env_default`, blocaj tinta indisponibila
|
||||
- [ ] e2e (TestClient + SQLite temporar) acopera import->queued cu env corect, ambele cai
|
||||
- [ ] `python3 -m pytest tests/test_e2e_rar_env.py -q` PASS
|
||||
- **Verificare E2E**: jurnal arata „mediu Productie activat" + „default schimbat" cu cont + timestamp.
|
||||
|
||||
### US-013: Retragerea `accounts.rar_creds_enc` (toate citirile -> per-env, apoi DROP)
|
||||
**Ca** sistem **vreau** ca toate cele ~40 de locuri care citesc `accounts.rar_creds_enc` sa treaca pe coloanele
|
||||
per-mediu si apoi sa sterg coloana veche **pentru ca** modelul per-env sa fie sursa unica, fara schema dubla.
|
||||
|
||||
- **Depinde de**: US-005, US-006, US-008 (consumatorii principali deja pe per-env)
|
||||
- **Fisiere**: `app/worker/__main__.py` (fallback + bucla keepalive „toate conturile cu creds"),
|
||||
`app/web/routes.py` (indicatorii `are_creds`), `app/api/v1/integrare_router.py` (`are_creds_rar`),
|
||||
`app/api/v1/router.py` (`POST /v1/conturi/rar-creds` devine env-aware), `app/accounts.py` (purge la stergere cont),
|
||||
`app/db.py` (DROP cu garda), `app/models.py`, `tests/test_retragere_creds_enc.py`
|
||||
- **Test intai (RED)**: `tests/test_retragere_creds_enc.py` — `test_niciun_read_pe_coloana_veche`,
|
||||
`test_conturi_rar_creds_env_aware`, `test_are_creds_pe_per_env`, `test_drop_cu_garda_blocat_daca_lipsa_copiere`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] worker fallback + keepalive citesc `rar_creds_{env}_enc` (per env), nu coloana veche
|
||||
- [ ] `are_creds` (web) + `are_creds_rar` (integrare) devin per-mediu („are creds pe Testare/Productie")
|
||||
- [ ] `POST /v1/conturi/rar-creds` primeste mediul (`rar_target`/`env`) si scrie in slotul corect — **schimbare
|
||||
de contract API**, documentata in `docs/api-rar-contract.md`
|
||||
- [ ] purjarea la stergere cont (`accounts.py`) sterge ambele sloturi per-env
|
||||
- [ ] **DROP cu garda**: migrarea verifica intai ca fiecare `rar_creds_enc` non-null a aterizat intr-un slot
|
||||
per-env (assert), apoi `ALTER TABLE accounts DROP COLUMN rar_creds_enc` (SQLite 3.45 OK); verificare esuata
|
||||
-> NU dropa, ridica eroare (fail-safe)
|
||||
- [ ] **(AUTO-FIX 6a — CRITIC) Elimina ATOMIC blocul `ADD COLUMN rar_creds_enc` din `db.py:77-78`** in aceeasi
|
||||
migrare cu DROP-ul. Altfel urmatorul boot vede coloana absenta si o re-ADD goala -> ping-pong perpetuu,
|
||||
garda se rupe. Garda e one-way: dropeaza doar cand sloturile per-env sunt populate SI coloana inca exista
|
||||
- [ ] **(AUTO-FIX 6b — HIGH) DROP-ul nu crapa boot-ul**: `init_db/_migrate` ruleaza la fiecare pornire a ambelor
|
||||
procese; un `DROP COLUMN` care arunca (SQLite < 3.35 / assert garda esuat) propaga -> API + worker
|
||||
crash-loop. Prinde + degradeaza (log + lasa coloana pe loc), NU arunca. Asserteaza `sqlite_version() >= 3.35`
|
||||
(verifica SQLite din imaginea Docker, nu doar dev box) si sare drop-ul gracios sub acel prag
|
||||
- [ ] **(AUTO-FIX 6c — HIGH) Re-ruleaza backfill old->new IMEDIAT inainte de assert**: creds setate via
|
||||
`POST /v1/conturi/rar-creds` intre deploy-ul US-001 si US-013 aterizeaza doar in coloana veche; copiaza-le
|
||||
in slotul per-env (ancora globala) inainte de garda, altfel garda blocheaza drop-ul la nesfarsit
|
||||
- [ ] **(AUTO-FIX 6d) Verificare prin `PRAGMA table_info(accounts)`** ca `rar_creds_enc` lipseste, NU doar prin
|
||||
grep (ambele coloane — `accounts` si `submissions` — au acelasi nume; purjarea worker-ului ramane pe
|
||||
`submissions.rar_creds_enc`)
|
||||
- [ ] `grep -rn "rar_creds_enc" app/` nu mai gaseste citiri pe `accounts` (doar `submissions.rar_creds_enc` ramane)
|
||||
- [ ] `python3 -m pytest tests/test_retragere_creds_enc.py -q` PASS
|
||||
- **Verificare E2E**: dupa migrare, `PRAGMA table_info(accounts)` nu mai contine `rar_creds_enc`; fluxul de cont
|
||||
(salvare creds, worker trimite) functioneaza pe per-env.
|
||||
|
||||
## 5. Riscuri
|
||||
|
||||
- **Trimitere reala accidentala** (FINALIZATA terminal, L.142): atenuat prin badge omniprezent + Productie disponibil
|
||||
doar dupa activare explicita + creds + confirmare unica la activare. NU exista anulare la RAR.
|
||||
- **Default invalid dupa dezactivare mediu**: REQ-DEFAULT recalculeaza; teste US-002 acopera caderea pe disponibil.
|
||||
- **Migrare ambigua** (CONFIRMAT): `rar_creds_enc` poate fi test SAU prod; migrarea aterizeaza in slotul
|
||||
`AUTOPASS_RAR_ENV` global + activeaza doar acel mediu. De validat pe DB-ul real inainte de deploy.
|
||||
- **Client prod-only nu poate testa**: corect by design; UI explica explicit (nu „creds invalide"), nu ofera Testare
|
||||
fara creds test.
|
||||
- **Idempotenta**: schimbarea cheii (US-003) cere ca TOATE apelurile sa treaca env-ul; grep dupa `build_key` + teste.
|
||||
- **Retragere `rar_creds_enc` (US-013)**: ~40 citiri + endpoint API `POST /v1/conturi/rar-creds` (contract). Blast
|
||||
radius mare, dar single-release e mai curat decat schema dubla. DROP cu garda (assert copiere) = fara pierdere
|
||||
de date; produsul e in TESTE (putine conturi reale). Recuperarea via coloana veche dispare dupa DROP — acceptat.
|
||||
|
||||
## 6. Intrebari deschise — REZOLVATE (user 2026-06-29)
|
||||
|
||||
- [x] **Default API** = default-ul contului (NU „test" hardcodat), fiindca clientii sunt prod-only. CONFIRMAT.
|
||||
- [x] **Activare implicita cont nou** = Productie on / Testare off; contul operator setat manual pe Testare. CONFIRMAT.
|
||||
- [x] **Confirmare Productie** = o data, la activarea mediului in configurare (nu per trimitere). CONFIRMAT.
|
||||
- [x] **`rar_creds_enc` vechi** = se STERGE in acest PRD (US-013), nu in 5.2x. DROP cu garda (assert copiere),
|
||||
toate citirile mutate pe per-env, endpoint `POST /v1/conturi/rar-creds` devine env-aware. CONFIRMAT.
|
||||
|
||||
## 7. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
Val 1: [US-001] [US-003] ← schema + idempotenta (fisiere distincte) → paralel
|
||||
Val 2: [US-002] ← deblocat de US-001
|
||||
Val 3: [US-004] [US-006] [US-007] ← rezolvare ingestie / worker / validare → paralel
|
||||
Val 4: [US-005] [US-008] [US-009] [US-010] ← API / config cont / import / badge → paralel
|
||||
Val 5: [US-011] ← statusbar (depinde de US-008)
|
||||
Val 6: [US-012] [US-013] ← audit + e2e; retragere rar_creds_enc + DROP (depind de tot)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raport VERIFY
|
||||
|
||||
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
||||
> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY.
|
||||
|
||||
---
|
||||
|
||||
<!-- AUTONOMOUS DECISION LOG -->
|
||||
## /autoplan Review (2026-06-29, commit 7371c37)
|
||||
|
||||
Voci: Claude (primar) + Claude subagent (independent). Codex indisponibil (usage limit, revine 18 iul) -> mod `[subagent-only]`. Poarta premisa: user a ales **"Build full per-account multi-env (as planned)"** — premisa de baza (sisteme separate) verificata live; nevoia de dashboard unic justifica per-cont peste 2 deployment-uri pinned.
|
||||
|
||||
### Auto-fixuri (corectitudine/siguranta — incorporate in stories)
|
||||
|
||||
| # | Story | Gap (gasit de) | Fix incorporat | Principiu |
|
||||
|---|-------|----------------|----------------|-----------|
|
||||
| G | US-001 | **CRITIC** (subagent): migrarea backfill-eaza creds dar NU `submissions.rar_env` existent; randuri prod pre-migrare cad pe DEFAULT 'test' -> US-006 reconciliaza contra endpoint TEST -> no-match -> **re-send prod = duplicat real ireversibil** | Migrarea backfill-eaza `submissions.rar_env` din `AUTOPASS_RAR_ENV` global (DEFAULT 'test' doar pentru randuri net-noi). Test: rand prod pre-migrare reconciliaza contra endpoint prod | P1 completeness + siguranta |
|
||||
| L | **US-005/US-013** (NU US-006 — eng finding 5: write-back e in `router.py`, pe care US-006 nu-l atinge) | HIGH (ambele voci, `router.py:250`): write-back creds efemere API -> `accounts.rar_creds_enc` durabil nu e rutat pe slotul `submission.rar_env` | Write-back tinteste `accounts.rar_creds_{submission.rar_env}_enc` + test. **Plus**: nu auto-propaga creds API NEVALIDATE in slotul durabil per-env (ar putea clobber-i un slot login-validat); propaga doar dupa login reusit | P1 |
|
||||
| K | US-013 | HIGH (subagent): `POST /v1/conturi/rar-creds` e contract extern; env-aware in-place = breaking | Endpoint **aditiv**: param `env` optional, default = default cont; apelanti vechi neatinsi. (Independent de decizia DROP) | P5 explicit + back-compat |
|
||||
| M2 | US-013 | MEDIUM (Claude): `_keepalive_target` alege un cont fara notiune de env dupa per-env | Keepalive foloseste ancora globala `AUTOPASS_RAR_ENV` + un cont cu creds in slotul acelui env | P5 |
|
||||
| M3 | US-003 | MEDIUM (Claude): `_already_sent_lookup` (import_router.py:369) are dual-lookup legacy; adaugarea env in cheie cere extinderea lui, nu doar a parametrului | US-003 extinde dual-lookup (cheie noua env-aware + fallback legacy) | P1 |
|
||||
| D | US-001 | HIGH (subagent): corectitudinea migrarii e "de validat manual"; trebuie poarta testata | Script de audit pre-migrare (raporteaza slot-ul atribuit) + assert DROP-cu-garda existent ca poarta, nu nota manuala | P1 |
|
||||
| M | US-012 | MEDIUM (subagent): niciun test live dual-env; riscul dominant (rutare gresita env) e exact ce SQLite nu prinde | Test live opt-in dual-env (extinde `test_live_rar`): 1 rand test + 1 prod -> 2 login-uri, 2 endpoint-uri, badge corecte, reconciliere pe env corect | P1 |
|
||||
| backup | US-013 | MEDIUM (Claude): "recovery via coloana veche dispare dupa DROP — acceptat" | Inainte de DROP, dump coloana veche criptata intr-un backup timestamped (recuperare supravietuieste DROP) | P2 boil-lake |
|
||||
|
||||
### Decizii user la poarta finala (REZOLVATE 2026-06-29) — APROBAT
|
||||
|
||||
- **A (DROP US-013) -> PASTREAZA single-release.** User: "aplicatia e doar in teste, nu folosita de clienti" -> blast radius mic, rollback-ul conteaza mai putin. Decizia §6 ramane. **Garzile 6a/6b/6c sunt obligatorii in AC US-013** (eliminare atomica bloc ADD, catch+degrade fara boot-crash, re-backfill interim) + backup criptat inainte de DROP. NU se amana.
|
||||
- **J/H1 (interlock prod) -> doar butonul de commit colorat (F8), FARA modal.** REQ-CONF ramane. Lantul: bifa activare (o data) + badge "fierbinte" + buton "Declară la PRODUCȚIE (real)". Fara confirmare per-commit (evita oboseala de click; clientii prod-only oricum n-au selector).
|
||||
- **H (fallback default) -> doar toast zgomotos (F5), FARA re-confirmare.** REQ-DEFAULT auto-fallback ramane; toast-ul "Mediul implicit a trecut pe X" face flip-ul vizibil. Fara gate suplimentar.
|
||||
|
||||
### Taste (recomandari acceptate — fara override)
|
||||
- **T1**: token dedicat `--prod` (brick) pentru badge-ul Productie. **T2**: `rar_env` ca nume unic input+output (scoate `rar_target`/`env`).
|
||||
|
||||
### Taste decisions (auto-decise cu recomandare — override la poarta)
|
||||
- **T1 — token culoare Productie**: rosu (`--err`) se ciocneste cu erorile, amber (`--warn`) cu badge-ul legacy. Recomandat: token dedicat `--prod` (brick inchis) SAU `--accent` plin. (design F2)
|
||||
- **T2 — nume camp request**: recomandat `rar_env` peste tot (un singur nume input+output), scoate `rar_target`/`env`. (DX F1)
|
||||
|
||||
### Teme cross-fază (semnal de incredere ridicat — aparut independent in 2+ faze)
|
||||
- **Siguranta declaratiei reale ireversibile** — TOATE 4 fazele (CEO G/H1/J, Design F1/F8/F10, Eng 1b/3/G, DX F2/F3/F4). Semnalul dominant: badge + interlock + discoverability + rutare env corecta converg pe "nu declara real din greseala".
|
||||
- **Flip silentios al mediului default** — CEO-H, Design-F5, DX-F3 (3 faze). Fa flip-ul zgomotos + nu auto-promova prod silentios.
|
||||
- **Risc DROP US-013** — CEO-A, Eng 6a/6b/6c (2 faze). Intareste amanarea DROP-ului.
|
||||
- **Ambiguitate spec/nume care musca implementer-ul** — Design-F14, Eng-4a, DX-F1/F7. Auto-fixurile TREBUIE sa intre in AC + contract inainte de implementare.
|
||||
|
||||
### NOT in scope (confirmat)
|
||||
Eliminarea ancorei globale `AUTOPASS_RAR_ENV`; base_url din UI; al treilea mediu; gating plan/tier pe prod; schimbari masina-stari/backoff/payload; auto-migrare creds prod client. (PRD §2)
|
||||
|
||||
### Ce exista deja (leverage)
|
||||
`crypto.py` Fernet (creds per-env), `AccountSessions` (re-key (cont,env)), `RarClient` (primeste settings; +param env), `config.rar_base_url_test/prod` (deja prezent), `build_key` (+param), `account_scope_clause`. Fara infra noua.
|
||||
|
||||
### Auto-fixuri DESIGN (structurale — incorporate in stories)
|
||||
|
||||
Voci: Claude (primar) + Claude subagent. Scorecard: 1 CRITIC, 7 HIGH, 5 MEDIUM, toate CONFIRMED.
|
||||
|
||||
| # | Story | Gap | Fix incorporat | Sev |
|
||||
|---|-------|-----|----------------|-----|
|
||||
| F1 | US-010 | **CRITIC**: "culori distincte" e singura spec a singurului guard vizual contra riscului dominant | Badge **normativ**: Productie = fill plin, saturat, text alb, iconita + cuvant complet UPPERCASE cu diacritice ("PRODUCȚIE"); Testare = outline/tint linistit (muted/accent), receding. Asimetria de greutate ESTE designul | CRITIC |
|
||||
| F2 | US-010 | HIGH: rosu (`--err`) rezervat erorilor, amber (`--warn`) ocupat de `.badge-env` legacy + needs_* | Token dedicat `--prod` (ex. brick `#B4452F`) SAU `--accent` plin pentru Productie; hex/token scris in AC, nu improvizat per template. (taste: hexul exact -> poarta) | HIGH |
|
||||
| F3/F12 | US-010 | HIGH: "Test/Testare/prod/PRODUCTIE" folosite interschimbabil; bypass `labels.py` | `labels.py` adaugat in Fisiere: `ETICHETE_ENV` + `eticheta_env(env)->(text,css)` (oglindeste `eticheta_scurta`). Productie UPPERCASE+diacritice, Testare title-case; clase `.badge-prod/.badge-test` definite o data in base.html langa `.sugg-sursa` | HIGH |
|
||||
| F11 | US-011 | HIGH: `.badge-env` EXISTENT in header arata `AUTOPASS_RAR_ENV` global -> dupa 5.20 e semantic gresit; doua indicatoare env cu surse diferite in acelasi viewport | US-011 retrage/repurpune header `.badge-env` (preferat: scos pentru user logat, inlocuit de indicatorul account-scoped din statusbar). NU coexista doua surse de adevar | HIGH |
|
||||
| F4 | US-009 | HIGH: starea 0-medii e numita dar nedesignata; blocaj la commit (dupa munca) = calea minima | Blocaj la UPLOAD (nu commit): banner `--warn` (refoloseste pattern "Cont in asteptare", `_status.html:8`) + CTA link `?tab=cont`, inainte de drop-zone | HIGH |
|
||||
| F5 | US-011 | HIGH: schimbarea silentioasa a default-ului (mediu dezactivat) nu are UI -> target real/test comuta fara ca userul sa stie | Toast explicit (componenta `#toast` exista) la schimbarea `rar_env_default` ca efect al disponibilitatii: "Mediul implicit a trecut pe X". Leaga de CEO-H | HIGH |
|
||||
| F8 | US-009 | HIGH: o bifa la activare apoi nimic = sub-avertizare; modalul per-trimitere a fost respins (REQ-CONF) | Butonul de commit POARTA greutatea cand target=Productie: "Declară la PRODUCȚIE (real)" + culoarea Productie (FARA modal, FARA click extra -> nu incalca REQ-CONF). Copy bifa activare: adauga ireversibilitatea ("declarații oficiale, finale și fără anulare") | HIGH |
|
||||
| F6/F7 | US-008/US-011 | MEDIUM: stari loading/error pt toggle HTMX + validare creds la RAR nespecificate; stare per-sectiune (activat-fara-creds-valide) | toggle: `hx-indicator` + disabled in zbor, pe esec NU schimba default + eroare; US-008 validare creds arata `htmx-indicator` ("se verifica la RAR…") + esec in `.banner` cu copy per-env (US-007); fiecare sectiune arata 3 stari: dezactivat / activat-fara-creds / disponibil | MEDIUM |
|
||||
| F9/F10 | US-009 | MEDIUM/HIGH: selectorul absent la 1 mediu = env invizibil la import; default pre-bifat prod la prima trimitere | Mereu randeaza un indicator env la import (eticheta statica la 1 mediu, toggle la >=2, ACEEASI pozitie). Prod pre-bifat e sigur DOAR daca F8+F9 livreaza impreuna — legate explicit in AC | HIGH |
|
||||
| F13 | US-010 | MEDIUM: sa nu forkeze un badge structural nou | Refoloseste idiomul `.sugg-sursa` (10px, weight 700, tint+border) pt Testare; Productie = aceeasi geometrie dar fill plin+alb+icon (spargerea e semnalul) | MEDIUM |
|
||||
|
||||
### Auto-fixuri ENG (corectitudine/deploy — incorporate in stories)
|
||||
|
||||
Voci: Claude (primar) + Claude subagent (verificat contra codului real). **Meta (eng 4a): toate auto-fixurile de mai jos sunt NORMATIVE si trebuie sa intre in AC-ul story-urilor inainte de implementare — un implementer care urmeaza AC-ul literal, fara ele, livreaza bug-urile critice.** G + 6a deja imbinate in AC US-001/US-013.
|
||||
|
||||
| # | Story | Gap (vs cod real) | Fix | Sev |
|
||||
|---|-------|-------------------|-----|-----|
|
||||
| E1/1a | US-006 | `get_token` purjeaza `submissions.rar_creds_enc WHERE account_id=?` -> dupa re-key, login TEST sterge creds efemere ale submission-urilor PROD ale contului -> prod blocat | `WHERE account_id=? AND rar_env=?` + test `test_purge_creds_doar_pe_env` | HIGH |
|
||||
| 1b/E6 | US-006 | `recover_orphans` filtreaza doar pe `account_id`; iterat per sesiune (cont,env) reconciliaza orfanii prod contra endpoint TEST -> no-match -> re-POST prod = DUPLICAT real | +`rar_env` in WHERE; apelat per (cont,env) din `active()`; test orfan env A nereconciliat contra env B | HIGH/CRITIC |
|
||||
| 3/E4 | US-003 | API channel (`router.py:223`) NU are dual-lookup; re-POST al unui rand pre-5.20 cu cheie env-aware rateaza randul legacy -> duplicat. Import dual-lookup ignora env-ul randului matchuit | Recompute-keys la migrare (US-001, vezi acolo) acopera ambele canale uniform; daca pastrezi dual-lookup, exista si in `router.py` SI gate pe `matched_row.rar_env==target_env` | HIGH |
|
||||
| 1c/E8 | US-006 | `claim_one` nu selecteaza `s.rar_env` -> worker nu poate alege cheia sesiune/base_url/slot | AC explicit: claim selecteaza + propaga `rar_env` in dict-ul `claimed` | MEDIUM |
|
||||
| 1d | US-006/US-001 | `worker_heartbeat` e un singur rand global (`WHERE id=1`); US-006 cere `last_rar_login_ok` PER env dar US-001 nu adauga schema per-env -> neimplementabil ca scris | Decizie: pastreaza heartbeat global (JWT/sesiune per env e suficient), scoate "per env" din AC US-006; SAU adauga coloana in US-001. Recomandat: global | MEDIUM |
|
||||
| 1e | US-006 (doc) | `_refresh_nomenclator` upsert intr-un `nomenclator_rar` env-less la fiecare login; login test suprascrie cu coduri test, prod cu prod -> un cod valid pe prod poate fi respins la ingestie daca ultimul refresh a fost test | Documenteaza presupunerea (nomenclator identic intre medii — aceleasi 18 coduri) SAU scope per-env (out of scope acum). Minim: nota explicita | MEDIUM |
|
||||
| 5 | US-005/US-013 | write-back creds API nevalidate -> slot durabil (vezi L de mai sus) | re-asignat la US-005/US-013; propaga doar dupa login reusit | MEDIUM/HIGH |
|
||||
| 6a..6d | US-013 | ping-pong re-ADD / boot-crash / interim-creds / grep ambiguu | imbinate in AC US-013 (vezi acolo) | CRITIC/HIGH |
|
||||
|
||||
### ENG DUAL VOICES — CONSENSUS TABLE
|
||||
```
|
||||
Dimension Claude Subagent Consensus
|
||||
────────────────────────────── ──────── ───────── ────────────────────
|
||||
1. Architecture sound? da/cond da/cond CONFIRMED (cond. fixuri)
|
||||
2. Test coverage sufficient? lacune +API b/c CONFIRMED lacune
|
||||
3. Performance risks? low low CONFIRMED low
|
||||
4. Security (creds routing)? L/5 5+unvalid CONFIRMED
|
||||
5. Error paths (boot)? E1/E9 6a/6b CRIT CONFIRMED (boot-crash)
|
||||
6. Deployment risk (DROP)? migrare CRIT/HIGH CONFIRMED ELEVAT -> intareste challenge A
|
||||
```
|
||||
Codex: indisponibil (N/A). Mesaj-cheie: caile de duplicat ireversibil (1b, 3) si boot-crash/ping-pong (6a, 6b) musca in productie; intaresc recomandarea de a amana DROP-ul (challenge A).
|
||||
|
||||
### Diagrama teste (codepath -> acoperire)
|
||||
| Codepath nou | Story test | Stare |
|
||||
|---|---|---|
|
||||
| `medii_disponibile`/`rar_env_efectiv` | US-002 | acoperit |
|
||||
| resolve target (cerere>default), respinge indisponibil | US-004 | acoperit |
|
||||
| idempotency env-aware + **recompute legacy** | US-003/US-001 | GAP recompute -> adaugat |
|
||||
| **migrare backfill `submissions.rar_env`** | US-001 | GAP (G) -> adaugat in AC |
|
||||
| worker sesiune (cont,env) + base_url per env | US-006 | acoperit |
|
||||
| **purge creds scoped pe env** | US-006 | GAP (E1) -> adaugat |
|
||||
| **recover_orphans per env** | US-006 | GAP (1b) -> adaugat |
|
||||
| **write-back slot routing** | US-005/013 | GAP (L/5) -> adaugat |
|
||||
| reconcile endpoint per env (inline + **orfani**) | US-006 | inline acoperit; orfani GAP -> adaugat |
|
||||
| **keepalive env (ancora globala)** | US-013 | GAP (M2) -> adaugat |
|
||||
| DROP garda: assert + **idempotent re-run** + **fail-loud/no-crash** | US-013 | partial -> intarit (6a/6b/6c) |
|
||||
| **API-channel idempotency back-compat** | US-003 | GAP (3) -> adaugat |
|
||||
| badge/labels env | US-010 | acoperit |
|
||||
| API `rar_target` default/explicit/invalid/indisponibil | US-005 | acoperit |
|
||||
| config 2 sectiuni + confirmare prod | US-008 | acoperit |
|
||||
| statusbar toggle viz + **retragere header `.badge-env`** | US-011 | toggle acoperit; header GAP (F11) -> adaugat |
|
||||
| **live dual-env smoke** | US-012 | GAP (M) -> adaugat opt-in |
|
||||
|
||||
### Auto-fixuri DX (contract API extern — incorporate in stories)
|
||||
|
||||
Voci: Claude (primar) + Claude subagent (perspectiva integrator VFP/ROAAUTO). Riscul ireversibilitatii ridica stacheta pe claritate nume / eroare / discoverability pre-trimitere.
|
||||
|
||||
| # | Story | Gap | Fix | Sev |
|
||||
|---|-------|-----|-----|-----|
|
||||
| F1 | US-005/US-013 | Trei nume pt un concept: input `rar_target`, echo/DB `rar_env`, rar-creds `env` (US-013 AC scrie literal "rar_target/env") | **Un singur cheie: `rar_env`** pe input + output + rar-creds (englez snake, consistent cu coloana si `on_unmapped_error`). Scoate `rar_target`/`env`. (taste usor -> poarta) | HIGH |
|
||||
| F2 | US-004 | Eroarea "mediu indisponibil" e proza, fara `cod`/envelope 6-chei/status; `errors.py` nu e in Fisiere | `RAR_MEDIU_INDISPONIBIL` in `errors.CATALOG` (problema/cauza cu lista disponibile/fix "activeaza in Cont"); adauga `errors.py` la Fisiere US-004; distinge literal-invalid (422 pydantic) de valid-dar-indisponibil (cod dedicat); acopera si cazul 0-medii | HIGH |
|
||||
| F3 | US-004/contract | Flip runtime test->prod prin canal web: operator comuta disponibilitatea -> apelant API fara `rar_env` trece silentios pe prod (real). Migrarea previne flip la DEPLOY, nu la RUNTIME | Mitigat de F4+F5 (probe pre-trimitere); documenteaza reasignarea ca comportament cunoscut; leaga de CEO-H | HIGH |
|
||||
| F4 | US-010 (sau story noua) | Niciun GET nu expune `medii_disponibile`/`rar_env_default` -> integratorul afla env-ul doar din eroare sau dupa o trimitere reala | `GET /v1/conturi/medii` account-scoped: `{medii_disponibile, rar_env_default, test:{enabled,has_creds}, prod:{...}}` (refoloseste helper US-002, <1 fisier) | HIGH |
|
||||
| F5 | US-005 | `ValidareResult` (dry-run) NU ecou-ieste `rar_env`; dry-run e canalul sigur de a confirma unde ar ateriza o trimitere reala | adauga `rar_env: str` la `ValidareResult` + `/valideaza`; `models.py` | MEDIUM |
|
||||
| F6 | US-004/US-005 | Respingere whole-request vs per-rand inconsistenta cu `on_unmapped_error` (per-rand, 200) | Decide + documenteaza; recomandat: corp parsabil imbogatit cu `cod` (prietenos VFP), noteaza asimetria intentionat | MEDIUM |
|
||||
| F7 | US-005/US-010/US-004/US-013 | Contractul (sursa adevar) actualizat doar pt rar-creds; lipsesc field-ul nou, echo-ul, cod-ul nou. **`/v1/conturi/rar-creds` NU e documentat deloc azi** -> US-013 e documentare de la zero, nu amendament | AC explicit "update `api-rar-contract.md`" pe fiecare; US-013 documenteaza endpoint-ul intreg (req/resp, param env, slot default) | HIGH |
|
||||
| F8 | US-013 (doc) | `env` optional default = slot default cont: integrator cu creds TEST pe cont nou (default prod) le scrie silentios in slot prod -> US-007 le respinge "invalide pe PRODUCTIE" desi sunt valide (test) | pastreaza aditiv; documenteaza ca omiterea `env` tinteste slotul default; mesaj validare sugereaza nepotrivire env ("creds valide pentru alt mediu?") | MEDIUM |
|
||||
|
||||
### DX DUAL VOICES — CONSENSUS TABLE
|
||||
```
|
||||
Dimension Claude Subagent Consensus
|
||||
─────────────────────────────── ─────── ───────── ──────────────
|
||||
1. Getting started (aditiv)? low fr low fr CONFIRMED low
|
||||
2. Naming guessable? D1 incon F1 3-nume CONFIRMED -> rar_env
|
||||
3. Error messages actionable? D2 gap F2 gap CONFIRMED gap
|
||||
4. Docs findable & complete? D4 gap F7 gap+ CONFIRMED gap
|
||||
5. Back-compat safe? D3 resid F3 runtime CONFIRMED (1 rezidual)
|
||||
6. Discoverability pre-send? D5 gap F4 gap CONFIRMED gap
|
||||
```
|
||||
Codex: indisponibil (N/A). DX scor initial: ~6/10 (model API solid + aditiv, dar nume inconsistent + eroare neimbogatita + zero discoverability + contract neactualizat). Tinta dupa fixuri: ~9/10.
|
||||
|
||||
### Jurnal integrator (condensat)
|
||||
| Etapa | Azi (plan brut) | Dupa fixuri DX |
|
||||
|---|---|---|
|
||||
| Afla env-urile contului | doar din eroare / dupa trimitere reala | `GET /v1/conturi/medii` |
|
||||
| Trimite | `rar_target` (nume #1) | `rar_env` (un nume) |
|
||||
| Confirma tinta fara trimitere reala | imposibil (valideaza nu ecou-ieste) | `/valideaza` ecou-ieste `rar_env` |
|
||||
| Eroare tinta indisponibila | proza, fara cod | `cod: RAR_MEDIU_INDISPONIBIL` + fix |
|
||||
| Citeste rezultatul | `rar_env` (nume #2) | `rar_env` (acelasi) |
|
||||
| Doc | contract fara field/endpoint | contract complet |
|
||||
534
docs/raport-comparatie-mockup-5.16.md
Normal file
534
docs/raport-comparatie-mockup-5.16.md
Normal file
@@ -0,0 +1,534 @@
|
||||
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/feat-5.18-corpus-knn-exemple-etichetate-autoplan-restore-20260629-070833.md -->
|
||||
# Raport comparatie UI real vs. mockup-uri (PRD 5.16 + 5.17)
|
||||
|
||||
**Data**: 2026-06-29
|
||||
**Metoda**: comparatie in browser (Playwright, 1280px + 390px) intre aplicatia live
|
||||
(`http://localhost:8010`, cont 2 "Romfast SRL", 34 trimiteri) si mockup-urile de
|
||||
referinta din `docs/mockups/`. Pentru fiecare pagina/formular am pus fata in fata
|
||||
implementarea reala si intentia de design, apoi am evaluat in spiritul PRD-urilor.
|
||||
|
||||
> Concluzie pe scurt: **antetul, /login, selectorul de tema, contoarele si modalele
|
||||
> sunt conforme**. Abaterea dominanta este **densitatea informationala**: lista de
|
||||
> trimiteri si tabelul de preview din import afiseaza mult mai multa informatie pe rand
|
||||
> decat mockup-ul minimalist — exact observatia userului ("randurile foarte late").
|
||||
> Plus un **bug de layout** (coliziune coloane in preview-ul de import) si cateva
|
||||
> abateri minore de copy/stil.
|
||||
|
||||
---
|
||||
|
||||
## 1. Lista de trimiteri — rand cu 4 linii in loc de 2 (PRIORITATE INALTA)
|
||||
|
||||
**Aceasta e problema semnalata de user.**
|
||||
|
||||
| | Mockup (`prd-5.16-dashboard.html`) | Real (`_submissions.html:100-139`) |
|
||||
|---|---|---|
|
||||
| Linii / rand | **2**: VIN + `operatie · ora` | **4**: VIN; `operatie · data+ora+secunde`; cod RAR; `nr · data · #id` |
|
||||
| Pastila de stare | DOAR pe exceptii (In coada / De corectat / Trimis); finalizatele **nu au pastila** | **pe fiecare rand**, inclusiv "Finalizat" |
|
||||
| Marca de timp | ora scurta (`09:42`) | datetime complet cu secunde (`27.06.2026 22:25:52`) |
|
||||
| Inaltime efectiva | ~2 randuri text | ~2x mai mare; pe mobil un rand se desfasoara pe 5-6 linii |
|
||||
|
||||
Cauza in cod (`app/web/templates/_submissions.html`):
|
||||
- **Linia 3** — codul RAR (`OE-8`) / "nemapat": liniile 113-119.
|
||||
- **Linia 4** — `vehicul_nr · data_prestatie · #id_prezentare`: liniile 121-127.
|
||||
- **Marca de timp** foloseste `r.updated_at` complet (data+ora+secunde): linia 111
|
||||
(mockup-ul foloseste ora scurta).
|
||||
- **Pastila mereu randata** cu `r.stare_scurt`: liniile 137-139 (mockup-ul ascunde
|
||||
pastila pe starea implicita/finalizata — minimalism "linistit cand e ok, zgomotos
|
||||
cand e exceptie", in spiritul D6/zero-silent-failures).
|
||||
|
||||
**Recomandari** (in ordinea impactului):
|
||||
1. **Comprima la 2 linii pe starea normala**: pastreaza linia 1 (VIN) + linia 2
|
||||
(`operatie · data`). Muta cod RAR, nr. inmatriculare si `#id_prezentare` in modalul
|
||||
de detaliu (care le are deja — vezi sectiunea 5) sau intr-un al doilea rand afisat
|
||||
doar la hover/expand. Informatia completa nu trebuie sa coabiteze pe rand cu lista.
|
||||
2. **Ascunde pastila pe starea finalizata** (afiseaz-o doar pe `queued/sending/
|
||||
needs_*/error`), exact ca mockup-ul. Finalizat = implicit linistit.
|
||||
3. **Scurteaza marca de timp**: data fara secunde (`27.06.2026`) sau `data · ora`
|
||||
fara secunde. Secundele sunt zgomot.
|
||||
4. Daca cod RAR / nr. inmatriculare sunt considerate esentiale in lista, fa-le optional
|
||||
(toggle "afiseaza detalii") in loc sa fie mereu prezente — implicit colapsat.
|
||||
5. Minor: `eticheta-problema` are `font-size:10px` (`_submissions.html:133`) — sub
|
||||
pragul de 12px din scala 5.16/US-002; recableaza pe `--fs-xs`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Acasa — titlu de sectiune + toolbar mult mai greu decat mockup-ul (PRIORITATE MEDIE)
|
||||
|
||||
PRD 5.16/US-002 cere explicit: *"Se ELIMINA titlul de sectiune ... lista incepe direct
|
||||
sub tab-uri/filtre"* si *"fara subtitlu de sectiune"*. In real:
|
||||
|
||||
- **Titlul "Trimiterile tale" (h2) + link-urile "export CSV: trimise | toate"** sunt inca
|
||||
prezente ca antet de sectiune deasupra listei. Mockup-ul nu are titlu de sectiune —
|
||||
lista porneste direct sub tab-uri.
|
||||
- **Toolbar-ul de filtre e mult mai dens** decat mockup-ul. Mockup: 4 pastile simple de
|
||||
stare (`Toate / In coada / Trimise / De corectat`). Real: pastile de timp
|
||||
(`Azi / 7 zile / 30 zile / Custom`) + camp cautare `Vehicul (nr/VIN)` + butoane
|
||||
`Filtreaza` + `Toate` + un AL DOILEA rand de actiuni bulk (`Cod RAR ... / Aplica cod
|
||||
/ Sterge selectate`). Sunt functii reale, dar contrazic intentia minimalista.
|
||||
|
||||
**Recomandari**:
|
||||
1. Elimina antetul "Trimiterile tale" (sau redu-l la un label discret); muta link-urile
|
||||
de export CSV langa tab-uri sau in meniul de cont.
|
||||
2. Pastreaza filtrele de timp + cautarea (sunt utile), dar **colapseaza randul de actiuni
|
||||
bulk** (Cod RAR / Aplica cod / Sterge selectate) intr-un buton "Actiuni" care se
|
||||
deschide doar cand exista selectie — azi ocupa un rand permanent.
|
||||
3. Aliniaza pastilele de stare cu mockup-ul (stari, nu doar timp), eventual ambele
|
||||
grupuri pe acelasi rand.
|
||||
|
||||
---
|
||||
|
||||
## 3. Linia "Plan: Gratuit · 34/60 luna asta" reintroduce un meta-rand sub tab-uri (PRIORITATE MEDIE)
|
||||
|
||||
PRD 5.17/US-006 + 5.16 cer planul ca **badge in antet** (exista — "GRATUIT") si **linie
|
||||
in meniul burger**, NU ca rand in corpul paginii. Real afiseaza consumul si ca **rand
|
||||
standalone sub tab-uri**, pe FIECARE tab (Acasa, Mapari, Integrare). Asta:
|
||||
- duplica informatia din antet, si
|
||||
- recreeaza exact "meta-randul de sectiune" pe care 5.16/US-002 voia sa-l elimine.
|
||||
|
||||
**Recomandare**: muta `N/60 luna asta` in meniul burger / pagina Cont (cum cere PRD-ul);
|
||||
pastreaza in antet doar badge-ul de plan. Daca avertizarea de consum (>=80%) trebuie sa
|
||||
fie vizibila in corp, afiseaz-o **doar** in starea de avertizare, nu permanent.
|
||||
|
||||
---
|
||||
|
||||
## 4. Import — preview pas 3: coliziune de coloane + tabel mai greu decat mockup-ul
|
||||
|
||||
### 4a. BUG layout — pastila STARE se suprapune peste coloana VEHICUL (PRIORITATE INALTA)
|
||||
In tabelul de preview (pas Verifica), pastila de stare ("Date incomplete" / "Cod RAR
|
||||
lipsa") se **suprapune vizual** peste textul din coloana VEHICUL (`CT88NOE` / `B123ABC`
|
||||
apar lipite/sub pastila). Vizibil clar la 1280px. E un bug de latime de coloana / pastila
|
||||
fara `white-space:nowrap` sau coloana STARE prea ingusta.
|
||||
**Recomandare**: largeste coloana STARE / pune pastila pe `nowrap` cu min-width, sau
|
||||
muta stare si vehicul pe coloane clar separate; testeaza la 1280 si 390.
|
||||
|
||||
### 4b. Densitate — tabel cu 8 coloane vs. 4 in mockup (PRIORITATE MEDIE)
|
||||
Mockup pas 3 = 4 coloane (`VIN / OPERATIE / DATA / STARE` + link editeaza). Real = 8
|
||||
coloane (`# / STARE / VEHICUL / OPERATIE / DATA / KM FINAL / NOTE / ACTIUNI`), cu coloana
|
||||
NOTE care afiseaza inline mesaje de validare lungi ("VIN trebuie sa aiba exact 17
|
||||
caractere..."). Aceeasi tendinta ca lista de trimiteri: prea multa informatie pe rand.
|
||||
**Recomandare**: redu la coloanele esentiale (Stare / Vehicul / Operatie / Data +
|
||||
Editeaza); muta KM si mesajul de validare in randul de editare (care le are deja) sau
|
||||
intr-un tooltip pe pastila de stare.
|
||||
|
||||
### 4c. Pastilele de filtru sunt toate albastru-plin (par toate active) (PRIORITATE MICA)
|
||||
`Toate (2) / Cod RAR lipsa (1) / Date incomplete (1)` sunt randate ca butoane albastru
|
||||
plin — toate par selectate simultan. Mockup-ul foloseste pastile subtiri cu dot colorat,
|
||||
doar cea activa accentuata.
|
||||
**Recomandare**: stil outline + dot pentru filtrele inactive; plin doar pentru cel activ.
|
||||
|
||||
---
|
||||
|
||||
## 5. Import — pas 1: dropzone compact vs. zona mare din mockup (PRIORITATE MICA)
|
||||
|
||||
Mockup pas 1 = zona mare cu chenar punctat, iconita upload centrata, "Trage fisierul
|
||||
aici", buton "Alege fisier" + chips de format (`.xlsx .csv .xls`). Real = o bara
|
||||
orizontala slim ("Importa: [Alege fisier] sau trage aici"). Bara compacta se potriveste
|
||||
cu "import colapsat", deci e o abatere **acceptabila**; daca se doreste fidelitate cu
|
||||
mockup-ul, zona se poate inalta cand `<details>` e deschis (chenar punctat + iconita).
|
||||
Pozitiv: stepper-ul (4 pasi, cifre in cerc, pas finalizat = bifa verde) si saltul automat
|
||||
peste pas 2 la format recunoscut sunt conforme si bune.
|
||||
|
||||
---
|
||||
|
||||
## 6. Formularul de editare (modal corectie / editare rand)
|
||||
|
||||
Comparatie cu modalul din mockup ("Corecteaza trimiterea / randul"):
|
||||
|
||||
- **Conform**: structura (VIN; Data + Nr. inmatriculare pe 2 coloane; Observatii;
|
||||
"Prestatii — cod RAR pe fiecare operatie"; picker cu denumiri; "+ Adauga alta
|
||||
operatie / cod RAR"). Bug-urile US-004..007 sunt rezolvate functional.
|
||||
- **Anomalie (PRIORITATE MEDIE)**: intre randul de operatie si controlul "+ Adauga alta
|
||||
operatie" apare un **chenar gol** (container de chips fara continut) — pare nefinisat /
|
||||
neintentionat. De ascuns cand nu are chips.
|
||||
- **Stil nume operatie (PRIORITATE MICA)**: mockup-ul afiseaza numele operatiei
|
||||
**bold/uppercase, proeminent** ("SCHIMB PLACUTE FRANA — lipsa cod"); real il arata
|
||||
in greutate normala, mic ("Schimb placute frana · lipsa cod"). Mai putin emfatic.
|
||||
- **Copy butoane (PRIORITATE MICA)**: real "Salveaza / Anuleaza"; mockup + PRD/US-007
|
||||
spun "Renunta" (si "Salveaza si retrimite" in modalul de detaliu). Aliniaza eticheta
|
||||
"Anuleaza" -> "Renunta".
|
||||
|
||||
---
|
||||
|
||||
## 7. Tema transversala — diacritice in textul vizibil (PRIORITATE MICA)
|
||||
|
||||
Mockup-urile (intentia de design) folosesc diacritice romanesti complete in textul catre
|
||||
user ("Observatii" -> "Observații", "Salveaza" -> "Salvează", "Numar inmatriculare" ->
|
||||
"Număr înmatriculare", "Adauga" -> "Adaugă", "In coada" -> "În coadă"). Aplicatia reala
|
||||
omite diacriticele in majoritatea label-urilor. US-001 a confirmat ca fontul de sistem
|
||||
randeaza corect diacriticele, iar landing-ul le foloseste deja — deci e o diferenta de
|
||||
finisaj fata de mockup, nu o limitare tehnica.
|
||||
**Recomandare**: aplica diacritice la **textul vizibil pentru user** (label-uri, butoane,
|
||||
titluri), pastrand codul/comentariile fara diacritice ca azi. Optional (non-blocant);
|
||||
de decis daca se urmareste fidelitate completa cu mockup-urile.
|
||||
|
||||
---
|
||||
|
||||
## 8. Pagini fara mockup dedicat (judecate dupa design system) — CONFORME
|
||||
|
||||
- **Mapari** (`?tab=mapari`): carduri, tabele, fonturi uniforme — coerent cu sistemul.
|
||||
Singura observatie: cardul gol "De rezolvat" cand nu exista needs_mapping (se poate
|
||||
ascunde cand e gol).
|
||||
- **Integrare** (`?tab=integrare`): tab-uri de limbaj (curl/Python/PHP/C#/Node/VFP),
|
||||
blocuri de cod, carduri export + test cheie — curat si profesional.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ce este DEJA conform mockup-urilor (pentru context — fara actiune)
|
||||
|
||||
- **/login**: layout brandeit pe 2 coloane (panou ROMFAST + formular), badge mediu,
|
||||
link signup — conform `prd-5.16-header-login-tema.html`.
|
||||
- **Antet**: titlu "ROMFAST AUTOPASS" + badge mediu (TEST) + badge plan (GRATUIT) +
|
||||
"Service auto: Romfast SRL" + pastila "RAR online" (dot verde) + meniu burger.
|
||||
Conform US-010/003.
|
||||
- **Selector tema**: pill cu iconita + eticheta ("Auto"), iconita-only pe mobil.
|
||||
Conform US-011.
|
||||
- **Contoare**: 5 carduri separate desktop (Total / Luna asta / Azi / In coada /
|
||||
De corectat); bara compacta de cifre pe mobil. Conform US-002. (Minor: eticheta
|
||||
"Total" vs mockup "Total trimise"; pe mobil "Erori" vs mockup "Corectat".)
|
||||
- **Import colapsat pe Acasa** (`<details>` slim "+ Importa fisier"). Conform US-013.
|
||||
- **Modal detaliu trimitere finalizata**: read-only, label-uri clare, "Detalii tehnice"
|
||||
colapsabil — curat si conform.
|
||||
|
||||
---
|
||||
|
||||
## Rezumat prioritati
|
||||
|
||||
| # | Constatare | Prioritate | Fisier principal |
|
||||
|---|---|---|---|
|
||||
| 1 | Rand lista cu 4 linii + pastila mereu (rânduri late) | **INALTA** | `_submissions.html:110-139` |
|
||||
| 4a | Coliziune pastila STARE / coloana VEHICUL in preview import | **INALTA** | `_preview_import.html` |
|
||||
| 2 | Titlu sectiune "Trimiterile tale" + toolbar bulk permanent | MEDIE | `_acasa.html` / `_submissions.html` |
|
||||
| 3 | "Plan: N/60" ca rand in corp (duplica antetul) | MEDIE | `_acasa.html` / context layout |
|
||||
| 4b | Tabel preview cu 8 coloane vs 4 | MEDIE | `_preview_import.html` |
|
||||
| 6 | Chenar gol de chips in formularul de editare | MEDIE | `_chips_prestatii.html` |
|
||||
| 4c | Pastile de filtru toate albastru-plin | MICA | `_preview_import.html` |
|
||||
| 5 | Dropzone import compact vs zona mare | MICA | `_upload.html` |
|
||||
| 6 | Nume operatie ne-emfatic + copy "Anuleaza" vs "Renunta" | MICA | `_form_editare.html` / `_chips_prestatii.html` |
|
||||
| 7 | Diacritice lipsa in textul vizibil | MICA | transversal |
|
||||
|
||||
**Cele doua corectii cu impact maxim**: (1) comprimarea randului de lista la 2 linii +
|
||||
ascunderea pastilei pe finalizat, si (4a) bug-ul de coliziune din preview-ul de import.
|
||||
Restul sunt finisaje de aliniere la spiritul minimalist al mockup-urilor.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
<!-- ================= /autoplan REVIEW APPENDIX ================= -->
|
||||
# /autoplan — Revizuire automata (CEO → Design → Eng)
|
||||
|
||||
> Tratam acest raport ca **plan**: cele 10 recomandari (sectiunile 1-7) sunt
|
||||
> elementele de implementat. Scope UI: DA (Design conduce). Scope DX: NU
|
||||
> (sectiunea 8 "Integrare" e marcata CONFORM, fara actiune pe suprafata API/CLI).
|
||||
> Voci duale: Claude subagent + Codex per faza. Decizii intermediare auto-decise
|
||||
> pe cele 6 principii; deciziile de gust merg la poarta finala.
|
||||
|
||||
## Faza 1 — CEO (Strategie & Scope)
|
||||
|
||||
### 0A. Provocarea premiselor
|
||||
|
||||
Planul (raportul) se sprijina pe 4 premise implicite:
|
||||
|
||||
- **P1 — Fidelitatea fata de mockup este tinta.** Mockup-urile reprezinta intentia
|
||||
corecta de design; orice abatere a UI-ului real e un defect. *Status: in mare
|
||||
valida, dar nu absoluta* — raportul insusi recunoaste ca UI-ul real a adaugat
|
||||
FUNCTII pe care mockup-ul minimalist nu le are (cautare, filtre de timp, bulk-fix
|
||||
cod RAR, cod RAR + #id_prezentare pe rand). Acele functii pot sa-si merite densitatea.
|
||||
- **P2 — "Densitatea informationala" e problema centrala**, iar minimalismul ("linistit
|
||||
cand e ok, zgomotos pe exceptie", D6/zero-silent-failures) e principiul corect.
|
||||
*Status: validata de durere reala* — userul s-a plans explicit de "randurile foarte
|
||||
late". Aici premisa e bine sustinuta.
|
||||
- **P3 — Criteriile de acceptare PRD 5.16/5.17 sunt obligatorii** si UI-ul real a
|
||||
derivat de la ele (titlu sectiune de eliminat `_coada.html:10`; plan ca badge nu rand
|
||||
in corp `_status.html:140`; prag tipografic 12px incalcat de `font-size:10px`
|
||||
`_submissions.html:133`). *Status: validata — sunt AC contractuale, nu preferinte.*
|
||||
Acestea NU sunt decizii de gust; sunt conformare la PRD.
|
||||
- **P4 — Mutarea informatiei de pe rand nu pierde nimic** fiindca e deja in modalul
|
||||
de detaliu / randul de editare. *Status: tehnic adevarata* (verificat: modalul are
|
||||
cod RAR/nr/#id; randul de editare are KM + mesaj validare), dar muta un cost de la
|
||||
"vizibil la scanare" la "vizibil dupa click" — un compromis de UX, nu zero-cost.
|
||||
|
||||
**Premisa care merita judecata umana** (poarta de mai jos): pentru informatia scoasa
|
||||
de pe rand (cod RAR, #id_prezentare, marca de timp completa) — o **ascundem in modal**
|
||||
(minimalism strict, fidel mockup-ului) sau o **pastram in spatele unui toggle
|
||||
compact/detaliat** (operatorul de service poate vrea sa scaneze cod RAR/#id fara click)?
|
||||
Userul s-a plans de latime, NU neaparat ca informatia in sine e inutila.
|
||||
|
||||
### 0B. Harta de leverage (ce exista deja)
|
||||
|
||||
| Sub-problema | Cod existent reutilizat | Tip schimbare |
|
||||
|---|---|---|
|
||||
| Compresie rand lista | modal detaliu (`_fragments/trimitere/{id}`) are deja cod RAR/nr/#id | SCADERE (sterge L3/L4 din `_submissions.html`) |
|
||||
| Pastila pe finalizat | `r.stare_css/stare_scurt` exista; conditie lipsa | conditie `{% if %}` in jurul liniei 138 |
|
||||
| Prag tipografic 12px | sistemul de token-uri `--fs-xs:12px` exista deja in mockup/base | re-cablare literal `10px` → `--fs-xs` |
|
||||
| KM + validare in preview | randul de editare le are deja | SCADERE coloane din `_preview_import.html` |
|
||||
| Chenar gol chips | `_has_ops`/`_chips` deja calculate | conditie `{% if _chips %}` pe container |
|
||||
|
||||
Concluzie: planul e **dominant SCADERE + re-tokenizare**, putin cod nou, leverage mare.
|
||||
|
||||
### 0B-bis. Pattern de fond depistat (in afara raportului, in blast radius)
|
||||
|
||||
`_submissions.html` foloseste **literali px inline** peste tot (`font-size:13px`,
|
||||
`12px`, `11px`, `10px` — liniile 18, 45, 54, 63, 133, 153, 182...) in loc de token-uri
|
||||
`--fs-*`. Raportul a prins DOAR instanta de 10px (US-002). Cauza-radacina e ca scala
|
||||
tipografica 5.16 nu e aplicata sistematic in template-urile de lista/preview. *Flag
|
||||
pentru poarta finala: extindem fix-ul la re-tokenizarea template-urilor atinse, sau
|
||||
doar instanta 10px?* (In blast radius, < 1 zi CC — candidat de auto-aprobat pe P2.)
|
||||
|
||||
### 0C. Dream-state delta
|
||||
|
||||
```
|
||||
CURENT → ACEST PLAN → IDEAL 12 LUNI
|
||||
UI real, dens, derivat de Aliniat la minimalismul Sistem de token-uri aplicat
|
||||
la AC-urile PRD 5.16/5.17; mockup-ului; bug 4a rezolvat; uniform (zero literali px);
|
||||
bug coliziune coloane; randuri 2 linii; tipo 12px+ teste de regresie design vs
|
||||
literali px imprastiati. pe instantele semnalate. mockup (Playwright snapshot).
|
||||
```
|
||||
Delta ramasa dupa plan: re-tokenizarea completa + testele de regresie vizuala (defer).
|
||||
|
||||
### 0C-bis. Alternative de implementare
|
||||
|
||||
| # | Abordare | Efort (CC) | Risc | Pro / Contra |
|
||||
|---|---|---|---|---|
|
||||
| A | Fix exact ca raportul (scade L3/L4 in modal, ascunde pastila, fix bug, polish) | ~30 min | mic | + fidel mockup, simplu / − operatorul pierde cod RAR/#id la scanare |
|
||||
| B | Ca A, dar info de rand in spatele unui toggle compact/detaliat | ~60 min | mediu | + nu pierde info / − complexitate noua, contrazice "explicit over clever" (P5) |
|
||||
| C | Ca A + re-tokenizare px→token in template-urile atinse | ~50 min | mic | + rezolva cauza-radacina P2 / − atinge mai multe linii |
|
||||
|
||||
Recomandare CEO: **A pentru structura** (P5 explicit, P1 completeness fata de mockup),
|
||||
cu **C ca extindere in blast radius** (P2 boil-the-lake pe tipografie). B intra la poarta
|
||||
finala ca decizie de gust (toggle vs. mutare-in-modal).
|
||||
|
||||
### 0D. Mod: **SELECTIVE EXPANSION**
|
||||
Nucleu = sectiunile 1 + 4a (impact maxim, una e bug). Extindere selectiva in blast
|
||||
radius = re-tokenizarea (0B-bis) + AC-urile PRD (2, 3). Restul (polish MICA) = inclus,
|
||||
cost trivial.
|
||||
|
||||
### 0E. Interogare temporala
|
||||
- **Ora 1**: bug 4a (coliziune `_preview_import.html`) + compresie rand `_submissions.html`
|
||||
+ ascundere pastila finalizat. Astea ating durerea userului + singurul bug real.
|
||||
- **Ora 6+**: sectiunile 2, 3 (conformare AC), chenarul gol chips (6), polish copy/stil,
|
||||
diacritice (decizie separata).
|
||||
|
||||
### 0F. Confirmare mod
|
||||
SELECTIVE EXPANSION confirmat: planul livreaza nucleul de impact + extinderile in blast
|
||||
radius care isi platesc costul, defera testele de regresie vizuala.
|
||||
|
||||
### POARTA DE PREMISE — REZOLVATA (directiva user, 2026-06-29)
|
||||
|
||||
Userul a dat o directiva mai precisa decat oricare optiune A/B/C. **Spec guvernanta
|
||||
pentru randul de lista:**
|
||||
|
||||
> **2 linii MAXIM** (inaltime minimalista, ca in mockup), dar randul CONTINE:
|
||||
> **nr. inmatriculare · operatia RAR (cod) · operatia din service (denumire) · data**,
|
||||
> plus **pill de stare (inclusiv "Finalizat")**.
|
||||
|
||||
Consecinte (override-uri fata de recomandarile raportului):
|
||||
- **OVERRIDE rec. 1.1** (partial): cod RAR si operatia din service RAMAN pe rand, NU se
|
||||
muta in modal. Doar VIN (ca identificator primar), #id_prezentare si secundele din
|
||||
timestamp se scot. Identificatorul primar devine **nr. inmatriculare**, nu VIN.
|
||||
- **OVERRIDE rec. 1.2**: pastila RAMANE pe finalizat (userul cere explicit "+ pill
|
||||
finalizat"). NU se ascunde pe starea normala. (Raportul recomanda ascunderea — anulat.)
|
||||
- **CONFIRMA rec. 1.3**: marca de timp scurta (data, fara secunde).
|
||||
- **CONFIRMA rec. 1.4**: implicit 2 linii (fara toggle detaliat — userul nu vrea toggle).
|
||||
|
||||
Aceasta devine cerinta de design pentru Faza 2 (aranjarea celor 5 campuri in 2 linii).
|
||||
Campuri necesare pe rand: `vehicul_nr`, `cod_rar`, `operatie` (denumire service), `data`,
|
||||
`pill`. Campuri eliminate: `vin_scurt` (sau retrogradat), `#id_prezentare`, secunde.
|
||||
|
||||
> Nota proces: aceasta a fost singura poarta de judecata umana din Faza 1. Suprafata
|
||||
> strategica (minimalism vs. densitate) a fost decisa de user; nu mai exista premisa
|
||||
> deschisa de provocat. Vocile duale CEO sunt redundante pe aceasta suprafata si se
|
||||
> consolideaza in Faza 3 (vezi nota de proportionalitate).
|
||||
|
||||
### Voci (proportionalitate)
|
||||
- Codex: **INDISPONIBIL** (limita de utilizare atinsa, reset 18 iul) → tag `[subagent-only]`.
|
||||
- Claude subagent Design + Claude subagent Eng: rulate la adancime completa, pe cod real
|
||||
(template-uri + rute + teste), nu pe proza. Acestea sunt vocile substantiale.
|
||||
|
||||
## Faza 2 — Design (UI/UX)
|
||||
|
||||
### Aranjarea randului de 2 linii (livrabilul central)
|
||||
|
||||
Placuta-primul e corect: un operator identifica masina dupa nr. inmatriculare de pe
|
||||
comanda, nu dupa VIN de 17 caractere. Layout propus (peste `.trimitere-slim` existent):
|
||||
|
||||
```
|
||||
L1: B-123-ABC (placuta, --fs-md, weight 600, ink) ............ [ PILL dreapta ]
|
||||
L2: OE-8 (cod RAR, mono/accent) · Schimb placute frana (operatie, ink, ellipsis) · 27.06.2026 (muted)
|
||||
```
|
||||
|
||||
- L1 = `vehicul_nr` (stanga, `flex:1 1 auto; min-width:0`) + pill (dreapta, `flex:0 0 auto`).
|
||||
- L2 = flex 3 celule: cod RAR (auto, primul — e identificatorul scanabil) · operatie
|
||||
(`flex:1 1 auto; min-width:0; white-space:nowrap; text-overflow:ellipsis` — ellipsis-ul
|
||||
pe operatie garanteaza ca randul NU trece pe a 3-a linie nici la 390px) · data (muted, ultima).
|
||||
- Operatia ramane **ink, nu muted** (e al doilea cel mai citit camp dupa placuta).
|
||||
- Ierarhie vizuala: placuta → pill → cod+operatie → data.
|
||||
|
||||
### CONSTATARI DESIGN dincolo de raport (corectii)
|
||||
|
||||
| # | Constatare | Sev | Fix |
|
||||
|---|---|---|---|
|
||||
| D-1 | Linia `eticheta_problema` (L:129-134) e a **5-a linie** → strica "2 linii MAX" pe randurile de eroare | inalta | DECIZIE DE GUST (vezi poarta) — drop vs micro-linie doar pe eroare |
|
||||
| D-2 | Pastilele **NU sunt conforme** (raportul sec.9 gresit): chip outline gri, fara dot/fill, doar culoare text. Cu pill permanent pe orice rand → zgomot gri permanent | medie-inalta | restileaza pill ca mockup: fill tint + dot 7px + text colorat (DECIZIE DE GUST) |
|
||||
| D-3 | Bug 4a cauza-radacina: `table-layout:fixed` + `.col-stare width:104px` (base.html:401) + pill `nowrap` → overflow peste col-vehicul | inalta | widen `.col-stare`→~140px; reducerea 8→4 col NU rezolva bug-ul (curge in coloanele fluide, nu in col-stare fixa) |
|
||||
| D-4 | Lipsa stare de eroare la incarcarea listei (HTMX `/_fragments/submissions` 500 → spinner blocat) | medie | adauga partial de eroare / `hx-on::response-error` (DEFER TODOS — pre-existent) |
|
||||
| D-5 | Filtre 4c "toate albastru": raportul e **STALE** — codul are deja `background:transparent` + doar activ plin (`_preview_import.html:56-58,277`). Ramane doar diferenta stilistica (fara dot) | mica | NO-ACTION pe bug; eventual dot pe mockup (gust, optional) |
|
||||
|
||||
### Litmus design (consens)
|
||||
```
|
||||
DESIGN — voci: Claude-sub Codex Consens
|
||||
1. Layout 2 linii fezabil/curat? DA N/A Confirmat (single voice)
|
||||
2. Placuta-primul corect? DA N/A Confirmat
|
||||
3. Bug 4a cauza reala identificata? DA N/A Confirmat
|
||||
4. Pill conform mockup? NU N/A Flag (D-2)
|
||||
5. Stari complete (loading/error/mobil)? partial N/A Gap (D-4 error state)
|
||||
6. Polish: defect vs gust separat? DA N/A Confirmat (4c stale, 6 real)
|
||||
```
|
||||
|
||||
## Faza 3 — Eng (arhitectura, regresie)
|
||||
|
||||
### Arhitectura (grafic dependente)
|
||||
```
|
||||
_acasa.html ─include─ _coada.html ─include─ _submissions.html (LISTA: .lista-trimiteri-slim)
|
||||
└─ titlu "Trimiterile tale" (h2, L:10) + export CSV ← scoate (PRD)
|
||||
_preview_import.html (.tabel-trimiteri) ─include─ _preview_rand.html (pill inline-flex) ← bug 4a
|
||||
_chips_prestatii.html (.chips operatii-mode, L:122) ← chenar gol
|
||||
_status.html:140 rand plan N/60 in corp ← muta in burger/cont (PRD)
|
||||
|
||||
DATE: r.prez = prezentare_din_payload (payload_view.py:86) → vehicul_nr, cod_rar,
|
||||
operatie, data_prestatie TOATE prezente. Schimbare = TEMPLATE-ONLY (fara rute).
|
||||
```
|
||||
|
||||
### Decizie semantica: marca de timp
|
||||
`r.updated_at` (L:111) = `format_data_rar` care adauga MEREU `%H:%M:%S` (labels.py:158) →
|
||||
sursa secundelor zgomotoase. **Auto-decis: foloseste `r.prez.data_prestatie`** (data
|
||||
prestatiei declarate, deja date-only `2026-06-18`) — semantic e "data" pe care o cere
|
||||
userul, langa celelalte campuri de prezentare. (Alternativa: helper `format_data_scurta`
|
||||
%d.%m.%Y daca trebuie pastrat updated_at — respins ca redundant.)
|
||||
|
||||
### Eng consensus table
|
||||
```
|
||||
ENG — voci: Claude-sub Codex Consens
|
||||
1. Arhitectura sunet (template-only)? DA N/A Confirmat
|
||||
2. Acoperire teste suficienta? NU (3 rup) N/A Gap mapat (vezi test plan)
|
||||
3. Riscuri performanta? nule N/A Confirmat (subtractiv)
|
||||
4. Securitate? N/A N/A Fara suprafata noua
|
||||
5. Cai de eroare tratate? partial N/A Gap: vehicul_nr=='—' + D-4
|
||||
6. Risc deploy gestionabil? DA N/A Confirmat (4 teste de update)
|
||||
```
|
||||
|
||||
### Regresie (artefact pe disc)
|
||||
Test plan scris: `~/.gstack/projects/romfast-rar-autopass/feat-5.18-corpus-knn-test-plan-20260629-071500.md`
|
||||
- **3 teste se strica HARD**: `test_vin_pe_rand_separat_sub_nr`, `test_rand_slim_vin_operatie_pill`,
|
||||
`test_submissions_coloane_umane` (toate hard-codeaza VIN-primar / #id-pe-rand).
|
||||
- **2 la risc**: depind de numele claselor → **pastreaza `slim-vin`/`slim-meta`** (reumple, nu redenumi).
|
||||
- Invariant cod_rar ("OE-2 vizibil, fara prefix, nemapat") **pastrat** de spec.
|
||||
|
||||
### Registru moduri de esec
|
||||
| Mod | Trigger | Tratare in plan | Gap? |
|
||||
|---|---|---|---|
|
||||
| Placuta lipsa | payload fara `vehicul_nr` → `'—'` | azi mascat de VIN-primar | **GAP — auto-include fallback** (nu randa em-dash singur) |
|
||||
| cod_rar lipsa | nemapat | guard `!= '—'` → "nemapat" | OK (pastrat) |
|
||||
| operatie lunga la 390px | denumire lunga | ellipsis + min-width:0 (vezi L2) | OK daca se aplica layout-ul |
|
||||
| Lista 500 / network drop | HTMX swap esueaza | — | GAP D-4 (defer TODOS) |
|
||||
| Pill finalizat a11y | text-in-pill | stare prin TEXT + title | OK (invariant respectat) |
|
||||
|
||||
### Retokenizare px (auto-decis: BOUNDED)
|
||||
Eng: retokenizarea completa px→token e scope creep (`13px→--fs-sm`=13.5px schimba layout,
|
||||
risc regresie vizuala fara baza AC). **Auto-decis: doar instanta sub-12px** (`eticheta-problema`
|
||||
10px→`--fs-xs`) — singura cu acoperire AC. (Suprascrie sugestia CEO 0B-bis de auto-aprobare larga.)
|
||||
|
||||
## Decision Audit Trail
|
||||
|
||||
| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | CEO | Rand=2 linii cu placuta+codRAR+op+data+pill | Premisa (user) | — | directiva user la poarta | mutare cod RAR in modal |
|
||||
| 2 | CEO | Identificator primar=placuta, nu VIN | Mechanical | P1 | operator scaneaza placuta | VIN primar |
|
||||
| 3 | Eng | "data" = `data_prestatie`, nu `updated_at` | Mechanical | P5 | semantic corect, fara secunde, fara helper nou | slice updated_at |
|
||||
| 4 | Eng | Pastreaza clase `slim-vin`/`slim-meta` | Mechanical | P3 | minimizeaza churn de teste | redenumire clase |
|
||||
| 5 | Eng | Fallback `vehicul_nr=='—'` | Mechanical | P1 | evita em-dash singur ca id primar | lasa em-dash |
|
||||
| 6 | Design | Bug 4a: widen `.col-stare`~140px | Mechanical | P5 | cauza reala (fixed 104px+nowrap) | doar nowrap/min-width |
|
||||
| 7 | Design | 8→4 coloane preview (densitate) | Mechanical | P1 | match mockup; NU rezolva 4a singur | pastreaza 8 col |
|
||||
| 8 | Eng | Guard `{% if _extra %}` pe `.chips` | Mechanical | P5 | elimina chenar gol | container mereu |
|
||||
| 9 | Eng | Retokenizare px BOUNDED (doar 10px) | Taste→auto | P5 | evita shift vizual nebazat AC | retokenizare larga |
|
||||
| 10 | Design | Filtre 4c: NO-ACTION (raport stale) | Mechanical | P4 | codul deja corect | re-implementare |
|
||||
| 11 | CEO | Sec.2 titlu + sec.3 plan N/60: scoate | Mechanical | P1 | AC PRD 5.16/5.17 obligatorii | pastreaza |
|
||||
| 12 | Design | Stare eroare lista (D-4): DEFER TODOS | Mechanical | P3 | pre-existent, in afara cererii | include acum |
|
||||
| T1 | Design/Eng | eticheta_problema: **PASTREAZA micro-linie doar pe rand de eroare** (user) | Gust→rezolvat | — | normal/finalizat=2 linii strict; eroare=2+motiv (D6 loud-on-exception) | drop complet |
|
||||
| T2 | Design | **DA — restileaza pill fill+dot ca mockup** (user) | Gust→rezolvat | — | pill permanent isi merita greutatea vizuala | lasa contur gri |
|
||||
| T3 | trans | **NU aplica diacritice** (user) | Gust→rezolvat | — | non-blocant; ramane divergenta de finisaj acceptata | include/pas separat |
|
||||
|
||||
## PLAN APROBAT (user, 2026-06-29) — implementarea NU se executa in aceasta sesiune
|
||||
|
||||
> Status: **APROBAT ca plan**. User a ales "doar planul, nu implementa inca". Task-urile
|
||||
> de mai jos sunt gata de executat intr-o sesiune viitoare (sau /ship cand exista cod).
|
||||
|
||||
### Spec final randul de lista (de implementat in `_submissions.html`)
|
||||
- **L1**: `vehicul_nr` (placuta, primar, `--fs-md`/weight 600, `.slim-vin` reumplut) + **pill** dreapta.
|
||||
- **L2** (`.slim-meta`): `cod_rar` (sau "nemapat", mono/accent, prima) · `operatie` (ink, ellipsis,
|
||||
`min-width:0`) · `data_prestatie` (muted). Scoate: VIN primar, `#id_prezentare`, secundele.
|
||||
- **Pill**: ramane pe FIECARE rand inclusiv Finalizat; restilat fill-tint + dot 7px + text colorat per stare.
|
||||
- **eticheta_problema**: ramane micro-linie conditionala DOAR pe stari de problema; `10px`→`--fs-xs`.
|
||||
- **Fallback**: `vehicul_nr == '—'` → nu randa em-dash singur (mesaj fallback).
|
||||
- Pastreaza numele claselor `slim-vin`/`slim-meta` (reumple, nu redenumi) — minimizeaza churn teste.
|
||||
|
||||
### Implementation Tasks (agregat) — LIVRAT 2026-06-29 (toate verzi, 1392 teste)
|
||||
- [x] **T-1 (INALTA) — `_submissions.html`** — refactor rand 4→2 linii cu placuta+codRAR+operatie+data_prestatie+pill; fallback placuta; clase pastrate. Teste rescrise: test_rand_slim_vin_operatie_pill, test_submissions_coloane_umane, test_placuta_pe_rand_identificator_primar (fost test_vin_pe_rand_separat_sub_nr), test_placuta_lipsa_nu_genereaza_rand_gol (fallback "fara numar").
|
||||
- [x] **T-2 (INALTA) — `base.html` (CSS pill) + `_submissions.html`** — pill slim restilat (fill tint + dot 7px + text colorat per `stare_css` via currentColor), scopat `.lista-trimiteri-slim .pill`; ramane pe finalizat.
|
||||
- [x] **T-3 (INALTA) — `base.html`** — bug 4a: `.tabel-trimiteri .col-stare` 104px→140px. nowrap pe col-vehicul neatins.
|
||||
- [x] **T-4 (MEDIE) — `_preview_import.html` + `_preview_rand.html`** — reducere la 5 coloane (Stare/Vehicul/Operatie/Data/Actiuni); scoase col-id, col-km, col-note; motivul mutat in `title` pe pill, KM in modal.
|
||||
- [x] **T-5 (MEDIE) — `_coada.html`** — titlu vizibil "Trimiterile tale" → `<h2 class="sr-only">` (a11y pastrat); badge "de rezolvat" + export CSV intr-un rand discret. `.sr-only` adaugat in base.html.
|
||||
- [x] **T-6 (MEDIE) — `_status.html`** — linia plan in corp DOAR pe avertizare (`plan_warn`/`plan_limita_atinsa`); consum normal in badge antet + burger. Teste status mutate pe pagina completa.
|
||||
- [x] **T-7 (MEDIE) — `_chips_prestatii.html`** — guard `{% if _extra_chips %}` pe containerul `.chips`, chenarul gol eliminat.
|
||||
- [x] **T-8 (MICA) — `_submissions.html` / base.html** — `font-size:10px`→`var(--fs-xs)` (eticheta-problema, prin clasa scopata `.lista-trimiteri-slim .eticheta-problema`).
|
||||
- [x] **T-9 (MICA) — `_form_editare.html` + base.html** — "Anuleaza"→"Renunta" (default); `.op-row-name` emfatic (bold, `--fs-sm`).
|
||||
- [ ] **Defer — tracked in `TODOS.md`** (la cererea userului 2026-06-29): stare eroare HTMX lista (D-4); retokenizare px completa; diacritice in textul vizibil.
|
||||
- [x] **Defer — inchis ca acceptabil** (netrackuit): teste regresie vizuala (tooling viitor); dropzone zona-mare (sec.5, raportul il marcheaza acceptabil).
|
||||
|
||||
### Verificare la implementare
|
||||
`python3 -m pytest tests/test_web_submissions.py tests/test_web_submissions_layout.py tests/test_web_responsive.py tests/test_web_preview_compact.py -q`
|
||||
Test plan complet: `~/.gstack/projects/romfast-rar-autopass/feat-5.18-corpus-knn-test-plan-20260629-071500.md`
|
||||
|
||||
## ADDENDUM 2026-06-29 — bug live mobil Mapari (CORECTIE la sectiunea 8)
|
||||
|
||||
Sectiunea 8 a raportului a marcat **Mapari ca "CONFORME"**, dar nu a testat corect
|
||||
randarea mobila. User a raportat (cu screenshot, 390px) doua probleme reale, **REZOLVATE**:
|
||||
|
||||
1. **Butoanele Salveaza/Sterge taiate pe mobil.** Cauza: `.tabel-card td button {width:100%}`
|
||||
(base.html, specificitate 0,1,2) batea `.act {width:44px}` (0,1,0) → cele doua butoane
|
||||
`.act` deveneau full-width, iar al doilea (Sterge) iesea din card (celula are `nowrap`).
|
||||
Fix: bloc `@media (max-width:767px)` nou (ultimul in `<style>`) — celula Actiuni devine
|
||||
flex-row, butoanele `.act` `width:auto; flex:1 1 0` cu text vizibil. Acum ambele butoane
|
||||
sunt complet vizibile, una langa alta, cu eticheta.
|
||||
2. **Carduri prea inalte + label-uri inutile.** Cauza: `.tabel-card` randa etichetele
|
||||
`data-eticheta` ca pseudo-titluri ("Operatie"/"Cod RAR"/"Actiuni") + linia redundanta
|
||||
"acum: COD — nume" (duplica select-ul). Fix: pe mobil se ascund pseudo-etichetele
|
||||
(`.tabel-card td::before{display:none}`) si linia "acum:" (`.map-acum{display:none}`),
|
||||
padding strans. Cardul trece de la ~7 elemente la ~3 (nume + select + butoane).
|
||||
|
||||
Fisiere: `app/web/templates/base.html` (CSS), `app/web/templates/_mapari.html` (clasa `map-acum`).
|
||||
Verificare: 80 teste web verzi (test_web_responsive + mapari + submissions + tabs + modal);
|
||||
confirmare vizuala la 390px (render TestClient → screenshot Playwright). Atributele
|
||||
`data-eticheta` raman in DOM (a11y + teste). NEPLASAT inca: commit (la cererea userului).
|
||||
|
||||
> Lectie pentru viitor: "conform" in raportul vizual trebuie sa includa explicit verificarea
|
||||
> la 390px a PAGINILOR ACTIONABILE (butoane, formulare), nu doar a layout-ului general.
|
||||
|
||||
## GSTACK REVIEW REPORT
|
||||
- Faze: CEO (premisa rezolvata de user) → Design (subagent, full) → Eng (subagent, full). DX: skip (fara suprafata developer).
|
||||
- Voci: `[subagent-only]` — Codex indisponibil (limita utilizare, reset 18 iul). 2 subagenti Claude pe cod real.
|
||||
- Decizii: 15 (12 auto, 3 gust rezolvate de user). Audit trail complet mai sus.
|
||||
- Status: **APROBAT ca plan**; implementare amanata la cererea userului.
|
||||
- Artefacte: test plan pe disc; restore point pe disc; task list agregat mai sus.
|
||||
|
||||
## NOT in scope (defer TODOS.md)
|
||||
- Stare de eroare HTMX la incarcarea listei (D-4) — robustete pre-existenta, separata de cerere.
|
||||
- Teste de regresie vizuala (Playwright snapshot vs mockup) — ideal 12 luni.
|
||||
- Retokenizare px completa in template-uri — risc shift vizual fara baza AC.
|
||||
- Dropzone import zona-mare (sec.5) — raport il marcheaza acceptabil.
|
||||
|
||||
## Ce exista deja (leverage)
|
||||
- Toate cele 5 campuri pe `r.prez` (payload_view.py:86) → schimbare template-only.
|
||||
- Modal detaliu are deja VIN integral + #id (test_detaliu_trimitere) → P4 confirmata, zero pierdere date.
|
||||
- Sistem token `--fs-*` exista (base.html:52); lista si preview sunt suprafete CSS separate
|
||||
(`.lista-trimiteri-slim` vs `.tabel-trimiteri`) → widen col-stare e SIGUR pt lista.
|
||||
|
||||
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
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
export OMP_NUM_THREADS=1
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
exec ./start.sh test both --send
|
||||
fi
|
||||
|
||||
@@ -14,8 +14,40 @@ 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")
|
||||
# Embeddings e ON implicit in app (config.py), dar in teste il lasam OFF ca sa nu
|
||||
# lazy-load-eze modelul de ~230MB la fiecare test care atinge editorul de mapari
|
||||
# (suita rapida, fara download in CI). Testele de embeddings il pornesc punctual.
|
||||
os.environ.setdefault("AUTOPASS_EMBEDDINGS_ENABLED", "false")
|
||||
# Seed-ul de operatii etichetate (SILVER, PRD 5.18) e ON in app, dar OFF in teste:
|
||||
# multe teste presupun mapping_suggestions GOL la init_db. Testele US-004/005/006 il
|
||||
# pornesc punctual (object.__setattr__ pe settings sau apel direct la seeder).
|
||||
os.environ.setdefault("AUTOPASS_SEED_OPERATII_ENABLED", "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:
|
||||
|
||||
@@ -112,7 +112,7 @@ def test_list_accounts_ordonat_fara_creds(conn):
|
||||
assert ids == sorted(ids)
|
||||
for r in rows:
|
||||
assert "rar_creds_enc" not in r
|
||||
assert set(r.keys()) == {"id", "name", "cui", "email", "active", "status", "created_at"}
|
||||
assert set(r.keys()) == {"id", "name", "cui", "email", "active", "status", "created_at", "tier", "trial_until"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -170,3 +170,221 @@ def test_account_is_complete_false_pe_legacy_incomplet(conn):
|
||||
# contul sistem id=1 e EXCEPTAT (returneaza True indiferent)
|
||||
row_sys = conn.execute("SELECT * FROM accounts WHERE id=1").fetchone()
|
||||
assert account_is_complete(row_sys) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5.17 US-001/US-008: schema tier + trial_until + set_tier + CLI set-tier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migrare_tier_trial_defensiva(conn):
|
||||
"""_migrate adauga tier + trial_until pe conturi existente, e idempotenta."""
|
||||
from app.db import _migrate
|
||||
cols_before = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||
assert "tier" in cols_before
|
||||
assert "trial_until" in cols_before
|
||||
|
||||
# a doua rulare: idempotenta (nu arunca)
|
||||
_migrate(conn)
|
||||
cols_after = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||
assert "tier" in cols_after
|
||||
assert "trial_until" in cols_after
|
||||
|
||||
|
||||
def test_cont_nou_tier_free_si_trial_30z(conn):
|
||||
"""create_account seteaza tier='free' si trial_until = acum + ~30 zile."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.accounts import create_account
|
||||
|
||||
before = datetime.now(timezone.utc)
|
||||
acct_id = create_account(conn, "Service Trial", cui="RO_T1")
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
|
||||
assert row["tier"] == "free"
|
||||
assert row["trial_until"] is not None
|
||||
|
||||
tu = datetime.fromisoformat(row["trial_until"].replace(" ", "T"))
|
||||
if tu.tzinfo is None:
|
||||
tu = tu.replace(tzinfo=timezone.utc)
|
||||
|
||||
# trial_until trebuie sa fie intre now+29z si now+31z
|
||||
assert tu >= before + timedelta(days=29)
|
||||
assert tu <= after + timedelta(days=31)
|
||||
|
||||
|
||||
def test_cont_nou_effective_tier_pro_in_trial(conn):
|
||||
"""Cont nou are effective_tier='pro' (trial activ)."""
|
||||
from datetime import datetime, timezone
|
||||
from app.accounts import create_account
|
||||
from app.plans import effective_tier
|
||||
|
||||
acct_id = create_account(conn, "Service Pro Trial", cui="RO_T2")
|
||||
row = conn.execute("SELECT * FROM accounts WHERE id=?", (acct_id,)).fetchone()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
assert effective_tier(row, now) == "pro"
|
||||
|
||||
|
||||
def test_set_tier_valid(conn):
|
||||
"""set_tier seteaza tier-ul corect."""
|
||||
from app.accounts import create_account, set_tier
|
||||
|
||||
acct_id = create_account(conn, "Service Tier", cui="RO_T3")
|
||||
set_tier(conn, acct_id, "pro")
|
||||
|
||||
row = conn.execute("SELECT tier FROM accounts WHERE id=?", (acct_id,)).fetchone()
|
||||
assert row["tier"] == "pro"
|
||||
|
||||
|
||||
def test_set_tier_cu_trial(conn):
|
||||
"""set_tier cu trial_until seteaza ambele campuri."""
|
||||
from app.accounts import create_account, set_tier
|
||||
|
||||
acct_id = create_account(conn, "Service Tier Trial", cui="RO_T4")
|
||||
set_tier(conn, acct_id, "standard", trial_until="2026-12-31 00:00:00")
|
||||
|
||||
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
|
||||
assert row["tier"] == "standard"
|
||||
assert row["trial_until"] == "2026-12-31 00:00:00"
|
||||
|
||||
|
||||
def test_set_tier_no_trial_sterge_trial_until(conn):
|
||||
"""set_tier cu trial_until=None sterge trial-ul existent."""
|
||||
from app.accounts import create_account, set_tier
|
||||
|
||||
acct_id = create_account(conn, "Service No Trial", cui="RO_T5")
|
||||
# mai intai setam un trial
|
||||
set_tier(conn, acct_id, "pro", trial_until="2026-12-31 00:00:00")
|
||||
# acum stergem trial-ul
|
||||
set_tier(conn, acct_id, "pro", trial_until=None)
|
||||
|
||||
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
|
||||
assert row["tier"] == "pro"
|
||||
assert row["trial_until"] is None
|
||||
|
||||
|
||||
def test_set_tier_invalid_respins(conn):
|
||||
"""set_tier cu tier invalid ridica ValueError cu mesaj clar."""
|
||||
from app.accounts import create_account, set_tier
|
||||
|
||||
acct_id = create_account(conn, "Service Tier Invalid", cui="RO_T6")
|
||||
with pytest.raises(ValueError, match="tier invalid"):
|
||||
set_tier(conn, acct_id, "gold")
|
||||
|
||||
|
||||
def test_set_tier_protejeaza_id1(conn):
|
||||
"""set_tier pe contul de sistem id=1 ridica ValueError (protejat)."""
|
||||
from app.accounts import set_tier
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
set_tier(conn, 1, "pro")
|
||||
|
||||
|
||||
def test_set_tier_cont_inexistent_ridica(conn):
|
||||
"""set_tier pe cont inexistent ridica ValueError."""
|
||||
from app.accounts import set_tier
|
||||
|
||||
with pytest.raises(ValueError, match="inexistent"):
|
||||
set_tier(conn, 9999, "pro")
|
||||
|
||||
|
||||
def test_list_accounts_include_tier_trial(conn):
|
||||
"""list_accounts include coloanele tier si trial_until."""
|
||||
from app.accounts import create_account, list_accounts
|
||||
|
||||
create_account(conn, "Service List", cui="RO_L1")
|
||||
rows = list_accounts(conn)
|
||||
for r in rows:
|
||||
assert "tier" in r
|
||||
assert "trial_until" in r
|
||||
|
||||
|
||||
def test_default_account_tier_free_fara_trial(conn):
|
||||
"""Contul implicit id=1 (creat de schema) are tier='free' si trial_until=NULL."""
|
||||
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=1").fetchone()
|
||||
assert row["tier"] == "free"
|
||||
assert row["trial_until"] is None
|
||||
|
||||
|
||||
def test_cli_set_tier(monkeypatch):
|
||||
"""CLI set-tier seteaza tier-ul unui cont (--no-trial)."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_cli_tier.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
|
||||
from tools.account import main
|
||||
from app.db import init_db, get_connection
|
||||
from app.accounts import create_account
|
||||
|
||||
init_db()
|
||||
conn_tmp = get_connection()
|
||||
acct_id = create_account(conn_tmp, "CLI Test", cui="RO_CLI1")
|
||||
conn_tmp.close()
|
||||
|
||||
rc = main(["set-tier", "--account", str(acct_id), "--tier", "pro", "--no-trial"])
|
||||
assert rc == 0
|
||||
|
||||
conn_tmp2 = get_connection()
|
||||
row = conn_tmp2.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
|
||||
conn_tmp2.close()
|
||||
assert row["tier"] == "pro"
|
||||
assert row["trial_until"] is None
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_cli_set_tier_cu_trial_days(monkeypatch):
|
||||
"""CLI set-tier cu --trial-days 14 seteaza trial_until = acum + 14z."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_cli_tier2.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
|
||||
from tools.account import main
|
||||
from app.db import init_db, get_connection
|
||||
from app.accounts import create_account
|
||||
|
||||
init_db()
|
||||
conn_tmp = get_connection()
|
||||
acct_id = create_account(conn_tmp, "CLI Trial", cui="RO_CLI2")
|
||||
conn_tmp.close()
|
||||
|
||||
before = datetime.now(timezone.utc)
|
||||
rc = main(["set-tier", "--account", str(acct_id), "--tier", "pro", "--trial-days", "14"])
|
||||
assert rc == 0
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
conn_tmp2 = get_connection()
|
||||
row = conn_tmp2.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
|
||||
conn_tmp2.close()
|
||||
assert row["tier"] == "pro"
|
||||
assert row["trial_until"] is not None
|
||||
tu = datetime.fromisoformat(row["trial_until"].replace(" ", "T")).replace(tzinfo=timezone.utc)
|
||||
assert tu >= before + timedelta(days=13)
|
||||
assert tu <= after + timedelta(days=15)
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_cli_set_tier_invalid(monkeypatch):
|
||||
"""CLI set-tier cu tier invalid: exit code != 0."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_cli_tier3.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
|
||||
from tools.account import main
|
||||
from app.db import init_db, get_connection
|
||||
from app.accounts import create_account
|
||||
|
||||
init_db()
|
||||
conn_tmp = get_connection()
|
||||
acct_id = create_account(conn_tmp, "CLI Invalid", cui="RO_CLI3")
|
||||
conn_tmp.close()
|
||||
|
||||
rc = main(["set-tier", "--account", str(acct_id), "--tier", "diamond"])
|
||||
assert rc != 0
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -40,7 +40,8 @@ def _signup(client, name, email, password="parola_test_001"):
|
||||
tok = _csrf(client, "/signup")
|
||||
resp = client.post("/signup", data={"name": name, "cui": make_test_cui(email),
|
||||
"email": email, "parola": password,
|
||||
"csrf_token": tok}, follow_redirects=True)
|
||||
"consent": "1", "csrf_token": tok},
|
||||
follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
|
||||
@@ -62,6 +62,7 @@ def _signup(client: TestClient, name: str, email: str, password: str = "parola_t
|
||||
"cui": make_test_cui(email),
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"consent": "1",
|
||||
"csrf_token": token,
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200, f"signup esuat: {resp.text[:300]}"
|
||||
@@ -261,3 +262,158 @@ def test_activare_cont_incomplet_refuzata(client):
|
||||
assert not _get_account_active(incomplete_id), (
|
||||
"Contul incomplet (fara email/CUI) a fost activat — gate pe account_is_complete nu functioneaza"
|
||||
)
|
||||
|
||||
|
||||
def _get_tier_trial(account_id: int) -> tuple[str, str | None]:
|
||||
"""Citeste (tier, trial_until) din DB."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT tier, trial_until FROM accounts WHERE id=?", (account_id,)
|
||||
).fetchone()
|
||||
return (row["tier"], row["trial_until"]) if row else ("", None)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _get_tier(account_id: int) -> str:
|
||||
"""Citeste accounts.tier din DB."""
|
||||
return _get_tier_trial(account_id)[0]
|
||||
|
||||
|
||||
def test_set_tier_din_admin_incheie_trial(client):
|
||||
"""POST /admin/set-tier -> tier actualizat, trial_until=NULL (trial incheiat), 303.
|
||||
|
||||
Contul nou are trial Pro 30z; alocarea manuala trebuie sa-l incheie ca alegerea
|
||||
sa aiba efect imediat (decizie user 2026-06-29)."""
|
||||
target_id = _signup(client, "Firma Upgrade SRL", "upgrade@test.ro")
|
||||
tier0, trial0 = _get_tier_trial(target_id)
|
||||
assert tier0 == "free", "cont nou trebuie sa porneasca pe free"
|
||||
assert trial0, "cont nou trebuie sa aiba trial_until setat (trial Pro 30z)"
|
||||
|
||||
admin_id = _signup(client, "Admin Tier SA", "admintier@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "admintier@test.ro")
|
||||
|
||||
csrf = _get_csrf(client, "/admin")
|
||||
resp = client.post("/admin/set-tier", data={
|
||||
"account_id": str(target_id),
|
||||
"tier": "pro",
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 303, f"asteptat 303 PRG, primit {resp.status_code}"
|
||||
tier1, trial1 = _get_tier_trial(target_id)
|
||||
assert tier1 == "pro", "tier-ul nu a fost mutat pe pro"
|
||||
assert trial1 is None, "trial_until trebuie sters la alocarea manuala (efect imediat)"
|
||||
|
||||
|
||||
def test_set_tier_free_opreste_pro_imediat(client):
|
||||
"""Setarea pe 'free' pe un cont in trial -> efectiv 'free' acum (trial incheiat).
|
||||
|
||||
Fara stergerea trial-ului, effective_tier ar fi ramas 'pro' inca ~30 zile."""
|
||||
from datetime import datetime, timezone
|
||||
from app.plans import effective_tier
|
||||
|
||||
target_id = _signup(client, "Firma Abuz Trial SRL", "abuztrial@test.ro")
|
||||
admin_id = _signup(client, "Admin Stop SA", "adminstop@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "adminstop@test.ro")
|
||||
|
||||
csrf = _get_csrf(client, "/admin")
|
||||
resp = client.post("/admin/set-tier", data={
|
||||
"account_id": str(target_id),
|
||||
"tier": "free",
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 303
|
||||
|
||||
tier1, trial1 = _get_tier_trial(target_id)
|
||||
assert tier1 == "free" and trial1 is None
|
||||
eff = effective_tier({"tier": tier1, "trial_until": trial1}, datetime.now(timezone.utc))
|
||||
assert eff == "free", "dupa setarea pe free, planul efectiv trebuie sa fie free imediat"
|
||||
|
||||
|
||||
def test_set_tier_invalid_respins(client):
|
||||
"""Tier invalid -> nu schimba nimic (re-randare cu eroare sau ignorat)."""
|
||||
target_id = _signup(client, "Firma Tier Invalid SRL", "tierinvalid@test.ro")
|
||||
admin_id = _signup(client, "Admin TI SA", "adminti@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "adminti@test.ro")
|
||||
|
||||
csrf = _get_csrf(client, "/admin")
|
||||
resp = client.post("/admin/set-tier", data={
|
||||
"account_id": str(target_id),
|
||||
"tier": "platinum", # invalid
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code in (200, 422), f"tier invalid ar trebui respins, primit {resp.status_code}"
|
||||
assert _get_tier(target_id) == "free", "tier invalid nu trebuie aplicat"
|
||||
|
||||
|
||||
def test_set_tier_fara_csrf_respins(client):
|
||||
"""POST /admin/set-tier fara token CSRF valid -> respins, tier neschimbat."""
|
||||
target_id = _signup(client, "Firma CSRF Tier SRL", "csrftier@test.ro")
|
||||
admin_id = _signup(client, "Admin CSRF SA", "admincsrf@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "admincsrf@test.ro")
|
||||
|
||||
resp = client.post("/admin/set-tier", data={
|
||||
"account_id": str(target_id),
|
||||
"tier": "pro",
|
||||
"csrf_token": "token-fals",
|
||||
})
|
||||
assert resp.status_code in (400, 403), f"CSRF invalid trebuie respins, primit {resp.status_code}"
|
||||
assert _get_tier(target_id) == "free", "tier schimbat desi CSRF era invalid"
|
||||
|
||||
|
||||
def test_set_trial_din_admin(client):
|
||||
"""POST /admin/set-trial -> trial_until setat, tier de baza neschimbat, efectiv pro, 303."""
|
||||
from datetime import datetime, timezone
|
||||
from app.plans import effective_tier
|
||||
|
||||
target_id = _signup(client, "Firma Trial SRL", "trialnou@test.ro")
|
||||
# incheie intai orice trial (set-tier free) ca sa pornim de la baza curata
|
||||
admin_id = _signup(client, "Admin Trial SA", "admintrialacord@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "admintrialacord@test.ro")
|
||||
csrf = _get_csrf(client, "/admin")
|
||||
client.post("/admin/set-tier", data={
|
||||
"account_id": str(target_id), "tier": "free", "csrf_token": csrf,
|
||||
})
|
||||
assert _get_tier_trial(target_id) == ("free", None)
|
||||
|
||||
# acorda trial Pro 15 zile
|
||||
csrf = _get_csrf(client, "/admin")
|
||||
resp = client.post("/admin/set-trial", data={
|
||||
"account_id": str(target_id),
|
||||
"trial_days": "15",
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 303, f"asteptat 303 PRG, primit {resp.status_code}"
|
||||
tier1, trial1 = _get_tier_trial(target_id)
|
||||
assert tier1 == "free", "tier-ul de baza NU trebuie schimbat de acordarea de trial"
|
||||
assert trial1, "trial_until trebuie setat"
|
||||
eff = effective_tier({"tier": tier1, "trial_until": trial1}, datetime.now(timezone.utc))
|
||||
assert eff == "pro", "trial activ trebuie sa ridice planul efectiv la pro"
|
||||
|
||||
|
||||
def test_set_trial_zile_invalide_respins(client):
|
||||
"""trial_days <= 0 -> 422, trial neschimbat."""
|
||||
target_id = _signup(client, "Firma Trial Invalid SRL", "trialinvalid@test.ro")
|
||||
admin_id = _signup(client, "Admin TInv SA", "admintinv@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "admintinv@test.ro")
|
||||
# porneste de la trial sters
|
||||
csrf = _get_csrf(client, "/admin")
|
||||
client.post("/admin/set-tier", data={
|
||||
"account_id": str(target_id), "tier": "free", "csrf_token": csrf,
|
||||
})
|
||||
csrf = _get_csrf(client, "/admin")
|
||||
resp = client.post("/admin/set-trial", data={
|
||||
"account_id": str(target_id),
|
||||
"trial_days": "0",
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
assert _get_tier_trial(target_id) == ("free", None), "trial nu trebuie setat la zile invalide"
|
||||
|
||||
532
tests/test_api_scope.py
Normal file
532
tests/test_api_scope.py
Normal file
@@ -0,0 +1,532 @@
|
||||
"""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)."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PRD 5.17 — enforcement planuri: gate API (T4) + volum (T3) + kill-switch (T5)
|
||||
# ============================================================================
|
||||
|
||||
_PREZ_PLAN = {
|
||||
"vin": "WVWZZZ1KZAW900001",
|
||||
"nr_inmatriculare": "B900TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "50000",
|
||||
"prestatii": [{"cod_op_service": "OP-PLAN-T4", "denumire": "Test plan gate"}],
|
||||
}
|
||||
|
||||
|
||||
def _set_tier_acct(account_id: int, tier: str, trial_until=None) -> None:
|
||||
"""Seteaza tier si trial_until pe un cont existent."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET tier=?, trial_until=? WHERE id=?",
|
||||
(tier, trial_until, account_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _insert_n_submissions(account_id: int, n: int) -> None:
|
||||
"""Insereaza N submissions queued in luna curenta pentru cont."""
|
||||
from datetime import datetime, timezone
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
for i in range(n):
|
||||
conn.execute(
|
||||
"INSERT INTO submissions "
|
||||
"(idempotency_key, account_id, status, payload_json, created_at) "
|
||||
"VALUES (?, ?, 'queued', '{}', ?)",
|
||||
(f"plan-seed-{account_id}-{i}-{os.urandom(4).hex()}", account_id, now_str),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: Gate API — free/standard NU pot accesa rutele de ingestie API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_free_fara_api_403(client):
|
||||
"""Cont free (non-default, cu cheie API) → 403 PLAN_FARA_API pe POST /v1/prezentari.
|
||||
|
||||
T4 PRD 5.17: gate API refuza conturi cu api_access=False in PLANS.
|
||||
"""
|
||||
acct_id, key = _create_account_with_key("FreePlanGate")
|
||||
_set_tier_acct(acct_id, "free", trial_until=None)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/prezentari",
|
||||
json={"prezentari": [_PREZ_PLAN]},
|
||||
headers={"X-API-Key": key},
|
||||
)
|
||||
assert resp.status_code == 403, f"Asteptat 403, primit {resp.status_code}: {resp.text}"
|
||||
detail = resp.json().get("detail", {})
|
||||
assert detail.get("cod") == "PLAN_FARA_API", f"Cod eroare gresit: {detail}"
|
||||
|
||||
|
||||
def test_standard_fara_api_403(client):
|
||||
"""Cont standard (non-default) → 403 PLAN_FARA_API pe POST /v1/prezentari.
|
||||
|
||||
Standard are api_access=False, deci gate-ul API respinge la fel ca free.
|
||||
"""
|
||||
acct_id, key = _create_account_with_key("StdPlanGate")
|
||||
_set_tier_acct(acct_id, "standard", trial_until=None)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/prezentari",
|
||||
json={"prezentari": [_PREZ_PLAN]},
|
||||
headers={"X-API-Key": key},
|
||||
)
|
||||
assert resp.status_code == 403, f"Asteptat 403, primit {resp.status_code}"
|
||||
assert resp.json().get("detail", {}).get("cod") == "PLAN_FARA_API"
|
||||
|
||||
|
||||
def test_pro_api_ok(client):
|
||||
"""Cont pro (non-default) → 200 pe POST /v1/prezentari (trece gate-ul API)."""
|
||||
acct_id, key = _create_account_with_key("ProPlanGate")
|
||||
_set_tier_acct(acct_id, "pro", trial_until=None)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/prezentari",
|
||||
json={"prezentari": [_PREZ_PLAN]},
|
||||
headers={"X-API-Key": key},
|
||||
)
|
||||
assert resp.status_code == 200, (
|
||||
f"Pro trebuie sa aiba acces API, primit: {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
def test_trial_pro_api_ok(client):
|
||||
"""Cont free cu trial Pro activ → 200 pe POST /v1/prezentari.
|
||||
|
||||
effective_tier() intoarce 'pro' cand trial_until > now, deci gate-ul permite.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
acct_id, key = _create_account_with_key("TrialPlanGate")
|
||||
trial_until = (datetime.now(timezone.utc) + timedelta(days=15)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
_set_tier_acct(acct_id, "free", trial_until=trial_until)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/prezentari",
|
||||
json={"prezentari": [_PREZ_PLAN]},
|
||||
headers={"X-API-Key": key},
|
||||
)
|
||||
assert resp.status_code == 200, (
|
||||
f"Trial Pro trebuie sa treaca gate-ul API, primit: {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
def test_dry_run_valideaza_ramane_permis(client):
|
||||
"""POST /v1/prezentari/valideaza (dry-run) e permis pe orice plan, inclusiv free.
|
||||
|
||||
Decizie design (PRD 5.17): valideaza nu face enqueue si nu consuma cota,
|
||||
deci NU e protejat de gate-ul API — integratorii pot testa fara upgrade.
|
||||
"""
|
||||
acct_id, key = _create_account_with_key("FreeValideazaTest")
|
||||
_set_tier_acct(acct_id, "free", trial_until=None)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/prezentari/valideaza",
|
||||
json={"prezentari": [_PREZ_PLAN]},
|
||||
headers={"X-API-Key": key},
|
||||
)
|
||||
assert resp.status_code == 200, (
|
||||
f"valideaza trebuie permis pe plan free (fara gate API), "
|
||||
f"primit {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3: Enforce volum — limita 60/luna pe planul Gratuit
|
||||
# Dev account (id=1) are bypass la gate-ul API, dar NU la verificarea de volum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_free_peste_60_respins_api(client):
|
||||
"""Dev account (id=1) la 60/60 pe free → a 61-a prezentare respinsa 422 PLAN_LIMITA_LUNARA.
|
||||
|
||||
Dev account (id=1) in dev mode (require_api_key=False) are bypass la gate-ul API (T4),
|
||||
dar NU la verificarea de volum (T3). La 60/60, urmatoarea cerere trebuie respinsa 422.
|
||||
"""
|
||||
_set_tier_acct(1, "free", trial_until=None)
|
||||
_insert_n_submissions(1, 60)
|
||||
|
||||
# A 61-a cerere (fara cheie = cont 1 in dev mode, bypass gate API, dar volumul e plin)
|
||||
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
|
||||
assert resp.status_code == 422, (
|
||||
f"Asteptat 422 la depasire volum, primit {resp.status_code}: {resp.text}"
|
||||
)
|
||||
detail = resp.json().get("detail", {})
|
||||
assert detail.get("cod") == "PLAN_LIMITA_LUNARA", f"Cod gresit: {detail}"
|
||||
|
||||
|
||||
def test_eroare_3_niveluri_plan_limita(client):
|
||||
"""Eroarea PLAN_LIMITA_LUNARA contine toate 3 nivelurile: cod, problema, cauza, fix.
|
||||
|
||||
Pattern standard (PRD 5.17): eroare structurata pe 3 niveluri — nu 500, nu catch-all.
|
||||
"""
|
||||
_set_tier_acct(1, "free", trial_until=None)
|
||||
_insert_n_submissions(1, 60)
|
||||
|
||||
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
|
||||
assert resp.status_code == 422
|
||||
detail = resp.json().get("detail", {})
|
||||
assert detail.get("cod") == "PLAN_LIMITA_LUNARA", f"Camp 'cod' gresit: {detail}"
|
||||
assert detail.get("problema"), f"Camp 'problema' lipsa: {detail}"
|
||||
assert detail.get("cauza"), f"Camp 'cauza' lipsa: {detail}"
|
||||
assert detail.get("fix"), f"Camp 'fix' lipsa: {detail}"
|
||||
|
||||
|
||||
def test_pro_si_trial_nelimitat(client):
|
||||
"""Pro si trial Pro nu sunt blocati de volum indiferent de numarul de submissions.
|
||||
|
||||
PLANS['pro']['monthly_limit'] is None -> nelimitat; la fel trial Pro activ.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Cont pro cu 70 submissions (peste limita free de 60)
|
||||
pro_id, pro_key = _create_account_with_key("ProVolumTest")
|
||||
_set_tier_acct(pro_id, "pro", trial_until=None)
|
||||
_insert_n_submissions(pro_id, 70)
|
||||
|
||||
# Cont trial Pro cu 70 submissions
|
||||
trial_id, trial_key = _create_account_with_key("TrialVolumTest")
|
||||
trial_until = (datetime.now(timezone.utc) + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
_set_tier_acct(trial_id, "free", trial_until=trial_until)
|
||||
_insert_n_submissions(trial_id, 70)
|
||||
|
||||
# Pro: trebuie 200 (nu 422 de volum)
|
||||
r_pro = client.post(
|
||||
"/v1/prezentari",
|
||||
json={"prezentari": [_PREZ_PLAN]},
|
||||
headers={"X-API-Key": pro_key},
|
||||
)
|
||||
assert r_pro.status_code == 200, f"Pro trebuie sa fie nelimitat, primit: {r_pro.text}"
|
||||
|
||||
# Trial Pro: trebuie 200 (nu 422 de volum)
|
||||
prez2 = dict(_PREZ_PLAN, vin="WVWZZZ1KZAW900002", nr_inmatriculare="B900TS2")
|
||||
r_trial = client.post(
|
||||
"/v1/prezentari",
|
||||
json={"prezentari": [prez2]},
|
||||
headers={"X-API-Key": trial_key},
|
||||
)
|
||||
assert r_trial.status_code == 200, f"Trial Pro trebuie sa fie nelimitat, primit: {r_trial.text}"
|
||||
|
||||
|
||||
def test_retry_idempotent_nu_consuma_cota(client):
|
||||
"""Un retry idempotent al aceleiasi prestatii nu consuma cota de doua ori.
|
||||
|
||||
Invariant idempotenta (arhitectura): monthly_usage creste O SINGURA DATA
|
||||
per submission unic. Retryurile (acelasi idempotency_key) sunt dedup-ate,
|
||||
deci usage ramine la 1 dupa doua trimiteri identice.
|
||||
|
||||
Folsim cod_prestatie "OE-1" (in nomenclatorul seed) ca sa obtinem status
|
||||
'queued' (statusuri "needs_mapping" nu se numara in monthly_usage).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from app.plans import monthly_usage
|
||||
from app.db import get_connection
|
||||
|
||||
_set_tier_acct(1, "free", trial_until=None)
|
||||
|
||||
# cod_prestatie "OE-1" e in nomenclatorul seed -> submission va fi 'queued' (contat in usage)
|
||||
prez_unic = {
|
||||
"vin": "WVWZZZ1KZAW901001",
|
||||
"nr_inmatriculare": "B901TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "50000",
|
||||
"prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}],
|
||||
}
|
||||
|
||||
# Prima trimitere — submission noua
|
||||
r1 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]})
|
||||
assert r1.status_code == 200, f"Prima trimitere trebuie sa treaca: {r1.text}"
|
||||
assert not r1.json()["results"][0].get("deduped"), "Prima trimitere nu trebuia sa fie deduped"
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
usage_1 = monthly_usage(conn, 1, datetime.now(timezone.utc))
|
||||
finally:
|
||||
conn.close()
|
||||
assert usage_1 == 1, f"Dupa prima trimitere: asteptat usage=1, primit {usage_1}"
|
||||
|
||||
# Retry (acelasi payload -> acelasi idempotency_key -> dedup, fara INSERT nou)
|
||||
r2 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]})
|
||||
assert r2.status_code == 200, f"Retry trebuie sa treaca (dedup): {r2.text}"
|
||||
assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped"
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
usage_2 = monthly_usage(conn, 1, datetime.now(timezone.utc))
|
||||
finally:
|
||||
conn.close()
|
||||
assert usage_2 == 1, (
|
||||
f"Retry nu trebuia sa creasca usage: inainte={usage_1}, dupa retry={usage_2}"
|
||||
)
|
||||
|
||||
|
||||
def test_dev_id1_neblocat(client):
|
||||
"""Dev account (id=1) in dev mode (require_api_key=False) nu e blocat de gate-ul API.
|
||||
|
||||
Bypass explicit in require_api_access: require_api_key=False + account_id==DEFAULT_ACCOUNT_ID
|
||||
-> skip gate, indiferent de tier. DB proaspata (0 submissions -> fara blocare volum).
|
||||
"""
|
||||
_set_tier_acct(1, "free", trial_until=None)
|
||||
|
||||
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
|
||||
assert resp.status_code == 200, (
|
||||
f"Dev id=1 nu trebuie blocat de gate-ul API in dev mode, "
|
||||
f"primit {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
def test_flag_enforce_plans_false_sare_enforcement(client, monkeypatch):
|
||||
"""Kill-switch AUTOPASS_ENFORCE_PLANS=false sare toate gate-urile de plan.
|
||||
|
||||
T5 PRD 5.17: flag de operare pentru debugging sau rollback rapid fara revert de cod.
|
||||
Chiar si cu free la 60/60, nu trebuie 422 cand flag-ul e oprit.
|
||||
"""
|
||||
from app.config import get_settings
|
||||
|
||||
monkeypatch.setenv("AUTOPASS_ENFORCE_PLANS", "false")
|
||||
get_settings.cache_clear()
|
||||
|
||||
# Cont dev (id=1) pe free la 60/60 (normalmente respins)
|
||||
_set_tier_acct(1, "free", trial_until=None)
|
||||
_insert_n_submissions(1, 60)
|
||||
|
||||
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
|
||||
assert resp.status_code == 200, (
|
||||
f"Cu enforce_plans=False, enforcement trebuia sarat. Primit: {resp.status_code}"
|
||||
)
|
||||
|
||||
get_settings.cache_clear() # curata cache-ul dupa test
|
||||
@@ -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
|
||||
|
||||
@@ -93,8 +93,13 @@ def test_submissions_fragment_scoped(env, monkeypatch):
|
||||
assert f'id="trimitere-row-{sub_a}"' not in r.text
|
||||
|
||||
|
||||
def test_nelogat_redirect(monkeypatch):
|
||||
"""web_auth_required=True + fara sesiune -> 303 redirect /login."""
|
||||
def test_nelogat_landing(monkeypatch):
|
||||
"""web_auth_required=True + fara sesiune -> landing comercial (200) la /.
|
||||
|
||||
"/" e suprafata publica: vizitatorul vede landing-ul cu formularele de
|
||||
inregistrare/autentificare (post la /signup, /login). Rutele protejate
|
||||
(fragmente, POST-uri) raman redirect /login.
|
||||
"""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t_auth.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
@@ -103,8 +108,13 @@ def test_nelogat_redirect(monkeypatch):
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
r = c.get("/")
|
||||
assert r.status_code == 303
|
||||
assert "/login" in r.headers.get("location", "")
|
||||
assert r.status_code == 200
|
||||
assert 'action="/signup"' in r.text
|
||||
assert 'action="/login"' in r.text
|
||||
# ruta protejata fara sesiune -> tot redirect /login
|
||||
r2 = c.get("/_fragments/submissions")
|
||||
assert r2.status_code == 303
|
||||
assert "/login" in r2.headers.get("location", "")
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
|
||||
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}"
|
||||
)
|
||||
150
tests/test_embeddings_corpus_etichetat.py
Normal file
150
tests/test_embeddings_corpus_etichetat.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""US-005 (PRD 5.18) — embeddings indexeaza corpusul etichetat (NU nomenclatorul).
|
||||
|
||||
k-NN peste exemple reale etichetate (denumire_normalizata -> cod, is_nul) e net mai
|
||||
precis decat peste cele 18 categorii generice. Acopera si simetria corpus/query (F1):
|
||||
corpusul e text NORMALIZAT, deci query-ul trebuie normalizat la fel inainte de embedding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Backend mock determinist: vector = histograma de caractere (similaritate stabila).
|
||||
class MockBackend:
|
||||
def embed(self, texts):
|
||||
out = []
|
||||
for t in texts:
|
||||
v = [0.0] * 27
|
||||
for ch in t.upper():
|
||||
if "A" <= ch <= "Z":
|
||||
v[ord(ch) - 65] += 1.0
|
||||
else:
|
||||
v[26] += 1.0
|
||||
out.append(v)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us005.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "true") # US-005 are nevoie de embeddings ON
|
||||
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()
|
||||
|
||||
|
||||
def _inject_mock_engine():
|
||||
import app.embeddings as emb
|
||||
from app.embeddings import EmbeddingEngine
|
||||
emb._engine = EmbeddingEngine(backend=MockBackend())
|
||||
return emb
|
||||
|
||||
|
||||
def _seed_silver(conn, rows):
|
||||
"""rows = [(denumire_normalizata, cod, is_nul)]."""
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO mapping_suggestions "
|
||||
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, 'llm_seed', 0.7)",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_corpus_din_mapping_suggestions(conn):
|
||||
emb = _inject_mock_engine()
|
||||
_seed_silver(conn, [
|
||||
("SCHIMB ULEI MOTOR", "OE-3", 0),
|
||||
("INLOCUIT PLACUTE FRANA", "OE-1", 0),
|
||||
("13 X ITP", None, 1),
|
||||
])
|
||||
from app.mapping import ensure_embeddings_corpus
|
||||
ensure_embeddings_corpus(conn)
|
||||
assert emb.has_corpus()
|
||||
# Corpusul indexat = denumirile din mapping_suggestions, NU din nomenclator_rar.
|
||||
texte = {it["denumire"] for it in emb._engine._corpus_items}
|
||||
assert texte == {"SCHIMB ULEI MOTOR", "INLOCUIT PLACUTE FRANA", "13 X ITP"}
|
||||
|
||||
|
||||
def test_suggest_nearest_intoarce_is_nul(conn):
|
||||
emb = _inject_mock_engine()
|
||||
_seed_silver(conn, [
|
||||
("SCHIMB ULEI MOTOR", "OE-3", 0),
|
||||
("13 X ITP", None, 1),
|
||||
])
|
||||
from app.mapping import ensure_embeddings_corpus
|
||||
ensure_embeddings_corpus(conn)
|
||||
res = emb.suggest_nearest("13 X ITP", top_k=1)
|
||||
assert res and res[0]["is_nul"] is True # vecin NUL -> semnal de supresie
|
||||
res2 = emb.suggest_nearest("SCHIMB ULEI MOTOR", top_k=1)
|
||||
assert res2 and res2[0]["is_nul"] is False
|
||||
assert res2[0]["cod"] == "OE-3"
|
||||
|
||||
|
||||
def test_semnatura_corpus_pe_seed(conn):
|
||||
emb = _inject_mock_engine()
|
||||
_seed_silver(conn, [("SCHIMB ULEI MOTOR", "OE-3", 0)])
|
||||
from app.mapping import ensure_embeddings_corpus
|
||||
ensure_embeddings_corpus(conn)
|
||||
sig1 = emb.corpus_signature()
|
||||
assert sig1 is not None
|
||||
# Re-apel fara schimbare -> aceeasi semnatura (nu re-indexeaza).
|
||||
ensure_embeddings_corpus(conn)
|
||||
assert emb.corpus_signature() == sig1
|
||||
# Adaugare rand -> semnatura se schimba.
|
||||
_seed_silver(conn, [("INLOCUIT BATERIE", "OE-1", 0)])
|
||||
ensure_embeddings_corpus(conn)
|
||||
assert emb.corpus_signature() != sig1
|
||||
|
||||
|
||||
def test_query_normalizat_ca_si_corpusul(conn, monkeypatch):
|
||||
"""F1 (HIGH): enrich_suggestions interogheaza suggest_nearest cu textul NORMALIZAT."""
|
||||
import app.embeddings as emb
|
||||
captura = {}
|
||||
monkeypatch.setattr(emb, "has_corpus", lambda: True)
|
||||
|
||||
def fake_suggest(text, top_k=1):
|
||||
captura["text"] = text
|
||||
return [{"cod": "OE-3", "is_nul": False, "similaritate": 0.99}]
|
||||
|
||||
monkeypatch.setattr(emb, "suggest_nearest", fake_suggest)
|
||||
from app.mapping import enrich_suggestions
|
||||
enrich_suggestions(conn, "Schimb Uleiul Motor")
|
||||
# Corpusul e denumire_normalizata -> query-ul trebuie normalizat la fel.
|
||||
from app.mapping import normalize_for_match
|
||||
assert captura["text"] == normalize_for_match("Schimb Uleiul Motor")
|
||||
assert captura["text"] == "SCHIMB ULEIUL MOTOR"
|
||||
|
||||
|
||||
def test_degradare_gratioasa_pastrata(conn):
|
||||
"""Backend care arunca -> ensure + enrich NU arunca exceptie."""
|
||||
import app.embeddings as emb
|
||||
from app.embeddings import EmbeddingEngine
|
||||
|
||||
class BrokenBackend:
|
||||
def embed(self, texts):
|
||||
raise RuntimeError("model indisponibil")
|
||||
|
||||
emb._engine = EmbeddingEngine(backend=BrokenBackend())
|
||||
_seed_silver(conn, [("SCHIMB ULEI MOTOR", "OE-3", 0)])
|
||||
from app.mapping import ensure_embeddings_corpus, enrich_suggestions
|
||||
ensure_embeddings_corpus(conn) # nu arunca
|
||||
out = enrich_suggestions(conn, "SCHIMB ULEI") # nu arunca
|
||||
assert "sugestie_principala" in out
|
||||
133
tests/test_enrich_corpus_etichetat.py
Normal file
133
tests/test_enrich_corpus_etichetat.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""US-006 (PRD 5.18) — enrich_suggestions = pre-filtru NUL + k-NN pe corpus etichetat.
|
||||
|
||||
Ordinea de precedenta: pre-filtru NUL -> (daca NUL: fara cod) altfel GOLD partajat >
|
||||
exact (SILVER) > k-NN embeddings. k-NN sub prag -> abtinere. Vecin k-NN NUL -> supresie.
|
||||
Invariant #13: nimic din asta nu intra in resolve_prestatii/load_mapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us006.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "true")
|
||||
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()
|
||||
|
||||
|
||||
def _silver(conn, denumire_norm, cod, is_nul=0):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO mapping_suggestions "
|
||||
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, 'llm_seed', 0.7)",
|
||||
(denumire_norm, cod, is_nul),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _mock_embedding(monkeypatch, cod, sim, is_nul=False):
|
||||
import app.embeddings as emb
|
||||
monkeypatch.setattr(emb, "has_corpus", lambda: True)
|
||||
monkeypatch.setattr(emb, "suggest_nearest",
|
||||
lambda text, top_k=1: [{"cod": cod, "is_nul": is_nul, "similaritate": sim}])
|
||||
|
||||
|
||||
def test_prefiltru_nul_supreseaza_inainte_de_knn(conn, monkeypatch):
|
||||
# Embedding-ul AR sugera un cod, dar pre-filtrul NUL trebuie sa scurtcircuiteze.
|
||||
chemat = {"da": False}
|
||||
import app.embeddings as emb
|
||||
monkeypatch.setattr(emb, "has_corpus", lambda: True)
|
||||
|
||||
def spion(text, top_k=1):
|
||||
chemat["da"] = True
|
||||
return [{"cod": "OE-1", "is_nul": False, "similaritate": 0.99}]
|
||||
|
||||
monkeypatch.setattr(emb, "suggest_nearest", spion)
|
||||
from app.mapping import enrich_suggestions
|
||||
out = enrich_suggestions(conn, "13 X ITP")
|
||||
assert out["sugestie_principala"] is None # non-operatie -> fara cod
|
||||
assert out["surse"]["nul"] is True
|
||||
assert chemat["da"] is False # k-NN nici macar interogat
|
||||
|
||||
|
||||
def test_precedenta_gold_exact_embedding(conn, monkeypatch):
|
||||
from app.shared_store import record_human_validation
|
||||
from app.mapping import enrich_suggestions, normalize_for_match
|
||||
den = "OPERATIE DE TEST UNICA"
|
||||
norm = normalize_for_match(den)
|
||||
|
||||
# Toate trei sursele dau coduri diferite.
|
||||
record_human_validation(conn, den, "OE-1") # GOLD partajat
|
||||
_silver(conn, norm, "OE-2") # SILVER exact
|
||||
_mock_embedding(monkeypatch, "OE-3", 0.99) # embedding
|
||||
conn.commit()
|
||||
|
||||
out = enrich_suggestions(conn, den)
|
||||
assert out["sugestie_principala"] == {"cod_prestatie": "OE-1", "sursa": "gold_partajat"}
|
||||
|
||||
# Fara GOLD -> castiga SILVER.
|
||||
conn.execute("DELETE FROM shared_mappings")
|
||||
conn.commit()
|
||||
out = enrich_suggestions(conn, den)
|
||||
assert out["sugestie_principala"]["sursa"] == "silver"
|
||||
assert out["sugestie_principala"]["cod_prestatie"] == "OE-2"
|
||||
|
||||
# Fara GOLD si fara SILVER -> castiga embedding.
|
||||
conn.execute("DELETE FROM mapping_suggestions")
|
||||
conn.commit()
|
||||
out = enrich_suggestions(conn, den)
|
||||
assert out["sugestie_principala"] == {"cod_prestatie": "OE-3", "sursa": "embedding"}
|
||||
|
||||
|
||||
def test_prag_similaritate(conn, monkeypatch):
|
||||
from app.mapping import enrich_suggestions, EMB_MIN_SIMILARITATE
|
||||
_mock_embedding(monkeypatch, "OE-3", EMB_MIN_SIMILARITATE + 0.01)
|
||||
out = enrich_suggestions(conn, "CEVA NEVAZUT")
|
||||
assert out["surse"]["embedding"] == "OE-3"
|
||||
|
||||
|
||||
def test_abtinere_sub_prag(conn, monkeypatch):
|
||||
from app.mapping import enrich_suggestions, EMB_MIN_SIMILARITATE
|
||||
_mock_embedding(monkeypatch, "OE-3", EMB_MIN_SIMILARITATE - 0.01)
|
||||
out = enrich_suggestions(conn, "CEVA NEVAZUT")
|
||||
assert out["surse"]["embedding"] is None # sub prag -> abtinere
|
||||
assert out["sugestie_principala"] is None
|
||||
|
||||
|
||||
def test_vecin_knn_nul_supreseaza(conn, monkeypatch):
|
||||
from app.mapping import enrich_suggestions
|
||||
_mock_embedding(monkeypatch, None, 0.99, is_nul=True) # vecin NUL peste prag
|
||||
out = enrich_suggestions(conn, "CEVA CARE SEAMANA CU GUNOI")
|
||||
assert out["surse"]["embedding"] is None # NUL -> nu produce cod
|
||||
assert out["surse"]["nul"] is True
|
||||
assert out["sugestie_principala"] is None
|
||||
|
||||
|
||||
def test_invariant_13_resolve_neatins(conn):
|
||||
"""Regresie #13: SILVER populat NU produce auto-rezolvare in resolve_prestatii."""
|
||||
from app.mapping import resolve_prestatii, normalize_for_match
|
||||
_silver(conn, normalize_for_match("OPERATIE X"), "OE-1")
|
||||
resolved, unmapped = resolve_prestatii(
|
||||
[{"cod_op_service": "OPERATIE X", "denumire": "OPERATIE X"}], mapping={}, valid_codes={"OE-1"}
|
||||
)
|
||||
assert resolved[0]["cod_prestatie"] is None # ramane nemapat, NU ia codul din SILVER
|
||||
assert unmapped and unmapped[0]["cod_op_service"] == "OPERATIE X"
|
||||
103
tests/test_eticheteaza_tool.py
Normal file
103
tests/test_eticheteaza_tool.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""US-002 (PRD 5.18) — etichetator offline multi-backend cu prompt procedural.
|
||||
|
||||
Toate testele ruleaza FARA retea reala (transport injectabil / inspectie body).
|
||||
Acopera: prompt 3 pasi, envelope json_schema strict + enum, backend selectabil
|
||||
prin env, scrub PII inainte de orice request, garda de truncare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Numele pachetului `tools/mapare-llm` contine cratima -> nu e importabil ca modul.
|
||||
# Incarcam fisierul direct prin importlib pe cale.
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
_PATH = os.path.join(os.path.dirname(__file__), "..", "tools", "mapare-llm", "eticheteaza.py")
|
||||
_spec = importlib.util.spec_from_file_location("eticheteaza", _PATH)
|
||||
eticheteaza = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["eticheteaza"] = eticheteaza # necesar pt. @dataclass introspection
|
||||
_spec.loader.exec_module(eticheteaza)
|
||||
|
||||
|
||||
def test_construieste_prompt_3pasi():
|
||||
msgs = eticheteaza.construieste_mesaje(["INLOCUIT PLACUTE FRANA"])
|
||||
assert isinstance(msgs, list) and msgs[0]["role"] == "system"
|
||||
sys = msgs[0]["content"].upper()
|
||||
# Procedura in 3 pasi explicita.
|
||||
assert "PAS 1" in sys and "PAS 2" in sys and "PAS 3" in sys
|
||||
# Regula NUL + avarie grava doar la accident.
|
||||
assert "NUL" in sys
|
||||
assert "ACCIDENT" in sys
|
||||
# Dezactivare thinking Qwen3 (token /no_think undeva in mesaje).
|
||||
joined = " ".join(m["content"] for m in msgs)
|
||||
assert "/no_think" in joined
|
||||
# User message enumera operatiile.
|
||||
assert "1." in msgs[1]["content"] and "INLOCUIT PLACUTE FRANA" in msgs[1]["content"]
|
||||
|
||||
|
||||
def test_envelope_json_schema_strict_si_enum():
|
||||
backend = eticheteaza.get_backend("lmstudio")
|
||||
body = eticheteaza.construieste_body(["REVIZIE"], backend)
|
||||
rf = body["response_format"]
|
||||
# Envelope COMPLET, NU json_object.
|
||||
assert rf["type"] == "json_schema"
|
||||
js = rf["json_schema"]
|
||||
assert js["strict"] is True
|
||||
assert "name" in js
|
||||
schema = js["schema"]
|
||||
cod_schema = schema["properties"]["rez"]["items"]["properties"]["cod"]
|
||||
# cod = enum peste cele 19 ALL_LABELS (18 coduri + NUL).
|
||||
assert set(cod_schema["enum"]) == set(eticheteaza.ALL_LABELS)
|
||||
assert len(eticheteaza.ALL_LABELS) == 19
|
||||
assert "NUL" in eticheteaza.ALL_LABELS
|
||||
# temperatura 0 (determinist) si strict items.
|
||||
assert body["temperature"] == 0
|
||||
assert schema["properties"]["rez"]["items"]["additionalProperties"] is False
|
||||
|
||||
|
||||
def test_parseaza_raspuns_si_garda_truncare():
|
||||
batch = ["A", "B", "C"]
|
||||
# Raspuns complet, ordine amestecata, un cod invalid.
|
||||
content = {"rez": [{"i": 2, "cod": "OE-1"}, {"i": 1, "cod": "NUL"}, {"i": 3, "cod": "INEXISTENT"}]}
|
||||
codes = eticheteaza.parseaza_raspuns(content, len(batch))
|
||||
assert codes == ["NUL", "OE-1", "?"] # cod invalid -> '?', NU ascuns
|
||||
# Raspuns trunchiat: lipseste pozitia 3 -> '?' pe lipsa, nu eroare.
|
||||
content_trunc = {"rez": [{"i": 1, "cod": "OE-1"}, {"i": 2, "cod": "OE-2"}]}
|
||||
codes2 = eticheteaza.parseaza_raspuns(content_trunc, len(batch))
|
||||
assert codes2 == ["OE-1", "OE-2", "?"]
|
||||
assert len(codes2) == len(batch)
|
||||
|
||||
|
||||
def test_backend_selectabil_env(monkeypatch):
|
||||
# Default = lmstudio (backend aprobat v1, D4).
|
||||
monkeypatch.delenv("ETICHETARE_BACKEND", raising=False)
|
||||
assert eticheteaza.get_backend().name == "lmstudio"
|
||||
# Selectie prin env.
|
||||
monkeypatch.setenv("ETICHETARE_BACKEND", "groq")
|
||||
assert eticheteaza.get_backend().name == "groq"
|
||||
# Endpoint + model configurabile prin env.
|
||||
monkeypatch.setenv("ETICHETARE_BACKEND", "lmstudio")
|
||||
monkeypatch.setenv("ETICHETARE_ENDPOINT", "http://exemplu:1234/v1/chat/completions")
|
||||
monkeypatch.setenv("ETICHETARE_MODEL", "qwen/qwen3-custom")
|
||||
b = eticheteaza.get_backend()
|
||||
assert b.url == "http://exemplu:1234/v1/chat/completions"
|
||||
assert b.model == "qwen/qwen3-custom"
|
||||
|
||||
|
||||
def test_scrub_pii_inainte_de_request(monkeypatch):
|
||||
"""Nicio placuta/VIN nu ajunge la transport — scrub inainte de orice apel."""
|
||||
capturat = {}
|
||||
|
||||
def fake_transport(url, headers, payload, timeout):
|
||||
capturat["payload"] = payload
|
||||
return {"choices": [{"message": {"content": '{"rez":[{"i":1,"cod":"OE-1"}]}'}}]}
|
||||
|
||||
backend = eticheteaza.get_backend("lmstudio")
|
||||
codes, meta = eticheteaza.call(["VOPSIT USA B 123 ABC"], backend, transport=fake_transport)
|
||||
assert codes == ["OE-1"]
|
||||
body = capturat["payload"]
|
||||
user_content = body["messages"][1]["content"]
|
||||
assert "B 123 ABC" not in user_content
|
||||
assert "[NR]" in user_content
|
||||
assert meta["err"] is None
|
||||
175
tests/test_genereaza_seed.py
Normal file
175
tests/test_genereaza_seed.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""US-003 (PRD 5.18) — generare seed etichetat in faze pe frecventa.
|
||||
|
||||
Pipeline dedup OBLIGATORIU inainte de orice apel LLM (D5):
|
||||
brut -> normalize_for_match -> arunca chei vide -> dedup pe cheie (freq=suma NR)
|
||||
-> reuse etichete existente (labels-groq + seed comis, conflict freq-max) -> de_etichetat.
|
||||
|
||||
Idempotenta cross-run (F2/F7): a doua rulare consuma seedul comis ca cache -> 0 apeluri LLM.
|
||||
Toate testele FARA retea: `clasifica` e injectat (mock care inregistreaza ce primeste).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
path = os.path.join(os.path.dirname(__file__), "..", "tools", "mapare-llm", f"{name}.py")
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
gs = _load("genereaza_seed")
|
||||
|
||||
|
||||
def _scrie_csv(path, randuri):
|
||||
"""randuri = [(denumire, nr)]. Format CSV ca docs/operatii-service (`;`, header)."""
|
||||
linii = ['" ";"DENOP";"NR"']
|
||||
for i, (den, nr) in enumerate(randuri, 1):
|
||||
linii.append(f'"{i}";"{den}";"{nr}"')
|
||||
path.write_text("\n".join(linii) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _mock_recorder():
|
||||
"""Returneaza (clasifica, vazute) — clasifica raspunde OE-1 pe tot, inregistreaza inputul."""
|
||||
vazute = []
|
||||
|
||||
def clasifica(batch):
|
||||
vazute.append(list(batch))
|
||||
return ["OE-1"] * len(batch)
|
||||
|
||||
return clasifica, vazute
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_dedup_normalizat(tmp_path):
|
||||
f1 = tmp_path / "a.csv"
|
||||
f2 = tmp_path / "b.csv"
|
||||
_scrie_csv(f1, [("REVIZIE", 10), ("D/R BARA FATA", 3)])
|
||||
_scrie_csv(f2, [(" revizie ", 5)]) # acelasi logic, case+spatii
|
||||
corpus = gs.agrega_corpus([str(f1), str(f2)])
|
||||
assert "REVIZIE" in corpus
|
||||
assert corpus["REVIZIE"]["freq"] == 15 # 10 + 5, dedup pe cheie
|
||||
assert len([k for k in corpus]) == 2 # REVIZIE + D/R BARA FATA
|
||||
|
||||
|
||||
def test_skip_cheie_normalizata_vida(tmp_path):
|
||||
f = tmp_path / "a.csv"
|
||||
_scrie_csv(f, [(" ", 99), ("REVIZIE", 5)]) # cheie vida (doar spatii)
|
||||
corpus = gs.agrega_corpus([str(f)])
|
||||
assert "" not in corpus
|
||||
assert list(corpus) == ["REVIZIE"]
|
||||
|
||||
|
||||
def test_ordine_pe_frecventa(tmp_path):
|
||||
f = tmp_path / "a.csv"
|
||||
_scrie_csv(f, [("OP MICA", 5), ("OP MARE", 50), ("OP MEDIE", 20)])
|
||||
seed = tmp_path / "seed.json"
|
||||
clasifica, vazute = _mock_recorder()
|
||||
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed),
|
||||
etichetare_all=True, clasifica=clasifica, batch=32)
|
||||
# Ordinea in care LLM-ul a vazut operatiile = desc pe frecventa.
|
||||
primul_batch = vazute[0]
|
||||
assert primul_batch[:3] == ["OP MARE", "OP MEDIE", "OP MICA"]
|
||||
|
||||
|
||||
def test_reuse_in_spatiu_normalizat(tmp_path):
|
||||
f = tmp_path / "a.csv"
|
||||
_scrie_csv(f, [("Revizie", 10), ("SCHIMB ULEI", 5)])
|
||||
labels = tmp_path / "labels.json"
|
||||
labels.write_text(json.dumps({"REVIZIE": "OE-3"}), encoding="utf-8") # cheiat brut, dar normalizeaza la fel
|
||||
seed = tmp_path / "seed.json"
|
||||
clasifica, vazute = _mock_recorder()
|
||||
gs.genereaza([str(f)], labels_path=str(labels), seed_path=str(seed),
|
||||
etichetare_all=True, clasifica=clasifica)
|
||||
trimise = {d for b in vazute for d in b}
|
||||
assert "Revizie" not in trimise and "REVIZIE" not in trimise # deja etichetat -> nu se trimite
|
||||
seed_data = json.loads(seed.read_text(encoding="utf-8"))
|
||||
rev = [e for e in seed_data if e["denumire_normalizata"] == "REVIZIE"][0]
|
||||
assert rev["cod"] == "OE-3"
|
||||
|
||||
|
||||
def test_reuse_conflict_determinist(tmp_path):
|
||||
f = tmp_path / "a.csv"
|
||||
# Doua variante raw ale aceleiasi chei, etichetate diferit; freq decide.
|
||||
_scrie_csv(f, [("CURATAT CATALIZATOR", 100), ("curatat catalizator", 5)])
|
||||
labels = tmp_path / "labels.json"
|
||||
labels.write_text(json.dumps({
|
||||
"CURATAT CATALIZATOR": "OE-1", # freq 100
|
||||
"curatat catalizator": "OE-2", # freq 5
|
||||
}), encoding="utf-8")
|
||||
seed = tmp_path / "seed.json"
|
||||
clasifica, _ = _mock_recorder()
|
||||
gs.genereaza([str(f)], labels_path=str(labels), seed_path=str(seed), etichetare_all=True, clasifica=clasifica)
|
||||
seed_data = json.loads(seed.read_text(encoding="utf-8"))
|
||||
cat = [e for e in seed_data if e["denumire_normalizata"] == "CURATAT CATALIZATOR"][0]
|
||||
assert cat["cod"] == "OE-1" # freq-max castiga (100 > 5)
|
||||
|
||||
|
||||
def test_zero_duplicate_trimis_la_llm(tmp_path):
|
||||
f1 = tmp_path / "a.csv"
|
||||
f2 = tmp_path / "b.csv"
|
||||
_scrie_csv(f1, [("REVIZIE", 10), (" revizie ", 4), ("OP NOUA", 7), (" ", 3)])
|
||||
_scrie_csv(f2, [("REVIZIE", 2), ("OP NOUA", 1)]) # cross-file duplicate
|
||||
labels = tmp_path / "labels.json"
|
||||
labels.write_text(json.dumps({"REVIZIE": "OE-3"}), encoding="utf-8") # REVIZIE deja etichetat
|
||||
seed = tmp_path / "seed.json"
|
||||
clasifica, vazute = _mock_recorder()
|
||||
from app.mapping import normalize_for_match
|
||||
gs.genereaza([str(f1), str(f2)], labels_path=str(labels), seed_path=str(seed),
|
||||
etichetare_all=True, clasifica=clasifica)
|
||||
trimise = [d for b in vazute for d in b]
|
||||
chei = [normalize_for_match(d) for d in trimise]
|
||||
assert len(chei) == len(set(chei)) # nicio cheie normalizata trimisa de doua ori
|
||||
assert "" not in chei # nicio cheie vida
|
||||
assert "REVIZIE" not in chei # nicio cheie deja etichetata
|
||||
assert "OP NOUA" in chei # doar ce lipseste
|
||||
|
||||
|
||||
def test_rerun_zero_apeluri_llm(tmp_path):
|
||||
"""Criteriul real de idempotenta (F2/F7): a doua rulare = 0 apeluri LLM, seed identic."""
|
||||
f = tmp_path / "a.csv"
|
||||
_scrie_csv(f, [("OP UNU", 10), ("OP DOI", 5)])
|
||||
seed = tmp_path / "seed.json"
|
||||
|
||||
clasifica1, vazute1 = _mock_recorder()
|
||||
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed), etichetare_all=True, clasifica=clasifica1)
|
||||
assert sum(len(b) for b in vazute1) == 2 # prima rulare eticheteaza ambele
|
||||
bytes1 = seed.read_bytes()
|
||||
|
||||
clasifica2, vazute2 = _mock_recorder()
|
||||
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed), etichetare_all=True, clasifica=clasifica2)
|
||||
assert vazute2 == [] # a doua rulare: 0 apeluri LLM (seed = cache)
|
||||
bytes2 = seed.read_bytes()
|
||||
assert bytes1 == bytes2 # seed identic byte-cu-byte
|
||||
|
||||
|
||||
def test_format_seed_valid(tmp_path):
|
||||
f = tmp_path / "a.csv"
|
||||
_scrie_csv(f, [("OP REALA", 10), ("13 X ITP", 5)])
|
||||
seed = tmp_path / "seed.json"
|
||||
|
||||
def clasifica(batch):
|
||||
# marcheaza ITP ca NUL, restul OE-1
|
||||
return ["NUL" if "ITP" in d.upper() else "OE-1" for d in batch]
|
||||
|
||||
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed), etichetare_all=True, clasifica=clasifica)
|
||||
data = json.loads(seed.read_text(encoding="utf-8"))
|
||||
chei = [e["denumire_normalizata"] for e in data]
|
||||
assert len(chei) == len(set(chei)) # unice
|
||||
assert all(e["denumire_normalizata"] for e in data) # non-vide
|
||||
for e in data:
|
||||
assert set(e) >= {"denumire", "denumire_normalizata", "cod", "is_nul", "source", "confidence"}
|
||||
if e["is_nul"]:
|
||||
assert e["cod"] is None # NUL -> cod NULL (oglindeste CHECK-ul DB)
|
||||
else:
|
||||
assert e["cod"]
|
||||
nul = [e for e in data if e["is_nul"]][0]
|
||||
assert "ITP" in nul["denumire_normalizata"]
|
||||
@@ -51,7 +51,8 @@ def test_export_doar_contul_cheii(env):
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
# tier='pro' ca sa treaca gate-ul API (T4 PRD 5.17); testul masoara scoping, nu planuri.
|
||||
conn.execute("INSERT INTO accounts (id, name, tier) VALUES (2, 'al-doilea', 'pro')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
k2 = create_api_key(conn, 2)
|
||||
finally:
|
||||
|
||||
@@ -47,7 +47,9 @@ def test_lista_doar_contul_cheii(env):
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
# tier='pro' pe ambele conturi — testul verifica scoping GET, nu planuri (T4 PRD 5.17).
|
||||
conn.execute("UPDATE accounts SET tier='pro' WHERE id=1")
|
||||
conn.execute("INSERT INTO accounts (id, name, tier) VALUES (2, 'al-doilea', 'pro')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
k2 = create_api_key(conn, 2)
|
||||
finally:
|
||||
|
||||
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")
|
||||
28
tests/test_idempotency.py
Normal file
28
tests/test_idempotency.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""US-003 (PRD 5.20): build_key incorporeaza rar_env."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.idempotency import build_key, canonicalize_row
|
||||
|
||||
|
||||
def _canon():
|
||||
raw = {
|
||||
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B 123 ABC",
|
||||
"data_prestatie": "2026-01-10", "odometru_final": "123456.0",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
canon = canonicalize_row(raw)
|
||||
canon["prestatii"] = raw["prestatii"]
|
||||
return canon
|
||||
|
||||
|
||||
def test_key_difera_intre_test_si_prod():
|
||||
canon = _canon()
|
||||
assert build_key(1, canon, "test") != build_key(1, canon, "prod")
|
||||
|
||||
|
||||
def test_key_stabil_pe_env():
|
||||
canon = _canon()
|
||||
assert build_key(1, canon, "prod") == build_key(1, canon, "prod")
|
||||
# None si 1 colapseaza la aceeasi cheie (account_or_default), pe acelasi env
|
||||
assert build_key(None, canon, "test") == build_key(1, canon, "test")
|
||||
@@ -316,9 +316,10 @@ def test_confirmare_in_modal_seteaza_reviewed_si_devine_ok(client):
|
||||
Verifica:
|
||||
- Raspuns 200
|
||||
- reviewed=1 in DB
|
||||
- Raspuns contine OOB cu pill 'Gata de trimis' (starea ok)
|
||||
- Header HX-Trigger-After-Settle: inchideModal
|
||||
- HX-Trigger: randSalvat cu noua stare 'Gata de trimis' (pentru toast)
|
||||
- HX-Trigger: reincarcaPreview + HX-Trigger-After-Settle: inchideModal
|
||||
"""
|
||||
import json as _json
|
||||
_seed_op1()
|
||||
iid = _upload_and_preview_needs_review(client)
|
||||
|
||||
@@ -334,14 +335,11 @@ def test_confirmare_in_modal_seteaza_reviewed_si_devine_ok(client):
|
||||
assert _get_reviewed(iid, 0) == 1, \
|
||||
"reviewed trebuie sa fie 1 in DB dupa confirmare"
|
||||
|
||||
# Raspuns contine OOB cu randul actualizat
|
||||
html = r.text
|
||||
assert 'id="preview-row-0"' in html or "preview-row-0" in html, \
|
||||
"Raspunsul trebuie sa contina randul actualizat (OOB)"
|
||||
|
||||
# Starea a devenit ok
|
||||
assert "Gata de trimis" in html or "s-ok" in html, \
|
||||
"Dupa confirmare, randul trebuie sa fie ok (pill 'Gata de trimis')"
|
||||
# Contractul nou: reload preview + randSalvat cu noua stare (nu OOB pe <tr>).
|
||||
trig = _json.loads(r.headers.get("HX-Trigger", "{}"))
|
||||
assert trig.get("reincarcaPreview") is True, "confirma-review trebuie sa ceara reincarcaPreview"
|
||||
assert trig.get("randSalvat", {}).get("stare") == "Gata de trimis", \
|
||||
"Dupa confirmare, randSalvat.stare trebuie sa fie 'Gata de trimis' (pentru toast)"
|
||||
|
||||
# Modal se inchide
|
||||
trigger = r.headers.get("HX-Trigger-After-Settle", "")
|
||||
@@ -585,16 +583,17 @@ def test_confirma_review_form_nu_foloseste_hx_swap_none():
|
||||
)
|
||||
|
||||
|
||||
def test_confirma_review_raspuns_contine_script_updateN(client):
|
||||
"""Bug B1 (functional): raspunsul POST confirma-review contine scriptul
|
||||
updateN in payload-ul principal (nu doar OOB), astfel ca htmx il va executa
|
||||
cand face swap in #detaliu-modal-body.
|
||||
def test_confirma_review_cere_reincarcarea_preview(client):
|
||||
"""Contractul nou (dogfood 5.13): confirma-review NU mai depinde de scriptul updateN
|
||||
din payload (care, cu OOB pe <tr> rupt, lasa randul stale). Acum cere reincarcaPreview,
|
||||
iar preview-ul reincarcat re-randeaza contorul si butonul de confirmare cu n_confirmat
|
||||
corect server-side — deci problema B1 (n_confirmat stale -> 422) dispare structural.
|
||||
|
||||
Verifica:
|
||||
- Raspuns 200
|
||||
- Raspunsul contine 'window.updateN' (scriptul de recalcul contor)
|
||||
- Raspunsul contine 'updateN' inainte de ultimul OOB-element (@script tag nu e OOB)
|
||||
- HX-Trigger contine reincarcaPreview (reincarca contorul/confirmarea, fresh)
|
||||
"""
|
||||
import json as _json
|
||||
_seed_op1()
|
||||
iid = _upload_and_preview_needs_review(client)
|
||||
|
||||
@@ -602,17 +601,8 @@ def test_confirma_review_raspuns_contine_script_updateN(client):
|
||||
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
html = r.text
|
||||
# Scriptul trebuie sa fie in raspuns
|
||||
assert "window.updateN" in html or "updateN" in html, (
|
||||
"Raspunsul confirma-review trebuie sa contina scriptul updateN "
|
||||
"pentru ca htmx sa-l execute la swap in #detaliu-modal-body."
|
||||
)
|
||||
# Scriptul NU trebuie sa aiba hx-swap-oob (altfel nu ar fi executat nici asa)
|
||||
script_idx = html.rfind("<script>")
|
||||
assert script_idx >= 0, "Tag-ul <script> nu a fost gasit in raspuns"
|
||||
script_content = html[script_idx:]
|
||||
assert "hx-swap-oob" not in script_content, (
|
||||
"Scriptul updateN NU trebuie sa aiba hx-swap-oob — trebuie sa fie in "
|
||||
"continutul principal pentru executie."
|
||||
trig = _json.loads(r.headers.get("HX-Trigger", "{}"))
|
||||
assert trig.get("reincarcaPreview") is True, (
|
||||
"confirma-review trebuie sa ceara reincarcaPreview — preview-ul reincarcat aduce "
|
||||
"n_confirmat corect server-side (fara dependenta de scriptul updateN din payload)."
|
||||
)
|
||||
|
||||
582
tests/test_mapare_integrare_l14.py
Normal file
582
tests/test_mapare_integrare_l14.py
Normal file
@@ -0,0 +1,582 @@
|
||||
"""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()))
|
||||
|
||||
# Corpusul sursa = mapping_suggestions (SILVER) -- PRD 5.18 US-005.
|
||||
# (Inainte era nomenclator_rar; migrat la mapping_suggestions ca k-NN sa
|
||||
# opereze pe exemple reale etichetate, nu pe categorii generice RAR.)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO mapping_suggestions "
|
||||
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, ?, ?)",
|
||||
("Schimb ulei", "UL-1", 0, "llm", 0.95),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO mapping_suggestions "
|
||||
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, ?, ?)",
|
||||
("Placute frana", "FR-1", 0, "llm", 0.95),
|
||||
)
|
||||
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
|
||||
113
tests/test_operatii_seed.py
Normal file
113
tests/test_operatii_seed.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""US-004 (PRD 5.18) — seeder corpus etichetat in mapping_suggestions (SILVER).
|
||||
|
||||
INSERT OR IGNORE din artefactul comis -> SILVER nu mai e gol in productie.
|
||||
NB (F10): confirmarile UMANE stau in shared_mappings, NU aici; deci INSERT OR IGNORE
|
||||
pastreaza codul LLM existent la re-seed (v1 = ignore, nu upsert).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us004.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 tmp
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(env):
|
||||
from app.db import get_connection
|
||||
c = get_connection()
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
def _scrie_seed(tmp, items) -> str:
|
||||
p = os.path.join(tmp, "operatii-etichetate.json")
|
||||
with open(p, "w", encoding="utf-8") as fh:
|
||||
json.dump(items, fh, ensure_ascii=False)
|
||||
return p
|
||||
|
||||
|
||||
SEED_OE = {"denumire": "SCHIMB ULEI MOTOR", "denumire_normalizata": "SCHIMB ULEI MOTOR",
|
||||
"cod": "OE-3", "is_nul": False, "source": "llm_seed", "confidence": 0.7}
|
||||
SEED_NUL = {"denumire": "13 X ITP", "denumire_normalizata": "13 X ITP",
|
||||
"cod": None, "is_nul": True, "source": "llm_seed", "confidence": 0.7}
|
||||
|
||||
|
||||
def test_seed_populeaza_mapping_suggestions(env, conn):
|
||||
from app.operatii_seed import seed_operatii_etichetate
|
||||
path = _scrie_seed(env, [SEED_OE])
|
||||
n = seed_operatii_etichetate(conn, path)
|
||||
conn.commit()
|
||||
assert n == 1
|
||||
row = conn.execute(
|
||||
"SELECT cod_prestatie, source, confidence FROM mapping_suggestions "
|
||||
"WHERE denumire_normalizata = 'SCHIMB ULEI MOTOR'"
|
||||
).fetchone()
|
||||
assert row["cod_prestatie"] == "OE-3"
|
||||
assert row["source"] == "llm_seed"
|
||||
assert abs(row["confidence"] - 0.7) < 1e-9
|
||||
|
||||
|
||||
def test_is_nul_din_seed(env, conn):
|
||||
from app.operatii_seed import seed_operatii_etichetate
|
||||
path = _scrie_seed(env, [SEED_NUL])
|
||||
seed_operatii_etichetate(conn, path)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT cod_prestatie, is_nul FROM mapping_suggestions WHERE denumire_normalizata = '13 X ITP'"
|
||||
).fetchone()
|
||||
assert row["is_nul"] == 1
|
||||
assert row["cod_prestatie"] is None # respecta CHECK-ul (NUL -> cod NULL)
|
||||
|
||||
|
||||
def test_insert_or_ignore_nu_clobber(env, conn):
|
||||
from app.operatii_seed import seed_operatii_etichetate
|
||||
# Un rand pre-existent (ex. embedding) pe aceeasi cheie, cu alt cod.
|
||||
conn.execute(
|
||||
"INSERT INTO mapping_suggestions (denumire_normalizata, cod_prestatie, is_nul, source, confidence) "
|
||||
"VALUES ('SCHIMB ULEI MOTOR', 'OE-1', 0, 'embedding', 0.5)"
|
||||
)
|
||||
conn.commit()
|
||||
path = _scrie_seed(env, [SEED_OE])
|
||||
n = seed_operatii_etichetate(conn, path)
|
||||
conn.commit()
|
||||
assert n == 0 # INSERT OR IGNORE -> nu suprascrie
|
||||
row = conn.execute(
|
||||
"SELECT cod_prestatie, source FROM mapping_suggestions WHERE denumire_normalizata = 'SCHIMB ULEI MOTOR'"
|
||||
).fetchone()
|
||||
assert row["cod_prestatie"] == "OE-1" # randul existent ramane neatins
|
||||
assert row["source"] == "embedding"
|
||||
|
||||
|
||||
def test_idempotent_la_reinit(env, conn):
|
||||
from app.operatii_seed import seed_operatii_etichetate
|
||||
path = _scrie_seed(env, [SEED_OE, SEED_NUL])
|
||||
n1 = seed_operatii_etichetate(conn, path)
|
||||
conn.commit()
|
||||
n2 = seed_operatii_etichetate(conn, path)
|
||||
conn.commit()
|
||||
assert n1 == 2
|
||||
assert n2 == 0 # a doua rulare nu dubleaza
|
||||
total = conn.execute("SELECT COUNT(*) AS n FROM mapping_suggestions").fetchone()["n"]
|
||||
assert total == 2
|
||||
|
||||
|
||||
def test_seed_inexistent_e_noop(env, conn):
|
||||
from app.operatii_seed import seed_operatii_etichetate
|
||||
n = seed_operatii_etichetate(conn, os.path.join(env, "nu-exista.json"))
|
||||
assert n == 0
|
||||
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)
|
||||
359
tests/test_plans.py
Normal file
359
tests/test_plans.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""Teste US-001/US-002 (PRD 5.17): app/plans.py — definitia planurilor + helperi tier/consum."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_plans.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.db import get_connection, init_db
|
||||
init_db()
|
||||
c = get_connection()
|
||||
yield c
|
||||
c.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PLANS — sursa de adevar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_plan_definitii_free():
|
||||
from app.plans import PLANS, FREE_MONTHLY_LIMIT
|
||||
p = PLANS["free"]
|
||||
assert p["monthly_limit"] == FREE_MONTHLY_LIMIT
|
||||
assert p["monthly_limit"] == 60
|
||||
assert p["api_access"] is False
|
||||
assert p["label"] == "Gratuit"
|
||||
|
||||
|
||||
def test_plan_definitii_standard():
|
||||
from app.plans import PLANS
|
||||
p = PLANS["standard"]
|
||||
assert p["monthly_limit"] is None
|
||||
assert p["api_access"] is False
|
||||
assert "label" in p
|
||||
|
||||
|
||||
def test_plan_definitii_pro():
|
||||
from app.plans import PLANS
|
||||
p = PLANS["pro"]
|
||||
assert p["monthly_limit"] is None
|
||||
assert p["api_access"] is True
|
||||
assert "label" in p
|
||||
|
||||
|
||||
def test_plan_definitii_premium():
|
||||
from app.plans import PLANS
|
||||
p = PLANS["premium"]
|
||||
assert p["monthly_limit"] is None
|
||||
assert p["api_access"] is True
|
||||
assert "label" in p
|
||||
|
||||
|
||||
def test_toate_tierurile_prezente():
|
||||
from app.plans import PLANS
|
||||
assert set(PLANS.keys()) == {"free", "standard", "pro", "premium"}
|
||||
|
||||
|
||||
def test_consumed_statuses_exportata():
|
||||
from app.plans import CONSUMED_STATUSES
|
||||
assert "queued" in CONSUMED_STATUSES
|
||||
assert "sending" in CONSUMED_STATUSES
|
||||
assert "sent" in CONSUMED_STATUSES
|
||||
# statusuri blocate nu se numara
|
||||
assert "error" not in CONSUMED_STATUSES
|
||||
assert "needs_mapping" not in CONSUMED_STATUSES
|
||||
assert "needs_data" not in CONSUMED_STATUSES
|
||||
|
||||
|
||||
def test_free_monthly_limit_constanta():
|
||||
"""FREE_MONTHLY_LIMIT e o singura constanta (DRY), referita din PLANS."""
|
||||
from app.plans import FREE_MONTHLY_LIMIT, PLANS
|
||||
assert isinstance(FREE_MONTHLY_LIMIT, int)
|
||||
assert FREE_MONTHLY_LIMIT == 60
|
||||
# PLANS["free"]["monthly_limit"] refera aceeasi valoare (nu hardcodat separat)
|
||||
assert PLANS["free"]["monthly_limit"] == FREE_MONTHLY_LIMIT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# effective_tier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _now_utc():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_effective_tier_trial_activ_returneaza_pro():
|
||||
from app.plans import effective_tier
|
||||
now = _now_utc()
|
||||
trial_until = (now + timedelta(days=15)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
account = {"tier": "free", "trial_until": trial_until}
|
||||
assert effective_tier(account, now) == "pro"
|
||||
|
||||
|
||||
def test_effective_tier_trial_expirat_returneaza_tier_baza():
|
||||
from app.plans import effective_tier
|
||||
now = _now_utc()
|
||||
trial_until = (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
account = {"tier": "free", "trial_until": trial_until}
|
||||
assert effective_tier(account, now) == "free"
|
||||
|
||||
|
||||
def test_effective_tier_fara_trial_returneaza_tier():
|
||||
from app.plans import effective_tier
|
||||
now = _now_utc()
|
||||
account = {"tier": "standard", "trial_until": None}
|
||||
assert effective_tier(account, now) == "standard"
|
||||
|
||||
|
||||
def test_effective_tier_plan_platit_nu_downgradat_de_trial_expirat():
|
||||
"""Un cont pro setat de admin NU e downgradat de expirarea trial-ului."""
|
||||
from app.plans import effective_tier
|
||||
now = _now_utc()
|
||||
# tier=pro, trial_until in trecut: downgrade nu se produce (pro > free)
|
||||
trial_until = (now - timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
account = {"tier": "pro", "trial_until": trial_until}
|
||||
# tier de baza e pro, deci effective = pro (nu se coboara la free)
|
||||
assert effective_tier(account, now) == "pro"
|
||||
|
||||
|
||||
def test_effective_tier_trial_malformat_fallback_defensiv():
|
||||
from app.plans import effective_tier
|
||||
now = _now_utc()
|
||||
account = {"tier": "free", "trial_until": "nu-e-o-data-valida"}
|
||||
# malformat -> fallback la tier de baza, fara exceptie
|
||||
assert effective_tier(account, now) == "free"
|
||||
|
||||
|
||||
def test_effective_tier_trial_null_fallback():
|
||||
from app.plans import effective_tier
|
||||
now = _now_utc()
|
||||
account = {"tier": "free", "trial_until": None}
|
||||
assert effective_tier(account, now) == "free"
|
||||
|
||||
|
||||
def test_effective_tier_injectat_determinist():
|
||||
"""now injectabil: putem simula orice moment — teste deterministe fara datetime.now()."""
|
||||
from app.plans import effective_tier
|
||||
# trial_until fix
|
||||
trial_until = "2026-07-10 12:00:00"
|
||||
account = {"tier": "free", "trial_until": trial_until}
|
||||
|
||||
# inainte de expirare
|
||||
now_before = datetime(2026, 7, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
assert effective_tier(account, now_before) == "pro"
|
||||
|
||||
# dupa expirare
|
||||
now_after = datetime(2026, 7, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
assert effective_tier(account, now_after) == "free"
|
||||
|
||||
|
||||
def test_effective_tier_premium_cu_trial_pro():
|
||||
"""premium are api_access=True oricum; trial_until viitor nu strica."""
|
||||
from app.plans import effective_tier
|
||||
now = _now_utc()
|
||||
trial_until = (now + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
account = {"tier": "premium", "trial_until": trial_until}
|
||||
# trial activ -> 'pro', dar premium e oricum superior (nu ne intereseaza downgrade)
|
||||
# functia intoarce 'pro' cand trial e activ; consumatorul vede pro (care are api_access)
|
||||
assert effective_tier(account, now) == "pro"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# monthly_usage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _uid():
|
||||
"""Cheie idempotenta unica per apel (pentru INSERT in teste)."""
|
||||
import binascii
|
||||
return binascii.hexlify(os.urandom(8)).decode()
|
||||
|
||||
|
||||
def _insert_submission(conn, account_id, status, created_at_str):
|
||||
"""Insereaza o submisie de test cu timestamp explicit."""
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, created_at) "
|
||||
"VALUES (?, ?, ?, '{}', ?)",
|
||||
(_uid(), account_id, status, created_at_str),
|
||||
)
|
||||
|
||||
|
||||
def test_consum_lunar_numara_consumed_statuses(conn):
|
||||
from app.plans import monthly_usage
|
||||
from app.accounts import create_account
|
||||
now = _now_utc()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
account_id = create_account(conn, "Test Consum", cui="RO1001")
|
||||
|
||||
# 3 statusuri consumate
|
||||
_insert_submission(conn, account_id, "queued", now_str)
|
||||
_insert_submission(conn, account_id, "sending", now_str)
|
||||
_insert_submission(conn, account_id, "sent", now_str)
|
||||
|
||||
assert monthly_usage(conn, account_id, now) == 3
|
||||
|
||||
|
||||
def test_consum_lunar_exclude_statusuri_blocate(conn):
|
||||
from app.plans import monthly_usage
|
||||
from app.accounts import create_account
|
||||
now = _now_utc()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
account_id = create_account(conn, "Test Blocat", cui="RO1002")
|
||||
|
||||
# statusuri care NU se numara
|
||||
for status in ("error", "needs_mapping", "needs_data"):
|
||||
_insert_submission(conn, account_id, status, now_str)
|
||||
|
||||
assert monthly_usage(conn, account_id, now) == 0
|
||||
|
||||
|
||||
def test_consum_lunar_scoped_pe_cont(conn):
|
||||
from app.plans import monthly_usage
|
||||
from app.accounts import create_account
|
||||
now = _now_utc()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
acct_a = create_account(conn, "Cont A", cui="RO1003")
|
||||
acct_b = create_account(conn, "Cont B", cui="RO1004")
|
||||
|
||||
_insert_submission(conn, acct_a, "sent", now_str)
|
||||
_insert_submission(conn, acct_a, "sent", now_str)
|
||||
_insert_submission(conn, acct_b, "sent", now_str)
|
||||
|
||||
assert monthly_usage(conn, acct_a, now) == 2
|
||||
assert monthly_usage(conn, acct_b, now) == 1
|
||||
|
||||
|
||||
def test_consum_lunar_luna_trecuta_nu_se_numara(conn):
|
||||
from app.plans import monthly_usage
|
||||
from app.accounts import create_account
|
||||
now = _now_utc()
|
||||
|
||||
account_id = create_account(conn, "Test Luna Trecuta", cui="RO1005")
|
||||
|
||||
# Calculam o data din luna trecuta (prima zi a lunii curente - 1 zi)
|
||||
first_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
last_of_prev_month = first_of_month - timedelta(days=1)
|
||||
prev_str = last_of_prev_month.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
_insert_submission(conn, account_id, "sent", prev_str)
|
||||
|
||||
# luna curenta: 0
|
||||
assert monthly_usage(conn, account_id, now) == 0
|
||||
|
||||
|
||||
def test_consum_lunar_granita_luna_noua(conn):
|
||||
"""Submisii la granita intre luni sunt bucketate corect (timp local RO = UTC in container)."""
|
||||
from app.plans import monthly_usage
|
||||
from app.accounts import create_account
|
||||
now = _now_utc()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
account_id = create_account(conn, "Test Granita", cui="RO1006")
|
||||
|
||||
# Prima secunda a lunii curente (calculata consistent cu 'localtime' = UTC in container)
|
||||
first_of_month = now.replace(day=1, hour=0, minute=0, second=1, microsecond=0)
|
||||
first_str = first_of_month.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Ultima secunda a lunii trecute
|
||||
last_of_prev_month = first_of_month - timedelta(seconds=2)
|
||||
prev_str = last_of_prev_month.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
_insert_submission(conn, account_id, "sent", first_str) # luna curenta
|
||||
_insert_submission(conn, account_id, "sent", prev_str) # luna trecuta
|
||||
_insert_submission(conn, account_id, "sent", now_str) # luna curenta
|
||||
|
||||
assert monthly_usage(conn, account_id, now) == 2
|
||||
|
||||
|
||||
def test_consum_lunar_zero_pe_cont_gol(conn):
|
||||
from app.plans import monthly_usage
|
||||
from app.accounts import create_account
|
||||
now = _now_utc()
|
||||
|
||||
account_id = create_account(conn, "Cont Gol", cui="RO1007")
|
||||
|
||||
assert monthly_usage(conn, account_id, now) == 0
|
||||
|
||||
|
||||
def test_consum_lunar_nu_numara_cross_account(conn):
|
||||
"""Verificare scoping: contul default (id=1) nu influenteaza alt cont."""
|
||||
from app.plans import monthly_usage
|
||||
from app.accounts import create_account
|
||||
now = _now_utc()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
account_id = create_account(conn, "Cont Izolat", cui="RO1008")
|
||||
|
||||
# Inseram pentru contul default (id=1)
|
||||
_insert_submission(conn, 1, "sent", now_str)
|
||||
_insert_submission(conn, 1, "sent", now_str)
|
||||
|
||||
# Contul nou nu trebuie sa numere al celor de pe id=1
|
||||
assert monthly_usage(conn, account_id, now) == 0
|
||||
assert monthly_usage(conn, 1, now) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PRD 5.17 enforcement — logica de limita + kill-switch config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_volume_la_limita_exacta(conn):
|
||||
"""La exact FREE_MONTHLY_LIMIT submissions, usage == limita (nu inca depasit).
|
||||
|
||||
Enforcer-ul verifica usage + nr_cerut > limit, deci la usage=60, nr_cerut=1 ->
|
||||
61 > 60 -> respins; dar usage=60 in sine (inainte de cerere) e valid.
|
||||
"""
|
||||
from app.plans import monthly_usage, FREE_MONTHLY_LIMIT
|
||||
from app.accounts import create_account
|
||||
now = _now_utc()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
account_id = create_account(conn, "Test La Limita", cui="RO2001")
|
||||
for _ in range(FREE_MONTHLY_LIMIT):
|
||||
_insert_submission(conn, account_id, "queued", now_str)
|
||||
conn.commit()
|
||||
|
||||
usage = monthly_usage(conn, account_id, now)
|
||||
assert usage == FREE_MONTHLY_LIMIT, (
|
||||
f"La limita exacta: asteptat {FREE_MONTHLY_LIMIT}, primit {usage}"
|
||||
)
|
||||
# Simulam logica enforcer: 1 cerere noua depaseste limita
|
||||
assert usage + 1 > FREE_MONTHLY_LIMIT, "O cerere noua trebuia sa depaseasca limita"
|
||||
# La 0 cereri noi: nu depaseste
|
||||
assert usage + 0 <= FREE_MONTHLY_LIMIT, "La 0 cereri noi, limita nu e depasita"
|
||||
|
||||
|
||||
def test_enforce_plans_config_default_true(monkeypatch):
|
||||
"""AUTOPASS_ENFORCE_PLANS implicit True — enforcement activ de la deploy.
|
||||
|
||||
Decizie user (autoplan 2026-06-28): nu exista conturi legacy, produs in TESTE,
|
||||
enforcement DUR activ implicit. Kill-switch oprit explicit cand e necesar.
|
||||
"""
|
||||
from app.config import Settings
|
||||
# Creem Settings fresh (fara env var setata) -> default True
|
||||
monkeypatch.delenv("AUTOPASS_ENFORCE_PLANS", raising=False)
|
||||
s = Settings()
|
||||
assert s.enforce_plans is True, (
|
||||
"AUTOPASS_ENFORCE_PLANS trebuia sa fie True implicit (enforcement activ din start)"
|
||||
)
|
||||
|
||||
|
||||
def test_enforce_plans_kill_switch_false(monkeypatch):
|
||||
"""AUTOPASS_ENFORCE_PLANS=false dezactiveaza enforcement."""
|
||||
from app.config import Settings
|
||||
monkeypatch.setenv("AUTOPASS_ENFORCE_PLANS", "false")
|
||||
s = Settings()
|
||||
assert s.enforce_plans is False
|
||||
72
tests/test_prefiltru_nul.py
Normal file
72
tests/test_prefiltru_nul.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""US-001 (PRD 5.18) — pre-filtru determinist non-operatii (NUL).
|
||||
|
||||
Masuratoarea k-NN (memorie test-precizie-knn-embeddings) arata recall NUL doar 64%:
|
||||
gunoiul evident (ITP, plata, discount, nr. inmatriculare, tractare) scapa ca OE-1.
|
||||
Un pre-filtru determinist il marcheaza NUL INAINTE de k-NN.
|
||||
|
||||
Garantie non-negociabila (AC): ZERO fals-pozitiv pe operatii reale. Regulile
|
||||
text/regex au fost calibrate pe `docs/operatii-service/*.csv` (vezi sesiunea de
|
||||
implementare): triggerele ambigue (TRACTARE, NR INMATRICULARE/placuta) sunt
|
||||
ECRANATE de un context de piesa/operatie (D/R, CARLIG, CAPAC, INLOCUIT...).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.mapping import prefiltru_nul
|
||||
|
||||
|
||||
def test_itp_e_nul():
|
||||
assert prefiltru_nul("13 X ITP") is True
|
||||
assert prefiltru_nul("11XITP") is True # glue fara spatii
|
||||
assert prefiltru_nul("ITP") is True
|
||||
assert prefiltru_nul("2 X ITP") is True
|
||||
|
||||
|
||||
def test_plata_discount_nul():
|
||||
assert prefiltru_nul("DISCOUNT FIDELITATE 10%") is True
|
||||
assert prefiltru_nul("REDUCERE COMERCIALA") is True
|
||||
assert prefiltru_nul("ACHITAT DE CONF.URBAN") is True
|
||||
assert prefiltru_nul("PLATA AVANS") is True
|
||||
assert prefiltru_nul("TAXA DE MEDIU") is True
|
||||
|
||||
|
||||
def test_nr_inmatriculare_nul():
|
||||
assert prefiltru_nul("NR INMATRICULARE") is True
|
||||
assert prefiltru_nul("NUMAR INMATRICULARE") is True
|
||||
assert prefiltru_nul("B 123 ABC") is True # pattern placuta standalone
|
||||
assert prefiltru_nul("CT 44 MKY") is True
|
||||
|
||||
|
||||
def test_tractare_serviciu_nul():
|
||||
# Serviciul de tractare (rmorca) = non-operatie de service.
|
||||
assert prefiltru_nul("TRACTARE CTA-SLOBOZIA") is True
|
||||
assert prefiltru_nul("TRACTARE 100 KM") is True
|
||||
|
||||
|
||||
def test_operatie_reala_nu_e_nul():
|
||||
# Punctul critic: trigger ambiguu intr-un context de piesa reala -> NU e NUL.
|
||||
assert prefiltru_nul("INLOCUIT PLACUTE FRANA") is False
|
||||
assert prefiltru_nul("D/R CARLIG TRACTARE") is False # carlig = piesa, nu serviciu
|
||||
assert prefiltru_nul("D/R CAPAC TRACTARE BARA SPATE") is False
|
||||
assert prefiltru_nul("D/R NR INMATRICULARE") is False # suport placuta = piesa
|
||||
assert prefiltru_nul("D/R ELECTROMOTOR CT 44 MKY") is False # placuta lipita la o operatie reala
|
||||
|
||||
|
||||
def test_zero_fals_pozitiv_pe_set_operatii_reale():
|
||||
"""AC: zero fals-pozitiv pe un set de 20 operatii reale (din docs/operatii-service)."""
|
||||
reale = [
|
||||
"REVIZIE", "SCHIMB ULEI MOTOR", "INLOCUIT PLACUTE FRANA FATA",
|
||||
"D/R BARA FATA", "VOPSIT USA DR FATA", "INLOCUIT FILTRU AER",
|
||||
"AERISIT INSTALATIE FRANA", "INLOCUIT AMORTIZOR SPATE", "ABSORBANT SOC BARA SPATE",
|
||||
"INLOCUIT CUREA DISTRIBUTIE", "REGLAT FARURI", "INLOCUIT BUJII",
|
||||
"REPARAT ARIPA FATA DR", "INLOCUIT DISCURI FRANA", "GRESAT PLANETARA",
|
||||
"INLOCUIT RULMENT ROATA", "MONTAT ANVELOPE", "INLOCUIT BATERIE",
|
||||
"DIAGNOZA COMPUTERIZATA", "INLOCUIT CONTACT PORNIRE",
|
||||
]
|
||||
for op in reale:
|
||||
assert prefiltru_nul(op) is False, f"fals-pozitiv pe operatie reala: {op!r}"
|
||||
|
||||
|
||||
def test_input_gol_nu_e_nul():
|
||||
assert prefiltru_nul("") is False
|
||||
assert prefiltru_nul(None) is False # type: ignore[arg-type]
|
||||
@@ -95,17 +95,23 @@ def test_editeaza_intra_in_mod_editare_form_propriu(client):
|
||||
assert 'name="data_prestatie"' in html and 'name="vin"' in html
|
||||
|
||||
|
||||
def test_salveaza_reda_doar_randul(client):
|
||||
"""POST editeaza: raspuns = fragmentul randului + OOB contoare, NU tot #import-section (D-3.1)."""
|
||||
def test_salveaza_cere_reincarcare_si_toast(client):
|
||||
"""POST editeaza: raspuns minimal + HX-Trigger(reincarcaPreview + randSalvat).
|
||||
|
||||
Contractul nou (dogfood 5.13): nu mai facem OOB swap pe <tr> (fragil in htmx 1.9 ->
|
||||
randul ramanea cu starea veche). Raspunsul cere reincarcarea preview-ului si emite
|
||||
detaliile randului salvat pentru toast/evidentiere."""
|
||||
import json as _json
|
||||
_seed_op1()
|
||||
iid = _upload_and_preview(client)
|
||||
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"data_prestatie": "2026-06-10"})
|
||||
assert r.status_code == 200
|
||||
html = r.text
|
||||
assert 'id="preview-row-0"' in html
|
||||
# OOB pe rezumat (contoare), NU re-randarea sectiunii intregi.
|
||||
assert 'id="preview-rezumat"' in html and 'hx-swap-oob="true"' in html
|
||||
assert 'id="import-section"' not in html
|
||||
trig = _json.loads(r.headers.get("HX-Trigger", "{}"))
|
||||
assert trig.get("reincarcaPreview") is True
|
||||
assert trig.get("randSalvat", {}).get("nr") == 1
|
||||
# Raspunsul e doar un stub; randul real vine din reload-ul preview-ului.
|
||||
assert 'id="preview-row-0"' not in r.text
|
||||
assert 'id="import-section"' not in r.text
|
||||
|
||||
|
||||
def test_enter_in_camp_editare_nu_declanseaza_confirm(client):
|
||||
|
||||
50
tests/test_rar_env_disponibil.py
Normal file
50
tests/test_rar_env_disponibil.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""US-002 (PRD 5.20): medii_disponibile + rar_env_efectiv (REQ-DISP / REQ-DEFAULT)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.rar_env import medii_disponibile, rar_env_efectiv
|
||||
|
||||
|
||||
def _cont(**kw):
|
||||
base = {
|
||||
"rar_test_enabled": 0, "rar_prod_enabled": 0,
|
||||
"rar_creds_test_enc": None, "rar_creds_prod_enc": None,
|
||||
"rar_env_default": "prod",
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_doar_prod_cu_creds():
|
||||
c = _cont(rar_prod_enabled=1, rar_creds_prod_enc="TOK")
|
||||
assert medii_disponibile(c) == ["prod"]
|
||||
assert rar_env_efectiv(c) == "prod"
|
||||
|
||||
|
||||
def test_ambele():
|
||||
c = _cont(
|
||||
rar_test_enabled=1, rar_creds_test_enc="T",
|
||||
rar_prod_enabled=1, rar_creds_prod_enc="P",
|
||||
rar_env_default="test",
|
||||
)
|
||||
assert medii_disponibile(c) == ["test", "prod"]
|
||||
assert rar_env_efectiv(c) == "test"
|
||||
|
||||
|
||||
def test_zero_cand_lipsesc_creds():
|
||||
# activat dar fara creds -> nu e disponibil
|
||||
c = _cont(rar_test_enabled=1, rar_prod_enabled=1)
|
||||
assert medii_disponibile(c) == []
|
||||
assert rar_env_efectiv(c) is None
|
||||
|
||||
|
||||
def test_default_cade_pe_singurul_disponibil():
|
||||
# default='prod' dar prod nu e disponibil; doar test e -> efectiv = test
|
||||
c = _cont(rar_test_enabled=1, rar_creds_test_enc="T", rar_env_default="prod")
|
||||
assert medii_disponibile(c) == ["test"]
|
||||
assert rar_env_efectiv(c) == "test"
|
||||
|
||||
|
||||
def test_enabled_fara_creds_nu_e_disponibil():
|
||||
c = _cont(rar_prod_enabled=1, rar_creds_prod_enc=" ") # whitespace = gol
|
||||
assert medii_disponibile(c) == []
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user