Compare commits

..

19 Commits

Author SHA1 Message Date
Claude Agent
d5ce0e2e2b feat(branding): redenumire ROMFAST AUTOPASS -> ROA AUTOPASS in UI
Titlu pagina, antet brand si /login afiseaza acum 'ROA AUTOPASS'.
Include redesignul sectiunii Problem+Calculator combinata din landing.
Teste de antet/nav aliniate la noul nume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:42:31 +00:00
Claude Agent
deb6afff3e feat(5.20): US-001/002/003 schema medii per cont + disponibilitate + idempotenta env-aware
US-001: coloane accounts (rar_test/prod_enabled, rar_creds_test/prod_enc,
rar_env_default) + submissions.rar_env; migrare cu backfill din ancora globala
AUTOPASS_RAR_ENV (creds->slot, enabled doar pe mediul cu creds) + recompute
idempotency_key env-aware (AUTO-FIX G + E4/3).
US-002: app/rar_env.py — medii_disponibile + rar_env_efectiv (REQ-DISP/DEFAULT).
US-003: build_key(account_id, canon, rar_env) — test vs prod = trimiteri distincte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:42:28 +00:00
Claude Agent
b4818349be docs(5.20): PRD medii RAR per cont (Testare/Productie) aprobat + roadmap
Doua medii RAR configurabile per cont, fiecare cu bifa de activare si set
propriu de credentiale. medii_disponibile=enabled AND creds deriva tot UX-ul.
13 stories / 6 valuri. Premisa verificata live: test/prod = sisteme separate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:27:50 +00:00
Claude Agent
ff9d0f41d1 feat(landing): titlu ROMFAST AUTOPASS, calculator pe trimiteri, grila preturi uniforma
- header cu titlu ROMFAST AUTOPASS + subtitlu Gateway RAR, nav la dreapta
- title/meta description aliniate pe mesajul "incarci fisierul, coduri o data"
- hero: subtext rescris + linie beneficiu "Gratuit pana la 60 de trimiteri/luna"
- scoase toate referintele la card bancar
- calculator: slider pe Trimiteri/luna (default 100), cifre uniforme grid 2x2,
  rotunjite fara zecimale
- preturi: carduri egale cu aceleasi componente (bifa/minus), Standard 49 lei +
  badge Popular + buton verde, Gratuit fara badge, "* fara TVA" la preturi
- sectiune separata beneficiu "30 de zile Pro gratuit"; FINAL CTA eliminat
- suport: Standard maxim 24h, Pro maxim 8h
- signup: pret Standard aliniat la 49 lei

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:56:49 +00:00
Claude Agent
7371c3703d chore(compose): parametrizeaza RAR_ENV si WORKER_SEND_ENABLED pentru staging
Permite override din Dokploy environment fara a schimba comportamentul prod
(default-uri pastrate: api RAR_ENV=prod, worker RAR_ENV=test, SEND_ENABLED=true).
Necesar pentru serviciul de staging autopass-test.roa.romfast.ro, care forteaza
RAR_ENV=test si WORKER_SEND_ENABLED=false ca sa NU trimita declaratii reale la RAR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:39:35 +00:00
Claude Agent
851f76ca16 feat(signup+admin): aliniere formular signup la landing + plan cerut, GDPR, control tier/trial in panou
Signup:
- /signup aliniat ca format la formularul din landing (campuri, etichete,
  placeholder-uri, select plan, checkbox GDPR, buton). Eticheta `name` = "Companie"
  (corecta: backendul salveaza nume de firma), uniform si in landing.
- Consimtamant GDPR validat server-side (functional, nu doar client-side) + salvat
  cu marca temporala (accounts.consent_at).
- Plan ales la signup salvat in accounts.requested_plan (intentie, NU drept): tier
  ramane sursa de adevar pentru gate-ul API; coloana pregateste integrarea platilor.
- landing: valorile `plan` = coduri tier (free/standard/pro/premium), data-plan
  sincronizat pe butoanele de pret; checkbox consimtamant primeste name.

Schema/DB:
- accounts: coloane noi requested_plan + consent_at (cu migrare aditiva in db.py).

Panou admin:
- Coloane noi: Plan curent (plan EFECTIV acum + zile trial ramase) si Plan cerut.
- Buton "Aplica" (POST /admin/set-tier): aloca plan real si INCHEIE trial-ul
  (efect imediat; altfel trial-ul Pro universal de 30z masca alegerea).
- Control "Trial Pro N zile" (POST /admin/set-trial via accounts.set_trial):
  acorda/prelungeste trial fara a schimba tier-ul de baza.

Teste: signup (consent obligatoriu, requested_plan persistat, tier ramane free),
panou admin (set-tier incheie trial, free opreste Pro imediat, set-trial, validari
+ CSRF). Call-site-urile existente POST /signup actualizate cu consent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:02:37 +00:00
Claude Agent
a29896a790 docs(5.19): PRD bifa "Trimite automat la RAR" + coada tinuta/eliberare manuala
PRD prin /prd + /autoplan (CEO/Design/Eng/DX, voce unica - Codex la plafon).
Per-cont accounts.auto_send_enabled (default OFF time-boxed) + per-rand
submissions.held; snapshot la TOATE ~8 situri queued via held_for_account()
(Eng a prins bug reactivare router:237 ce ocolea Auto OFF); claim_one AND held=0.
Crescut 6->10 stories: US-007 banner/metrics coada imbatranita, US-008 retentie
held (GDPR/L.142), US-009 fixturi teste + audit, US-010 onestitate API (invariant
5.7). 26 taskuri. Eticheta redenumita; testare sigura (rar_env/valideaza) -> TODOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:46:23 +00:00
Claude Agent
3f513f6c12 fix(landing): elimina announce bar, actualizeaza badge hero, ancoreaza nav, simplifica login
- Scoate announce bar-ul de deasupra header-ului
- Badge hero reformulat: "Este gratuit pentru service-urile mici — pana la 60 de trimiteri RAR/luna" + link "Creeaza cont in 2 minute"
- Nav links "Cum functioneaza", "API", "Pret" devin <a href="#..."> cu id-uri pe sectiunile corespunzatoare
- Pagina /login: scoate <aside> cu logo/tagline/trust, layout trece la o singura coloana centrata

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:04:40 +00:00
Claude Agent
8f39dfbc1e feat(5.16): aliniere lista/preview la mockup + fix lock seed la boot
Implementeaza planul aprobat din docs/raport-comparatie-mockup-5.16.md (T-1..T-9):

- T-1/T-8: rand lista 4->2 linii (placuta primar + cod RAR · operatie · data + pill),
  fallback placuta, eticheta-problema 10px->--fs-xs (_submissions.html, base.html)
- T-2: pill slim restilat fill-tint + dot 7px + text colorat per stare (base.html)
- T-3: bug 4a coliziune pill/vehicul in preview — col-stare 104->140px (base.html)
- T-4: preview 8->5 coloane (scos #, KM, Note; motivul -> title pe pill)
- T-5: titlu sectiune "Trimiterile tale" -> sr-only (a11y) + badge/export discret
- T-6: linia plan N/60 in corp doar pe avertizare; consum normal in badge+burger
- T-7: guard chenar gol chips extra (_chips_prestatii.html)
- T-9: "Anuleaza"->"Renunta"; nume operatie emfatic bold

Fix boot: init_db reincarca seedul de ~17k operatii (5.18) pe FIECARE pornire, pe
API + worker concurent -> "database is locked" la al doilea proces. Guard "_if_empty"
pe mapping_suggestions (ca seed_nomenclator_if_empty) -> boot rapid, fara cursa.

Teste actualizate (slim 2-linii, fallback placuta, plan in burger). TODOS.md:
defer trackuit (eroare HTMX lista, retokenizare px, diacritice).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:44:10 +00:00
Claude Agent
e1243f603e fix(mapari-mobil): butoane Salveaza/Sterge vizibile + carduri compacte la 390px
Doua probleme raportate de user pe pagina Mapari pe mobil (screenshot 390px):

1. Butoanele Salveaza/Sterge taiate: regula `.tabel-card td button {width:100%}`
   (specificitate 0,1,2) batea `.act {width:44px}` (0,1,0), deci cele doua butoane
   .act deveneau full-width si al doilea (Sterge) iesea din card (celula are nowrap).
   Fix: bloc @media (max-width:767px) nou, ultimul in <style> (castiga pe cascada) —
   celula Actiuni devine flex-row, butoanele .act width:auto/flex:1 cu text vizibil.

2. Carduri prea inalte + label-uri inutile: .tabel-card randa etichetele data-eticheta
   ca pseudo-titluri + linia redundanta "acum: COD — nume" (duplica select-ul de sub).
   Fix: pe mobil se ascund pseudo-etichetele si linia .map-acum, padding strans.
   Cardul trece de la ~7 la ~3 elemente. Atributele data-eticheta raman in DOM (a11y+teste).

Include si raportul de comparatie UI 5.16 cu appendix-ul /autoplan (CEO/Design/Eng,
audit trail, plan aprobat) + addendum cu corectia la sectiunea 8 ("Mapari conform" era
gresit: nu testase randarea mobila a paginilor actionabile).

Verificare: 80 teste web verzi (test_web_responsive + mapari + submissions + tabs + modal);
confirmare vizuala la 390px (render TestClient -> screenshot Playwright).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:18:27 +00:00
Claude Agent
80d90f317d fix(5.18): corectie 8 etichete coduri rare (verificare Opus pe coada sparse)
Subagent Opus a verificat cele 139 operatii cu coduri rare (R-ODO, OE-I,
OE-5, AITLV, OE-R, OE-8, OE-7): 130 confirmate, 8 corectate (source=opus_review):
- PERNA AER STG SPATE / PUNTE MOTRICA (x3): OE-R -> OE-1 (burduf suspensie
  pneumatica, NU airbag de retinere)
- INLOCUIT CABLU/SENZOR KM (x2): OE-I -> R-ODO (reparatie odometru, nu istoric import)
- SCHIMBAT FOAIE ARC: OE-8 -> OE-1 (arc suspensie, nu anvelopa)
- RULMENT SPATE (PIESA): OE-7 -> NUL (doar nume piesa, fara actiune)
- INLOCUIT CALCULATOR P INJECTIE: OE-7 -> OE-1 (inlocuire hardware, fara programare)

NUL 2200 -> 2201. SERVICII VULCANIZARE (Opus->NUL, increderea 0.5) tinut
neschimbat: vulcanizarea e reparatie reala de pneu, nu non-operatie (de decis user).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 07:41:32 +00:00
Claude Agent
12021eb269 feat(5.18): VERIFY+CLOSE — US-007 badge sursa + fix findings code-review
VERIFY PASS pe corpus k-NN exemple etichetate (seed real 17181 Haiku, comis
in 756f777): suita 1392 passed, 1 deselected (live); smoke init_db seeder
(17181/NUL=2200/idempotent); toate codurile in nomenclator.

US-007 (cerere user la CLOSE) — badge sursa pe sugestia fuzzy din editor:
- _mapari.html: chip confirmat (GOLD) / similar (SILVER+k-NN) / non-operatie (NUL)
- base.html: .sugg-sursa--{confirmat,similar,nul} pe tokeni de tema (color-mix)
- routes.py: cheia `nul` adaugata in surse_sugestie default (finding cross-file)
- tests/test_web_badge_sursa.py: gold/silver/nul/fara-sursa (4 teste)
- E2E render live verificat in serverul real (/_fragments/mapari)

CLOSE /code-review high (main..HEAD, 3 finder x 8 unghiuri) — runtime curat,
invariant #13 intact; 3 findings low/cosmetic REPARATE + lock-uite:
- shared_store.seed_suggestions: cod whitespace -> NULL (era ''), + test lock
- genereaza_seed.py: with open(...) in loc de open().read() (FD leak tool offline)
- embeddings.py: docstring-uri aliniate la [{cod, is_nul, similaritate}]

ROADMAP: 5.18 LIVRAT. PRD: raport VERIFY/CLOSE scris.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 07:29:14 +00:00
Claude Agent
308fee6c27 fix(start-test): suprima erorile ONNX thread affinity in LXC
OMP_NUM_THREADS=1 previne incercarea ONNX de a seta pthread affinity,
care esua cu EINVAL in containere LXC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 07:03:35 +00:00
Claude Agent
756f77730f feat(5.18): corpus k-NN exemple etichetate + seed real Haiku (17181 op)
Seed app/data/operatii-etichetate.json regenerat cu subagenti Haiku pe TOATE
cele 17181 operatii distincte (ordine frecventa, 100%), inlocuind seed-ul Groq
(3758). Validare Haiku vs Groq pe 157 op etichetate: la dezacorduri Haiku corect
~22/30, Groq ~0. Haiku prinde gunoiul ratat de Groq (ITP, chirie anvelope, nume
piese fara actiune): NUL 2200 (12.8%) vs ~7.6% Groq; adaptare electronica OE-7
(nu OE-5), placute frana uzura OE-1 (nu OE-F avarie).

US-001..006: prefiltru NUL determinist, etichetator offline, generator seed,
seeder mapping_suggestions (in init_db, gated seed_operatii_enabled), embeddings
indexeaza corpus etichetat, enrich NUL+kNN. Distributie seed: OE-1 80.1%, NUL
12.8%, OE-2 3.5%, restul rar (OE-4/3/7/8/R/I/5, AITLV, R-ODO).

config: seed_operatii_enabled=True + embeddings_enabled=True implicit (SILVER
populat + sugestii semantice; ambele suggestion-only, dezactivabile prin env).

Suita: 1387 passed, 1 deselected (live).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 06:59:15 +00:00
Claude Agent
c05fa00007 fix(worker): keepalive RAR ca dashboard-ul sa nu afiseze fals "RAR inaccesibil"
Dashboard-ul deduce starea RAR din vechimea ultimului login reusit (>30h ->
"indisponibil?"). Cand coada e goala, worker-ul nu are de ce sa se logheze,
deci timestamp-ul devine stale si banner-ul "Blocat: RAR inaccesibil —
declaratiile NU pleaca" apare fals, desi RAR raspunde.

Worker-ul face acum un login de proba o data pe zi (interval configurabil,
24h < pragul de 30h) cand coada e goala: pe succes reimprospateaza
last_rar_login_ok; pe esec real last_rar_login_ok ramane vechi -> dashboard
degradeaza corect. Forteaza login real (invalideaza sesiunea) ca proba sa fie
autentica. Gating: cel mult o sondare pe interval, sa nu hartuiasca RAR jos.

_keepalive_target sare conturile ale caror creds NU se decripteaza sub cheia
curenta (start.sh both genereaza cheie efemera noua la fiecare pornire ->
creds durabile vechi dau decrypt None) si cade pe creds <test> in dev.

Teste: tests/test_worker_keepalive_rar.py (6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:48:32 +00:00
Claude Agent
ce90dac833 docs(roadmap): 5.16 + 5.17 LIVRAT + VERIFY PASS + COMMIT
Actualizeaza "Stadiu Implementare": 5.16 (tipografie system-stack + antet branded +
bug-fix editor) si 5.17 (tipuri cont + trial Pro + enforcement) marcate LIVRAT pe
feat/5.16-5.17-design-tiers (c9f9a1c). Regresie 1380 passed; E2E browser; 1 defect
contoare-mobil prins de E2E si reparat. Lucrul 5.18 ramane separat/necomis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:09:59 +00:00
Claude Agent
c9f9a1ca0e feat(5.16+5.17): tipografie/antet branded + tipuri cont, planuri si enforcement
PRD 5.16 — propagare design finalizata (system font stack, fara IBM Plex self-hostat):
- US-001/002/008: tokeni --font-ui/--font-mono (system stack) + scala --fs-*; zero
  @font-face si zero /static/fonts/; landing aliniat la acelasi stack
- US-003: RAR online = dot compact in antet + meniu burger; banda rosie DOAR pe blocat
  (invariant zero-silent-failures pastrat)
- US-010: antet "ROMFAST AUTOPASS" + nume service + /login brandeit 2 coloane + badge plan;
  meniu burger cu separatoare; gate strict pe is_authenticated
- US-011: selector tema pill icon+eticheta (reuse THEMES)
- US-004/005/006/007: bug-fix editor prestatii (picker cod+denumire, add_extra in mod
  operatii, cod ales se salveaza fara "+", Renunta inchide via closest)
- US-012/013: landing Autentificare->/login; wizard import colapsat + 4 pasi pe tokeni
- fix VERIFY E2E: contoare duplicate pe 390px (inline display:flex batea @media) -> CSS + test-lock

PRD 5.17 — tipuri de cont + trial Pro 30z + enforcement DUR:
- US-001/002/008: accounts.tier + trial_until (migrare aditiva defensiva); app/plans.py
  sursa unica (PLANS, FREE_MONTHLY_LIMIT=60, effective_tier(now injectabil), monthly_usage,
  CONSUMED_STATUSES); create_account trial Pro 30z; CLI set-tier (protejat id=1, audit)
- US-003/004/005: enforce volum 60/luna INAINTE de build_key pe ambele canale
  (PLAN_LIMITA_LUNARA, 3 niveluri + log_event); gate API Pro+ (PLAN_FARA_API 403 actionabil);
  valideaza/nomenclator raman permise; downgrade lazy; flag AUTOPASS_ENFORCE_PLANS (kill-switch)
- US-006: badge plan antet + linie burger + consum N/60 + warn>=80% + 6 stari + copy RO
  pluralizat + banner one-time trial->Gratuit + pagina Cont

Regresie: 1380 passed, 0 failed, 1 deselected (live). E2E browser pe 390/1280 confirmat.
Backend trimitere (worker/masina stari/idempotenta/contract RAR) NEATINS. Lucrul 5.18
(corpus kNN) ramane separat, necomis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:02:40 +00:00
Claude Agent
9eccb9f6fa docs(autoplan): review 5.16+5.17 + decizii porti umane + ROADMAP
Rulare /autoplan in paralel (2 agenti) pe PRD 5.16 si 5.17, faze
CEO/Design/Eng(/DX), single-voice (Codex la plafon pana 2026-07-18).
Audit trail + GSTACK REVIEW REPORT scrise in fiecare PRD; 0 decizii
deschise pe ambele.

Decizii inchise cu user:
- 5.16 User Challenge -> system-ui (scoate IBM Plex self-hostat; risc
  per-OS + design slop acceptat constient). Pre-ship: teste Eng E1/E3.
- 5.17 User Challenge -> enforcement DUR direct de la deploy; CRITICAL
  GAP migrare legacy = MOOT (pre-productie/fara conturi legacy); flag
  AUTOPASS_ENFORCE_PLANS optional; 3 taste decisions rezolvate pe
  recomandare (limita 60 = constanta config; banner one-time
  trial->Gratuit; valideaza dry-run permis pe orice plan).

ROADMAP: linia "Ultima actualizare" + randuri noi 5.16/5.17 (TODO,
gata de implementare) in tabelul Etapa 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:58:33 +00:00
Claude Agent
8dd0e1678c docs(prd): 5.16 tipografie+bugfix editare + 5.17 tipuri cont + mockup-uri
PRD 5.16 (draft) — propagare design uniform peste aplicatie:
- fonturi standard web (system font stack), scala uniforma --fs-* (carduri aerisite)
- RAR online = dot in antet (datetime pe hover) + meniu burger; banda doar cand e blocat
- antet branded "ROMFAST AUTOPASS" + nume service + badge plan (gate is_authenticated)
- /login profesional (antet minimal pre-login), selector tema stil landing
- bug-uri editare: denumiri in picker, adaugare operatie extra, fix save no-op, fix Renunta
- dashboard compact: strip-less, contoare separate (mobil = bara numere), import colapsat,
  ordine carduri->import->tab-uri->lista, meniu cu separatoare
- wizard import (4 pasi) + editare/corectie aliniate la design

PRD 5.17 (draft) — tipuri de cont (Gratuit/Standard/Pro/Premium) + trial Pro 30 zile:
- model accounts.tier + trial_until, app/plans.py sursa unica
- enforcement DUR: limita Gratuit 60/luna (era 100) + API doar Pro+
- downgrade automat la expirare trial; aliniere landing (60, "Pro gratuit 30 zile")

Mockup-uri vizuale (docs/mockups/prd-5.16-*.html): fonturi, header+login+tema,
dashboard desktop+mobil, wizard import. Doar documentatie + mockup-uri; fara cod aplicatie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:20:20 +00:00
92 changed files with 149091 additions and 704 deletions

View File

@@ -53,3 +53,26 @@ Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand de
- [ ] **US-009/US-010 ca PRD separat daca propagarea design e urgenta** — salvarea mapare-din-chip si - [ ] **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 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.) 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.)

View File

@@ -15,6 +15,7 @@ inca fluxul de trimitere. (Addendum A2.)
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone
def _norm_cui(cui: str | None) -> str | None: def _norm_cui(cui: str | None) -> str | None:
@@ -43,6 +44,8 @@ def create_account(
cui: str | None = None, cui: str | None = None,
email: str | None = None, email: str | None = None,
active: bool = True, active: bool = True,
requested_plan: str | None = None,
consent_at: str | None = None,
) -> int: ) -> int:
"""Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1). """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. `email` se normalizeaza (trim+lower); sir gol -> ValueError.
Un CUI deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial 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. `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() name = (name or "").strip()
if not name: if not name:
raise ValueError("name gol (un cont are nevoie de nume)") raise ValueError("name gol (un cont are nevoie de nume)")
cui = _norm_cui(cui) cui = _norm_cui(cui)
email = _norm_email(email) 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: 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'. # Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
cur = conn.execute( cur = conn.execute(
"INSERT INTO accounts (name, cui, email, active, status) VALUES (?, ?, ?, ?, ?)", "INSERT INTO accounts (name, cui, email, active, status, tier, trial_until, "
(name, cui, email, 1 if active else 0, "active" if active else "pending"), "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: except sqlite3.IntegrityError:
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone() 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 # Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de
# retentie); restul sunt reversibile. # retentie); restul sunt reversibile.
VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted") 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). # Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b).
_PROTECTED_ACCOUNT_ID = 1 _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: def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele """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 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' """Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
(stergere soft -> invizibile in panou).""" (stergere soft -> invizibile in panou)."""
rows = conn.execute( 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" "WHERE status != 'deleted' ORDER BY id"
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]

View File

@@ -29,8 +29,10 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from datetime import datetime, timezone
from ... import errors 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 ...crypto import decrypt_creds, encrypt_creds
from ...db import get_connection from ...db import get_connection
from ...idempotency import build_key, canonicalize_row from ...idempotency import build_key, canonicalize_row
@@ -413,7 +415,7 @@ def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, di
async def upload_import( async def upload_import(
file: UploadFile, file: UploadFile,
sheet_name: str | None = None, sheet_name: str | None = None,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(require_api_access),
) -> dict: ) -> dict:
"""Upload fisier xlsx/csv -> staging in import_batches/import_rows. """Upload fisier xlsx/csv -> staging in import_batches/import_rows.
@@ -934,7 +936,7 @@ class CommitIn(BaseModel):
def commit_import( def commit_import(
import_id: int, import_id: int,
req: CommitIn, req: CommitIn,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(require_api_access),
) -> dict: ) -> dict:
"""Gate HARD confirmare + enqueue randuri ok + log atestare. """Gate HARD confirmare + enqueue randuri ok + log atestare.
@@ -1022,6 +1024,48 @@ def commit_import(
if n_total_ok == 0: if n_total_ok == 0:
raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.") 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 # Incarca maparea de coloane pentru a construi payload-ul
first_row_db = conn.execute( first_row_db = conn.execute(
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1", "SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",

View File

@@ -13,11 +13,13 @@ import csv
import io import io
import json import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field 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 ...crypto import encrypt_creds
from ...db import get_connection from ...db import get_connection
from ...errors import eroare as err_eroare 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) @router.post("/prezentari", response_model=PrezentariResponse)
def create_prezentari( def create_prezentari(
req: PrezentareRequest, req: PrezentareRequest,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(require_api_access),
) -> PrezentariResponse: ) -> PrezentariResponse:
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission. """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). # Reguli text incarcate o data per cerere (seam partajat cu dry-run).
text_rules = load_text_rules(conn, acct) text_rules = load_text_rules(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) 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: for prez in req.prezentari:
content = prez.model_dump() content = prez.model_dump()
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper). # canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).

View File

@@ -18,8 +18,9 @@ from __future__ import annotations
import hashlib import hashlib
import secrets import secrets
import sqlite3 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 .config import get_settings
from .db import get_connection from .db import get_connection
@@ -162,3 +163,59 @@ def resolve_account_id(
_log_auth_esuat(request, plaintext, "cheie API invalida sau revocata") _log_auth_esuat(request, plaintext, "cheie API invalida sau revocata")
raise HTTPException(status_code=401, detail="cheie API invalida sau revocata") raise HTTPException(status_code=401, detail="cheie API invalida sau revocata")
return account_id 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

View File

@@ -99,17 +99,39 @@ class Settings(BaseSettings):
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie # Dev: foloseste creds <test> din settings.xml pt login worker. In productie
# creds vin per-cerere de la ROAAUTO — lasa False. # creds vin per-cerere de la ROAAUTO — lasa False.
worker_use_test_creds: bool = 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_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_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
worker_retry_max_s: int = 300 worker_retry_max_s: int = 300
worker_max_retries: int = 8 # peste atat -> error + banner worker_max_retries: int = 8 # peste atat -> error + banner
# --- 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) --- # --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
# DEZACTIVAT implicit: prima folosire lazy-load-eaza modelul fastembed/ONNX # ACTIVAT implicit: editorul de mapari ofera sugestii semantice (model fastembed/ONNX).
# (~230MB pe disc) sincron in thread-ul de cerere -> hang la prima cerere /mapari. # Cost: prima folosire lazy-load-eaza modelul (~230MB pe disc) sincron in thread-ul de
# Activeaza explicit in productie (start.sh/Docker/.env) cand vrei sugestii semantice. # cerere -> prima cerere /mapari poate dura 30-120s pana modelul intra in memorie; cererile
# OFF pastreaza suita de teste rapida si /mapari instant (cade pe GOLD/SILVER+fuzzy). # urmatoare sunt instant. SUGGESTION-ONLY: nu intra in resolve_prestatii (nu auto-trimite).
embeddings_enabled: bool = False # 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 @property
def rar_base_url(self) -> str: def rar_base_url(self) -> str:

File diff suppressed because it is too large Load Diff

143
app/db.py
View File

@@ -37,6 +37,22 @@ def init_db() -> None:
from .mapping import seed_nomenclator_if_empty from .mapping import seed_nomenclator_if_empty
seed_nomenclator_if_empty(conn) 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: finally:
conn.close() conn.close()
@@ -55,11 +71,26 @@ def _migrate(conn: sqlite3.Connection) -> None:
conn.execute("ALTER TABLE submissions ADD COLUMN batch_id INTEGER") conn.execute("ALTER TABLE submissions ADD COLUMN batch_id INTEGER")
if "row_index" not in sub_cols: if "row_index" not in sub_cols:
conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER") 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 # Coloane accounts
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()} acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
if "rar_creds_enc" not in acc_cols: if "rar_creds_enc" not in acc_cols:
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT") 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: if "active" not in acc_cols:
# Conturi existente raman active (default 1). # Conturi existente raman active (default 1).
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL 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: if "email" not in acc_cols:
# Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy. # Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy.
conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT") 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. # Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
conn.execute( conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL" "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: def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds") return datetime.now(timezone.utc).isoformat(timespec="seconds")

View File

@@ -11,7 +11,7 @@ Design (PRD 5.14, Decision #16/#16b):
API public (nivel modul): API public (nivel modul):
index_corpus(items) -> None index_corpus(items) -> None
suggest_nearest(text, top_k) -> [{cod, similaritate}] suggest_nearest(text, top_k) -> [{cod, is_nul, similaritate}]
is_available() -> bool is_available() -> bool
Clase (pentru teste / injectare backend): Clase (pentru teste / injectare backend):
@@ -135,10 +135,12 @@ class EmbeddingEngine:
denumire: str, denumire: str,
top_k: int = 3, top_k: int = 3,
) -> list[dict]: ) -> list[dict]:
"""Returneaza top_k vecini cosine [{cod, similaritate}]. """Returneaza top_k vecini cosine [{cod, is_nul, similaritate}].
Returneaza [] daca backend-ul lipseste, corpus-ul e gol sau apare `is_nul` (PRD 5.18 US-005): cand corpusul include exemple NUL (non-operatii),
orice exceptie (degradare gratioasa -- nu blocheaza ingestia). 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: if not self.is_available() or not self._corpus_items:
return [] return []
@@ -149,6 +151,7 @@ class EmbeddingEngine:
scored = [ scored = [
{ {
"cod": item["cod"], "cod": item["cod"],
"is_nul": bool(item.get("is_nul", False)),
"similaritate": _cosine_similarity(query_vec, vec), "similaritate": _cosine_similarity(query_vec, vec),
} }
for item, vec in zip(self._corpus_items, self._corpus_vecs) for item, vec in zip(self._corpus_items, self._corpus_vecs)
@@ -239,7 +242,7 @@ def index_corpus(items: list[dict], signature: str | None = None) -> None:
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]: def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]:
"""Returneaza top_k sugestii [{cod, similaritate}] sau [] la eroare. """Returneaza top_k sugestii [{cod, is_nul, similaritate}] sau [] la eroare.
Sigur de apelat indiferent de starea backend-ului. Sigur de apelat indiferent de starea backend-ului.
""" """

View File

@@ -178,6 +178,22 @@ CATALOG: dict[str, dict[str, str]] = {
" cererii (request_id) afisat." " 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."
),
},
} }

View File

@@ -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: def build_key(account_id: int | None, canon: dict[str, Any], rar_env: str = "test") -> str:
"""SHA-256 partajat canal-API + canal-import. """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 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. 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) # Import local ca sa evitam import circular (mapping importa din idempotency via validator)
from .mapping import account_or_default from .mapping import account_or_default
acct = account_or_default(account_id) acct = account_or_default(account_id)
canonic = { canonic = {
"account_id": acct, "account_id": acct,
"rar_env": rar_env,
"vin": canon.get("vin", ""), "vin": canon.get("vin", ""),
"nr_inmatriculare": canon.get("nr_inmatriculare", ""), "nr_inmatriculare": canon.get("nr_inmatriculare", ""),
"data_prestatie": canon.get("data_prestatie"), "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() return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str: def idempotency_key(account_id: int | None, prezentare: dict[str, Any], rar_env: str = "test") -> str:
"""SHA-256 peste (account_id + campurile semnificative ale prezentarii). """SHA-256 peste (account_id + rar_env + campurile semnificative ale prezentarii).
Wrapper backward-compat peste canonicalize_row + build_key. Wrapper backward-compat peste canonicalize_row + build_key.
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei). 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. acoperite automat — dual-lookup sau recompute-keys la migrare productie.
""" """
canon = canonicalize_row(prezentare) 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: def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re
import unicodedata import unicodedata
from typing import Any from typing import Any
@@ -49,6 +50,60 @@ def normalize_for_match(value: object) -> str:
return " ".join(s.upper().split()) 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( def suggest_codes(
denumire: object, denumire: object,
nomenclator: list[dict], nomenclator: list[dict],
@@ -576,51 +631,58 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
EMB_MIN_SIMILARITATE = 0.5 EMB_MIN_SIMILARITATE = 0.5
def _corpus_signature(nomenclator: list[dict]) -> str: def _corpus_signature_silver(rows: list) -> str:
"""Semnatura stabila a nomenclatorului pentru cache-ul corpusului embeddings. """Semnatura stabila a corpusului SILVER (mapping_suggestions) pentru cache.
Hash pe perechile (cod, denumire) sortate dupa cod -> se schimba la orice Hash pe (denumire_normalizata, cod, is_nul) sortat -> se schimba la orice
add/remove/redenumire de cod, ramane stabila altfel (evita re-embed inutil). add/remove/redenumire/relabel, ramane stabila altfel (evita re-embed inutil).
""" """
pairs = sorted( triples = sorted(
(str(n.get("cod_prestatie") or ""), str(n.get("nume_prestatie") or "")) (str(r["denumire_normalizata"] or ""), str(r["cod_prestatie"] or ""), int(r["is_nul"] or 0))
for n in nomenclator for r in rows
) )
blob = "".join(f"{c}{d}" for c, d in pairs) blob = "".join(f"{d}|{c}|{n}" for d, c, n in triples)
return hashlib.sha256(blob.encode("utf-8")).hexdigest() return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None: def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
"""Construieste/actualizeaza corpusul embeddings din nomenclator (Stratul 2 PRD 5.14). """Construieste/actualizeaza corpusul embeddings din corpusul ETICHETAT (PRD 5.18 US-005).
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default OFF): cand e dezactivat, e un Sursa corpusului = `mapping_suggestions` (SILVER): exemple reale etichetate
no-op total (nu atinge modelul, nu interogheaza nomenclatorul) -> /mapari instant {denumire_normalizata -> cod, is_nul}, NU cele 18 categorii generice din
+ suita de teste rapida; sugestiile cad pe GOLD/SILVER + fuzzy. `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.
Cand e activat: indexeaza corpusul {denumire=nume_prestatie, cod=cod_prestatie} Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default ON; OFF in teste): cand e
o singura data (lazy-load modelul ~230MB la prima chemare), re-indexeaza doar dezactivat, e un no-op total -> /mapari instant + suita de teste rapida.
cand semnatura nomenclatorului s-a schimbat. Degradare gratioasa: orice eroare
(model absent, embed esuat) lasa corpusul gol -> enrich_suggestions cade pe restul.
Apelat de apelantii care imbogatesc sugestii (pending_unmapped, Cand e activat: indexeaza corpusul o singura data (lazy-load modelul ~230MB la
_nemapate_pentru_submission) INAINTE de bucla de enrich_suggestions, NU din prima chemare), re-indexeaza doar cand semnatura corpusului SILVER s-a schimbat.
enrich_suggestions (care ramane o interogare ieftina cu garda has_corpus()). 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 from .config import get_settings
if not get_settings().embeddings_enabled: if not get_settings().embeddings_enabled:
return return
try: try:
from . import embeddings as _emb from . import embeddings as _emb
nomen = nomenclator if nomenclator is not None else load_nomenclator(conn) rows = conn.execute(
if not nomen: "SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions"
).fetchall()
if not rows:
return return
sig = _corpus_signature(nomen) sig = _corpus_signature_silver(rows)
if _emb.corpus_signature() == sig and _emb.has_corpus(): if _emb.corpus_signature() == sig and _emb.has_corpus():
return # deja indexat pe acelasi nomenclator -> nimic de facut return # deja indexat pe acelasi corpus SILVER -> nimic de facut
items = [ items = [
{"denumire": str(n["nume_prestatie"]), "cod": str(n["cod_prestatie"])} {
for n in nomen "denumire": str(r["denumire_normalizata"]),
if n.get("nume_prestatie") and n.get("cod_prestatie") "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) _emb.index_corpus(items, signature=sig)
except Exception: except Exception:
@@ -641,26 +703,38 @@ def enrich_suggestions(
(Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping; (Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping;
nu apare in needs_mapping, deci nu e in precedenta de sugestie.) 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: Returneaza:
{ {
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None, 'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None} 'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None, 'nul': bool}
} }
INVARIANTE: INVARIANTE:
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13). - Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13).
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie (#4). - 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, - Degradare gratioasa pe embeddings (#16b): daca motorul nu e disponibil sau arunca,
returneaza sugestia disponibila din celelalte surse, fara exceptie. returneaza sugestia disponibila din celelalte surse, fara exceptie.
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store - Import local shared_store/embeddings: evita ciclu la import-time (shared_store
importa normalize_for_match din mapping). importa normalize_for_match din mapping).
""" """
sugestie_principala: dict | None = None sugestie_principala: dict | None = None
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None} surse: dict = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
if not denumire: if not denumire:
return {"sugestie_principala": sugestie_principala, "surse": surse} 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 # Colecteaza TOATE sursele (fara short-circuit) in `surse`: editorul le poate afisa
# toate, independent de care castiga ca sugestie principala. # toate, independent de care castiga ca sugestie principala.
# Precedenta Eng-F2 se aplica DOAR la alegerea sugestiei_principale. # Precedenta Eng-F2 se aplica DOAR la alegerea sugestiei_principale.
@@ -693,11 +767,18 @@ def enrich_suggestions(
# ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand # ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand
# flagul e off, has_corpus() ramane False si calea e un no-op real. # flagul e off, has_corpus() ramane False si calea e un no-op real.
if _emb.has_corpus(): if _emb.has_corpus():
nn = _emb.suggest_nearest(str(denumire), top_k=1) # 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. # Prag minim: similaritate prea mica = sugestie inutila.
# Evita recomandari irelevante cand corpus-ul e mic/partial. # Evita recomandari irelevante cand corpus-ul e mic/partial.
if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE: if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
surse["embedding"] = str(nn[0]["cod"]) 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: except Exception:
pass # degradare gratioasa (#16b): motorul absent nu blocheaza pass # degradare gratioasa (#16b): motorul absent nu blocheaza

59
app/operatii_seed.py Normal file
View 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)

130
app/plans.py Normal file
View 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
View 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))

View File

@@ -19,12 +19,36 @@ CREATE TABLE IF NOT EXISTS accounts (
-- vezi accounts.delete_account — randul ramane doar pentru audit). -- vezi accounts.delete_account — randul ramane doar pentru audit).
status TEXT NOT NULL DEFAULT 'active' status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('pending','active','blocked','archived','deleted')), 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: -- Comportament implicit la cod prestatie necunoscut/nemapat pe canalul API:
-- 0 (default, non-distructiv: submission 'needs_mapping', intra in editorul de mapare) sau -- 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. -- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
CHECK (on_unmapped_error_default IN (0, 1)), 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')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi -- 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' status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')), CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
payload_json TEXT NOT NULL, 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_creds_enc TEXT, -- creds RAR criptate (Fernet), sterse dupa primul login reusit
rar_status_code INTEGER, rar_status_code INTEGER,
rar_error TEXT, rar_error TEXT,

View File

@@ -46,9 +46,12 @@ def seed_suggestions(
continue continue
is_nul = 1 if item.get("is_nul") else 0 is_nul = 1 if item.get("is_nul") else 0
# NUL -> cod NULL obligatoriu (supresie stricta, #4) # NUL -> cod NULL obligatoriu (supresie stricta, #4)
cod = None if is_nul else ((item.get("cod_prestatie") or "") or None) # Normalizeaza INAINTE de truthiness: un cod whitespace-only (" ") sau
if cod: # ne-string trebuie sa devina NULL, nu '' (altfel rand non-NUL cu cod gol).
cod = cod.strip().upper() 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") source = str(item.get("source") or "llm")
confidence = float(item.get("confidence") or 0.0) confidence = float(item.get("confidence") or 0.0)
cur = conn.execute( cur = conn.execute(

View File

@@ -8,6 +8,7 @@ Rute:
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
@@ -15,12 +16,42 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from .. import __version__ 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 ..config import get_settings
from ..db import get_connection from ..db import get_connection
from ..plans import PLANS, effective_tier
from ..web.csrf import get_csrf_token, verify_csrf from ..web.csrf import get_csrf_token, verify_csrf
from ..web.session import require_admin 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() router = APIRouter()
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) _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.""" """Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare."""
accounts = list_accounts(conn) accounts = list_accounts(conn)
emails = _emails_by_account(conn) emails = _emails_by_account(conn)
now = datetime.now(timezone.utc)
for acct in accounts: for acct in accounts:
# Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului # Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului
acct["is_complete"] = account_is_complete(acct) acct["is_complete"] = account_is_complete(acct)
acct["email"] = emails.get(acct["id"]) 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) # 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. # 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] 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") 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) @router.post("/admin/deactivate", response_class=HTMLResponse)
async def admin_deactivate( async def admin_deactivate(
request: Request, request: Request,

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
@@ -9,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from .. import __version__ from .. import __version__
from ..accounts import create_account from ..accounts import VALID_TIERS, create_account
from ..auth import create_api_key from ..auth import create_api_key
from ..config import get_settings from ..config import get_settings
from ..db import get_connection from ..db import get_connection
@@ -47,10 +48,18 @@ async def signup_post(
cui: str = Form(default=""), cui: str = Form(default=""),
email: str = Form(default=""), email: str = Form(default=""),
parola: str = Form(default=""), parola: str = Form(default=""),
plan: str = Form(default=""),
consent: str = Form(default=""),
csrf_token: str = Form(default=""), csrf_token: str = Form(default=""),
): ):
verify_csrf(request, csrf_token) 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() settings = get_settings()
ip = request.client.host if request.client else "unknown" ip = request.client.host if request.client else "unknown"
if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s): if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s):
@@ -58,7 +67,7 @@ async def signup_post(
request, request,
csrf_token=get_csrf_token(request), csrf_token=get_csrf_token(request),
error=_RATE_MSG, error=_RATE_MSG,
name=name, cui=cui, email=email, name=name, cui=cui, email=email, plan=requested_plan,
), status_code=429) ), status_code=429)
if len(parola) < _PASSWORD_MIN: if len(parola) < _PASSWORD_MIN:
@@ -66,7 +75,7 @@ async def signup_post(
request, request,
csrf_token=get_csrf_token(request), csrf_token=get_csrf_token(request),
error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.", 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) ), status_code=422)
# CUI obligatoriu la signup (US-001, PRD 5.12) # CUI obligatoriu la signup (US-001, PRD 5.12)
@@ -76,9 +85,19 @@ async def signup_post(
request, request,
csrf_token=get_csrf_token(request), csrf_token=get_csrf_token(request),
error="CUI-ul firmei este obligatoriu.", error="CUI-ul firmei este obligatoriu.",
name=name, cui=cui, email=email, name=name, cui=cui, email=email, plan=requested_plan,
), status_code=422) ), 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, # Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1. # astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
conn = get_connection() conn = get_connection()
@@ -86,7 +105,10 @@ async def signup_post(
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
try: try:
is_first = count_admins(conn) == 0 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) user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
api_key = create_api_key(conn, account_id) api_key = create_api_key(conn, account_id)
conn.execute("COMMIT") conn.execute("COMMIT")
@@ -121,7 +143,7 @@ async def signup_post(
request, request,
csrf_token=get_csrf_token(request), csrf_token=get_csrf_token(request),
error=error_msg, error=error_msg,
name=name, cui=cui, email=email, name=name, cui=cui, email=email, plan=requested_plan,
), status_code=422) ), status_code=422)
except Exception as exc: except Exception as exc:
conn.execute("ROLLBACK") conn.execute("ROLLBACK")
@@ -129,7 +151,7 @@ async def signup_post(
request, request,
csrf_token=get_csrf_token(request), csrf_token=get_csrf_token(request),
error=str(exc), error=str(exc),
name=name, cui=cui, email=email, name=name, cui=cui, email=email, plan=requested_plan,
), status_code=422) ), status_code=422)
finally: finally:
conn.close() conn.close()

View File

@@ -26,6 +26,7 @@ from fastapi.templating import Jinja2Templates
from .. import __version__ from .. import __version__
from .. import errors as _errors from .. import errors as _errors
from ..auth import rotate_api_key from ..auth import rotate_api_key
from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS
from ..payload_view import prezentare_din_payload from ..payload_view import prezentare_din_payload
from ..web.csrf import get_csrf_token, verify_csrf from ..web.csrf import get_csrf_token, verify_csrf
from .labels import ( from .labels import (
@@ -374,7 +375,7 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str:
).fetchone() ).fetchone()
are_creds = bool(row and row["rar_creds_enc"]) are_creds = bool(row and row["rar_creds_enc"])
account_meta = _fetch_account_meta(conn, acct) account_meta = _fetch_account_meta(conn, acct)
return templates.get_template("_cont.html").render({ cont_ctx = {
"request": request, "request": request,
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
"api_key": None, "api_key": None,
@@ -385,7 +386,10 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str:
"account_meta": account_meta, "account_meta": account_meta,
"date_firma_mesaj": None, "date_firma_mesaj": None,
"date_firma_eroare": None, "date_firma_eroare": None,
}) }
# US-006 (5.17): context plan pentru sectiunea Plan din _cont.html.
cont_ctx.update(_plan_ctx(conn, account_id))
return templates.get_template("_cont.html").render(cont_ctx)
def _render_panel_nomenclator(request: Request, conn) -> str: def _render_panel_nomenclator(request: Request, conn) -> str:
@@ -531,6 +535,139 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, sta
return _render_panel_acasa(request) return _render_panel_acasa(request)
# Etichete tier pentru badge in antet (US-010 PRD 5.16).
_TIER_LABELS: dict[str, str] = {
"free": "Gratuit",
"standard": "Standard",
"pro": "Pro",
"premium": "Premium",
}
def _plan_ctx(conn, account_id: int, now: datetime | None = None) -> dict:
"""Context afisaj plan (6 stari US-006 PRD 5.17) pentru _status.html, _cont.html si burger.
Returneaza:
plan_linie — linie completa cu copy RO (cele 6 stari)
plan_warn — True la >=80% consum sau limita atinsa (culoare + text)
plan_limita_atinsa — True la 100% consum (--err in loc de --warn)
trial_expirat_recent — True daca trial_until era setat si a expirat (banner one-time)
usage_lunar — numar prestatii acceptate in coada luna curenta
monthly_limit_val — limita lunara (60 pt free, None pt nelimitat)
effective_tier_name — tier-ul efectiv ('free','standard','pro','premium')
"""
if now is None:
now = datetime.now(timezone.utc)
acct = account_or_default(account_id)
row = conn.execute(
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
).fetchone()
tier_base = (row["tier"] if row else None) or "free"
trial_until_str = (row["trial_until"] if row else None)
eff = _eff_tier(row, now) if row else "free"
monthly_limit = _PLANS.get(eff, _PLANS["free"]).get("monthly_limit")
usage = _monthly_usage(conn, acct, now)
# Calcul zile ramase din trial activ
trial_ultima_zi = False
trial_days: int | None = None
if trial_until_str and eff == "pro" and tier_base == "free":
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)
delta = tu - now_cmp
trial_days = delta.days # 0 = < 1 zi ramasa (azi), 1 = < 2 zile, etc.
trial_ultima_zi = (trial_days <= 0)
except (ValueError, AttributeError, TypeError):
pass
# Construieste plan_linie si stari aferente (cele 6 stari din PRD)
warn_aproape = False
plan_limita_atinsa = False
trial_expirat_recent = False
if eff == "pro" and tier_base == "free" and trial_until_str:
# Trial Pro activ
if trial_ultima_zi:
plan_linie = "Plan: Pro · trial expira azi"
else:
n = trial_days or 0
z = "zi" if n == 1 else "zile"
plan_linie = f"Plan: Pro · trial {n} {z} ramase"
elif eff == "free":
# Free — cu sau fara trial expirat recent
if trial_until_str:
trial_expirat_recent = True
if monthly_limit is not None:
if usage >= monthly_limit:
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — limita atinsa"
warn_aproape = True
plan_limita_atinsa = True
elif monthly_limit > 0 and usage >= int(monthly_limit * 0.8):
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — aproape de limita"
warn_aproape = True
else:
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} luna asta"
else:
plan_linie = "Plan: Gratuit"
else:
# Platit (tier de baza != free, ex. standard/pro/premium alocat de admin)
label = _PLANS.get(eff, {}).get("label", eff.capitalize())
plan_linie = f"Plan: {label}"
return {
"plan_linie": plan_linie,
"plan_warn": warn_aproape,
"plan_limita_atinsa": plan_limita_atinsa,
"trial_expirat_recent": trial_expirat_recent,
"usage_lunar": usage,
"monthly_limit_val": monthly_limit,
"effective_tier_name": eff,
}
def _layout_header_ctx(conn, account_id: int) -> dict:
"""Context suplimentar pentru antetul branduit (US-010/003, PRD 5.16).
Citeste account_name, tier si starea de sanatate RAR pentru a popula:
- account_name: numele service-ului, afisat sub titlu cand logat
- tier_label: eticheta planului (Gratuit/Standard/Pro/Premium)
- sanatate_ok: True daca worker viu si RAR ok (dot verde in antet)
- last_login: data/ora ultimei autentificari RAR (format romanesc)
- plan_linie + plan_warn + ...: context plan US-006 (5.17) pentru burger
Apelat aditiv din dashboard() fara a atinge alti handlere.
"""
row = conn.execute(
"SELECT name, tier FROM accounts WHERE id=?", (account_id,)
).fetchone()
account_name = (row["name"] if row else None) or ""
tier = (row["tier"] if row else "free") or "free"
tier_label = _TIER_LABELS.get(tier, "Gratuit")
hb = read_heartbeat(conn)
worker_alive = _worker_alive(hb)
rar_state = _rar_state(hb, worker_alive)
rar_ok = rar_state == "ok"
sanatate_ok = worker_alive and rar_ok
ctx = {
"account_name": account_name,
"tier_label": tier_label,
"sanatate_ok": sanatate_ok,
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
}
# US-006 (5.17): context plan pentru linia detaliata din meniul burger.
ctx.update(_plan_ctx(conn, account_id))
return ctx
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse: def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse:
"""Dashboard principal cu tab-uri. """Dashboard principal cu tab-uri.
@@ -578,6 +715,9 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -
"is_admin": is_account_admin(conn, account_id), "is_admin": is_account_admin(conn, account_id),
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
} }
# US-010/003 (PRD 5.16): context antet (account_name, tier, sanatate RAR).
# Adaugat aditiv, fara a atinge handlerele altora.
ctx.update(_layout_header_ctx(conn, account_id))
return templates.TemplateResponse("dashboard.html", ctx) return templates.TemplateResponse("dashboard.html", ctx)
finally: finally:
conn.close() conn.close()
@@ -749,7 +889,7 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa
else: else:
sanatate_text = "Declaratiile curg normal" sanatate_text = "Declaratiile curg normal"
return { status_ctx = {
"request": request, "request": request,
"worker_lbl": worker_lbl, "worker_lbl": worker_lbl,
"rar_lbl": rar_lbl, "rar_lbl": rar_lbl,
@@ -771,6 +911,9 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa
"mapari_badge": counts.get("needs_mapping", 0), "mapari_badge": counts.get("needs_mapping", 0),
"oob": oob, "oob": oob,
} }
# US-006 (5.17): context plan pentru linia de consum/trial in _status.html.
status_ctx.update(_plan_ctx(conn, account_id))
return status_ctx
@router.get("/_fragments/status", response_class=HTMLResponse) @router.get("/_fragments/status", response_class=HTMLResponse)
@@ -1104,7 +1247,7 @@ def _nemapate_pentru_submission(row, nomenclator: list[dict], conn=None) -> list
"denumire": item.get("denumire"), "denumire": item.get("denumire"),
"suggestions": suggest_codes(item.get("denumire"), nomenclator, limit=5), "suggestions": suggest_codes(item.get("denumire"), nomenclator, limit=5),
"sugestie_principala": None, "sugestie_principala": None,
"surse_sugestie": {"gold_partajat": None, "silver": None, "embedding": None}, "surse_sugestie": {"gold_partajat": None, "silver": None, "embedding": None, "nul": False},
} }
# L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13) # L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13)
if conn is not None: if conn is not None:
@@ -1363,6 +1506,18 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
c.strip().upper() if isinstance(c, str) else "" c.strip().upper() if isinstance(c, str) else ""
for c in codes_raw for c in codes_raw
] ]
# US-006 (5.16): codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare.
# Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit.
# Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional.
# Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899).
_flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
if _flat_picker and _flat_picker not in codes_positional:
codes_positional.append(_flat_picker)
for _i in range(len(codes_positional)):
if not codes_positional[_i]:
_op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper()
if _op_val:
codes_positional[_i] = _op_val
# Verifica daca cel putin un cod non-gol a fost trimis # Verifica daca cel putin un cod non-gol a fost trimis
codes_nonempty = [c for c in codes_positional if c] codes_nonempty = [c for c in codes_positional if c]
if codes_nonempty: if codes_nonempty:
@@ -1923,6 +2078,7 @@ async def post_form_chips(request: Request) -> HTMLResponse:
}) })
action = str(form.get("chips_action") or "").strip() action = str(form.get("chips_action") or "").strip()
chips_extra_error = False # T-C1/T-E4 (5.16): semnal pentru add_extra esuat
conn = get_connection() conn = get_connection()
try: try:
@@ -1950,6 +2106,28 @@ async def post_form_chips(request: Request) -> HTMLResponse:
if exists: if exists:
chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""}) chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""})
elif action == "add_extra":
# US-005 (5.16): Adauga cod RAR liber (extra, fara op_service) in modul operatii.
# Refoloseste `chips_add_cod_flat` (acelasi select; dedup per-item E4 pastrat).
# T-C1/T-E4: select gol sau cod invalid → chips_extra_error = True (semnal vizibil).
add_cod_extra = str(form.get("chips_add_cod_flat") or "").strip().upper()
if add_cod_extra:
exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (add_cod_extra,)
).fetchone()
if exists:
# Dedup per-item (E4): nu adauga un chip extra identic deja existent
existing_pairs = {
(c.get("cod_op_service", ""), c.get("cod_prestatie", ""))
for c in chips
}
if ("", add_cod_extra) not in existing_pairs:
chips.append({"cod_prestatie": add_cod_extra, "cod_op_service": "", "denumire": ""})
else:
chips_extra_error = True # cod necunoscut in nomenclator
else:
chips_extra_error = True # select gol
elif action == "remove": elif action == "remove":
# Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata) # Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata)
try: try:
@@ -1983,6 +2161,7 @@ async def post_form_chips(request: Request) -> HTMLResponse:
"has_r_odo": has_r_odo, "has_r_odo": has_r_odo,
"form_chips_url": "/form-chips", "form_chips_url": "/form-chips",
"chips_section_id": "chips-section", "chips_section_id": "chips-section",
"chips_extra_error": chips_extra_error, # T-C1/T-E4 (5.16)
}) })
@@ -3525,6 +3704,46 @@ async def web_confirma_import(
n_total_ok = len(to_enqueue) n_total_ok = len(to_enqueue)
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut). Canal web.
from ..config import get_settings as _get_settings_plan
from ..plans import PLANS as _PLANS, effective_tier as _effective_tier, monthly_usage as _monthly_usage
_plan_settings = _get_settings_plan()
if _plan_settings.enforce_plans and n_total_ok > 0:
from datetime import datetime, timezone as _tz
_acct_row = conn.execute(
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
).fetchone()
_now_plan = datetime.now(_tz.utc)
_et = _effective_tier(_acct_row, _now_plan)
_plan_limit = _PLANS[_et].get("monthly_limit")
if _plan_limit is not None:
_usage = _monthly_usage(conn, acct, _now_plan)
if _usage + n_total_ok > _plan_limit:
_remaining = max(0, _plan_limit - _usage)
log_event(
"plan_limita_lunara_atinsa",
account_id=acct,
nivel="WARNING",
mesaj=f"Import web 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,
)
_err_msg = (
f"Ai atins limita planului Gratuit: {_usage}/{_plan_limit} prezentari luna aceasta."
f" Mai poti trimite {_remaining} luna aceasta."
f" Treci pe Standard sau Pro, sau asteapta luna viitoare."
)
_prev_result = _web_compute_preview(conn, import_id, account_id)
if isinstance(_prev_result, str):
return templates.TemplateResponse("_upload.html", _ctx(request, error=_err_msg))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, message=_err_msg, error=True, **_prev_result
))
# Gate HARD: n_confirmat trebuie sa fie exact egal cu numarul de randuri de trimis # Gate HARD: n_confirmat trebuie sa fie exact egal cu numarul de randuri de trimis
if n_confirmat != n_total_ok: if n_confirmat != n_total_ok:
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)

View File

@@ -21,7 +21,7 @@
In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul
intern) → <details> ramane neatins → containerul ramane deschis intre pasi. === #} intern) → <details> ramane neatins → containerul ramane deschis intre pasi. === #}
<details id="import-details"{% if not are_trimiteri %} open{% endif %}> <details id="import-details"{% if not are_trimiteri %} open{% endif %}>
<summary>Importa un fisier</summary> <summary>+ Importa fisier (XLSX / CSV)</summary>
{% include '_upload.html' %} {% include '_upload.html' %}
</details> </details>

View File

@@ -117,6 +117,63 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{# ===== US-005 (5.16): Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #}
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat).
T-7 (5.16): containerul .chips se randeaza DOAR cand exista chips extra — altfel ramanea
un chenar gol nefinisat sub randurile de operatie. #}
{% set _extra_chips = _chips | rejectattr('cod_op_service') | selectattr('cod_prestatie') | list %}
{% if _extra_chips %}
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;">
{% for chip in _extra_chips %}
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %}"
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}">
{{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">&times;</button>
</span>
{% endfor %}
</div>
{% endif %}
{% if nomenclator_rar %}
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;">
<select name="chips_add_cod_flat"
aria-label="Adauga cod RAR suplimentar"
style="min-width:160px;font-size:11px;height:26px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
<option value="">+ Adauga alta operatie / cod RAR</option>
{% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add_extra"}'
aria-label="Adauga cod RAR suplimentar la trimitere">
+
</button>
</span>
{% else %}
{# T-D1/T-E5 (5.16): empty state in mod operatii cand nomenclatorul lipseste #}
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;margin-top:4px;">
Nomenclator indisponibil — adaugarea de coduri suplimentare nu e posibila.
</div>
{% endif %}
{% if chips_extra_error %}
{# T-C1/T-E4 (5.16): semnal vizibil cand add_extra are select gol sau cod invalid #}
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
Selecteaza un cod RAR din lista inainte de a adauga.
</div>
{% endif %}
{% else %} {% else %}
{# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #} {# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #}
<div class="chips" role="group" aria-label="Coduri RAR selectate"> <div class="chips" role="group" aria-label="Coduri RAR selectate">
@@ -144,7 +201,7 @@
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);"> 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> <option value="">+ cod</option>
{% for n in nomenclator_rar %} {% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }}</option> <option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %} {% endfor %}
</select> </select>
<button type="button" <button type="button"
@@ -158,6 +215,11 @@
+ +
</button> </button>
</span> </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 %} {% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -6,19 +6,26 @@
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);" style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"
{% if oob %}hx-swap-oob="outerHTML"{% endif %}> {% if oob %}hx-swap-oob="outerHTML"{% endif %}>
<div class="card"> <div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;"> {# US-002 (5.16): titlul de sectiune vizibil ("Trimiterile tale") a fost eliminat —
<h2 id="trimiteri-heading" style="font-size:15px; margin:0;"> lista incepe direct sub filtre. Heading pastrat sr-only pentru a11y (section
Trimiterile tale aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
{% if blocate_total %} <h2 id="trimiteri-heading" class="sr-only">Trimiterile tale</h2>
<span class="tab-badge" title="{{ blocate_total }} necesita atentie" {% if blocate_total %}
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> <div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin:0 0 10px;">
{% endif %} <span class="tab-badge" title="{{ blocate_total }} necesita atentie"
</h2> 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;"> <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=sent" download>export CSV: trimise</a>
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a> <a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
</span> </span>
</div> </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]. <!-- 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). Pill-urile de stare scriu campul hidden status si re-trimit form-ul (filtreazaStare).

View File

@@ -1,6 +1,38 @@
<div class="card" id="card-cont"> <div class="card" id="card-cont">
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2> <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) --> <!-- Sectiunea: Date firma (US-002) -->
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);"> <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> <h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Date firma</h3>

View File

@@ -103,8 +103,8 @@
<div class="act-group" style="margin-top:14px;"> <div class="act-group" style="margin-top:14px;">
<button type="submit" class="act act-primary" aria-label="{{ btn_label or 'Salveaza' }}"> <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> <span class="act-tx">{{ btn_label or 'Salveaza' }}</span>{{ icon('save') }}</button>
<button type="button" class="act" aria-label="{{ cancel_label or 'Anuleaza' }}" data-modal-close> <button type="button" class="act" aria-label="{{ cancel_label or 'Renunta' }}" data-modal-close>
<span class="act-tx">{{ cancel_label or 'Anuleaza' }}</span>{{ icon('x') }}</button> <span class="act-tx">{{ cancel_label or 'Renunta' }}</span>{{ icon('x') }}</button>
</div> </div>
{% else %} {% else %}
<div style="margin-top:14px;"> <div style="margin-top:14px;">

View File

@@ -54,11 +54,22 @@
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div> <div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
</td> </td>
<td class="muted" style="font-size:12px;" data-eticheta="Sugestii"> <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 %} {% if e.suggestions %}
{% for s in e.suggestions[:3] %} {% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %} <span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %} {% endfor %}
{% else %}—{% endif %} {% elif not e.sugestie_principala and not (e.surse_sugestie and e.surse_sugestie.nul) %}—{% endif %}
</td> </td>
<td data-eticheta="Cod RAR"> <td data-eticheta="Cod RAR">
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required <select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
@@ -123,7 +134,7 @@
<input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}"> <input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
</form> </form>
<div><strong>{{ m.cod_op_service }}</strong></div> <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 %} acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
</div> </div>
</td> </td>

View File

@@ -4,7 +4,7 @@
{# prima_inregistrare poate veni din context (web_upload_import) sau derivat din sample_rows #} {# 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) -%} {%- set prima_inreg = prima_inregistrare if prima_inregistrare is defined else (sample_rows[0] if sample_rows else none) -%}
<div class="card"> <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 — Mapare coloane —
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span> <span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
</h2> </h2>
@@ -20,19 +20,19 @@
</div> </div>
{% endif %} {% 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. Asociaza fiecare coloana din fisier cu campul canonic corespunzator.
Maparea se retine automat pentru fisiere cu acelasi antet. Maparea se retine automat pentru fisiere cu acelasi antet.
</p> </p>
{# Tabel orizontal preview: antet + prima inregistrare (US-003) #} {# Tabel orizontal preview: antet + prima inregistrare (compatibilitate teste) #}
<div class="tablewrap" style="margin-bottom:16px;"> <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> <thead>
<tr> <tr>
{% for col in columns %} {% for col in columns %}
<th style="padding:4px 10px; text-align:left; background:var(--card); border:1px solid var(--line); <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 }} {{ col }}
</th> </th>
{% endfor %} {% endfor %}
@@ -44,7 +44,7 @@
{% for col in columns %} {% for col in columns %}
{%- set val = prima_inreg.get(col, '') | string -%} {%- set val = prima_inreg.get(col, '') | string -%}
<td style="padding:4px 10px; border:1px solid var(--line); white-space:nowrap; <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 }}"> title="{{ val }}">
{{ val[:40] }}{% if val | length > 40 %}…{% endif %} {{ val[:40] }}{% if val | length > 40 %}…{% endif %}
</td> </td>
@@ -53,7 +53,7 @@
{% else %} {% else %}
<tr> <tr>
<td colspan="{{ columns | length }}" <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;"> color:var(--muted); font-style:italic; text-align:center;">
Antet fara randuri de date Antet fara randuri de date
</td> </td>
@@ -69,7 +69,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}"> <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;"> <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 Format data
</label> </label>
<input type="text" id="format-data" name="format_data" <input type="text" id="format-data" name="format_data"
@@ -77,66 +77,97 @@
placeholder="ex: DD.MM.YYYY" placeholder="ex: DD.MM.YYYY"
style="max-width:160px;" style="max-width:160px;"
aria-describedby="format-data-hint"> 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. sau YYYY-MM-DD, MM/DD/YYYY etc.
</span> </span>
</div> </div>
{% for col in columns %} {# Tabel mapare: coloana din fisier | exemplu | camp RAR (mockup 5.16 / US-013) #}
{%- set sugg = fuzzy_suggestions.get(col, []) -%} <div class="tablewrap" style="margin-bottom:16px;">
{%- set best = sugg[0].camp_canonic if sugg else '' -%} <table style="border-collapse:collapse; width:100%;">
<input type="hidden" name="colname" value="{{ col }}"> <thead>
<div class="maprow"> <tr>
<div class="mapcol grow"> <th style="font-size:var(--fs-xs); width:34%; padding:6px 10px; text-align:left;
<div><strong>{{ col }}</strong></div> background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
{% if sugg %} font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
<div class="muted" style="font-size:12px; margin-top:2px;"> Coloana din fisier
sugestie: <span class="sugg">{{ sugg[0].camp_canonic }} </th>
({{ sugg[0].score | round | int }}%)</span> <th style="font-size:var(--fs-xs); width:28%; padding:6px 10px; text-align:left;
</div> background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
{% endif %} font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
{%- set ns = namespace(samples=[]) -%} Exemplu
{%- for row in sample_rows -%} </th>
{%- if row.get(col) is not none and row.get(col) != '' -%} <th style="font-size:var(--fs-xs); padding:6px 10px; text-align:left;
{%- set ns.samples = ns.samples + [row[col] | string] -%} background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
{%- endif -%} font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
{%- endfor -%} Camp RAR
{% if ns.samples %} </th>
<div class="muted" style="font-size:11px; margin-top:2px;"> </tr>
ex: {{ ns.samples[:2] | join(", ") }} </thead>
</div> <tbody>
{% endif %} {% for col in columns %}
</div> {%- set sugg = fuzzy_suggestions.get(col, []) -%}
<div class="mapcol" style="min-width:200px;"> {%- set best = sugg[0].camp_canonic if sugg else '' -%}
<label for="canon-{{ loop.index }}" {%- set ns = namespace(samples=[]) -%}
style="display:block; font-size:12px; color:var(--muted); margin-bottom:2px;"> {%- for row in sample_rows -%}
Camp canonic {%- if row.get(col) is not none and row.get(col) != '' -%}
</label> {%- set ns.samples = ns.samples + [row[col] | string] -%}
<select id="canon-{{ loop.index }}" name="canon"> {%- endif -%}
<option value="">— ignorat —</option> {%- endfor -%}
{% for field_key, field_label in canonical_fields %} <tr style="border-bottom:1px solid var(--line);">
<option value="{{ field_key }}" <td style="padding:9px 10px; vertical-align:top;">
{% if field_key == best %}selected{% endif %}> <input type="hidden" name="colname" value="{{ col }}">
{{ field_key }} — {{ field_label }} <strong style="font-family:var(--font-mono); font-size:var(--fs-sm);">{{ col }}</strong>
</option> {% 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 %} {% endfor %}
</select> </tbody>
</div> </table>
</div> </div>
{% endfor %}
<div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;"> <div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<button type="submit" <button type="submit"
{% if not prima_inreg %}disabled aria-disabled="true"{% endif %} {% 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 Salveaza si continua la preview
</button> </button>
{% if not prima_inreg %} {% 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. Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare.
</span> </span>
{% else %} {% 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 maparea se retine pentru fisiere cu acelasi antet
</span> </span>
{% endif %} {% endif %}
@@ -144,7 +175,7 @@
</form> </form>
<div style="margin-top:12px;"> <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> </div>
</div> </div>

View File

@@ -10,11 +10,11 @@
{% set pas = 3 %}{% include '_stepper.html' %} {% set pas = 3 %}{% include '_stepper.html' %}
<div class="card"> <div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> <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 — Preview —
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span> <span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
</h2> </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> </div>
{% if message %} {% if message %}
@@ -37,7 +37,8 @@
{% for status_key, label in status_labels %} {% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%} {%- set cnt = summary.get(status_key, 0) -%}
{% if cnt > 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 %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
@@ -46,14 +47,14 @@
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;" role="group" <div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;" role="group"
aria-label="Filtrare dupa stare"> aria-label="Filtrare dupa stare">
<button type="button" class="filter-btn" data-filter="all" <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 }}) Toate ({{ total }})
</button> </button>
{% for status_key, label in status_labels %} {% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%} {%- set cnt = summary.get(status_key, 0) -%}
{% if cnt > 0 %} {% if cnt > 0 %}
<button type="button" class="filter-btn" data-filter="{{ status_key }}" <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);"> background:transparent; border-color:var(--line); color:var(--ink);">
{{ label }} ({{ cnt }}) {{ label }} ({{ cnt }})
</button> </button>
@@ -66,7 +67,7 @@
{% if unmapped_ops %} {% if unmapped_ops %}
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;"> <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> <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 Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e
preselectata) si salveaza — randurile blocate trec automat in preselectata) si salveaza — randurile blocate trec automat in
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare. <span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
@@ -143,19 +144,18 @@
{% endif %} {% endif %}
<!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri). <!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
US-007: 8 coloane (coloana de verificare eliminata). 5.16 (T-4): densitate redusa la coloanele esentiale — Stare / Vehicul /
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). --> 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"> <div id="preview-tabel" class="tablewrap tabel-trimiteri">
<table> <table>
<thead> <thead>
<tr> <tr>
<th class="col-id">#</th>
<th class="col-stare">Stare</th> <th class="col-stare">Stare</th>
<th class="col-vehicul">Vehicul</th> <th class="col-vehicul">Vehicul</th>
<th class="col-operatie">Operatie</th> <th class="col-operatie">Operatie</th>
<th class="col-data">Data</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> <th class="col-actiuni">Actiuni</th>
</tr> </tr>
</thead> </thead>
@@ -167,7 +167,7 @@
</table> </table>
<!-- Mesaj "filtrat la zero": afisat de JS cand filtrul ascunde toate randurile --> <!-- Mesaj "filtrat la zero": afisat de JS cand filtrul ascunde toate randurile -->
<p id="preview-zero-message" class="muted" <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. Niciun rand nu corespunde filtrului selectat.
</p> </p>
</div> </div>
@@ -190,7 +190,7 @@
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;"> <div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<label for="n-confirmat" <label for="n-confirmat"
style="font-size:13px; color:var(--muted);"> style="font-size:var(--fs-sm); color:var(--muted);">
Confirma numarul Confirma numarul
</label> </label>
<input type="number" id="n-confirmat" name="n_confirmat" <input type="number" id="n-confirmat" name="n_confirmat"
@@ -198,7 +198,7 @@
min="0" required min="0" required
style="max-width:80px;" style="max-width:80px;"
aria-describedby="n-hint"> aria-describedby="n-hint">
<span id="n-hint" class="muted" style="font-size:12px;"> <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 din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
</span> </span>
</div> </div>
@@ -207,13 +207,13 @@ din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
<div style="display:flex; flex-direction:column; gap:6px; align-self:flex-end;"> <div style="display:flex; flex-direction:column; gap:6px; align-self:flex-end;">
<button type="submit" <button type="submit"
id="confirm-btn" 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 %}> {% if not summary.get('ok', 0) %}disabled title="Niciun rand ok de trimis"{% endif %}>
Trimite la RAR Trimite la RAR
</button> </button>
{% if summary.get('needs_data', 0) or summary.get('needs_mapping', 0) or summary.get('needs_review', 0) %} {% 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 <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) descarca randuri cu probleme (CSV)
</a> </a>
{% endif %} {% endif %}
@@ -226,7 +226,7 @@ din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
<span id="preview-ok-count" data-ok="{{ summary.get('ok', 0) }}" hidden></span> <span id="preview-ok-count" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
<div style="padding:8px 0 4px;"> <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> hx-get="/_import/reset" hx-target="#import-section" hx-swap="outerHTML">Incarca alt fisier</a>
</div> </div>

View File

@@ -23,9 +23,22 @@
{% if _sent_dup %}class="preview-sent-row"{% endif %} {% if _sent_dup %}class="preview-sent-row"{% endif %}
{% if oob_tr %}hx-swap-oob="outerHTML:#preview-row-{{ row.row_index }}"{% 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 _sent_dup %}opacity:.6;{% endif %}"> style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif _sent_dup %}opacity:.6;{% endif %}">
<td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td> {#- 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"> <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>
<td class="col-vehicul" data-eticheta="Vehicul"> <td class="col-vehicul" data-eticheta="Vehicul">
{{ row.prez.vehicul_nr }} {{ row.prez.vehicul_nr }}
@@ -42,20 +55,6 @@
{% endif %} {% endif %}
</td> </td>
<td class="col-data" data-eticheta="Data prestatie">{{ row.prez.data_prestatie }}</td> <td class="col-data" data-eticheta="Data prestatie">{{ row.prez.data_prestatie }}</td>
<td class="col-km" data-eticheta="KM final">{{ row.prez.odometru }}</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-actiuni" data-eticheta="Actiuni" style="text-align:center;"> <td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
{% if status not in ('already_sent', 'duplicate_in_file') %} {% if status not in ('already_sent', 'duplicate_in_file') %}
<button type="button" class="btn-editeaza" <button type="button" class="btn-editeaza"
@@ -78,7 +77,7 @@
style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
{% for status_key, label in status_labels %} {% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%} {%- 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 %} {% endfor %}
</div> </div>
<span id="preview-ok-count" hx-swap-oob="true" data-ok="{{ summary.get('ok', 0) }}" hidden></span> <span id="preview-ok-count" hx-swap-oob="true" data-ok="{{ summary.get('ok', 0) }}" hidden></span>

View File

@@ -14,55 +14,84 @@
</div> </div>
{% endif %} {% endif %}
{# === D6: Strip sanatate mereu-vizibil DEASUPRA contoarelor === {# US-006 (5.17) — Banner one-time trial->Gratuit (T-DES-1): afisat la prima incarcare
Verde: worker viu + RAR ok → "Declaratiile curg normal" dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage.
Rosu: worker oprit SAU RAR inaccesibil → "Blocat: ... — declaratiile NU pleaca" Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #}
Glife accesibile ✓/✗ (nu doar culoare). Layout: glifa+text stanga, ultima auth dreapta. {% 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 %}
{# === 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" <div id="strip-sanatate"
role="status" role="status"
aria-live="polite" aria-live="polite"
style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;
padding:10px 14px; border-radius:8px; margin-bottom:14px; padding:10px 14px; border-radius:8px; margin-bottom:14px;
{% if sanatate_ok %}background:color-mix(in srgb, var(--ok) 13%, transparent); border:1px solid color-mix(in srgb, var(--ok) 30%, transparent); background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);">
{% else %}background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);
{% endif %}">
<div style="display:flex; align-items:center; gap:9px;"> <div style="display:flex; align-items:center; gap:9px;">
{% if sanatate_ok %}
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--ok);">&#10003;</span>
{% else %}
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--err);">&#10007;</span> <span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--err);">&#10007;</span>
{% endif %}
<span style="font-weight:700; font-size:13px;">{{ sanatate_text }}</span> <span style="font-weight:700; font-size:13px;">{{ sanatate_text }}</span>
</div> </div>
<span style="font:400 11px/1.4 'IBM Plex Mono',ui-monospace,monospace; color:var(--muted); white-space:nowrap;"> <span style="font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted); white-space:nowrap;">
{{ eticheta_ultima_auth }}: {{ last_login }} {{ eticheta_ultima_auth }}: {{ last_login }}
</span> </span>
</div> </div>
{% endif %}
{# === D4: 3 carduri-contor (mockup exact: Trimise / In coada / De corectat) === {# === US-002 (PRD 5.16): 5 carduri-contor separate (desktop) + bara compacta (mobil <=560px).
Responsive: flex-wrap => 3 pe rand desktop, 2/stivuite pe mobil (min-width:120px). Total / Luna asta / Azi / In coada / De corectat.
Trimise: all-time (cifra mare) + sub-linie "luna N · azi N" (D4 + E7).
De corectat: rosu cand >0 (s-error), muted cand 0.
#} #}
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:14px;"> {# 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">
{# Trimise (all-time principal, luna/azi secundar) #} {# Total trimise (all-time) #}
<div class="contor-card" style="flex:1; min-width:120px;"> <div class="contor-card" style="flex:1; min-width:100px;">
<div class="contor-cifra">{{ counts_sent }}</div> <div class="contor-cifra">{{ counts_sent }}</div>
<div class="contor-label">Trimise (total)</div> <div class="contor-label">Total</div>
<div class="contor-sub">luna {{ sent_month }} &middot; azi {{ sent_today }}</div>
</div> </div>
{# In coada (accent/albastru) #} {# Luna asta #}
<div class="contor-card" style="flex:1; min-width:120px;"> <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-cifra s-queued">{{ counts_queued }}</div>
<div class="contor-label">In coada</div> <div class="contor-label">In coada</div>
</div> </div>
{# De corectat (rosu daca >0, muted la 0; link catre lista) #} {# De corectat (rosu daca >0, muted la 0; link catre lista) #}
<a href="/" class="contor-card" <a href="/" class="contor-card"
style="flex:1; min-width:120px; text-decoration:none; display:block; cursor:pointer;" style="flex:1; min-width:80px; text-decoration:none; display:block; cursor:pointer;"
aria-label="De corectat: {{ blocate_total }} — click pentru lista de trimiteri"> 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-cifra {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
<div class="contor-label">De corectat</div> <div class="contor-label">De corectat</div>
@@ -70,6 +99,30 @@
</div> </div>
{# 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>
{# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping === {# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping ===
Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ. Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ.
#} #}
@@ -84,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> 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> </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) %}
&nbsp;<a href="/?tab=cont" style="font-size:var(--fs-xs); font-weight:400; color:var(--accent);">Detalii plan</a>
{% endif %}
</div>
{% endif %}
</div> </div>

View File

@@ -96,41 +96,43 @@
{% endif %} {% endif %}
</div> </div>
{# Bloc text principal — stanga, ocupa spatiul ramas #} {# 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;"> <div style="flex:1 1 auto; min-width:0;">
{# Linia 1: VIN mono scurt (slim-vin). {# Linia 1: nr. inmatriculare (placuta) — identificatorul primar pe care il
Guard: vin_scurt='—' inseamna VIN lipsa; fallback la vehicul_nr. #} scaneaza operatorul. .slim-vin reumplut (acelasi nume de clasa, churn minim).
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %} Fallback cand placuta lipseste ('—'): VIN scurt, apoi mesaj neutru
<div class="slim-vin">{{ r.prez.vin_scurt }}</div> (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 %} {% else %}
<div class="slim-vin muted">{{ r.prez.vehicul_nr }}</div> <div class="slim-vin muted">fara numar</div>
{% endif %} {% endif %}
{# Linia 2: Operatie · ora/data (slim-meta muted) #} {# Linia 2: cod RAR (sau 'nemapat') · operatie (ink, ellipsis) · data prestatie.
<div class="slim-meta">{{ r.prez.operatie }} · {{ r.updated_at }}</div> Separatorul "·" e injectat prin CSS intre celule. Operatia primeste ellipsis
ca randul sa NU treaca pe a 3-a linie nici la 390px.
{# Cod RAR sau indicatorul 'nemapat': discret sub operatie. VIN integral, #id_prezentare si secundele traiesc in modalul de detaliu. #}
Mentine compatibilitatea cu testele cod_rar: OE-2 vizibil, fara prefix 'cod RAR:'. #} <div class="slim-meta slim-rand2">
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %} {% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
<div class="slim-meta"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div> <span class="cod-rar-cod">{{ r.prez.cod_rar }}</span>
{% else %} {% else %}
<div class="slim-meta muted cod-rar-sub">nemapat</div> <span class="cod-rar-cod cod-rar-sub muted">nemapat</span>
{% endif %} {% endif %}
<span class="slim-op">{{ r.prez.operatie }}</span>
{# Linia meta discreta: nr inmatriculare · data prestatie · nr prezentare RAR. {% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %}
Accesibila pe rand; informatia completa e in modalul de detaliu. #} <span class="slim-data muted">{{ r.prez.data_prestatie }}</span>
<div class="slim-meta" style="opacity:0.7;"> {% endif %}
{{ r.prez.vehicul_nr -}}
{%- if r.prez.data_prestatie and r.prez.data_prestatie != '—' %} · {{ r.prez.data_prestatie }}{% endif -%}
{%- if r.id_prezentare %} · #{{ r.id_prezentare }}{% endif %}
</div> </div>
{# Eticheta umana scurta sub pill — text mic, s-error pe error/needs_*. {# Micro-linie umana a problemei — text mic s-error, DOAR pe stari de problema
Afisata DOAR pe randuri cu problema (eticheta_problema ne-goala). (loud-on-exception D6). Randul normal/finalizat ramane strict 2 linii.
Starea transmisa prin TEXT, nu doar culoare. #} 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 %} {% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
<div class="eticheta-problema s-error" style="font-size:10px; margin-top:2px;">{{ r.eticheta_problema }}</div> <div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -52,15 +52,15 @@
role="region" aria-label="Zona de incarcare fisier" role="region" aria-label="Zona de incarcare fisier"
style="display:flex; align-items:center; gap:14px; flex-wrap:wrap; style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;
padding:12px 16px; text-align:left;"> 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" <input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv"
style="display:none;" aria-label="Selecteaza fisier xlsx sau csv"> style="display:none;" aria-label="Selecteaza fisier xlsx sau csv">
<button type="button" id="upload-btn" <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) Alege fisier (xlsx/csv)
</button> </button>
<span class="muted" style="font-size:13px;">sau trage aici</span> <span class="muted" style="font-size:var(--fs-sm);">sau trage aici</span>
<span class="muted" style="font-size:12px; margin-left:auto;"> <span class="muted" style="font-size:var(--fs-xs); margin-left:auto;">
NU se trimite nimic la RAR pana confirmi. NU se trimite nimic la RAR pana confirmi.
</span> </span>
</div> </div>
@@ -69,10 +69,10 @@
<div class="drop-zone" id="drop-zone" <div class="drop-zone" id="drop-zone"
role="region" aria-label="Zona de incarcare fisier"> role="region" aria-label="Zona de incarcare fisier">
{% if not sheets %} {% if not sheets %}
<p style="font-size:17px; margin:0 0 4px; font-weight:600;">Primul fisier? Trage-l aici.</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:13px;">xlsx sau csv, max 5000 randuri</p> <p class="muted" style="margin:0 0 16px; font-size:var(--fs-sm);">xlsx sau csv, max 5000 randuri</p>
{% else %} {% 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. Incarca fisierul din nou dupa ce ai ales foaia.
</p> </p>
{% endif %} {% endif %}
@@ -80,18 +80,18 @@
<input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv" <input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv"
style="display:none;" aria-label="Selecteaza fisier xlsx sau csv"> style="display:none;" aria-label="Selecteaza fisier xlsx sau csv">
<button type="button" id="upload-btn" <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) Alege fisier (xlsx/csv)
</button> </button>
</div> </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. NU se trimite nimic la RAR pana confirmi explicit.
</p> </p>
{% endif %} {% endif %}
<span id="upload-spinner" class="htmx-indicator muted" <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... se parseaza fisierul...
</span> </span>
</form> </form>

View File

@@ -10,6 +10,9 @@
'delete': ('Sterge', '/admin/delete', 'danger') '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) %} {% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
<div class="card"> <div class="card">
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3> <h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
@@ -34,7 +37,7 @@
<thead><tr> <thead><tr>
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}" <th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
aria-label="Selecteaza tot"></th> 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> </tr></thead>
<tbody> <tbody>
{% for acct in rows %} {% for acct in rows %}
@@ -46,6 +49,45 @@
<td>{{ acct.name }}</td> <td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td> <td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email 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><span class="pill">{{ acct.status }}</span></td>
<td class="muted">{{ acct.created_at or "—" }}</td> <td class="muted">{{ acct.created_at or "—" }}</td>
<td style="white-space:nowrap;"> <td style="white-space:nowrap;">

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title> <title>{% block title %}ROA AUTOPASS{% endblock %}</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script> <script>
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS // Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
@@ -36,78 +36,22 @@
})(); })();
</script> </script>
<style> <style>
/* IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti). /* US-001 PRD 5.16: stive de font standard web. Toate regulile font-face IBM Plex sterse.
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex); Motiv: decizie user (risc AI-Slop #11 acceptat constient), uniformitate cross-page.
reflow-ul vizibil pe VIN/coduri e acceptat explicit. */ Fisierele woff2 raman pe disc (curatare = follow-up optional, non-blocant).
@font-face { Referinte catre directorul de fonturi statice eliminate — font-ui si font-mono sunt stive sistem. */
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. /* Paleta dark (default) — accent azur ROMFAST.
--card2: fundal input/contor (= --bg, nivelul cel mai adanc). --card2: fundal input/contor (= --bg, nivelul cel mai adanc).
--line2: separator subtire (intre --bg si --line). */ --line2: separator subtire (intre --bg si --line). */
:root { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530; :root { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; } --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6;
/* 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) */ /* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --card2:#f5f7fa; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea; --line2:#eaedf2; [data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --card2:#f5f7fa; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea; --line2:#eaedf2;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; } --ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
@@ -135,7 +79,7 @@
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout `@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */ 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; } background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
/* Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */ /* Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */
header { padding:16px 24px; border-bottom:1px solid var(--line); header { padding:16px 24px; border-bottom:1px solid var(--line);
@@ -160,6 +104,18 @@
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; } 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; } .empty { color:var(--muted); padding:24px; text-align:center; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); } .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 /* Pill-uri de filtrare a starii (bara de filtre Trimiteri). Inactiv = contur+text pe
culoarea categoriei (injectata inline); activ = umplere pe acea culoare. */ culoarea categoriei (injectata inline); activ = umplere pe acea culoare. */
.pills-categorii { display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center; } .pills-categorii { display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center; }
@@ -187,6 +143,9 @@
.s-needs_review{color:var(--warn);} .s-needs_review{color:var(--warn);}
.s-already_sent,.s-duplicate_in_file{color:var(--muted);} .s-already_sent,.s-duplicate_in_file{color:var(--muted);}
.muted { 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); } a { color:var(--accent); }
/* Drop zone upload fisier */ /* Drop zone upload fisier */
.drop-zone { border:2px dashed var(--line); border-radius:8px; padding:32px 20px; .drop-zone { border:2px dashed var(--line); border-radius:8px; padding:32px 20px;
@@ -200,6 +159,44 @@
flex-wrap:wrap; z-index:10; } flex-wrap:wrap; z-index:10; }
/* Indicator HTMX — ascuns pana la request */ /* Indicator HTMX — ascuns pana la request */
.htmx-indicator { display:none; } .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; } .htmx-indicator.htmx-request { display:inline; }
/* Link-uri de actiune in antetul cardurilor: zona de atins mai mare (>=36px) si /* 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. */ feedback la hover; pe ecrane inguste antetul se rupe curat sub titlu. */
@@ -317,7 +314,7 @@
border-radius:0 6px 6px 0; } border-radius:0 6px 6px 0; }
.eroare-3n-sep { margin-top:6px; } .eroare-3n-sep { margin-top:6px; }
.eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; } .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-cauza { color:var(--muted); font-size:12px; margin-top:3px; }
.eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; } .eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; }
.eroare-3n-label { font-weight:500; } .eroare-3n-label { font-weight:500; }
@@ -416,7 +413,9 @@
.tabel-trimiteri th, .tabel-trimiteri td { white-space:normal; word-break:break-word; vertical-align:top; } .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-chk { width:30px; }
.tabel-trimiteri .col-id { width:48px; } .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-data { width:104px; }
.tabel-trimiteri .col-rar { width:96px; } .tabel-trimiteri .col-rar { width:96px; }
.tabel-trimiteri .col-actualizat { width:128px; } .tabel-trimiteri .col-actualizat { width:128px; }
@@ -424,8 +423,8 @@
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */ /* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; } .tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
/* Codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */ /* 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; .tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:var(--font-mono);
font-size:12px; padding:1px 7px; border:1px solid var(--line); font-size:var(--fs-xs); padding:1px 7px; border:1px solid var(--line);
border-radius:99px; color:var(--muted); } border-radius:99px; color:var(--muted); }
/* Eticheta umana scurta sub pill — text mic; clasa `s-error` o coloreaza /* Eticheta umana scurta sub pill — text mic; clasa `s-error` o coloreaza
(apare doar pe error/needs_*). Stare prin text, nu doar culoare. */ (apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
@@ -696,37 +695,75 @@
} }
/* === SENTINEL-COMPONENTE-SLIM: inceput componente slim US-002 (PRD 5.15). /* === SENTINEL-COMPONENTE-SLIM: inceput componente slim US-002 (PRD 5.15).
Testele ancoreaza pe acest marker. Nu muta/sterge. === */ Testele ancoreaza pe acest marker. Nu muta/sterge. === */
/* .contor-card — card cifra contor: fundal --card2, bordura --line, radius 8px, padding 10-12px. /* .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). */ Variante de culoare a cifrei prin clasele .s-* existente (verde/accent/rosu). */
.contor-card { background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:10px 12px; } .contor-card { background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:18px 18px; }
.contor-cifra { font-size:22px; font-weight:700; line-height:1; } .contor-cifra { font-size:var(--fs-2xl); font-weight:700; line-height:1; }
.contor-label { font-size:11px; color:var(--muted); margin-top:5px; } .contor-label { font-size:var(--fs-sm); color:var(--muted); margin-top:8px; }
.contor-sub { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:10px; color:var(--muted); margin-top:3px; } .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. /* .lista-trimiteri-slim + .trimitere-slim — lista compacta cu separator --line2.
Randul e clickabil (rol button), tinta min-height:44px pe mobil. */ Randul e clickabil (rol button), tinta min-height:44px pe mobil. */
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; } .lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
.trimitere-slim { display:flex; align-items:center; justify-content:space-between; gap:12px; .trimitere-slim { display:flex; align-items:center; justify-content:space-between; gap:12px;
padding:11px 14px; border-bottom:1px solid var(--line2); min-height:44px; cursor:pointer; } padding:14px 16px; border-bottom:1px solid var(--line2); min-height:44px; cursor:pointer; }
.trimitere-slim:last-child { border-bottom:none; } .trimitere-slim:last-child { border-bottom:none; }
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); } .trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } .trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.slim-vin { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:13px; font-weight:500; color:var(--ink); } .slim-vin { font-family:var(--font-mono); font-size:var(--fs-md); font-weight:600; color:var(--ink); }
.slim-meta { font-size:11px; color:var(--muted); margin-top:3px; } .slim-meta { font-size:var(--fs-sm); color:var(--muted); margin-top:3px; }
/* .camp-slim — varianta compacta camp formular: label 11px muted deasupra, input ~30px, fundal --card2. /* 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. */ Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
.camp-slim { margin-bottom:8px; } .camp-slim { margin-bottom:8px; }
.camp-slim label { font-size:11px; color:var(--muted); display:block; margin-bottom:4px; } .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); height:30px; width:100%; .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:inherit; color:var(--ink); } padding:0 10px; border:1px solid var(--line); border-radius:6px; font-family:var(--font-ui);
.camp-slim textarea { height:auto; min-height:48px; padding:8px 10px; resize:vertical; } font-size:var(--fs-md); color:var(--ink); }
.camp-slim .camp-mono { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:12px; } .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). /* .chips + .chip — prestatii multi-select cu buton de stergere accesibil (.chip-del).
Fundal accent 18%, font IBM Plex Mono 11px. */ Fundal accent 18%, font mono --fs-xs. */
.chips { min-height:30px; display:flex; align-items:center; gap:6px; flex-wrap:wrap; .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); } 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; .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); background:color-mix(in srgb, var(--accent) 18%, transparent); color:var(--accent);
font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:11px; font-weight:600; } 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; .chip .chip-del { background:transparent; border:none; color:inherit; opacity:.7; cursor:pointer;
padding:0; font-size:13px; line-height:1; display:inline-flex; padding:0; font-size:13px; line-height:1; display:inline-flex;
align-items:center; justify-content:center; min-width:16px; min-height:16px; } align-items:center; justify-content:center; min-width:16px; min-height:16px; }
@@ -737,23 +774,53 @@
/* .add-code — buton dashed pentru adaugare cod in chipbox */ /* .add-code — buton dashed pentru adaugare cod in chipbox */
.add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent; .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:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line));
border-radius:5px; color:var(--accent); font:500 10px inherit; cursor:pointer; } border-radius:5px; color:var(--accent); font:500 10px var(--font-ui); cursor:pointer; }
.add-code:hover, .add-code:focus-visible { border-style:solid; } .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 — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px; .op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
padding:8px 10px; border:1px solid var(--line); border-radius:6px; padding:8px 10px; border:1px solid var(--line); border-radius:6px;
background:var(--card2); margin-bottom:8px; } background:var(--card2); margin-bottom:8px; }
.op-row-name { font-size:12px; font-weight:500; color:var(--ink); } /* 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)); } .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) */ /* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */
@media (max-width:767px) { @media (max-width:767px) {
.trimitere-slim { padding:12px 14px; } .trimitere-slim { padding:12px 14px; }
} }
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim US-002 === */ /* === 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> </style>
</head> </head>
<body> <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> <header>
{# Celula stanga: logo ROMFAST #} {# Celula stanga: logo ROMFAST #}
<div class="header-left"> <div class="header-left">
@@ -763,35 +830,86 @@
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo"> <img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
</a> </a>
</div> </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. #} Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
<div class="header-center"> <div class="header-center">
<a href="/" style="text-decoration:none; color:inherit;"><h1>Gateway RAR AUTOPASS</h1></a> <a href="/" style="text-decoration:none; color:inherit;">
<span class="env">{{ rar_env }}</span> <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> </div>
{# Celula dreapta: comutator tema + versiune + meniu cont #} {# Celula dreapta: dot RAR (numai cand logat) + selector tema + versiune + meniu burger #}
<div class="header-right"> <div class="header-right">
<button id="tema-toggle" class="icon-btn" {# US-003 (PRD 5.16): dot RAR in antet — OK = chip verde pulsant, BLOCAT = chip rosu.
aria-label="Comuta tema (luminos/intunecat)" Banda plina apare DOAR in _status.html cand BLOCAT (nu mai e mereu vizibila). #}
title="Comuta tema">&#9728;</button> {% if is_authenticated|default(false) %}
<span class="muted" style="font-size:13px;">v{{ version }}</span> {% 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">&#9728;</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) %} {% if is_authenticated|default(false) %}
{# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout. {# 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. #} Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
<div class="cont-menu-wrap"> <div class="cont-menu-wrap">
<button id="cont-menu-toggle" class="icon-btn" <button id="cont-menu-toggle" class="icon-btn"
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu" aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button> aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden> <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> <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) %} {% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a> <a role="menuitem" href="/?tab=mapari">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> <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=cont">Cont</a>
<a role="menuitem" href="/?tab=integrare">Integrare</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> <a role="menuitem" href="/?tab=jurnal">Jurnal</a>
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %} {% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %}
<hr> <hr>
@@ -863,7 +981,11 @@
} }
function _syncButton(stored) { function _syncButton(stored) {
var s = VALID[stored] ? stored : 'auto'; 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.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
btn.title = LABELS[s]; // doar numele temei (ex. "Petrol"), nu ciclul intreg btn.title = LABELS[s]; // doar numele temei (ex. "Petrol"), nu ciclul intreg
} }
@@ -1129,7 +1251,7 @@
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc. // Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
overlay.addEventListener('click', function(e) { 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) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && isOpen()) { e.preventDefault(); close(); } if (e.key === 'Escape' && isOpen()) { e.preventDefault(); close(); }

View File

@@ -3,22 +3,16 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gateway RAR AUTOPASS — declară automat la RAR | ROMFAST</title> <title>ROA AUTOPASS — declari prestațiile la RAR din câteva click-uri</title>
<meta name="description" content="Gateway web care declară prestațiile de service-auto la RAR AUTOPASS, automat. Conform Legii 142/2023. Gratuit până la 100 de prestații/lună, fără card bancar."> <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> <style>
@font-face{font-family:"IBM Plex Sans";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;} /* US-001/US-008 (PRD 5.16): IBM Plex eliminat complet — stive font sistem standard web.
@font-face{font-family:"IBM Plex Sans";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+20AC,U+2122;} Tokenurile --font-ui / --font-mono definite in :root (sursa unica de adevar). */
@font-face{font-family:"IBM Plex Sans";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;} :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;}
@font-face{font-family:"IBM Plex Sans";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+20AC,U+2122;}
@font-face{font-family:"IBM Plex Sans";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-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+20AC,U+2122;}
@font-face{font-family:"IBM Plex Mono";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-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+20AC,U+2122;}
@font-face{font-family:"IBM Plex Mono";font-weight:500;font-display:swap;src:url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+20AC,U+2122;}
*{box-sizing:border-box;} *{box-sizing:border-box;}
html,body{margin:0;padding:0;} html,body{margin:0;padding:0;}
body{font-family:'IBM Plex Sans',system-ui,sans-serif;-webkit-font-smoothing:antialiased;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);} 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="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="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="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}
@@ -38,6 +32,7 @@
.page [style*="grid-template-columns"]{grid-template-columns:1fr!important;} .page [style*="grid-template-columns"]{grid-template-columns:1fr!important;}
.page [style*="padding:80px 40px"]{padding:48px 20px!important;} .page [style*="padding:80px 40px"]{padding:48px 20px!important;}
.page [style*="padding:0 40px 80px"]{padding:0 20px 48px!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:44px"]{padding:28px!important;}
.page [style*="padding:56px 40px"]{padding:40px 22px!important;} .page [style*="padding:56px 40px"]{padding:40px 22px!important;}
.page [style*="height:68px"]{height:60px!important;} .page [style*="height:68px"]{height:60px!important;}
@@ -53,56 +48,53 @@
.lp-hactions button{height:38px!important;padding:0 11px!important;font-size:13px!important;} .lp-hactions button{height:38px!important;padding:0 11px!important;font-size:13px!important;}
} }
@media (max-width:430px){ @media (max-width:430px){
.lp-hactions [data-act="auth"][data-tab="login"]{display:none!important;} .lp-hactions a.auth-login-link{display:none!important;}
} }
</style> </style>
</head> </head>
<body data-theme="grafit"> <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> <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"> <main class="page">
<!-- ANNOUNCE BAR --> <!-- HEADER -->
<div style="display:flex;align-items:center;justify-content:center;gap:16px;padding:10px 40px;background:var(--card,#181c24);border-bottom:1px solid var(--line,#262b36);font:500 13px 'IBM Plex Sans';color:var(--text,#e6e9ef);flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;gap:8px;"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Vrei să testezi sau ai un service mic? Este <strong style="font-weight:700;color:#1F9D5C;">gratuit</strong> — până la 100 de prestații/lună, fără card bancar.</span>
<a data-act="auth" data-tab="register" style="display:inline-flex;align-items:center;gap:5px;color:var(--accent,#2E74D6);font-weight:700;cursor:pointer;text-decoration:none;transition:color .18s ease, transform .18s ease;" style-hover="color:#17a96e;transform:translateX(2px)">Creează cont în 2 minute <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
</div>
<!-- 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 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:48px;"> <div style="display:flex;align-items:center;gap:14px;">
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" /> <img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px 'IBM Plex Sans';color:var(--sub,#8b93a7);"> <div style="display:flex;flex-direction:column;line-height:1.05;">
<span>Cum funcționează</span><span>API</span><span>Preț</span> <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> </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;"> <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 'IBM Plex Sans';cursor:pointer;"> <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> <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> <span id="theme-label">Grafit</span>
</button> </button>
<button data-act="auth" data-tab="login" style="height:44px;padding:0 18px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px 'IBM Plex Sans';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)">Autentificare</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 'IBM Plex Sans';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> <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>
</div> </div>
<!-- HERO --> <!-- HERO -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
<div> <div>
<div style="display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:99px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 13px 'IBM Plex Sans';margin-bottom:24px;"> <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>
<svg width="15" height="15" 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> <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>
<span><strong style="font-weight:700;color:#1F9D5C;">Gratuit</strong> pentru testare și service-uri mici · 100 prestații/lună</span> <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>
<h1 class="lp-h1" style="font:700 50px/1.06 'IBM Plex Sans';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 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 0 32px;max-width:480px;">Conformitate RAR fără bătaie de cap. Încarci un fișier sau conectezi softul de service — noi trimitem prezentările la RAR în siguranță, conform Legii 142/2023.</p>
<div style="display:flex;gap:12px;margin-bottom:22px;"> <div style="display:flex;gap:12px;margin-bottom:22px;">
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px 'IBM Plex Sans';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 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 'IBM Plex Sans';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> <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>
<div style="display:flex;align-items:center;gap:14px;font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);flex-wrap:wrap;">
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span> <span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
<span style="color:var(--line,#262b36);">·</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> <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>
<span style="color:var(--line,#262b36);">·</span>
<span>Fără card bancar</span>
</div> </div>
</div> </div>
@@ -110,132 +102,134 @@
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);overflow:hidden;"> <div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);"> <div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);">
<div> <div>
<div style="font:700 14px 'IBM Plex Sans';color:var(--text,#e6e9ef);">Trimiteri RAR AUTOPASS</div> <div style="font:700 14px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri RAR AUTOPASS</div>
<div style="font:400 12px 'IBM Plex Mono';color:var(--sub,#8b93a7);margin-top:2px;">Service Auto Vâlcea · 28 iun 2026</div> <div style="font:400 12px var(--font-mono);color:var(--sub,#8b93a7);margin-top:2px;">Service Auto Vâlcea · 28 iun 2026</div>
</div> </div>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<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 'IBM Plex Sans';color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Live</div> <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> </div>
<div style="display:flex;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line,#262b36);"> <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 'IBM Plex Sans';color:var(--text,#e6e9ef);">847</div><div style="font:400 11px 'IBM Plex Sans';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(--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 'IBM Plex Sans';color:var(--accent,#2E74D6);">12</div><div style="font:400 11px 'IBM Plex Sans';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: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 'IBM Plex Sans';color:#E05D5D;">2</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">De corectat</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>
<div style="padding:6px 0;"> <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 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 'IBM Plex Mono';color:var(--text,#e6e9ef);">WBA8E9...K7F2</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Inspecție tehnică · 09:42</div></div> <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 'IBM Plex Sans';color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</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 style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);"> <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 'IBM Plex Mono';color:var(--text,#e6e9ef);">WVWZZZ...3M1</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Revizie periodică · 09:38</div></div> <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 'IBM Plex Sans';color:var(--infot,#6ea2ec);"><span style="width:6px;height:6px;border-radius:99px;background:var(--accent,#2E74D6);"></span>În coadă</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>
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);"> <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 'IBM Plex Mono';color:var(--text,#e6e9ef);">VF1RFB...A88</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Sistem frânare · 09:31</div></div> <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 'IBM Plex Sans';color:var(--errt,#E05D5D);"><span style="width:6px;height:6px;border-radius:99px;background:#E05D5D;"></span>Eroare VIN</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>
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;"> <div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;">
<div><div style="font:500 13px 'IBM Plex Mono';color:var(--text,#e6e9ef);">ZAR937...C04</div><div style="font:400 11px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Schimb ulei · 09:24</div></div> <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 'IBM Plex Sans';color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</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>
</div> </div>
</div> </div>
<!-- PROBLEM --> <!-- PROBLEM + CALCULATOR (combinat) -->
<div style="padding:80px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));"> <div style="padding:80px 40px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
<div style="display:grid;grid-template-columns:1.05fr .95fr;gap:48px;align-items:start;margin:0 auto;"> <div style="text-align:center;max-width:760px;margin:0 auto 40px;">
<div> <h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 23 minute pe RAR AUTOPASS.<br><span style="color:var(--errt,#E05D5D);">Minutele acelea sunt bani.</span></h2>
<h2 style="font:700 38px/1.14 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 23 minute și tastezi pe rar-autopass.ro</h2> <p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi. Mută cursorul la volumul service-ului tău și vezi cât te costă.</p>
<p style="font:400 16px/1.65 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 0 16px;">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.</p> </div>
<p style="font:400 16px/1.65 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0;">Iar dacă greșești o cifră din VIN, prestația e respinsă și o iei de la capăt — cu risc de amendă pentru raportare incompletă sau întârziată.</p>
</div> <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:stretch;">
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:20px;"> <!-- STANGA: formularul RAR AUTOPASS -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px 'IBM Plex Mono';color:var(--sub,#8b93a7);">rar-autopass.ro · 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 'IBM Plex 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="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:flex;flex-direction:column;gap:10px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div><div style="font:400 11px 'IBM Plex Sans';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 'IBM Plex Mono';color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div> <div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
<div><div style="font:400 11px 'IBM Plex Sans';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 'IBM Plex 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>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div><div style="font:400 11px 'IBM Plex Sans';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 'IBM Plex 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;">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 'IBM Plex Sans';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 'IBM Plex Mono';color:var(--text,#e6e9ef);">CT88NOE</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><div style="font:400 11px 'IBM Plex Sans';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 'IBM Plex Sans';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;">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 'IBM Plex Sans';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 'IBM Plex Sans';"><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 'IBM Plex Sans';"><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;">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 'IBM Plex Sans';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 'IBM Plex Mono';color:var(--text,#e6e9ef);">39000</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> </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 'IBM Plex Sans';cursor:not-allowed;align-self:flex-start;">Salvează Prezentarea</button> <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:12px;font:400 12px 'IBM Plex Sans';color:var(--sub,#8b93a7);text-align:center;">se repetă pentru fiecare comandă · zi de zi</div> <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> </div>
</div> </div>
<!-- AGITATE / CALCULATOR --> <!-- LEGE / AMENZI -->
<div style="padding:80px 40px;"> <div style="padding:56px 40px 80px;">
<div style="text-align:center;max-width:720px;margin:0 auto 40px;"> <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="font:500 13px 'IBM Plex Sans';color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Cât te costă de fapt</div>
<h2 style="font:700 36px 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 12px;color:var(--text,#e6e9ef);">Fă socoteala. Minutele acelea sunt bani.</h2>
<p style="font:400 16px/1.6 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0;">Mută cursorul la volumul service-ului tău și vezi cât timp și câți bani pleacă pe raportarea manuală.</p>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:0 auto;align-items:stretch;">
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:32px;">
<div style="margin-bottom:28px;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;"><span style="font:500 14px 'IBM Plex Sans';color:var(--text,#e6e9ef);">Prestații pe lună</span><span style="font:700 20px 'IBM Plex Mono';color:var(--accent,#2E74D6);" id="out-pres">300</span></div>
<input type="range" min="50" max="1500" step="10" value="300" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
</div>
<div style="margin-bottom:24px;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;"><span style="font:500 14px 'IBM Plex Sans';color:var(--text,#e6e9ef);">Cost manoperă</span><span style="font:700 20px 'IBM Plex Mono';color:var(--accent,#2E74D6);"><span id="out-rate">60</span> lei/h</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 style="display:flex;align-items:center;gap:9px;padding-top:18px;border-top:1px solid var(--line,#262b36);font:400 13px/1.5 'IBM Plex Sans';color:var(--sub,#8b93a7);"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="flex-shrink:0;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>Estimat la ~2,5 minute de introdus manual pentru fiecare prestație.</div>
</div>
<div style="background:color-mix(in srgb,#E05D5D 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E05D5D 32%,var(--line,#262b36));border-radius:12px;padding:32px;display:flex;flex-direction:column;justify-content:center;">
<div style="font:600 12px 'IBM Plex Sans';color:var(--errt,#E05D5D);letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px;">Pierdut pe raportare manuală</div>
<div style="display:flex;align-items:baseline;gap:8px;"><span style="font:700 52px/1 'IBM Plex Sans';letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiMonth">0</span></span><span style="font:500 15px 'IBM Plex Sans';color:var(--sub,#8b93a7);">lei / lună</span></div>
<div style="font:400 14px/1.6 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-top:8px;"><span data-calc="hMonth">0</span> ore pe lună &middot; <span data-calc="leiYear">0</span> lei pe an &middot;<span data-calc="days">0</span> zile lucrătoare/an doar cu raportarea.</div>
<div style="margin-top:20px;padding-top:18px;border-top:1px solid color-mix(in srgb,#E05D5D 24%,var(--line,#262b36));">
<div style="display:flex;align-items:center;gap:9px;font:600 14px 'IBM Plex Sans';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 ROMFAST: câteva secunde pentru tot lotul</div>
<div style="font:400 13px/1.55 'IBM Plex Sans';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>
</div>
<div style="margin:24px auto 0;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 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>
<div style="font:700 16px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:7px;">Evită riscul amenzilor — transmite automat la RAR Auto-Pass</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 'IBM Plex Sans';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> <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> </div>
</div> </div>
<!-- SOLVE --> <!-- SOLVE -->
<div style="padding:80px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);"> <div id="cum-functioneaza" style="padding:80px 40px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
<div style="max-width:780px;margin:0 auto;text-align:center;"> <div style="max-width:780px;margin:0 auto;text-align:center;">
<h2 style="font:700 36px 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2> <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 'IBM Plex Sans';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 Auto-Pass îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p> <p style="font:400 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>
<div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 'IBM Plex Sans';color:var(--sub,#8b93a7);"> <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;">23 minute de tastat pentru fiecare comandă</span><span style="color:var(--text,#e6e9ef);font-weight:700;">&nbsp;&nbsp; câteva secunde pentru tot lotul.</span> <span style="text-decoration:line-through;text-decoration-color:var(--errt,#E05D5D);text-decoration-thickness:2px;">23 minute de tastat pentru fiecare comandă</span><span style="color:var(--text,#e6e9ef);font-weight:700;">&nbsp;&nbsp; câteva secunde pentru tot lotul.</span>
</div> </div>
</div> </div>
<!-- API INTEGRATION --> <!-- API INTEGRATION -->
<div style="padding:0 40px 80px;"> <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 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>
<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 'IBM Plex Sans';margin-bottom:18px;">Pentru service-uri cu soft propriu · ROAAUTO</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 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Ai deja un soft de service? Conectează-l automat</h2> <h2 style="font:700 30px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Ai deja un soft de service? Conectează-l automat</h2>
<p style="font:400 15px/1.65 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 0 24px;">Softul tău se poate conecta și direct la API-ul RAR Auto-Pass. Cu ROMFAST primești în plus asistență la maparea automată a operațiilor tale (prin mai multe metode) și salvarea mapărilor pentru trimiterile viitoare — totul printr-un singur apel, cu cheie API per cont.</p> <p style="font:400 15px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Softul tău se poate conecta și direct la API-ul RAR Auto-Pass. Cu ROMFAST primești în plus asistență la maparea automată a operațiilor tale (prin mai multe metode) și salvarea mapărilor pentru trimiterile viitoare — totul printr-un singur apel, cu cheie API per cont.</p>
<button style="height:44px;padding:0 20px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px 'IBM Plex Sans';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> <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>
<div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;"> <div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;">
<div style="display:flex;align-items:center;gap:7px;padding:11px 14px;border-bottom:1px solid #262b36;"> <div style="display:flex;align-items:center;gap:7px;padding:11px 14px;border-bottom:1px solid #262b36;">
<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="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 'IBM Plex Mono';color:#8b93a7;margin-left:8px;">request.sh</span> <span style="font:400 12px var(--font-mono);color:#8b93a7;margin-left:8px;">request.sh</span>
</div> </div>
<pre style="margin:0;padding:18px;font:400 13px/1.7 'IBM Plex Mono';color:#e6e9ef;overflow-x:auto;"><span style="color:#2FBF8F;">POST</span> /v1/prezentari <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;">Authorization:</span> <span style="color:#E0A93B;">rfak_••••••••</span>
<span style="color:#8b93a7;">Content-Type:</span> application/json <span style="color:#8b93a7;">Content-Type:</span> application/json
@@ -248,72 +242,93 @@
</div> </div>
</div> </div>
<!-- PRICING --> <!-- TRIAL BENEFIT -->
<div style="padding:0 40px 80px;"> <div style="padding:0 40px 80px;">
<div style="text-align:center;margin-bottom:44px;"> <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="font:500 13px 'IBM Plex Sans';color:var(--accent,#2E74D6);letter-spacing:.08em;text-transform:uppercase;margin-bottom:12px;">Preț</div> <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>
<h2 style="font:700 34px 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2> <div style="flex:1;min-width:240px;">
<p style="font:400 15px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0;">Fiecare cont începe cu <strong style="color:var(--text,#e6e9ef);font-weight:600;">Premium gratuit 30 de zile</strong>. Apoi trece automat pe Gratuit — fără plată, dacă nu alegi alt plan. Fără card bancar.</p> <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>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:start;"> </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 --> <!-- Gratuit -->
<div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;"> <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="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px 'IBM Plex Sans';letter-spacing:.04em;text-transform:uppercase;">Testare și firme mici</div> <div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div>
<div style="font:700 17px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div> <div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px 'IBM Plex Sans';letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px 'IBM Plex Sans';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="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:20px;">Fără card bancar</div> <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;"> <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 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Până la 100 de prestații/lună</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';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 prestații RAR (din mii)</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 'IBM Plex Sans';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 manuală coloane, cu salvare</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 'IBM Plex Sans';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>Import prin API</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';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 contact/email în 48h</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 'IBM Plex Sans';color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import 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>Integrare în softul tău</div>
<span style="display:none;"></span> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, în 48h</div>
</div> </div>
<button data-act="auth" data-tab="register" data-plan="Gratuit" style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px 'IBM Plex Sans';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 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> </div>
<!-- Standard --> <!-- Standard -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;"> <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="font:700 17px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:8px;">Standard</div> <div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Popular</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px 'IBM Plex Sans';letter-spacing:-.02em;color:var(--text,#e6e9ef);">39 lei</span><span style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">/lună</span></div> <div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Standard</div>
<div style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:20px;">Volum nelimitat, fără API</div> <div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">49 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;"> <div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Trimiteri nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';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>Tot din Gratuit</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 'IBM Plex Sans';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>Prestii 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>Sugestii automate de operații RAR</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';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 contact/email în 48h</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
<span style="display:none;"></span> <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> </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 'IBM Plex Sans';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="Standard">Creează cont gratuit</button> <button style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="standard">Creează cont gratuit</button>
</div> </div>
<!-- Pro --> <!-- Pro -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;position:relative;"> <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="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 90%,#000);color:#fff;font:700 10px 'IBM Plex Sans';letter-spacing:.04em;text-transform:uppercase;">Cel mai ales</div> <div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div>
<div style="font:700 17px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div> <div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px 'IBM Plex Sans';letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">/lună</span></div> <div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Nelimitat + acces API</div>
<div style="font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:20px;">Cu acces API</div> <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;"> <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 'IBM Plex Sans';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>Tot din Standard</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 'IBM Plex Sans';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 + cheie API per cont</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 'IBM Plex Sans';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 pe email în 24h</div> <div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';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 la operațiile incerte</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>
<span style="display:none;"></span> <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> </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 'IBM Plex Sans';cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="Pro">Creează cont gratuit</button> <button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="pro">Creează cont gratuit</button>
</div> </div>
<!-- Premium --> <!-- Premium -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;"> <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 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div> <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 30px 'IBM Plex Sans';letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></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 'IBM Plex Sans';color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</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;"> <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 'IBM Plex Sans';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>Tot din Pro</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>Sugestii automate de operații RAR</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';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>Opțiune de 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>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 'IBM Plex Sans';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 și online</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 'IBM Plex Sans';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>Asistență și onboarding dedicate</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> </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 'IBM Plex Sans';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> <button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="premium">Creează cont gratuit</button>
</div> </div>
</div> </div>
</div> </div>
@@ -321,19 +336,19 @@
<!-- PRIVACY --> <!-- PRIVACY -->
<div style="padding:80px 40px;border-top:1px solid var(--line,#262b36);"> <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;"> <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 'IBM Plex Sans';letter-spacing:-.02em;margin:0;color:var(--text,#e6e9ef);">Datele clienților tăi nu devin marfă</h2> <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="display:flex;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);"> <div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
<div style="font:700 16px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:7px;">Reținem doar strict necesarul</div> <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 'IBM Plex Sans';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 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>
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);"> <div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
<div style="font:700 16px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:7px;">Doar pentru scopul declarat</div> <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 'IBM Plex Sans';color:var(--sub,#8b93a7);">Maparea și trimiterea la RAR. Nu le vindem și nu le dăm mai departe.</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>
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);"> <div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
<div style="font:700 16px 'IBM Plex Sans';color:var(--text,#e6e9ef);margin-bottom:7px;">Se șterg la 3 luni</div> <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 'IBM Plex Sans';color:var(--sub,#8b93a7);">Automat, fără să ceri — sau chiar acum, cu un singur click.</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>
</div> </div>
@@ -343,63 +358,50 @@
<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 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 style="display:grid;grid-template-columns:1fr 1fr;gap:56px;margin:0 auto;align-items:center;">
<div> <div>
<div style="font:500 13px 'IBM Plex Sans';color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Creează cont</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 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2> <h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2>
<p style="font:400 16px/1.6 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit, fără card bancar. Imediat poți încărca primul fișier sau conecta softul de service.</p> <p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit. Imediat poți încărca primul fișier sau conecta softul de service.</p>
<div style="display:flex;flex-direction:column;gap:12px;"> <div style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;gap:10px;align-items:center;font:400 15px 'IBM Plex Sans';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>Premium 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>Pro gratuit 30 de zile, apoi automat pe Gratuit</div>
<div style="display:flex;gap:10px;align-items:center;font:400 15px 'IBM Plex Sans';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>Fără card bancar la înscriere</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 'IBM Plex Sans';color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023 și OMTI 210/2024</div> <div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Datele cu caracter personal criptate (GDPR)</div>
<div style="display:flex;gap:10px;align-items:center;font:400 15px 'IBM Plex Sans';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> </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="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;"> <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 'IBM Plex Sans';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="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 'IBM Plex Sans';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> <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> </div>
<form method="post" action="/signup" data-pane="register"> <form method="post" action="/signup" data-pane="register">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Nume contact</span><input type="text" name="name" required placeholder="Ion Popescu" 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 'IBM Plex Sans';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);">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 'IBM Plex Sans';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 'IBM Plex Mono';outline:none;" /></label> <label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';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 'IBM Plex Sans';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 'IBM Plex Sans';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 'IBM Plex Sans';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 'IBM Plex Sans';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 'IBM Plex Sans';outline:none;cursor:pointer;"><option value="Gratuit" selected>Gratuit — 0 lei/lună</option><option value="Standard">Standard — 39 lei/lună</option><option value="Pro">Pro — 59 lei/lună</option><option value="Premium">Premium — la cerere</option></select></label> <label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="free" selected>Gratuit — 0 lei/lună</option><option value="standard">Standard — 49 lei/lună</option><option value="pro">Pro — 59 lei/lună</option><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 'IBM Plex Sans';color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label> <label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />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 'IBM Plex Sans';cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button> <button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button>
<div style="text-align:center;margin-top:14px;font:400 13px 'IBM Plex Sans';color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div> <div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div>
</form> </form>
<form method="post" action="/login" data-pane="login" style="display:none;"> <form method="post" action="/login" data-pane="login" style="display:none;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';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 'IBM Plex Sans';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:10px;"><span style="display:block;margin-bottom:6px;font:500 13px 'IBM Plex Sans';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 'IBM Plex Sans';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 'IBM Plex Sans';color:var(--accent,#2E74D6);cursor:pointer;">Ai uitat parola?</a></div> <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 'IBM Plex Sans';cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease;">Autentificare</button> <button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease;">Autentificare</button>
<div style="text-align:center;margin-top:14px;font:400 13px 'IBM Plex Sans';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> <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> </form>
</div> </div>
</div> </div>
</div> </div>
<!-- FINAL CTA -->
<div style="padding:0 40px 80px;">
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:16px;padding:56px 40px;text-align:center;">
<h2 style="font:700 36px 'IBM Plex Sans';letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Începe să declari la RAR în câteva minute</h2>
<p style="font:400 16px 'IBM Plex Sans';color:var(--sub,#8b93a7);margin:0 0 28px;">Gratuit până la 100 de prezentări pe lună. Fără card bancar.</p>
<div style="display:flex;gap:12px;justify-content:center;">
<button data-act="auth" data-tab="register" style="height:50px;padding:0 28px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px 'IBM Plex Sans';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 data-act="auth" data-tab="login" style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px 'IBM Plex Sans';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)">Autentificare</button>
</div>
</div>
</div>
<!-- FOOTER --> <!-- 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="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 'IBM Plex Sans';letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div> <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 'IBM Plex Sans';color:var(--sub,#8b93a7);"> <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> <span>Termeni</span><span>Confidențialitate / GDPR</span><span>Documentație API</span><span>Contact</span>
</div> </div>
<div style="font:400 13px 'IBM Plex Sans';color:var(--mut,#5c6473);">© 2026 ROMFAST</div> <div style="font:400 13px var(--font-ui);color:var(--mut,#5c6473);">© 2026 ROMFAST</div>
</div> </div>
</main> </main>
<script> <script>
@@ -427,7 +429,7 @@
var hMonth=(p*minPer)/60,leiMonth=hMonth*r; var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
document.getElementById('out-pres').textContent=p; document.getElementById('out-pres').textContent=p;
document.getElementById('out-rate').textContent=r; document.getElementById('out-rate').textContent=r;
var map={leiMonth:nf.format(Math.round(leiMonth)),hMonth:nf1.format(hMonth),leiYear:nf.format(Math.round(leiMonth*12)),days:nf1.format((hMonth*12)/8)}; 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];});}); 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();} if(pres&&rate){pres.addEventListener('input',recalc);rate.addEventListener('input',recalc);recalc();}

View File

@@ -1,28 +1,59 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Autentificare — Gateway RAR AUTOPASS{% endblock %} {% block title %}Autentificare — ROA AUTOPASS{% endblock %}
{% block content %} {% block content %}
<div class="card auth-card" style="max-width:400px;margin:40px auto;"> {# US-010 (PRD 5.16): /login — layout 2 coloane branduit.
<h2 style="margin-top:0;">Autentificare</h2> 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 %} {% if error %}
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div> <div class="banner" style="margin-bottom:14px; padding:8px 12px;">{{ error }}</div>
{% endif %} {% endif %}
<form method="post" action="/login"> <form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p> <div class="camp-slim">
<label>Email</label><br> <label for="lf-email">Email</label>
<input type="email" name="email" required style="width:100%;"> <input id="lf-email" type="email" name="email" required autocomplete="email">
</p> </div>
<p> <div class="camp-slim" style="margin-bottom:14px;">
<label>Parola</label><br> <label for="lf-parola">Parola</label>
<input type="password" name="parola" required style="width:100%;"> <input id="lf-parola" type="password" name="parola" required autocomplete="current-password">
</p> </div>
<button type="submit" style="width:100%;margin-top:8px;">Intra in cont</button> <button type="submit" class="btn-primary-full">Intra in cont</button>
</form> </form>
<p style="text-align:center;font-size:13px;margin-top:16px;"> <p class="login-foot">
Cont nou? <a href="/signup">Inregistrare</a> Cont nou? <a href="/signup" style="color:var(--accent);">Inregistreaza service-ul</a>
</p> </p>
</div>
</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 %} {% endblock %}

View File

@@ -37,33 +37,53 @@
}); });
</script> </script>
{% else %} {% else %}
<h2 style="margin-top:0;">Inregistrare cont nou</h2> <h2 style="margin-top:0;">Creează cont nou</h2>
{% if error %} {% if error %}
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div> <div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
{% endif %} {% 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"> <form method="post" action="/signup">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p> <label style="display:block;margin-bottom:14px;">
<label>Companie <span style="color:var(--err)">*</span></label><br> <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 style="width:100%;"> <input type="text" name="name" value="{{ name or '' }}" required placeholder="SC Service Auto SRL"
</p> 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;">
<p> </label>
<label>CUI <span style="color:var(--err)">*</span></label><br> <label style="display:block;margin-bottom:14px;">
<input type="text" name="cui" value="{{ cui or '' }}" required style="width:100%;"> <span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">CUI</span>
</p> <input type="text" name="cui" value="{{ cui or '' }}" required placeholder="RO12345678"
<p> 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>Email <span style="color:var(--err)">*</span></label><br> </label>
<input type="email" name="email" value="{{ email or '' }}" required style="width:100%;"> <label style="display:block;margin-bottom:14px;">
</p> <span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Email</span>
<p> <input type="email" name="email" value="{{ email or '' }}" required placeholder="nume@service.ro"
<label>Parola <span style="color:var(--err)">*</span> 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;">
<span style="color:var(--muted);font-size:12px;">(minim 10 caractere)</span> </label>
</label><br> <label style="display:block;margin-bottom:14px;">
<input type="password" name="parola" required style="width:100%;"> <span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Parolă</span>
</p> <input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere"
<button type="submit" style="width:100%;margin-top:8px;">Creeaza cont</button> 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> </form>
<p style="text-align:center;font-size:13px;margin-top:16px;"> <p style="text-align:center;font-size:13px;margin-top:16px;">
Ai deja cont? <a href="/login">Autentificare</a> Ai deja cont? <a href="/login">Autentificare</a>

View File

@@ -34,7 +34,7 @@ import httpx
from .. import errors from .. import errors
from ..config import Settings, get_settings, load_test_credentials from ..config import Settings, get_settings, load_test_credentials
from ..crypto import decrypt_creds from ..crypto import decrypt_creds
from ..db import get_connection, init_db, write_heartbeat from ..db import get_connection, init_db, read_heartbeat, write_heartbeat
from ..observ import log_event, set_source from ..observ import log_event, set_source
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
from ..payload import build_rar_payload from ..payload import build_rar_payload
@@ -428,6 +428,68 @@ def _creds_from_account(conn, account_id: int) -> dict | None:
return 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: def run() -> int:
signal.signal(signal.SIGTERM, _stop) signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop) signal.signal(signal.SIGINT, _stop)
@@ -440,6 +502,7 @@ def run() -> int:
sessions = AccountSessions(settings) sessions = AccountSessions(settings)
_last_purge_time: float = 0.0 _last_purge_time: float = 0.0
_keepalive_state = {"last_attempt": 0.0}
while _running: while _running:
try: try:
@@ -466,6 +529,9 @@ def run() -> int:
# Nimic de trimis: recupereaza orfanii conturilor deja logate. # Nimic de trimis: recupereaza orfanii conturilor deja logate.
for acct, rar, tok in sessions.active(): for acct, rar, tok in sessions.active():
recover_orphans(conn, settings, rar, tok, account_id=acct) 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) time.sleep(settings.worker_poll_interval_s)
continue continue

View File

@@ -13,7 +13,8 @@ services:
- autopass-data:/data - autopass-data:/data
environment: environment:
AUTOPASS_DB_PATH: /data/autopass.db 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). # Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
TZ: ${TZ:-Europe/Bucharest} TZ: ${TZ:-Europe/Bucharest}
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)} AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
@@ -35,10 +36,11 @@ services:
- autopass-data:/data - autopass-data:/data
environment: environment:
AUTOPASS_DB_PATH: /data/autopass.db 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)} 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. # Send activ by default (prod); pe staging seteaza AUTOPASS_WORKER_SEND_ENABLED=false
AUTOPASS_WORKER_SEND_ENABLED: "true" # 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 restart: always
depends_on: depends_on:
- api - api

File diff suppressed because one or more lines are too long

View File

@@ -494,5 +494,18 @@ Record de test creat: `data.id = 68514` (FINALIZATA, permanent pe test). Confirm
- header `User-Agent` obligatoriu (altfel 403 WAF). - header `User-Agent` obligatoriu (altfel 403 WAF).
Rămas neprobat: ce alte valori `sistemReparat` (în afară de `"null"`) acceptă (Open Q #2). 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> </content>
</invoke> </invoke>

View 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">&#9776;</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">&times;</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">&times;</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>&times;</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>

View 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">&#9776;
<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">&times;</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>&times;</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>

View 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>&times;</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>

View 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">&#9776;</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> &nbsp;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>

View 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">&#9776;</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>

File diff suppressed because it is too large Load Diff

View 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

View 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.

View 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

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

View 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.

View File

@@ -12,6 +12,8 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
export OMP_NUM_THREADS=1
if [ $# -eq 0 ]; then if [ $# -eq 0 ]; then
exec ./start.sh test both --send exec ./start.sh test both --send
fi fi

View File

@@ -18,6 +18,14 @@ import pytest
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false") os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false") os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
# 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) @pytest.fixture(autouse=True)

View File

@@ -112,7 +112,7 @@ def test_list_accounts_ordonat_fara_creds(conn):
assert ids == sorted(ids) assert ids == sorted(ids)
for r in rows: for r in rows:
assert "rar_creds_enc" not in r 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) # contul sistem id=1 e EXCEPTAT (returneaza True indiferent)
row_sys = conn.execute("SELECT * FROM accounts WHERE id=1").fetchone() row_sys = conn.execute("SELECT * FROM accounts WHERE id=1").fetchone()
assert account_is_complete(row_sys) is True 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()

View File

@@ -40,7 +40,8 @@ def _signup(client, name, email, password="parola_test_001"):
tok = _csrf(client, "/signup") tok = _csrf(client, "/signup")
resp = client.post("/signup", data={"name": name, "cui": make_test_cui(email), resp = client.post("/signup", data={"name": name, "cui": make_test_cui(email),
"email": email, "parola": password, "email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True) "consent": "1", "csrf_token": tok},
follow_redirects=True)
assert resp.status_code == 200 assert resp.status_code == 200
from app.db import get_connection from app.db import get_connection
conn = get_connection() conn = get_connection()

View File

@@ -62,6 +62,7 @@ def _signup(client: TestClient, name: str, email: str, password: str = "parola_t
"cui": make_test_cui(email), "cui": make_test_cui(email),
"email": email, "email": email,
"parola": password, "parola": password,
"consent": "1",
"csrf_token": token, "csrf_token": token,
}, follow_redirects=True) }, follow_redirects=True)
assert resp.status_code == 200, f"signup esuat: {resp.text[:300]}" 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), ( assert not _get_account_active(incomplete_id), (
"Contul incomplet (fara email/CUI) a fost activat — gate pe account_is_complete nu functioneaza" "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"

View File

@@ -224,3 +224,309 @@ def test_get_listare_filtru_status_nu_sparge_scope(client):
assert VIN_A not in vins, ( assert VIN_A not in vins, (
"Filtrul status a scos date din alt cont (scurgere cross-account prin filtru)." "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

View 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

View 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"

View 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

View 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"]

View File

@@ -51,7 +51,8 @@ def test_export_doar_contul_cheii(env):
from app.db import get_connection from app.db import get_connection
conn = get_connection() conn = get_connection()
try: 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) k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2) k2 = create_api_key(conn, 2)
finally: finally:

View File

@@ -47,7 +47,9 @@ def test_lista_doar_contul_cheii(env):
from app.db import get_connection from app.db import get_connection
conn = get_connection() conn = get_connection()
try: 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) k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2) k2 = create_api_key(conn, 2)
finally: finally:

28
tests/test_idempotency.py Normal file
View 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")

View File

@@ -272,14 +272,18 @@ def test_embeddings_functional_cand_flag_activ(conn, monkeypatch):
get_settings.cache_clear() get_settings.cache_clear()
monkeypatch.setattr(emb_mod, "_engine", EmbeddingEngine(backend=_FakeEmbedBackend())) monkeypatch.setattr(emb_mod, "_engine", EmbeddingEngine(backend=_FakeEmbedBackend()))
# Nomenclatorul (din fixtura conn) are OE-1..OE-4; adaug coduri cu denumiri keyword. # 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( conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)", "INSERT OR REPLACE INTO mapping_suggestions "
("UL-1", "Schimb ulei"), "(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, ?, ?)",
("Schimb ulei", "UL-1", 0, "llm", 0.95),
) )
conn.execute( conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)", "INSERT OR REPLACE INTO mapping_suggestions "
("FR-1", "Placute frana"), "(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, ?, ?)",
("Placute frana", "FR-1", 0, "llm", 0.95),
) )
conn.commit() conn.commit()

113
tests/test_operatii_seed.py Normal file
View 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

359
tests/test_plans.py Normal file
View 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

View 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]

View 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) == []

View File

@@ -0,0 +1,145 @@
"""US-001 (PRD 5.20): schema medii per cont + env pe submission + migrare/backfill."""
from __future__ import annotations
import json
import os
import sqlite3
import tempfile
import pytest
@pytest.fixture()
def fresh_conn(monkeypatch):
"""DB nou cu schema curenta (init_db)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _old_db(path: str) -> sqlite3.Connection:
"""Construieste un DB in forma PRE-5.20 (fara coloanele de mediu)."""
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute(
"CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, "
"cui TEXT, rar_creds_enc TEXT)"
)
conn.execute(
"CREATE TABLE submissions (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"idempotency_key TEXT NOT NULL UNIQUE, account_id INTEGER, status TEXT, "
"payload_json TEXT NOT NULL)"
)
return conn
def _migrate_old(path: str, env: str, monkeypatch) -> sqlite3.Connection:
monkeypatch.setenv("AUTOPASS_RAR_ENV", env)
from app.config import get_settings
get_settings.cache_clear()
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
from app.db import _migrate
_migrate(conn)
return conn
def test_coloane_medii_pe_cont(fresh_conn):
acc = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert {
"rar_test_enabled", "rar_prod_enabled",
"rar_creds_test_enc", "rar_creds_prod_enc", "rar_env_default",
} <= acc
sub = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(submissions)").fetchall()}
assert "rar_env" in sub
def test_default_client_prod_on_test_off(fresh_conn):
from app.accounts import create_account
aid = create_account(fresh_conn, "Service X")
row = fresh_conn.execute(
"SELECT rar_test_enabled, rar_prod_enabled, rar_env_default FROM accounts WHERE id=?",
(aid,),
).fetchone()
assert row["rar_prod_enabled"] == 1
assert row["rar_test_enabled"] == 0
assert row["rar_env_default"] == "prod"
@pytest.mark.parametrize("env,slot,other", [
("test", "rar_creds_test_enc", "rar_creds_prod_enc"),
("prod", "rar_creds_prod_enc", "rar_creds_test_enc"),
])
def test_migrare_creds_in_slotul_env_global(tmp_path, monkeypatch, env, slot, other):
path = str(tmp_path / "old.db")
old = _old_db(path)
old.execute(
"INSERT INTO accounts (id, name, rar_creds_enc) VALUES (5, 'Legacy', 'TOKEN_CREDS')"
)
old.close()
conn = _migrate_old(path, env, monkeypatch)
row = conn.execute("SELECT * FROM accounts WHERE id=5").fetchone()
assert row[slot] == "TOKEN_CREDS"
assert row[other] is None
assert row[f"rar_{env}_enabled"] == 1
assert row[f"rar_{'prod' if env == 'test' else 'test'}_enabled"] == 0
assert row["rar_env_default"] == env
conn.close()
def test_migrare_cont_fara_creds_ramane_pe_default(tmp_path, monkeypatch):
path = str(tmp_path / "old.db")
old = _old_db(path)
old.execute("INSERT INTO accounts (id, name, rar_creds_enc) VALUES (6, 'NoCreds', NULL)")
old.close()
conn = _migrate_old(path, "test", monkeypatch)
row = conn.execute("SELECT * FROM accounts WHERE id=6").fetchone()
assert row["rar_prod_enabled"] == 1
assert row["rar_test_enabled"] == 0
assert row["rar_env_default"] == "prod"
conn.close()
def test_submissions_rar_env(tmp_path, monkeypatch):
"""Un rand PRE-migrare ajunge cu env-ul global (NU 'test') + cheie recalculata env-aware."""
path = str(tmp_path / "old.db")
old = _old_db(path)
payload = {
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-01-10", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
old.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES ('LEGACY_KEY', 7, 'sent', ?)",
(json.dumps(payload),),
)
old.close()
conn = _migrate_old(path, "prod", monkeypatch)
row = conn.execute("SELECT rar_env, idempotency_key FROM submissions").fetchone()
assert row["rar_env"] == "prod" # ancora globala, NU DEFAULT 'test'
from app.idempotency import build_key, canonicalize_row
canon = canonicalize_row(payload)
canon["prestatii"] = payload["prestatii"]
assert row["idempotency_key"] == build_key(7, canon, "prod")
# si difera de varianta env-aware pe test (reconciliere pe endpoint corect)
assert row["idempotency_key"] != build_key(7, canon, "test")
conn.close()
def test_migrare_idempotenta(fresh_conn):
"""A doua rulare _migrate pe DB deja migrat nu strica nimic."""
from app.db import _migrate
_migrate(fresh_conn) # nu arunca, nu dubleaza coloane
acc = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "rar_env_default" in acc

View File

@@ -148,6 +148,8 @@ def test_prod_requires_key(env):
conn = get_connection() conn = get_connection()
try: try:
key = create_api_key(conn, 1) key = create_api_key(conn, 1)
# Testul verifica autentificarea, nu planul — tier='pro' ca sa treaca gate-ul API (T4).
conn.execute("UPDATE accounts SET tier='pro' WHERE id=1")
finally: finally:
conn.close() conn.close()
r2 = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": key}) r2 = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": key})
@@ -184,7 +186,9 @@ def test_key_account_routes_idempotency(env):
conn = get_connection() conn = get_connection()
try: try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") # tier='pro' pe ambele conturi — testul verifica idempotenta, 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) k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2) k2 = create_api_key(conn, 2)
finally: finally:

View File

@@ -141,6 +141,21 @@ def test_seed_suggestions_nul_cu_cod_explicit_tot_nul(conn):
assert row["cod_prestatie"] is None # cod explicit ignorat cand is_nul assert row["cod_prestatie"] is None # cod explicit ignorat cand is_nul
def test_seed_suggestions_cod_whitespace_devine_null(conn):
"""Rand non-NUL cu cod whitespace-only (' ') -> cod_prestatie NULL, NU '' (corectitudine)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{"denumire": "OPERATIE CU COD GOL", "cod_prestatie": " ", "source": "llm", "confidence": 0.5},
])
conn.commit()
row = lookup_suggestion(conn, "OPERATIE CU COD GOL")
assert row is not None
assert row["is_nul"] == 0 # nu e marcat NUL
assert row["cod_prestatie"] is None # whitespace -> NULL, nu '' (rand non-NUL fara cod gol)
def test_seed_suggestions_normalizare_diacritice(conn): def test_seed_suggestions_normalizare_diacritice(conn):
"""Lookup pe forma cu diacritice gaseste randul seedat fara diacritice (normalize_for_match).""" """Lookup pe forma cu diacritice gaseste randul seedat fara diacritice (normalize_for_match)."""
from app.shared_store import seed_suggestions, lookup_suggestion from app.shared_store import seed_suggestions, lookup_suggestion

View File

@@ -65,6 +65,7 @@ def test_signup_fara_cui_422(client):
"cui": "", "cui": "",
"email": "fara_cui@test.com", "email": "fara_cui@test.com",
"parola": "parolasecreta123", "parola": "parolasecreta123",
"consent": "1",
"csrf_token": token, "csrf_token": token,
}) })
# trebuie sa returneze 422 (sau sa randeze formularul cu eroare) # trebuie sa returneze 422 (sau sa randeze formularul cu eroare)
@@ -96,6 +97,7 @@ def test_signup_scrie_email_pe_account(client):
"cui": "RO9999001", "cui": "RO9999001",
"email": "cu_email@test.com", "email": "cu_email@test.com",
"parola": "parolasecreta123", "parola": "parolasecreta123",
"consent": "1",
"csrf_token": token, "csrf_token": token,
}) })
assert resp.status_code == 200 assert resp.status_code == 200
@@ -131,6 +133,7 @@ def test_signup_email_duplicat_mesaj_email(client):
"cui": make_test_cui("email-dup-c1"), "cui": make_test_cui("email-dup-c1"),
"email": "emaildup@test.com", "email": "emaildup@test.com",
"parola": "parolasecreta123", "parola": "parolasecreta123",
"consent": "1",
"csrf_token": token, "csrf_token": token,
}) })
assert resp1.status_code == 200 assert resp1.status_code == 200
@@ -145,6 +148,7 @@ def test_signup_email_duplicat_mesaj_email(client):
"cui": cui_nou, "cui": cui_nou,
"email": "emaildup@test.com", "email": "emaildup@test.com",
"parola": "parolasecreta456", "parola": "parolasecreta456",
"consent": "1",
"csrf_token": token2, "csrf_token": token2,
}) })
@@ -179,6 +183,7 @@ def test_signup_cui_existent_mesaj_prietenos(client):
"cui": "RO8888001", "cui": "RO8888001",
"email": "firma1@test.com", "email": "firma1@test.com",
"parola": "parolasecreta123", "parola": "parolasecreta123",
"consent": "1",
"csrf_token": token, "csrf_token": token,
}) })
@@ -190,6 +195,7 @@ def test_signup_cui_existent_mesaj_prietenos(client):
"cui": "RO8888001", "cui": "RO8888001",
"email": "firma2@test.com", "email": "firma2@test.com",
"parola": "parolasecreta456", "parola": "parolasecreta456",
"consent": "1",
"csrf_token": token2, "csrf_token": token2,
}) })

View File

@@ -52,6 +52,7 @@ def _do_signup(c: TestClient, name: str, email: str, parola: str = "parolasecret
"cui": make_test_cui(email), "cui": make_test_cui(email),
"email": email, "email": email,
"parola": parola, "parola": parola,
"consent": "1",
"csrf_token": token, "csrf_token": token,
}) })

View File

@@ -370,3 +370,84 @@ def test_token_critic_in_tema_parametrizat(client, tema, token):
f"Componentele cu var({token}) vor arata gresit pe aceasta tema. " f"Componentele cu var({token}) vor arata gresit pe aceasta tema. "
f"Adauga '{token}:<valoare>;' in blocul CSS al temei '{tema}'." f"Adauga '{token}:<valoare>;' in blocul CSS al temei '{tema}'."
) )
# ── US-001 PRD 5.16: Stiva font sistem standard web ───────────────────────────
def test_font_stack_system_in_base(client):
"""T-E2 (PRD 5.16): base.html DEFINESTE --font-ui si --font-mono in :root
si body foloseste var(--font-ui). Niciun @font-face IBM Plex nu mai exista."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "--font-ui" in html, "Token --font-ui lipseste din :root (US-001 PRD 5.16)"
assert "--font-mono" in html, "Token --font-mono lipseste din :root (US-001 PRD 5.16)"
assert "var(--font-ui)" in html, "body nu foloseste var(--font-ui) (US-001 PRD 5.16)"
assert "@font-face" not in html, \
"@font-face inca prezent in base.html — sterge toate regulile IBM Plex (US-001 PRD 5.16)"
def test_zero_referinte_static_fonts(client):
"""T-E1 (PRD 5.16): nicio referinta /static/fonts/ in template-urile randate de app.
Toate literalele 'IBM Plex Sans' si 'IBM Plex Mono' sunt eliminate."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "/static/fonts/" not in html, \
"Referinta /static/fonts/ inca prezenta in HTML randat — @font-face nestersi complet"
assert "IBM Plex Sans" not in html, \
"Literalul 'IBM Plex Sans' inca prezent in HTML — inlocuieste cu var(--font-ui)"
assert "IBM Plex Mono" not in html, \
"Literalul 'IBM Plex Mono' inca prezent in HTML — inlocuieste cu var(--font-mono)"
def test_landing_fara_font_face_ibm_plex():
"""T-E1 (PRD 5.16): landing.html nu contine @font-face IBM Plex si niciun
literal 'IBM Plex Sans' sau 'IBM Plex Mono' ca font primary."""
landing = Path(__file__).parent.parent / "app" / "web" / "templates" / "landing.html"
assert landing.exists(), f"landing.html negasit la {landing}"
content = landing.read_text(encoding="utf-8")
assert "@font-face" not in content, \
"@font-face inca in landing.html — sterge toate regulile IBM Plex (US-008 PRD 5.16)"
assert "IBM Plex Sans" not in content, \
"Literal 'IBM Plex Sans' inca in landing.html — inlocuieste cu var(--font-ui)"
assert "IBM Plex Mono" not in content, \
"Literal 'IBM Plex Mono' inca in landing.html — inlocuieste cu var(--font-mono)"
# ── US-002 PRD 5.16: Scala tipografica ────────────────────────────────────────
def test_tokeni_scala_fs_definiti(client):
"""US-002 (PRD 5.16): tokenurile de scala tipografica --fs-xs..--fs-3xl si
--lh-tight/--lh-body sunt definiti in :root."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
tokeni = [
"--fs-xs", "--fs-sm", "--fs-base", "--fs-md",
"--fs-lg", "--fs-xl", "--fs-2xl", "--fs-3xl",
"--lh-tight", "--lh-body",
]
for tok in tokeni:
assert tok in html, f"Token {tok} lipseste din :root (US-002 PRD 5.16)"
# ── US-011 PRD 5.16: Selector tema pill cu eticheta ───────────────────────────
def test_selector_tema_are_eticheta(client):
"""US-011 (PRD 5.16): butonul de tema este un pill cu clasa .tema-btn,
contine .tema-icon si #tema-label (eticheta vizibila a temei curente)."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "tema-btn" in html, \
"Clasa .tema-btn lipseste din HTML — butonul de tema nu e pill (US-011 PRD 5.16)"
assert "tema-icon" in html, \
".tema-icon lipseste — iconita temei nu e separat de eticheta (US-011 PRD 5.16)"
assert 'id="tema-label"' in html, \
'#tema-label lipseste — eticheta temei nu e prezenta in pill (US-011 PRD 5.16)'

View File

@@ -40,7 +40,8 @@ def _signup(client, name, email, password="parola_test_001"):
from tests.conftest import make_test_cui from tests.conftest import make_test_cui
tok = _csrf(client, "/signup") tok = _csrf(client, "/signup")
client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email, client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email,
"parola": password, "csrf_token": tok}, follow_redirects=True) "parola": password, "consent": "1", "csrf_token": tok},
follow_redirects=True)
from app.db import get_connection from app.db import get_connection
conn = get_connection() conn = get_connection()
try: try:

View File

@@ -0,0 +1,140 @@
"""TDD 5.18 US-007 — Badge sursa sugestie in editorul de mapare (_mapari.html).
Chip mic langa sugestia sistemului care arata DE UNDE vine codul propus:
- "confirmat" -> GOLD partajat (validat de om, shared_mappings)
- "similar" -> SILVER exact-match / k-NN embeddings (exemplu deja vazut)
- "non-operatie" -> pre-filtru NUL determinist (ITP/plata/discount...)
Toate suggestion-only (#13): badge-ul e doar indiciu vizual, nu schimba enqueue.
Render real prin GET /_fragments/mapari (fragmentul HTMX scoped pe cont).
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
"""DB temporara cu schema, auth web dezactivata (mod dev -> cont id=1)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "badge_sursa_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 client(env):
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
def _seed_nomenclator(conn):
conn.executemany(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
[("OE-1", "REPARATIE"), ("OE-3", "REVIZIE PERIODICA")],
)
def _insert_needs_mapping(conn, *, op: str, denumire: str, key: str):
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, ?)",
(json.dumps({
"vin": "WVWZZZ1KZAW001111",
"prestatii": [{"cod_op_service": op, "denumire": denumire}],
}), key),
)
def test_badge_gold_confirmat(env, client):
"""O operatie cu match in GOLD partajat -> chip 'confirmat' in coloana Sugestii."""
from app.db import get_connection
from app.shared_store import record_human_validation
conn = get_connection()
try:
_seed_nomenclator(conn)
record_human_validation(conn, "Revizie anuala", "OE-3")
_insert_needs_mapping(conn, op="OP-REV", denumire="Revizie anuala", key="badge-gold-1")
conn.commit()
finally:
conn.close()
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200, resp.text
html = resp.text
assert "sugg-sursa--confirmat" in html
assert ">confirmat<" in html
def test_badge_similar_silver(env, client):
"""O operatie cu match in SILVER (mapping_suggestions) -> chip 'similar'."""
from app.db import get_connection
from app.shared_store import seed_suggestions
conn = get_connection()
try:
_seed_nomenclator(conn)
seed_suggestions(conn, [
{"denumire": "Reparatie motor", "cod_prestatie": "OE-1", "source": "llm", "confidence": 0.9},
])
_insert_needs_mapping(conn, op="OP-REP", denumire="Reparatie motor", key="badge-similar-1")
conn.commit()
finally:
conn.close()
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200, resp.text
html = resp.text
assert "sugg-sursa--similar" in html
assert ">similar<" in html
# NU trebuie sa fie marcat confirmat (sursa e SILVER, nu GOLD).
assert "sugg-sursa--confirmat" not in html
def test_badge_nul_non_operatie(env, client):
"""O operatie prinsa de pre-filtrul NUL (ITP) -> chip 'non-operatie', fara cod sugerat."""
from app.db import get_connection
conn = get_connection()
try:
_seed_nomenclator(conn)
_insert_needs_mapping(conn, op="OP-ITP", denumire="ITP CT 12 ABC", key="badge-nul-1")
conn.commit()
finally:
conn.close()
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200, resp.text
html = resp.text
assert "sugg-sursa--nul" in html
assert ">non-operatie<" in html
def test_fara_sursa_fara_badge(env, client):
"""O operatie fara nicio sursa (necunoscuta) NU primeste chip de sursa."""
from app.db import get_connection
conn = get_connection()
try:
_seed_nomenclator(conn)
_insert_needs_mapping(conn, op="OP-NISA", denumire="Operatie complet necunoscuta xyz", key="badge-none-1")
conn.commit()
finally:
conn.close()
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200, resp.text
assert "sugg-sursa" not in resp.text

View File

@@ -544,3 +544,316 @@ def test_repune_select_afiseaza_denumirea(client):
assert "AAA — Schimb ulei motor" in html, ( assert "AAA — Schimb ulei motor" in html, (
f"Optiunea select nu randeaza 'cod — denumire': {html[html.find('AAA'):html.find('AAA')+60]}" f"Optiunea select nu randeaza 'cod — denumire': {html[html.find('AAA'):html.find('AAA')+60]}"
) )
# ============================================================================= #
# Teste noi 5.16: US-004 (denumiri picker), US-005 (add_extra), #
# US-006 (save picker fara buton), T-E3 (by-index), T-D1/T-E5, T-C1/T-E4 #
# ============================================================================= #
def test_picker_flat_arata_cod_si_denumire(client):
"""US-004 (5.16): picker plat afiseaza 'cod — denumire', nu doar codul.
RED: _chips_prestatii.html:147 afiseaza doar {{ n.cod_prestatie }};
modul operatii (:101) afiseaza deja 'cod — nume'. Fix: uniformizare.
"""
acct = _create_account_user("picker.flat.denu@test.com")
_login(client, "picker.flat.denu@test.com")
_seed_cod("FRN1", "Sistem de franare")
# Submission flat: fara cod_op_service (mod plat)
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0US4001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [], # mod plat: fara operatii cu cod_op_service
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
# Optiunea trebuie sa arate 'FRN1 — Sistem de franare', nu doar 'FRN1'
assert "FRN1 — Sistem de franare" in resp.text, (
f"Picker plat nu arata denumirea: "
f"{resp.text[resp.text.find('FRN1'):resp.text.find('FRN1')+80] if 'FRN1' in resp.text else 'FRN1 absent'}"
)
def test_adauga_cod_extra_in_mod_operatii(client):
"""US-005 (5.16): in mod operatii, actiunea add_extra adauga un cod RAR liber.
RED: post_form_chips nu are actiunea 'add_extra' -> chips_action ignorata.
"""
acct = _create_account_user("add.extra.ops@test.com")
_login(client, "add.extra.ops@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
# Chips stare: 1 operatie deja mapata (mod ops) → _has_ops = True
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"], # chip existent (op mapata)
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei motor"],
"chips_action": "add_extra",
"chips_add_cod_flat": "FRN1", # codul extra de adaugat
},
)
assert resp.status_code == 200, resp.text[:300]
# FRN1 trebuie sa apara in raspuns (chip extra adaugat)
assert "FRN1" in resp.text, (
f"Codul extra FRN1 nu a fost adaugat in mod operatii: {resp.text[:300]}"
)
# OE-1 trebuie sa ramana (chip original neatins)
assert "OE-1" in resp.text, f"Chip original OE-1 disparut: {resp.text[:300]}"
def test_extra_cod_persistat_la_salvare(client):
"""US-005 (5.16): codul extra adaugat via form-chips e salvat la /corecteaza.
Simulam starea form dupa add_extra: hidden inputs pentru op mapata (OE-1)
+ hidden inputs pentru chip extra flat (FRN1, fara op_service).
"""
acct = _create_account_user("extra.persist@test.com")
_login(client, "extra.persist@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Sistem de franare")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0XP001",
[("SchimbUlei", "Schimb ulei motor")],
))
csrf = _csrf(client)
# Form state dupa add_extra: op mapata (idx=0, OE-1) + chip extra flat (idx=1, FRN1)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", "FRN1"], # OE-1 pt op, FRN1 chip extra
"chip_op_service": ["SchimbUlei", ""], # idx 0 are op_service, idx 1 nu
"chip_denumire": ["Schimb ulei motor", ""],
},
)
assert resp.status_code == 200, resp.text[:300]
r = _row(sid)
assert r["status"] == "queued", f"status asteptat queued, got {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "OE-1" in coduri, f"OE-1 (op mapata) lipsa: {prestatii}"
assert "FRN1" in coduri, f"FRN1 (chip extra) lipsa: {prestatii}"
def test_extra_cod_validat_nomenclator(client):
"""US-005 (5.16): add_extra respinge cod necunoscut in nomenclator (invariant ORA-12899).
RED: actiunea add_extra nu exista; dupa fix, cod invalid nu se adauga.
"""
acct = _create_account_user("extra.valid@test.com")
_login(client, "extra.valid@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# add_extra cu cod INVALID (XX-99 nu e in nomenclator)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei"],
"chips_action": "add_extra",
"chips_add_cod_flat": "XX-99", # cod necunoscut
},
)
assert resp.status_code == 200
html = resp.text
# XX-99 NU trebuie sa apara ca chip valid (hidden input cu valoarea XX-99)
import re as _re
hidden_xx99 = _re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="XX-99"', html)
assert hidden_xx99 is None, (
f"Codul invalid XX-99 a fost adaugat ca chip! HTML: {html[:500]}"
)
def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client):
"""US-006 (5.16): codul ales in picker flat se aplica la /corecteaza fara a apasa '+'.
RED: post_corectie_trimitere citeste form.getlist('cod_prestatie') (hidden inputs)
dar ignora 'chips_add_cod_flat' (picker neselectat ca chip) → submission ramane
needs_mapping desi codul e ales.
"""
acct = _create_account_user("picker.save.nobutton@test.com")
_login(client, "picker.save.nobutton@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
# Submission flat fara prestatii
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0PS001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [],
})
csrf = _csrf(client)
# Browser trimite chips_add_cod_flat=OE-1 (ales in picker) dar FARA hidden cod_prestatie
# (userul nu a apasat '+' sa promoveze selectia intr-un chip).
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"chips_add_cod_flat": "OE-1", # ales in picker, ne-aprobat prin '+'
# NU exista 'cod_prestatie' in form (zero hidden chips)
},
)
assert resp.status_code == 200, resp.text[:300]
r = _row(sid)
assert r["status"] == "queued", (
f"Codul ales in picker trebuia sa se aplice la salvare fara '+': status={r['status']}"
)
prestatii = _payload_json(sid)["prestatii"]
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "OE-1" in coduri, f"OE-1 (ales in picker) lipsa din prestatii: {prestatii}"
def test_salvare_fara_chip_explicit_nu_e_no_op(client):
"""US-006 (5.16): o trimitere needs_mapping cu cod ales in picker nu ramane no-op.
Complementar cu test_cod_ales_in_picker_se_salveaza_fara_buton_add: verifica
explicit ca statusul se schimba (nu ramane needs_mapping).
"""
acct = _create_account_user("noop.previne@test.com")
_login(client, "noop.previne@test.com")
_seed_cod("FRN1", "Sistem de franare")
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0NP001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [],
})
old_status = _row(sid)["status"]
assert old_status == "needs_mapping"
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"chips_add_cod_flat": "FRN1",
},
)
assert resp.status_code == 200
new_status = _row(sid)["status"]
assert new_status != "needs_mapping", (
f"Salvarea cu cod ales in picker trebuia sa nu fie no-op: status ramas {new_status}"
)
assert new_status == "queued", f"status asteptat queued, got {new_status}"
def test_picker_by_index_op2_nu_op1(client):
"""T-E3 (5.16): codul ales pe picker-ul op#2 aterizeaza pe op#2, NU pe op#1.
Verifica alinierea by-index in modul operatii: chips_add_op_index=1 + chips_add_cod_1
actualizeaza chips[1] (op#2), nu chips[0] (op#1).
"""
acct = _create_account_user("byindex.op2@test.com")
_login(client, "byindex.op2@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
# Chips: op#1 (idx=0) deja mapata cu OE-1, op#2 (idx=1) nemapata (cod gol)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", ""], # idx 0=OE-1 (mapata), idx 1="" (nemapata)
"chip_op_service": ["Op-A", "Op-B"],
"chip_denumire": ["Prima", "A doua"],
"chips_action": "add",
"chips_add_op_index": "1", # adauga pe op#2 (idx=1)
"chips_add_cod_1": "FRN1", # picker-ul op#2 contine FRN1
},
)
assert resp.status_code == 200, resp.text[:300]
html = resp.text
import re as _re
hidden_vals = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="([^"]*)"', html)
assert "OE-1" in hidden_vals, f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {hidden_vals}"
assert "FRN1" in hidden_vals, f"FRN1 nu a aterizat pe op#2: {hidden_vals}"
# By-index: OE-1 trebuie sa fie INAINTE de FRN1 (idx 0 < idx 1)
oe1_pos = hidden_vals.index("OE-1") if "OE-1" in hidden_vals else -1
frn1_pos = hidden_vals.index("FRN1") if "FRN1" in hidden_vals else -1
assert oe1_pos < frn1_pos, (
f"FRN1 (op#2, idx=1) trebuie dupa OE-1 (op#1, idx=0) by-index: {hidden_vals}"
)
def test_empty_state_picker_nomenclator_gol(client):
"""T-D1/T-E5 (5.16): empty-state vizibil cand nomenclatorul e gol.
RED: {% if nomenclator_rar %} fara {% else %} -> silentios; un rand needs_mapping
fara nomenclator nu are nicio cale de a adauga cod (nereparabil silentios).
GREEN: div.chips-nom-gol vizibil.
"""
acct = _create_account_user("empty.nom@test.com")
_login(client, "empty.nom@test.com")
# Golim nomenclatorul: seed_nomenclator_if_empty populeaza la initializare DB;
# testul simuleaza cazul extrem cand tabla e goala (post-update, inainte de re-seed).
from app.db import get_connection as _gconn
_c = _gconn()
_c.execute("DELETE FROM nomenclator_rar")
_c.commit()
_c.close()
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0EN001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
assert "chips-nom-gol" in resp.text, (
f"Empty state 'chips-nom-gol' lipsa cand nomenclatorul e gol: {resp.text[resp.text.find('chips'):resp.text.find('chips')+200] if 'chips' in resp.text else resp.text[:500]}"
)
def test_add_extra_semnal_vizibil_cod_invalid(client):
"""T-C1/T-E4 (5.16): add_extra cu cod invalid da semnal vizibil (nu esua silentios).
RED: actiunea add_extra nu exista → nu exista niciun semnal.
GREEN: div.chips-extra-error vizibil cand codul e invalid sau selectul e gol.
"""
acct = _create_account_user("extra.err.signal@test.com")
_login(client, "extra.err.signal@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# add_extra cu cod necunoscut in nomenclator
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei"],
"chips_action": "add_extra",
"chips_add_cod_flat": "XX-99", # cod inexistent
},
)
assert resp.status_code == 200
assert "chips-extra-error" in resp.text, (
f"Semnalul 'chips-extra-error' lipsa pentru cod invalid: {resp.text[:300]}"
)

View File

@@ -0,0 +1,195 @@
"""Teste E2E enforcement plan pe canalul web de import (PRD 5.17 T3).
Verifica ca limita de volum (60/luna free) e respectata si pe canalul web
(web_confirma_import in routes.py), nu doar pe canalul API.
"""
from __future__ import annotations
import csv
import io
import os
import re
import tempfile
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixture client web izolat (WEB_AUTH_REQUIRED=false -> fara login, cont 1)
# ---------------------------------------------------------------------------
@pytest.fixture()
def client(monkeypatch):
"""Client cu DB izolata, WEB_AUTH_REQUIRED=false (dev — fara login necesar)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "web-e2e-plan.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
# ---------------------------------------------------------------------------
# Utilitare
# ---------------------------------------------------------------------------
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
def _seed_nomenclator_si_mapare(cod_prestatie: str = "OE-1", cod_op: str = "OP-WEB-PLAN") -> None:
"""Semeaza nomenclatorul RAR si o mapare operatie->cod (necesare pentru randuri ok)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?,?)",
(cod_prestatie, "Operatie test plan"),
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) VALUES (1,?,?,1)",
(cod_op, cod_prestatie),
)
conn.commit()
finally:
conn.close()
def _insert_60_submissions_luna() -> None:
"""Insereaza 60 submissions queued in luna curenta pentru contul 1 (la limita free)."""
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(60):
conn.execute(
"INSERT INTO submissions "
"(idempotency_key, account_id, status, payload_json, created_at) "
"VALUES (?, 1, 'queued', '{}', ?)",
(f"web-vol60-{i}-{os.urandom(4).hex()}", now_str),
)
conn.commit()
finally:
conn.close()
def _upload_preview_si_commit(client: TestClient, rows: list[dict]): # type: ignore[return]
"""Parcurge fluxul web: upload -> mapare coloane (daca e necesar) -> confirma.
Intoarce (import_id, raspuns_confirma). Presupune nomenclatorul si maparea semanate.
"""
data = _csv_bytes(rows)
r = client.post(
"/_import/upload",
files={"file": ("plan_test.csv", io.BytesIO(data), "text/csv")},
)
assert r.status_code == 200, f"Upload esuat: {r.text[:300]}"
m = re.search(r"/_import/(\d+)/", r.text)
assert m, f"import_id negasit in raspunsul de upload: {r.text[:400]}"
iid = int(m.group(1))
if f"/_import/{iid}/mapare-coloane" in r.text:
r2 = client.post(
f"/_import/{iid}/mapare-coloane",
data={
"colname": ["VIN", "Nr", "Data", "KM", "Operatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
"format_data": "YYYY-MM-DD",
},
)
assert r2.status_code == 200, f"Mapare coloane esuata: {r2.text[:300]}"
# GET preview pentru n_ok
rp = client.get(f"/_import/{iid}/preview")
assert rp.status_code == 200, f"Preview esuat: {rp.text[:300]}"
m_ok = re.search(r'id="n-confirmat"[^>]*?value="(\d+)"', rp.text)
n_ok = int(m_ok.group(1)) if m_ok else len(rows)
r_conf = client.post(
f"/_import/{iid}/confirma",
data={
"csrf_token": "",
"n_confirmat": str(n_ok),
"confirmed_by": "test-plan@autopass.ro",
},
)
return iid, r_conf
# Date CSV: un singur rand ok
_ROWS_PLAN_WEB = [
{
"VIN": "WVWZZZ1KZAW700001",
"Nr": "B700TST",
"Data": "2026-06-15",
"KM": "70000",
"Operatie": "OP-WEB-PLAN",
},
]
# ---------------------------------------------------------------------------
# Test T3 — volum pe canalul web
# ---------------------------------------------------------------------------
def test_free_peste_60_respins_import_web(client):
"""Canal WEB de import: free la 60/60 → commit respins cu mesaj de limita plan.
T3 PRD 5.17: enforcement volum pe canalul web (web_confirma_import in routes.py).
Contul 1 e pe tier=free, fara trial, la 60/60 prestatii in luna curenta.
Commit-ul unui lot nou trebuie respins (intregul lot, nu partial) cu mesaj clar.
"""
from app.db import get_connection
# Seteaza contul 1 (implicit web in dev mode) pe free fara trial
conn = get_connection()
try:
conn.execute("UPDATE accounts SET tier='free', trial_until=NULL WHERE id=1")
conn.commit()
finally:
conn.close()
# Seed nomenclator si mapare operatie->cod
_seed_nomenclator_si_mapare()
# Insereaza 60 submissions (la limita)
_insert_60_submissions_luna()
# Parcurge fluxul web pana la commit
_iid, r_conf = _upload_preview_si_commit(client, _ROWS_PLAN_WEB)
assert r_conf.status_code == 200, ( # type: ignore[union-attr]
f"Commit trebuia sa intoarca 200 HTML (nu 5xx): {r_conf.status_code}" # type: ignore[union-attr]
)
# Raspunsul HTML trebuie sa contina mesajul de limita de plan
html = r_conf.text.lower() # type: ignore[union-attr]
assert ("limita" in html or "gratuit" in html or "60" in html), (
f"Mesajul de limita plan lipseste din raspunsul HTML al commit-ului:\n{r_conf.text[:600]}" # type: ignore[union-attr]
)
# Verifica ca nu s-au creat submissions noi (lotul a fost respins total)
from app.plans import monthly_usage
conn2 = get_connection()
try:
usage = monthly_usage(conn2, 1, datetime.now(timezone.utc))
finally:
conn2.close()
assert usage == 60, (
f"Lotul respins nu trebuia sa adauge submissions: asteptat usage=60, primit {usage}"
)

View File

@@ -291,3 +291,94 @@ def test_import_forms_pastreaza_csrf(client):
if "mapare-coloane" in text_map: # s-a primit fragmentul de mapare if "mapare-coloane" in text_map: # s-a primit fragmentul de mapare
assert 'name="csrf_token"' in text_map, \ assert 'name="csrf_token"' in text_map, \
"name='csrf_token' nu a fost gasit in formularul mapare-coloane" "name='csrf_token' nu a fost gasit in formularul mapare-coloane"
# ---------------------------------------------------------------------------
# US-013 Teste: import colapsat + tokeni scala + pill-uri cu dot (PRD 5.16)
# ---------------------------------------------------------------------------
def test_import_colapsat_implicit(client):
"""Pe Acasa (first-run, fara trimiteri), sectiunea de import e deschisa implicit.
La first-run (are_trimiteri=False), <details> trebuie sa aiba atributul `open`.
Summary-ul trebuie sa contina textul slim 'Importa fisier' (bara colapsabila).
Verifica si ca <details id="import-details"> este prezent pe pagina principala.
"""
r = client.get("/")
assert r.status_code == 200
text = r.text
# Elementul <details> trebuie sa fie prezent
assert 'id="import-details"' in text, \
"Elementul <details id='import-details'> lipseste de pe pagina principala"
# La first-run (nu exista trimiteri), details trebuie sa fie deschis (atribut open)
assert 'id="import-details" open' in text, \
"La first-run, <details id='import-details'> trebuie sa aiba atributul 'open'"
# Textul summary trebuie sa contina 'Importa fisier' (bara slim colapsabila)
assert "Importa fisier" in text, \
"Textul 'Importa fisier' nu a fost gasit in summary-ul sectiunii de import"
def test_wizard_foloseste_scala_tokeni(client):
"""Fragmentele wizard-ului de import folosesc tokeni var(--fs-*) in loc de px hardcodat.
Verifica ca fragmentul de mapare coloane (_mapcoloane.html) si cel de upload
(_upload.html) contin referinte la tokenii de scala --fs-* in inline styles,
nu font-size hardcodat in px sub 12px.
"""
# Fragment upload (/_import/reset) → _upload.html
r_upload = client.get("/_import/reset")
assert r_upload.status_code == 200
upload_text = r_upload.text
# Tokenii trebuie sa apara in inline styles
assert "var(--fs-" in upload_text, \
"Tokenii var(--fs-*) nu au fost gasiti in fragmentul de upload (_upload.html)"
# Fragment mapare coloane → _mapcoloane.html
csv_bytes = _make_csv_bytes(_SAMPLE_ROWS)
r_map = client.post(
"/_import/upload",
files={"file": ("test.csv", csv_bytes, "text/csv")},
)
assert r_map.status_code == 200
map_text = r_map.text
# Mapcoloane trebuie sa contina tokeni
assert "var(--fs-" in map_text, \
"Tokenii var(--fs-*) nu au fost gasiti in fragmentul mapare coloane (_mapcoloane.html)"
# Verifica ca nu exista font-size sub 12px hardcodat in fragmentele wizard
import re
for fragment_text, fragment_name in [(upload_text, "upload"), (map_text, "mapcoloane")]:
for size_str in re.findall(r'font-size:\s*(\d+)px', fragment_text):
size = int(size_str)
assert size >= 12, \
f"font-size:{size}px sub 12px gasit in fragmentul {fragment_name} — trebuie var(--fs-*)"
def test_preview_stari_pill_dot(client):
"""Pill-urile de stare din preview contin un dot consistent cu designul 5.16.
Verifica ca pill-urile din tabelul de preview si din rezumatul de stari contin
un element dot (span cu border-radius:99px ca inline style), consistent cu stripul
slim si cu designul 5.16 (dot + text, nu text gol).
Eticheta umana: din STARI_PREVIEW ('Gata de trimis', 'Cod RAR lipsa' etc.) — nicio
eticheta noua.
"""
_seed_op_mapping(client)
import_id = _upload_and_get_import_id(client)
text = _get_preview_via_mapare(client, import_id)
# Preview trebuie sa fie prezent
assert "confirm-form" in text or "Preview" in text, \
"Fragmentul de preview nu a fost randat"
# Pill-urile de stare trebuie sa contina un dot (span cu border-radius:99px)
assert "border-radius:99px" in text, \
"Dot-ul (border-radius:99px) nu a fost gasit in pill-urile de stare din preview"
# Etichetele umane din STARI_PREVIEW trebuie sa fie prezente (nicio eticheta noua)
# 'Gata de trimis' apare in rezumatul de stari (pill) sau in tabelul de randuri
assert "Gata de trimis" in text or "Cod RAR lipsa" in text or "Verifica valori" in text, \
"Etichetele umane din STARI_PREVIEW nu au fost gasite in preview"

View File

@@ -224,8 +224,8 @@ def test_logo_linkeaza_acasa(client):
"In prezent logo-ul nu e un link." "In prezent logo-ul nu e un link."
) )
# Titlul "Gateway RAR AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/"> # Titlul "ROA AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/">
# (PRD AC: Logo-ul ROMFAST + titlul linkeaza la /) # (PRD AC US-010: Logo-ul ROMFAST + titlul linkeaza la /; titlul a fost redenumit in 5.16)
assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?Gateway RAR AUTOPASS', header_html, re.DOTALL), ( assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?ROA AUTOPASS', header_html, re.DOTALL), (
"Titlul 'Gateway RAR AUTOPASS' trebuie sa fie intr-un <a href='/'> in header." "Titlul 'ROA AUTOPASS' trebuie sa fie intr-un <a href='/'> in header."
) )

View File

@@ -738,3 +738,141 @@ def test_strip_sanatate_fara_hex_hardcodat():
f"Hex literal de culoare in _status.html — strip sanatate va arata gresit pe " f"Hex literal de culoare in _status.html — strip sanatate va arata gresit pe "
f"tema hartie (luminoasa) / light. Folositi var(--token). Gasite: {hex_in_culori}" f"tema hartie (luminoasa) / light. Folositi var(--token). Gasite: {hex_in_culori}"
) )
# ============================================================
# PRD 5.16 US-010: Titlu ROA AUTOPASS + account_name in antet
# ============================================================
def test_titlu_romfast_autopass(client):
"""US-010 (PRD 5.16): titlul din antet si tag-ul <title> sunt 'ROA AUTOPASS',
nu 'Gateway RAR AUTOPASS'."""
_create_account_user("titlutest@test.com", name="Service Titlu")
_login(client, "titlutest@test.com")
html = client.get("/?tab=acasa").text
assert "ROA AUTOPASS" in html, \
"Titlul 'ROA AUTOPASS' lipseste din antet (US-010 PRD 5.16)"
assert "Gateway RAR AUTOPASS" not in html, \
"Titlul vechi 'Gateway RAR AUTOPASS' inca prezent — inlocuieste cu 'ROA AUTOPASS'"
def test_header_arata_nume_service_logat(client):
"""US-010 (PRD 5.16): cand utilizatorul e logat, antetul afiseaza numele service-ului
(accounts.name) ca sub-titlu cu clasa .h-sub."""
_create_account_user("numeservice@test.com", name="Service Auto Cluj SRL")
_login(client, "numeservice@test.com")
html = client.get("/?tab=acasa").text
assert "Service Auto Cluj SRL" in html, \
"Numele service-ului nu apare in antet (US-010 PRD 5.16) — verifica .h-sub"
assert "h-sub" in html, \
"Clasa .h-sub lipseste din antet (US-010 PRD 5.16) — sub-titlul account_name lipseste"
def test_login_branded_nu_schelet(client):
"""US-010 (PRD 5.16): /login are layout 2-coloane branduit cu clasa .login-shell,
titlul 'ROA AUTOPASS', si formular cu POST /login (CSRF intact)."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "login-shell" in html, \
"Clasa .login-shell lipseste din /login (US-010 PRD 5.16) — layout 2-coloane nenimplementat"
assert "login-aside" in html, \
"Clasa .login-aside lipseste — coloana stanga de brand lipseste (US-010)"
assert "ROA AUTOPASS" in html, \
"Titlul 'ROA AUTOPASS' lipseste din /login (US-010 PRD 5.16)"
# Formular intact: POST /login cu csrf_token
assert 'action="/login"' in html, "Actiunea formularului /login s-a schimbat — CSRF route invalida"
assert 'name="csrf_token"' in html, "csrf_token lipseste din formular — securitate compromisa"
assert 'name="parola"' in html, "Campul 'parola' lipseste din formular"
# ============================================================
# PRD 5.17 T7 (US-007): landing copy — limita 60 + trial Pro
# PRD 5.16 US-012: Autentificare → /login
# ============================================================
def _citeste_landing() -> str:
"""Returneaza continutul landing.html (template static; variabilele Jinja2 nu
afecteaza copy-ul de limita/plan/buton verificat mai jos)."""
from pathlib import Path
p = Path(__file__).parent.parent / "app" / "web" / "templates" / "landing.html"
assert p.exists(), f"landing.html negasit la {p}"
return p.read_text(encoding="utf-8")
def test_landing_limita_60():
"""5.17 T7 (US-007): limita planului Gratuit este 60 de prestatii/luna in landing,
nu 100. Verifica meta description, announce bar, hero badge, cardul Gratuit si
CTA-ul final."""
html = _citeste_landing()
assert "100 de prestații" not in html, \
"'100 de prestații' inca prezent in landing — limita trebuie sa fie 60 (5.17 T7)"
assert "100 prestații" not in html, \
"'100 prestații' inca prezent in landing — limita trebuie sa fie 60 (5.17 T7)"
assert "60 de prestații" in html, \
"'60 de prestații' lipseste din landing — verifica meta, announce bar, cardul Gratuit (5.17 T7)"
assert "60 prestații" in html, \
"'60 prestații' lipseste din hero badge in landing (5.17 T7)"
assert "60 de prezentări" in html, \
"'60 de prezentări' lipseste din CTA-ul final al landing-ului (5.17 T7)"
def test_landing_trial_pro_nu_premium():
"""5.17 T7 (US-007): trial-ul de 30 de zile este pe Pro, NU pe Premium.
Verifica sectiunea PRICING (subtitle) si sectiunea AUTH (lista beneficii)."""
html = _citeste_landing()
assert "Pro gratuit 30 de zile" in html, \
"'Pro gratuit 30 de zile' lipseste din landing — verifica sectiunile PRICING + AUTH (5.17 T7)"
assert "Premium gratuit 30 de zile" not in html, \
"'Premium gratuit 30 de zile' inca in landing — trial-ul e pe Pro, nu Premium (5.17 T7)"
def test_landing_autentificare_link_login():
"""5.16 US-012: butonul 'Autentificare' din header-ul landing este un link <a href='/login'>
cu clasa auth-login-link, NU un buton care deschide modalul de login.
CSS-ul responsive (.lp-hactions) trebuie sa foloseasca noul selector, nu cel vechi."""
html = _citeste_landing()
# Link real catre /login in header (cu clasa de identificare)
assert 'href="/login"' in html, \
"href='/login' lipseste din landing — 'Autentificare' din header trebuie sa fie link (5.16 US-012)"
assert "auth-login-link" in html, \
"Clasa auth-login-link lipseste — header 'Autentificare' nu a fost convertit la <a> (5.16 US-012)"
# CSS-ul responsive ascunde linkul pe <430px prin noul selector (nu cel vechi cu atribute)
assert "a.auth-login-link" in html, \
"Selectorul CSS 'a.auth-login-link' lipseste — CSS responsive neactualizat (5.16 US-012)"
# Selectorul CSS vechi cu [data-act="auth"][data-tab="login"] nu mai exista in CSS
assert '[data-act="auth"][data-tab="login"]' not in html, \
"Selectorul CSS vechi [data-act='auth'][data-tab='login'] inca prezent (5.16 US-012)"
def test_contoare_desktop_ascunse_pe_mobil_fara_inline_display():
"""US-002 (PRD 5.16): pe <=560px se vad DOAR contoarele compacte, nu si cele 5 carduri mari.
Regresie prinsa la VERIFY E2E (390px): un inline `style="display:flex"` pe `.contoare-desktop`
batea regula `@media (max-width:560px) { .contoare-desktop { display:none } }` (inline > stylesheet)
-> contoare DUPLICATE pe mobil. Lock: `display:flex` sta in CSS (nu inline pe element), iar media
query-ul ascunde cardurile mari pe mobil.
"""
from pathlib import Path
tdir = Path(__file__).parent.parent / "app" / "web" / "templates"
base = (tdir / "base.html").read_text(encoding="utf-8")
status = (tdir / "_status.html").read_text(encoding="utf-8")
# _status.html: containerul de carduri NU mai are inline display (altfel bate media query-ul).
assert 'class="contoare-desktop" style="display:flex' not in status, (
"containerul .contoare-desktop are inline display:flex -> media query-ul nu-l mai poate ascunde pe mobil"
)
# base.html: regula CSS default (display:flex) + ascunderea pe <=560px.
assert re.search(r"\.contoare-desktop\s*\{[^}]*display:\s*flex", base), (
"lipseste regula CSS .contoare-desktop { display:flex } in base.html"
)
assert re.search(r"\.contoare-desktop\s*\{[^}]*display:\s*none", base), (
"lipseste ascunderea .contoare-desktop { display:none } (media <=560px) in base.html"
)

View File

@@ -48,6 +48,7 @@ def test_signup_creeaza_cont_user_si_cheie(client):
"cui": "RO12345678", "cui": "RO12345678",
"email": "test@example.com", "email": "test@example.com",
"parola": "parolasecreta", "parola": "parolasecreta",
"consent": "1",
"csrf_token": token, "csrf_token": token,
}) })
assert resp.status_code == 200 assert resp.status_code == 200
@@ -87,6 +88,7 @@ def test_signup_email_duplicat_eroare(client):
"cui": make_test_cui("dup@example.com"), "cui": make_test_cui("dup@example.com"),
"email": "dup@example.com", "email": "dup@example.com",
"parola": "parolasecreta", "parola": "parolasecreta",
"consent": "1",
"csrf_token": token, "csrf_token": token,
}) })
@@ -102,6 +104,7 @@ def test_signup_email_duplicat_eroare(client):
"cui": make_test_cui("dup-b@example.com"), "cui": make_test_cui("dup-b@example.com"),
"email": "dup@example.com", "email": "dup@example.com",
"parola": "altaparola123", "parola": "altaparola123",
"consent": "1",
"csrf_token": token, "csrf_token": token,
}) })
assert resp2.status_code in (200, 422) assert resp2.status_code in (200, 422)
@@ -139,6 +142,72 @@ def test_signup_parola_scurta_eroare(client):
conn.close() conn.close()
def test_signup_fara_consent_eroare(client):
"""Consimtamant GDPR lipsa -> 422, fara creare cont; mesaj despre Termeni/GDPR.
Checkbox-ul de consimtamant trebuie validat server-side (functional, nu doar client-side):
fara el contul nu se creeaza si planul/datele introduse se pastreaza in re-render.
"""
from tests.conftest import make_test_cui
resp = client.get("/signup")
token = _csrf(resp.text)
resp = client.post("/signup", data={
"name": "Service Fara Consent",
"cui": make_test_cui("fara-consent@test.com"),
"email": "fara-consent@test.com",
"parola": "parolasecreta123",
# fara "consent"
"csrf_token": token,
})
assert resp.status_code == 422
assert "rfak_" not in resp.text
assert "GDPR" in resp.text or "Termeni" in resp.text
from app.db import get_connection
conn = get_connection()
try:
acct = conn.execute(
"SELECT * FROM accounts WHERE name='Service Fara Consent'"
).fetchone()
assert acct is None, "Cont creat desi consimtamantul lipsea"
finally:
conn.close()
def test_signup_salveaza_requested_plan_si_consent(client):
"""POST /signup cu plan ales -> accounts.requested_plan = codul ales, consent_at setat,
iar tier RAMANE 'free' (planul cerut NU acorda drepturi)."""
from tests.conftest import make_test_cui
resp = client.get("/signup")
token = _csrf(resp.text)
resp = client.post("/signup", data={
"name": "Service Plan Pro",
"cui": make_test_cui("plan-pro@test.com"),
"email": "plan-pro@test.com",
"parola": "parolasecreta123",
"plan": "pro",
"consent": "1",
"csrf_token": token,
})
assert resp.status_code == 200
assert "rfak_" in resp.text
from app.db import get_connection
conn = get_connection()
try:
acct = conn.execute(
"SELECT * FROM accounts WHERE name='Service Plan Pro'"
).fetchone()
assert acct is not None
assert acct["requested_plan"] == "pro", "Planul cerut nu a fost salvat"
assert acct["tier"] == "free", "tier NU trebuie urcat din planul cerut (doar dupa plata)"
assert acct["consent_at"], "consent_at trebuie setat la signup cu consimtamant"
finally:
conn.close()
def test_cheie_afisata_o_data(client): def test_cheie_afisata_o_data(client):
"""Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine.""" """Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine."""
from tests.conftest import make_test_cui from tests.conftest import make_test_cui
@@ -150,6 +219,7 @@ def test_cheie_afisata_o_data(client):
"cui": make_test_cui("cheie@test.com"), "cui": make_test_cui("cheie@test.com"),
"email": "cheie@test.com", "email": "cheie@test.com",
"parola": "parolasecreta", "parola": "parolasecreta",
"consent": "1",
"csrf_token": token, "csrf_token": token,
}) })
assert resp_post.status_code == 200 assert resp_post.status_code == 200

View File

@@ -81,7 +81,10 @@ def client(monkeypatch):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_status_are_bife_verzi_cand_totul_ok(client): def test_status_are_bife_verzi_cand_totul_ok(client):
"""Worker viu + RAR login recent -> glifa verde ✓ + text 'declaratiile curg normal'.""" """US-003 PRD 5.16: worker viu + RAR login recent -> strip-sanatate in DOM dar ASCUNS (hidden).
Banda rosie apare DOAR cand BLOCAT. Starea OK e indicata de dot-ul verde din antet (base.html).
Elementul id=strip-sanatate ramane in DOM pentru compatibilitate (nu dispare complet).
"""
_create_account_user("bifeok@test.com") _create_account_user("bifeok@test.com")
_login(client, "bifeok@test.com", "parolasecreta10") _login(client, "bifeok@test.com", "parolasecreta10")
@@ -91,12 +94,11 @@ def test_status_are_bife_verzi_cand_totul_ok(client):
resp = client.get("/_fragments/status") resp = client.get("/_fragments/status")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Glifa accesibila ✓ (nu doar culoare) # US-003: elementul strip-sanatate e prezent in DOM dar ascuns cand totul e ok
assert "&#10003;" in html, f"Lipseste glifa ✓ cand totul e ok. HTML: {html[:600]}" assert 'id="strip-sanatate"' in html, f"id=strip-sanatate lipseste complet din fragment. HTML: {html[:600]}"
# US-003 D6: strip unificat (nu bife individuale worker/RAR) # Cand OK, banda nu trebuie sa afiseze ✗ (eroare) — ✓ nu mai apare (banda e ascunsa)
assert "curg normal" in html.lower(), ( assert "&#10007;" not in html, \
f"Textul 'curg normal' din strip sanatate lipseste. HTML: {html[:600]}" f"Glifa ✗ (eroare) apare cand starea e ok — banda e gresit afisata. HTML: {html[:600]}"
)
def test_status_are_bife_rosii_cand_worker_oprit(client): def test_status_are_bife_rosii_cand_worker_oprit(client):
@@ -183,7 +185,8 @@ def test_strip_rosu_worker_oprit(client):
def test_trei_contoare_card(client): def test_trei_contoare_card(client):
"""US-003: fragment status contine exact 3 carduri .contor-card (In coada / Trimise / De corectat).""" """US-002 PRD 5.16: fragment status contine 5 carduri .contor-card separate:
Total / Luna asta / Azi / In coada / De corectat."""
_create_account_user("treicont@test.com") _create_account_user("treicont@test.com")
_login(client, "treicont@test.com", "parolasecreta10") _login(client, "treicont@test.com", "parolasecreta10")
@@ -192,12 +195,15 @@ def test_trei_contoare_card(client):
html = resp.text html = resp.text
count = html.count("contor-card") count = html.count("contor-card")
assert count >= 3, ( assert count >= 5, (
f"Trebuie minim 3 elemente contor-card in fragment, gasit: {count}. HTML: {html[:800]}" f"Trebuie minim 5 elemente contor-card (US-002 PRD 5.16: Total/Luna/Azi/Coada/Corectat), "
f"gasit: {count}. HTML: {html[:800]}"
) )
# Etichete asteptate # Etichete asteptate (US-002 PRD 5.16: 5 carduri separate)
assert "Total" in html, "Eticheta 'Total' lipseste din contoare (US-002 PRD 5.16)."
assert "Luna asta" in html, "Eticheta 'Luna asta' lipseste din contoare (US-002 PRD 5.16)."
assert "Azi" in html, "Eticheta 'Azi' lipseste din contoare (US-002 PRD 5.16)."
assert "In coada" in html, "Eticheta 'In coada' lipseste din contoare." assert "In coada" in html, "Eticheta 'In coada' lipseste din contoare."
assert "Trimise" in html, "Eticheta 'Trimise' lipseste din contoare."
assert "De corectat" in html, "Eticheta 'De corectat' lipseste din contoare." assert "De corectat" in html, "Eticheta 'De corectat' lipseste din contoare."
@@ -250,6 +256,98 @@ def test_fara_bara_veche(client):
) )
def test_banda_apare_doar_cand_blocat(client):
"""US-003 (PRD 5.16): banda rosie completa apare NUMAI cand BLOCAT.
Cand totul e ok, strip-sanatate are atributul 'hidden' (ascuns, nu disparut).
Cand worker e oprit, strip-sanatate NU are 'hidden' (e vizibil, rosu).
"""
_create_account_user("bandablocat@test.com")
_login(client, "bandablocat@test.com", "parolasecreta10")
# Stare OK: strip ascuns
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html_ok = resp.text
# Cand OK, elementul e ascuns
assert 'id="strip-sanatate"' in html_ok, "strip-sanatate lipseste din DOM cand totul e ok"
assert "&#10007;" not in html_ok, "Glifa eroare apare cand sanatate=ok (banda nu trebuie sa fie rosie)"
# Stare BLOCAT: strip vizibil cu glifa ✗
_set_heartbeat(last_beat=None, last_rar_login_ok=None)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html_err = resp.text
assert 'id="strip-sanatate"' in html_err, "strip-sanatate lipseste din DOM cand blocat"
assert "&#10007;" in html_err, "Glifa ✗ lipseste cand BLOCAT (banda trebuie sa fie rosie)"
def test_rar_dot_in_antet_ok(client):
"""US-003 (PRD 5.16): cand logat si sanatate_ok, antetul contine chip-ul RAR cu clasa rar-ok.
Starea ok se vede din header (dot verde pulsant), nu din banda de stare (care e ascunsa).
"""
_create_account_user("rardot@test.com")
_login(client, "rardot@test.com", "parolasecreta10")
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Chip RAR in antet (nu in banda de stare)
assert "rar-chip" in html, "Clasa rar-chip lipseste din HTML (dot RAR in antet, US-003)"
assert "rar-ok" in html, "Clasa rar-ok lipseste — dot verde cand sanatate ok (US-003)"
assert "rar-dot" in html, "Clasa rar-dot lipseste din chip (US-003)"
def test_rar_in_meniu_burger(client):
"""US-003/010 (PRD 5.16): meniul burger contine starea RAR ca prima intrare (RAR online / RAR indisponibil)."""
_create_account_user("rarmeniu@test.com")
_login(client, "rarmeniu@test.com", "parolasecreta10")
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Meniul burger (cont-menu) contine indicatorul RAR
assert "cont-menu" in html, "Meniu burger (cont-menu) lipseste din HTML"
assert "RAR online" in html or "RAR indisponibil" in html, \
"Starea RAR nu apare in meniu burger (US-003/010)"
# Prima intrare e starea RAR — prezenta class menu-rar-line
assert "menu-rar-line" in html, "Clasa menu-rar-line lipseste din burger (US-003)"
def test_anuleaza_are_data_modal_close(client):
"""US-007 (PRD 5.16): overlay-ul modal si butonul de inchidere au atributul data-modal-close."""
# Butonul si overlay-ul trebuie sa aiba data-modal-close pentru ca handler-ul cu .closest() sa functioneze
# Verificam in baza template-ului base.html (modal e definit acolo, randat pe toate paginile)
# Testam pe dashboard dupa login (unde baza e incarcata)
_create_account_user("modalclose@test.com")
_login(client, "modalclose@test.com", "parolasecreta10")
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "data-modal-close" in html, \
"data-modal-close lipseste din template — modalul nu se poate inchide (US-007)"
def test_modal_close_pe_element_interior(client):
"""US-007 (PRD 5.16): handler-ul modal foloseste .closest('[data-modal-close]') nu
.hasAttribute directe — astfel click pe un element interior al backdrop-ului functioneaza."""
_create_account_user("modalclosest@test.com")
_login(client, "modalclosest@test.com", "parolasecreta10")
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Verificam ca JS-ul foloseste closest, nu hasAttribute
assert "closest('[data-modal-close]')" in html, \
"Handler-ul modal foloseste hasAttribute in loc de closest (US-007) — click pe copil nu va inchide modalul"
def _set_tz_bucuresti(monkeypatch, request): def _set_tz_bucuresti(monkeypatch, request):
"""Forteaza TZ=Europe/Bucharest pentru ca modificatorul SQLite 'localtime' sa """Forteaza TZ=Europe/Bucharest pentru ca modificatorul SQLite 'localtime' sa
rezolve la fusul RO indiferent de TZ-ul runner-ului (CI ruleaza de regula in UTC). rezolve la fusul RO indiferent de TZ-ul runner-ului (CI ruleaza de regula in UTC).
@@ -359,3 +457,231 @@ def test_iarna_nu_bleed_in_ziua_urmatoare(monkeypatch, request):
finally: finally:
conn.close() conn.close()
get_settings.cache_clear() get_settings.cache_clear()
# ===========================================================================
# US-006 (PRD 5.17) — Afisaj plan curent: trial / consum / warn / banner
# ===========================================================================
def _set_trial_until(account_id: int, trial_until_str: str | None) -> None:
"""Seteaza direct trial_until pentru un cont (helper de test)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET trial_until=? WHERE id=?",
(trial_until_str, account_id),
)
conn.commit()
finally:
conn.close()
def _insert_submissions_sent(account_id: int, n: int) -> None:
"""Insereaza N submissions sent in luna curenta (helper de test)."""
from app.db import get_connection
import json as _json
conn = get_connection()
try:
for i in range(n):
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key, created_at) "
"VALUES (?, 'sent', ?, ?, datetime('now'))",
(account_id, _json.dumps({"vin": f"VIN{i:013d}"}), f"key-plan-{account_id}-{i}"),
)
conn.commit()
finally:
conn.close()
def test_afisaj_plan_si_zile_trial(client):
"""US-006 + T-6 (5.16): cont in trial Pro -> linia de plan din meniul burger (pagina
completa) arata 'Plan: Pro · trial N zile ramase'. In starea normala (non-warn) plan_linie
NU mai e rand in corpul fragmentului status — traieste in badge antet + burger.
Contul nou primeste trial_until=now+30z automat la creare.
"""
acct_id, _ = _create_account_user("trialzile@test.com")
_login(client, "trialzile@test.com", "parolasecreta10")
# trial_until = now + 18 zile + 12h (buffer pt a evita delta.days=17 din timing test)
future = (datetime.now(timezone.utc) + timedelta(days=18, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "Plan: Pro" in html, f"Textul 'Plan: Pro' lipseste in trial. HTML: {html[:800]}"
assert "trial" in html.lower(), f"Cuvantul 'trial' lipseste in starea de trial. HTML: {html[:800]}"
assert "18" in html, f"Numarul de zile (18) nu apare in afisaj. HTML: {html[:800]}"
assert "zile" in html, f"Cuvantul 'zile' lipseste (pluralizare). HTML: {html[:800]}"
def test_afisaj_consum_lunar(client):
"""US-006 + T-6 (5.16): cont free (fara trial) -> linia de plan din burger (pagina
completa) arata 'Gratuit · N/60 luna asta'. Consumul normal nu mai e rand in corp."""
acct_id, _ = _create_account_user("consumlun@test.com")
_login(client, "consumlun@test.com", "parolasecreta10")
# Dezactiveaza trial-ul (cont free pur)
_set_trial_until(acct_id, None)
# Insereaza 5 submissions sent luna asta
_insert_submissions_sent(acct_id, 5)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "Gratuit" in html, f"'Gratuit' lipseste din afisajul de consum. HTML: {html[:800]}"
assert "5" in html, f"Contorul de consum (5) nu apare. HTML: {html[:800]}"
assert "60" in html, f"Limita (60) nu apare in afisajul de consum. HTML: {html[:800]}"
assert "luna asta" in html, f"'luna asta' lipseste din afisajul de consum. HTML: {html[:800]}"
def test_avertizare_aproape_de_limita(client):
"""US-006: >=80% din 60 -> avertizare cu text 'aproape de limita' + culoare warn."""
acct_id, _ = _create_account_user("aproapelim@test.com")
_login(client, "aproapelim@test.com", "parolasecreta10")
_set_trial_until(acct_id, None)
# 50/60 = 83% -> warn
_insert_submissions_sent(acct_id, 50)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "aproape de limita" in html, (
f"Textul 'aproape de limita' lipseste la 50/60. HTML: {html[:800]}"
)
assert "50" in html, f"Contorul 50 nu apare. HTML: {html[:800]}"
# Warn = culoare (var(--warn) in inline style)
assert "var(--warn)" in html or "plan-warn" in html, (
f"Stilul de warn (var(--warn) sau clasa plan-warn) lipseste la aproape-de-limita. HTML: {html[:800]}"
)
def test_limita_atinsa(client):
"""US-006: 60/60 -> text 'limita atinsa'."""
acct_id, _ = _create_account_user("limitaatinsa@test.com")
_login(client, "limitaatinsa@test.com", "parolasecreta10")
_set_trial_until(acct_id, None)
_insert_submissions_sent(acct_id, 60)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "limita atinsa" in html, (
f"Textul 'limita atinsa' lipseste la 60/60. HTML: {html[:800]}"
)
def test_copy_pluralizare_zi_zile(client):
"""US-006: pluralizare RO corecta — 1 zi (nu '1 zile'), 18 zile (nu '18 zi')."""
acct_id, _ = _create_account_user("pluralzile@test.com")
_login(client, "pluralzile@test.com", "parolasecreta10")
# 18 zile: trebuie "18 zile ramase" (buffer 12h pt delta.days determinist)
future_18 = (datetime.now(timezone.utc) + timedelta(days=18, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future_18)
# T-6 (5.16): linia de plan (cu pluralizarea zilelor) traieste in burger pe pagina completa.
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "18 zile" in html, f"'18 zile' lipseste. HTML: {html[:800]}"
assert "18 zi " not in html and "18 zi<" not in html, (
f"'18 zi' (plural gresit) apare in loc de '18 zile'. HTML: {html[:800]}"
)
# 1 zi: trebuie "1 zi ramasa" (singular); buffer 12h
future_1 = (datetime.now(timezone.utc) + timedelta(days=1, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future_1)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "1 zi" in html, f"'1 zi' (singular) lipseste la o zi ramasa. HTML: {html[:800]}"
assert "1 zile" not in html, (
f"'1 zile' (plural gresit) apare in loc de '1 zi'. HTML: {html[:800]}"
)
def test_banner_one_time_trial_expirat(client):
"""US-006 T-DES-1: dupa expirarea trial-ului, banner 'Trial Pro expirat' apare in _status.html."""
acct_id, _ = _create_account_user("trialexp@test.com")
_login(client, "trialexp@test.com", "parolasecreta10")
# trial_until in trecut -> trial expirat -> banner one-time
past = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, past)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "Trial Pro expirat" in html, (
f"Banner 'Trial Pro expirat' lipseste dupa expirarea trial-ului. HTML: {html[:800]}"
)
assert "Gratuit" in html, (
f"Dupa expirarea trial-ului, planul trebuie sa afiseze 'Gratuit'. HTML: {html[:800]}"
)
# Bannerul are buton de dismiss
assert "banner-trial-expirat" in html, (
f"Elementul id=banner-trial-expirat lipseste. HTML: {html[:800]}"
)
def test_cont_arata_plan(client):
"""US-006: tab-ul Cont (/tab=cont) afiseaza planul curent si explicatia de upgrade."""
acct_id, _ = _create_account_user("contplan@test.com")
_login(client, "contplan@test.com", "parolasecreta10")
_set_trial_until(acct_id, None) # free fara trial
resp = client.get("/?tab=cont", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "Plan curent" in html or "sectiune-plan" in html, (
f"Sectiunea 'Plan curent' lipseste din tab-ul Cont. HTML: {html[:1000]}"
)
assert "Gratuit" in html, f"'Gratuit' lipseste din planul afisat in Cont. HTML: {html[:1000]}"
assert "Standard" in html or "Pro" in html, (
f"Optiunile de upgrade (Standard/Pro) lipsesc din sectiunea Plan. HTML: {html[:1000]}"
)
def test_plan_linie_in_burger(client):
"""US-006: meniul burger contine linia de plan (Plan: Gratuit / Pro · trial N zile)."""
acct_id, _ = _create_account_user("burgerplan@test.com")
_login(client, "burgerplan@test.com", "parolasecreta10")
_set_trial_until(acct_id, None) # free fara trial
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Meniul burger trebuie sa contina linia de plan
assert "Plan: Gratuit" in html, (
f"'Plan: Gratuit' lipseste din meniu burger. HTML (fragment): {html[html.find('cont-menu'):html.find('cont-menu')+500] if 'cont-menu' in html else html[:500]}"
)
def test_trial_pro_arata_zile_in_burger(client):
"""US-006: cont in trial -> burger arata 'Plan: Pro · trial N zile ramase'."""
acct_id, _ = _create_account_user("burgertrial@test.com")
_login(client, "burgertrial@test.com", "parolasecreta10")
future = (datetime.now(timezone.utc) + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "Plan: Pro" in html, f"'Plan: Pro' lipseste din burger in trial. HTML: {html[:800]}"
assert "trial" in html.lower(), f"'trial' lipseste din linia de plan din burger. HTML: {html[:800]}"

View File

@@ -107,8 +107,9 @@ def test_submissions_coloane_umane(client):
assert "B777ZZZ" in html, "Nr inmatriculare din payload lipseste" assert "B777ZZZ" in html, "Nr inmatriculare din payload lipseste"
assert "Reparatie frane" in html, "Operatia din payload lipseste" assert "Reparatie frane" in html, "Operatia din payload lipseste"
# Nr. prezentare RAR accesibil pe linia meta discreta # 5.16: #id_prezentare nu mai e pe rand (randul are MAX 2 linii) — detaliul complet
assert "68516" in html, "Nr. prezentare RAR lipseste din linia meta" # (inclusiv nr. prezentare RAR) traieste in modalul de detaliu.
assert "68516" not in html, "Nr. prezentare RAR nu trebuie sa mai apara pe randul slim"
def test_tab_eticheta_trimiteri(client): def test_tab_eticheta_trimiteri(client):
@@ -426,9 +427,9 @@ def test_detaliu_trimitere_404_cross_account(client):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_rand_slim_vin_operatie_pill(client): def test_rand_slim_vin_operatie_pill(client):
"""US-004: fiecare rand slim afiseaza VIN scurt in .slim-vin, operatie+ora in """5.16: fiecare rand slim are 2 linii — L1 placuta (nr. inmatriculare) in .slim-vin,
.slim-meta si un pill de stare cu clasa stare_css si eticheta stare_scurt. L2 cod RAR · operatie · data in .slim-meta, plus un pill de stare cu clasa stare_css
Lista e inconjurata de .lista-trimiteri-slim. si eticheta stare_scurt. Lista e inconjurata de .lista-trimiteri-slim.
""" """
acct = _create_account_user("slim1@test.com") acct = _create_account_user("slim1@test.com")
_insert_submission(acct, "sent", id_prezentare=80001) _insert_submission(acct, "sent", id_prezentare=80001)
@@ -442,14 +443,16 @@ def test_rand_slim_vin_operatie_pill(client):
assert "lista-trimiteri-slim" in html, "lista-trimiteri-slim lipseste din raspuns" assert "lista-trimiteri-slim" in html, "lista-trimiteri-slim lipseste din raspuns"
assert "trimitere-slim" in html, "trimitere-slim lipseste din raspuns" assert "trimitere-slim" in html, "trimitere-slim lipseste din raspuns"
# VIN scurt in clasa slim-vin (mono, linia 1) # L1: placuta (identificator primar) in clasa slim-vin
assert "slim-vin" in html, "slim-vin lipseste — linia 1 VIN mono" assert "slim-vin" in html, "slim-vin lipseste — linia 1 placuta"
assert "B777ZZZ" in html, "placuta (nr. inmatriculare) lipseste de pe rand"
# Linia 2 muted (operatie + ora/data) # L2: cod RAR · operatie · data (slim-meta / slim-rand2)
assert "slim-meta" in html, "slim-meta lipseste — linia 2 muted" assert "slim-meta" in html, "slim-meta lipseste — linia 2"
assert "slim-rand2" in html, "slim-rand2 lipseste — linia 2 (cod RAR · operatie · data)"
# VIN scurt randat (WVWZZZ1JZXW000777 -> …000777) # VIN integral nu mai e pe rand (5.16) — traieste in modalul de detaliu.
assert "000777" in html, "VIN scurt (ultimele 6 cifre) lipseste" assert "000777" not in html, "VIN scurt nu mai trebuie randat pe randul slim (2 linii)"
# Pill de stare: clasa CSS + eticheta scurta # Pill de stare: clasa CSS + eticheta scurta
assert "s-sent" in html, "clasa pill s-sent lipseste" assert "s-sent" in html, "clasa pill s-sent lipseste"

View File

@@ -81,12 +81,13 @@ def client(monkeypatch):
get_settings.cache_clear() get_settings.cache_clear()
def test_vin_pe_rand_separat_sub_nr(client): def test_placuta_pe_rand_identificator_primar(client):
"""VIN-ul apare intr-un element block-level cu clasa slim-vin (PRD 5.15 US-004). """Placuta (nr. inmatriculare) e identificatorul PRIMAR, linia 1 a randului slim
(5.16): in <div class="slim-vin"> (block-level, prominent).
PRD 5.10 (US-005): VIN era <div class="muted"> sub nr in coloana Vehicul. PRD 5.15 (US-004): VIN era identificatorul primar pe linia 1.
PRD 5.15 (US-004): VIN e acum identificatorul PRINCIPAL, linia 1 a randului slim, 5.16 (directiva user): operatorul scaneaza placuta de pe comanda, nu VIN-ul de 17
in <div class="slim-vin"> (mono, prominent, block-level). NU mai e muted. caractere — placuta devine linia 1, VIN integral se muta in modalul de detaliu.
""" """
acct = _create_account_user("vin_layout@test.com") acct = _create_account_user("vin_layout@test.com")
_ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ") _ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ")
@@ -96,46 +97,51 @@ def test_vin_pe_rand_separat_sub_nr(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# VIN trunchiat trebuie sa apara in HTML # Placuta trebuie sa apara in HTML
assert "000001" in html, "VIN-ul trunchiat trebuie sa apara in lista slim" assert "B123XYZ" in html, "placuta (nr. inmatriculare) trebuie sa apara in lista slim"
# VIN e intr-un element block-level (div cu clasa slim-vin) # Placuta e intr-un element block-level (div cu clasa slim-vin)
# Pattern: <div class="slim-vin">...000001...</div> plac = "B123XYZ"
vin_fragment = "000001"
found_slim_vin = re.search( found_slim_vin = re.search(
rf'<div[^>]*class="slim-vin[^"]*"[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</div>', rf'<div[^>]*class="slim-vin[^"]*"[^>]*>[^<]*{re.escape(plac)}[^<]*</div>',
html, html,
) )
assert found_slim_vin, ( assert found_slim_vin, (
f"VIN '{vin_fragment}' trebuie sa fie in <div class=\"slim-vin\"> (block-level, " f"placuta '{plac}' trebuie sa fie in <div class=\"slim-vin\"> (linia 1 a "
f"mono, linia 1 a randului slim). HTML gasit: " f"randului slim). HTML gasit: "
+ html[max(0, html.find(vin_fragment) - 80):html.find(vin_fragment) + 80] + html[max(0, html.find(plac) - 80):html.find(plac) + 80]
) )
# VIN integral NU mai e pe rand (max 2 linii) — traieste in modalul de detaliu.
assert "000001" not in html, "VIN-ul nu mai trebuie randat pe randul slim (5.16)"
def test_vin_lipsa_nu_genereaza_rand_gol(client):
"""Cand VIN-ul lipseste (sau e EMPTY=''), slim-vin nu afiseaza '' izolat. def test_placuta_lipsa_nu_genereaza_rand_gol(client):
Fallback: slim-vin afiseaza vehicul_nr (nr. inmatriculare) cu clasa muted. """Cand placuta SI VIN-ul lipsesc, slim-vin nu afiseaza '' izolat ca identificator.
(PRD 5.15 US-004: slim-vin are garda vin != '') Fallback (5.16): VIN scurt daca exista, altfel mesaj neutru ('fara numar') — niciodata
un em-dash singur ca identificator primar.
""" """
acct = _create_account_user("vin_gol@test.com") acct = _create_account_user("vin_gol@test.com")
sid = _ins(acct, vin="", nr="B999TST") # VIN gol -> vin_scurt='—' # Placuta prezenta -> e identificatorul primar pe linia 1.
sid1 = _ins(acct, vin="", nr="B999TST")
# Placuta SI VIN absente -> fallback 'fara numar' (nu '—' izolat).
sid2 = _ins(acct, vin="", nr="")
_login(client, "vin_gol@test.com") _login(client, "vin_gol@test.com")
resp = client.get("/_fragments/submissions") resp = client.get("/_fragments/submissions")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Randul trebuie sa existe # Ambele randuri exista
assert f'id="trimitere-row-{sid}"' in html assert f'id="trimitere-row-{sid1}"' in html
assert f'id="trimitere-row-{sid2}"' in html
# slim-vin NU trebuie sa contina '—' izolat (VIN lipsa -> fallback vehicul_nr) # Placuta vizibila cand exista
slim_vin_match = re.search(r'<div[^>]*class="slim-vin[^"]*"[^>]*>([^<]*)</div>', html) assert "B999TST" in html, "placuta (nr. inmatriculare) lipseste de pe rand"
assert slim_vin_match, "slim-vin lipseste din randul cu VIN gol"
slim_vin_content = slim_vin_match.group(1).strip() # Niciun slim-vin nu contine '—' izolat
assert slim_vin_content != "", ( for m in re.finditer(r'<div[^>]*class="slim-vin[^"]*"[^>]*>([^<]*)</div>', html):
"slim-vin afiseaza '' izolat cand VIN lipseste — " assert m.group(1).strip() != "", "slim-vin afiseaza '' izolat ca identificator"
"trebuie sa afiseze vehicul_nr ca fallback"
) # Fallback neutru cand placuta + VIN lipsesc
# Fallback: nr inmatriculare vizibil assert "fara numar" in html, "fallback 'fara numar' lipseste cand placuta+VIN absente"
assert "B999TST" in html, "Nr inmatriculare (fallback) lipseste cand VIN e gol"

View File

@@ -137,60 +137,57 @@ def test_paleta_accent_azur_definita(client):
) )
# ── test_font_ibm_plex_aplicat ──────────────────────────────────────────────── # ── test_font_system_stack_aplicat ───────────────────────────────────────────
def test_font_ibm_plex_aplicat(client): def test_font_system_stack_aplicat(client):
"""IBM Plex Sans si IBM Plex Mono declarate in font-family si @font-face cu font-display:swap. """US-001 (PRD 5.16): IBM Plex eliminat; body foloseste stiva de fonturi sistem.
Verifica: Verifica:
- body font-family contine 'IBM Plex Sans' (sau alias ibm-plex-sans) - body font-family foloseste var(--font-ui) (CSS custom property)
- exista cel putin un @font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono' - --font-ui este definit in :root si contine un system font stack (system-ui / -apple-system)
- @font-face include font-display:swap - ZERO @font-face cu 'IBM Plex' in <style> (IBM Plex eliminat complet)
- @font-face pointeaza spre /static/fonts/ - ZERO referinte catre /static/fonts/ in HTML (nu se mai servesc fisiere woff2)
""" """
resp = client.get("/login") resp = client.get("/login")
assert resp.status_code == 200 assert resp.status_code == 200
style = _get_style_block(resp.text) style = _get_style_block(resp.text)
# 1. body font-family contine IBM Plex Sans # 1. body font-family refera var(--font-ui) (nu IBM Plex inline)
body_m = re.search(r"body\s*\{([^}]+)\}", style, re.DOTALL) body_m = re.search(r"body\s*\{([^}]+)\}", style, re.DOTALL)
assert body_m, "Regula 'body { ... }' negasita in <style>" assert body_m, "Regula 'body { ... }' negasita in <style>"
body_block = body_m.group(1) body_block = body_m.group(1)
assert "IBM Plex Sans" in body_block or "ibm-plex-sans" in body_block.lower(), ( assert "var(--font-ui)" in body_block, (
f"'IBM Plex Sans' lipseste din font-family al body. body block: {body_block.strip()}" f"body font-family trebuie sa foloseasca var(--font-ui) (sistem font stack). "
f"body block: {body_block.strip()}"
) )
# 2. Exista cel putin un @font-face cu IBM Plex # 2. --font-ui definit in :root si contine un system font stack
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
assert root_m, "Blocul :root negasit in <style>"
root_block = root_m.group(1)
assert "--font-ui" in root_block, (
f"--font-ui lipseste din :root. Continut :root: {root_block.strip()}"
)
font_ui_m = re.search(r"--font-ui\s*:\s*([^;]+)", root_block)
assert font_ui_m, "--font-ui negasit in blocul :root"
font_ui_val = font_ui_m.group(1).lower()
assert "system-ui" in font_ui_val or "-apple-system" in font_ui_val, (
f"--font-ui trebuie sa contina system-ui sau -apple-system (stiva sistem). "
f"Valoare gasita: {font_ui_m.group(1).strip()}"
)
# 3. ZERO @font-face cu IBM Plex (eliminat in US-001)
font_face_blocks = re.findall(r"@font-face\s*\{([^}]+)\}", style, re.DOTALL) font_face_blocks = re.findall(r"@font-face\s*\{([^}]+)\}", style, re.DOTALL)
assert font_face_blocks, "@font-face negasit in <style>"
ibm_face = [b for b in font_face_blocks if "IBM Plex" in b or "ibm-plex" in b.lower()] ibm_face = [b for b in font_face_blocks if "IBM Plex" in b or "ibm-plex" in b.lower()]
assert ibm_face, ( assert not ibm_face, (
"@font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono' negasit. " f"@font-face cu IBM Plex trebuia eliminat (US-001 PRD 5.16). "
f"Blocuri @font-face gasite: {font_face_blocks}" f"Blocat gasit: {ibm_face}"
) )
# 3. font-display:swap prezent in cel putin un bloc IBM Plex @font-face # 4. ZERO referinte /static/fonts/ in HTML randat (nu mai servim woff2)
swap_present = any("swap" in b.lower() for b in ibm_face) html = resp.text
assert swap_present, ( assert "/static/fonts/" not in html, (
"font-display:swap lipseste din @font-face IBM Plex. " "Referinte catre /static/fonts/ gasite in HTML — trebuie eliminate (US-001 PRD 5.16)."
f"Blocuri @font-face IBM Plex: {ibm_face}"
)
# 4. @font-face pointeaza spre /static/fonts/
fonts_src = any("/static/fonts/" in b for b in ibm_face)
assert fonts_src, (
"@font-face IBM Plex nu pointeaza spre /static/fonts/. "
f"Blocuri: {ibm_face}"
)
# 5. IBM Plex Mono pentru monospace: exista un context monospace cu IBM Plex Mono
# (fie @font-face, fie o regula font-family cu monospace)
has_mono = any("IBM Plex Mono" in b or "ibm-plex-mono" in b.lower() for b in font_face_blocks)
if not has_mono:
# Acceptam si daca e in o regula CSS (nu neaparat @font-face)
has_mono = "IBM Plex Mono" in style
assert has_mono, (
"'IBM Plex Mono' lipseste din <style> (trebuie pentru coduri RAR/VIN/nr)."
) )

View File

@@ -0,0 +1,185 @@
"""Teste keepalive RAR — login de proba periodic ca dashboard-ul sa nu afiseze
fals "RAR inaccesibil" doar din lipsa de trafic.
Comportament asteptat (_maybe_keepalive):
- login vechi/lipsa + creds durabile -> sondeaza (get_token apelat) si forteaza
login real (invalidate inainte);
- login proaspat (sub interval) -> NU sondeaza;
- interval=0 -> dezactivat;
- fara cont cu creds durabile -> nu sondeaza;
- gating: dupa o incercare, nu re-sondeaza in cadrul intervalului (nu hartui RAR).
"""
from __future__ import annotations
import os
import tempfile
from datetime import datetime, timedelta, timezone
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
yield conn, get_settings()
conn.close()
get_settings.cache_clear()
class _FakeSessions:
"""Imita AccountSessions: get_token reusit reimprospateaza heartbeat-ul (ca realul)."""
def __init__(self, conn, *, fail: bool = False):
self._conn = conn
self._fail = fail
self.invalidated: list[int] = []
self.tokens: list[int] = []
def invalidate(self, account_id: int) -> None:
self.invalidated.append(account_id)
def get_token(self, conn, account_id: int, creds) -> str | None:
self.tokens.append(account_id)
if self._fail:
raise RuntimeError("RAR jos")
from app.db import write_heartbeat
write_heartbeat(conn, rar_login_ok=True, detail=f"login proba (cont {account_id})")
return "tok"
def _set_last_login(conn, *, ago_s: float | None):
"""Seteaza last_rar_login_ok la now-ago_s (None = niciun login)."""
from app.db import write_heartbeat
write_heartbeat(conn, detail="poll") # asigura randul heartbeat
if ago_s is None:
conn.execute("UPDATE worker_heartbeat SET last_rar_login_ok=NULL WHERE id=1")
else:
ts = (datetime.now(timezone.utc) - timedelta(seconds=ago_s)).isoformat()
conn.execute("UPDATE worker_heartbeat SET last_rar_login_ok=? WHERE id=1", (ts,))
conn.commit()
def _account_cu_creds(conn) -> int:
from app.accounts import create_account
from app.crypto import encrypt_creds
acct = create_account(conn, "Service Cu Creds", email="svc@example.com")
enc = encrypt_creds({"email": "svc@example.com", "password": "secret"})
conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, acct))
conn.commit()
return acct
def test_login_vechi_sondeaza_si_reimprospateaza(env):
"""Login mai vechi decat intervalul + creds durabile -> proba reala, heartbeat reimprospatat."""
from app.worker.__main__ import _maybe_keepalive
from app.db import read_heartbeat
conn, settings = env
settings.worker_rar_keepalive_interval_s = 86400
acct = _account_cu_creds(conn)
_set_last_login(conn, ago_s=100000) # > 24h
sessions = _FakeSessions(conn)
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
assert sessions.tokens == [acct] # a sondat contul cu creds
assert sessions.invalidated == [acct] # a fortat login real (nu token din cache)
last = read_heartbeat(conn)["last_rar_login_ok"]
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds()
assert age < 60 # heartbeat reimprospatat de proba
def test_login_proaspat_nu_sondeaza(env):
"""Login sub interval -> niciun login de proba."""
from app.worker.__main__ import _maybe_keepalive
conn, settings = env
settings.worker_rar_keepalive_interval_s = 86400
_account_cu_creds(conn)
_set_last_login(conn, ago_s=3600) # 1h < 24h
sessions = _FakeSessions(conn)
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
assert sessions.tokens == []
def test_interval_zero_dezactivat(env):
"""interval=0 -> keepalive dezactivat, nicio proba chiar cu login vechi."""
from app.worker.__main__ import _maybe_keepalive
conn, settings = env
settings.worker_rar_keepalive_interval_s = 0
_account_cu_creds(conn)
_set_last_login(conn, ago_s=100000)
sessions = _FakeSessions(conn)
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
assert sessions.tokens == []
def test_fara_creds_durabile_nu_sondeaza(env):
"""Niciun cont cu creds durabile + fara test-creds -> nimic de sondat."""
from app.worker.__main__ import _maybe_keepalive
conn, settings = env
settings.worker_rar_keepalive_interval_s = 86400
settings.worker_use_test_creds = False
_set_last_login(conn, ago_s=100000)
sessions = _FakeSessions(conn)
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
assert sessions.tokens == []
def test_target_sare_creds_nedecriptabile(env):
"""Cont cu creds criptate sub alta cheie (decrypt -> None) e sarit; alege contul valid.
Reproduce bug-ul real: start.sh both genereaza o cheie efemera noua la fiecare
pornire, deci creds-urile durabile vechi nu se mai decripteaza.
"""
from app.worker.__main__ import _keepalive_target
from app.accounts import create_account
from app.crypto import encrypt_creds
conn, settings = env
settings.worker_use_test_creds = False
# Cont cu creds GUNOI (nedecriptabile sub cheia curenta), id mai mic.
bad = create_account(conn, "Cont Cheie Veche", email="old@example.com")
conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", ("gAAAAA-token-invalid", bad))
# Cont cu creds valide, id mai mare.
good = create_account(conn, "Cont Valid", email="good@example.com")
enc = encrypt_creds({"email": "good@example.com", "password": "pw"})
conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, good))
conn.commit()
acct_id, creds = _keepalive_target(conn, settings)
assert acct_id == good # a sarit contul nedecriptabil
assert creds and creds["email"] == "good@example.com"
def test_gating_nu_hartuieste_pe_esec(env):
"""Pe esec (RAR jos) login-ul ramane vechi; a doua trecere imediata NU re-sondeaza."""
from app.worker.__main__ import _maybe_keepalive
conn, settings = env
settings.worker_rar_keepalive_interval_s = 86400
_account_cu_creds(conn)
_set_last_login(conn, ago_s=100000)
state = {"last_attempt": 0.0}
sessions = _FakeSessions(conn, fail=True)
_maybe_keepalive(conn, settings, sessions, state) # incearca, esueaza
_maybe_keepalive(conn, settings, sessions, state) # gating: nu re-incearca
assert sessions.tokens == [sessions.invalidated[0]] # o singura proba
assert len(sessions.tokens) == 1

View File

@@ -22,7 +22,7 @@ import argparse
import sqlite3 import sqlite3
import sys import sys
from app.accounts import create_account, list_accounts, set_active from app.accounts import create_account, list_accounts, set_active, set_tier
from app.auth import create_api_key from app.auth import create_api_key
from app.db import get_connection, init_db from app.db import get_connection, init_db
from app.users import set_admin from app.users import set_admin
@@ -68,6 +68,17 @@ def _set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> int:
return 0 return 0
def _set_tier(conn: sqlite3.Connection, account_id: int, tier: str, trial_until: str | None) -> int:
try:
set_tier(conn, account_id, tier, trial_until=trial_until)
except ValueError as exc:
print(f"eroare: {exc}", file=sys.stderr)
return 2
trial_msg = f", trial_until={trial_until}" if trial_until else ", fara trial"
print(f"Cont {account_id}: tier={tier}{trial_msg}")
return 0
def _set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool) -> int: def _set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool) -> int:
try: try:
set_admin(conn, account_id, is_admin=is_admin) set_admin(conn, account_id, is_admin=is_admin)
@@ -119,6 +130,29 @@ def main(argv: list[str] | None = None) -> int:
p_sadmin.add_argument("--account", type=int, required=True, help="account_id") p_sadmin.add_argument("--account", type=int, required=True, help="account_id")
p_sadmin.add_argument("--remove", action="store_true", help="sterge rolul admin (implicit: adauga)") p_sadmin.add_argument("--remove", action="store_true", help="sterge rolul admin (implicit: adauga)")
p_stier = sub.add_parser(
"set-tier",
help="seteaza planul unui cont (free/standard/pro/premium)",
description=(
"Aloca manual un plan de cont. Tier invalid -> eroare clara. "
"Contul de sistem id=1 e protejat."
),
)
p_stier.add_argument("--account", type=int, required=True, help="account_id")
p_stier.add_argument(
"--tier", required=True,
help="planul de alocat: free | standard | pro | premium"
)
_trial_grp = p_stier.add_mutually_exclusive_group()
_trial_grp.add_argument(
"--trial-days", type=int, metavar="N",
help="seteaza trial_until = acum + N zile"
)
_trial_grp.add_argument(
"--no-trial", action="store_true",
help="sterge trial-ul (trial_until=NULL)"
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
init_db() # asigura schema (accounts.active + index CUI) + cont default init_db() # asigura schema (accounts.active + index CUI) + cont default
@@ -134,6 +168,16 @@ def main(argv: list[str] | None = None) -> int:
return _set_active(conn, args.account, False) return _set_active(conn, args.account, False)
if args.cmd == "set-admin": if args.cmd == "set-admin":
return _set_admin(conn, args.account, is_admin=not args.remove) return _set_admin(conn, args.account, is_admin=not args.remove)
if args.cmd == "set-tier":
# Calculeaza trial_until din --trial-days sau None daca --no-trial
from datetime import datetime, timedelta, timezone
trial_until: str | None = None
if getattr(args, "trial_days", None):
trial_until = (
datetime.now(timezone.utc) + timedelta(days=args.trial_days)
).strftime("%Y-%m-%d %H:%M:%S")
# daca nici --trial-days nici --no-trial -> trial_until=None (fara trial)
return _set_tier(conn, args.account, args.tier, trial_until)
finally: finally:
conn.close() conn.close()
return 0 return 0

View File

@@ -0,0 +1,258 @@
"""Etichetator offline operatii service -> coduri RAR (US-002, PRD 5.18).
Backend implicit = **LM Studio local** (Qwen3-4B, GPU RX 6600M via Tailscale),
backend-ul APROBAT pentru bootstrap-ul v1 (decizia D4). Groq / OpenRouter raman
fallback-uri interschimbabile, dar NU sunt calea aprobata pentru v1.
Particularitati care justifica un tool NOU (nu reuse de `or_common.call`):
- LM Studio RESPINGE `response_format: json_object` (eroare 400). Cere envelope
`json_schema` STRICT complet: {"type":"json_schema","json_schema":{...,"strict":true}}.
- `cod` e ENUM peste cele 19 etichete (18 coduri RAR + NUL) -> modelul nu poate
inventa coduri; orice abatere e prinsa de garda de truncare ('?').
- Qwen3 emite `<think>...` daca nu dezactivam thinking-ul -> umfla tokeni/latenta
sub structured output strict. Punem `/no_think` in promptul de sistem.
Setari conservatoare OBLIGATORII pe GPU-box (a facut shutdown sub sarcina 2026-06-29,
probabil termic/alimentare): in LM Studio incarca modelul cu `n_parallel=1`,
`n_ctx=4096`, batch 32-40, monitorizeaza temperatura. NU mari batch/context fara
headroom termic. Vezi memorie `lmstudio-gpu-etichetare`.
Reutilizeaza din `or_common`: scrub-ul PII (F3) si lista de coduri.
"""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
# --- Coduri + scrub PII: sursa de adevar = or_common (acelasi nomenclator de etichete) ---
import importlib.util as _ilu
_OR_PATH = os.path.join(os.path.dirname(__file__), "or_common.py")
_spec = _ilu.spec_from_file_location("or_common", _OR_PATH)
or_common = _ilu.module_from_spec(_spec)
sys.modules.setdefault("or_common", or_common)
_spec.loader.exec_module(or_common)
scrub = or_common.scrub # VIN/placuta -> [VIN]/[NR]
# Cele 19 etichete (18 coduri RAR + NUL), extrase din CODURI (sursa unica or_common).
ALL_LABELS: list[str] = [c.split("=")[0].strip() for c in or_common.CODURI.replace(", ", ",").split(",")]
assert "NUL" in ALL_LABELS and len(ALL_LABELS) == 19, ALL_LABELS
_VALID = set(ALL_LABELS)
# --------------------------------------------------------------------------- #
# Prompt procedural in 3 pasi (versionat) #
# --------------------------------------------------------------------------- #
PROMPT_VERSION = "3pasi-v1"
_CODURI_LISTA = or_common.CODURI
SYS = (
"Esti expert RAR AUTOPASS. Clasifici fiecare operatie de service-auto in EXACT unul "
"din aceste coduri:\n" + _CODURI_LISTA + "\n\n"
"Urmeaza PROCEDURA in 3 pasi, in ordine:\n"
"PAS 1 (non-operatie -> NUL): daca textul NU e o operatie tehnica de service "
"(ITP, plata/achitat, discount/reducere, taxa, nr inmatriculare/placuta, manopera "
"generica, sau DOAR un nume de piesa fara actiune) -> cod = NUL. Opreste-te.\n"
"PAS 2 (avarie din ACCIDENT -> avarie grava): foloseste codurile de avarie grava DOAR "
"pentru daune in urma unui accident, pe sistemul avariat:\n"
" caroserie/structura rezistenta -> OE-C; sasiu -> OE-S; directie -> OE-D; "
"franare -> OE-F; sistem de retinere/airbag -> OE-R; ADAS (asistenta condus) -> OE-A.\n"
" Reparatiile curente, de uzura (NU dintr-un accident) NU sunt avarii grave -> mergi la PAS 3.\n"
"PAS 3 (operatie obisnuita): \n"
" inlocuire / D-R / reparare / vopsire / retus piese -> OE-1 (REPARATIE);\n"
" schimb ulei motor + filtre -> OE-3 (REVIZIE PERIODICA);\n"
" aerisit / gresat / completat nivele -> OE-2 (INTRETINERE);\n"
" reglare functionala (geometrie directie, faruri, ralanti) -> OE-4;\n"
" actualizare/programare software -> OE-7; schimb sezonier anvelope -> OE-8;\n"
" istoric/reparatie/inlocuire odometru -> OE-I / R-ODO / I-ODO; tahograf -> AITLV.\n\n"
"Raspunde DOAR cu JSON conform schemei. /no_think"
)
def construieste_mesaje(batch: list[str]) -> list[dict]:
"""Mesajele chat (system procedural + user enumerat). Scrub PII pe fiecare item."""
user = "\n".join(f"{i + 1}. {scrub(o)}" for i, o in enumerate(batch))
return [
{"role": "system", "content": SYS},
{"role": "user", "content": user},
]
# --------------------------------------------------------------------------- #
# Schema json_schema strict (envelope complet — LM Studio respinge json_object) #
# --------------------------------------------------------------------------- #
def _response_format() -> dict:
return {
"type": "json_schema",
"json_schema": {
"name": "etichete_operatii",
"strict": True,
"schema": {
"type": "object",
"properties": {
"rez": {
"type": "array",
"items": {
"type": "object",
"properties": {
"i": {"type": "integer"},
"cod": {"type": "string", "enum": ALL_LABELS},
},
"required": ["i", "cod"],
"additionalProperties": False,
},
}
},
"required": ["rez"],
"additionalProperties": False,
},
},
}
# --------------------------------------------------------------------------- #
# Backend-uri (LM Studio default; Groq/OpenRouter fallback) #
# --------------------------------------------------------------------------- #
@dataclass
class Backend:
name: str
url: str
model: str
api_key: str | None = None
# Endpoint LM Studio implicit = GPU-box pe Tailscale (memorie lmstudio-gpu-etichetare).
_DEFAULT_LMSTUDIO_URL = "http://100.64.151.22:1234/v1/chat/completions"
_BACKENDS = {
"lmstudio": {"url": _DEFAULT_LMSTUDIO_URL, "model": "qwen/qwen3-4b", "key_env": None},
"groq": {"url": "https://api.groq.com/openai/v1/chat/completions",
"model": "llama-3.3-70b-versatile", "key_env": "GROQ_KEY"},
"openrouter": {"url": "https://openrouter.ai/api/v1/chat/completions",
"model": "qwen/qwen3-4b:free", "key_env": "OPENROUTER_KEY"},
}
def get_backend(name: str | None = None) -> Backend:
"""Construieste backend-ul din env. Default = lmstudio (D4).
Override-uri: ETICHETARE_BACKEND, ETICHETARE_ENDPOINT, ETICHETARE_MODEL.
Cheia API (Groq/OpenRouter) se citeste din env-ul indicat de backend; LM Studio
local nu cere cheie.
"""
name = (name or os.environ.get("ETICHETARE_BACKEND") or "lmstudio").strip().lower()
if name not in _BACKENDS:
raise ValueError(f"backend necunoscut: {name} (alege din {list(_BACKENDS)})")
cfg = _BACKENDS[name]
url = os.environ.get("ETICHETARE_ENDPOINT") or cfg["url"]
model = os.environ.get("ETICHETARE_MODEL") or cfg["model"]
api_key = os.environ.get(cfg["key_env"]) if cfg["key_env"] else None
return Backend(name=name, url=url, model=model, api_key=api_key)
def construieste_body(batch: list[str], backend: Backend) -> dict:
"""Corpul request-ului OpenAI-compatibil cu envelope json_schema strict."""
return {
"model": backend.model,
"messages": construieste_mesaje(batch),
"temperature": 0,
"response_format": _response_format(),
}
# --------------------------------------------------------------------------- #
# Parsare + garda de truncare #
# --------------------------------------------------------------------------- #
def parseaza_raspuns(content: dict, n: int) -> list[str]:
"""Mapeaza raspunsul {"rez":[{i,cod}]} la o lista paralela cu batch-ul (len n).
Garda de truncare/validare (F8): pozitiile lipsa SAU codurile in afara enum-ului
devin '?', NU sunt ascunse tacit. Apelantul logheaza cate '?' au ramas.
"""
by_i: dict[int, str] = {}
for x in content.get("rez") or []:
try:
idx = int(x["i"])
except (KeyError, TypeError, ValueError):
continue
cod = str(x.get("cod") or "").strip().upper()
by_i[idx] = cod if cod in _VALID else "?"
return [by_i.get(i + 1, "?") for i in range(n)]
# --------------------------------------------------------------------------- #
# Transport (injectabil in teste) #
# --------------------------------------------------------------------------- #
def _urllib_transport(url: str, headers: dict, payload: dict, timeout: int) -> dict:
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
def call(
batch: list[str],
backend: Backend,
*,
timeout: int = 180,
max_attempts: int = 5,
transport=None,
) -> tuple[list[str], dict]:
"""Un apel pe un batch. Intoarce (codes, meta).
codes: lista paralela cu batch; '?' pe pozitiile fara raspuns valid (garda F8).
meta: {ms, err, missing} — `missing` = cate '?' au ramas (truncare/cod invalid).
transport: callable(url, headers, payload, timeout) -> dict raspuns OpenAI
(injectabil in teste; default urllib).
"""
transport = transport or _urllib_transport
body = construieste_body(batch, backend)
headers = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}
if backend.api_key:
headers["Authorization"] = f"Bearer {backend.api_key}"
t0 = time.time()
for attempt in range(max_attempts):
try:
resp = transport(backend.url, headers, body, timeout)
content = json.loads(resp["choices"][0]["message"]["content"])
codes = parseaza_raspuns(content, len(batch))
missing = codes.count("?")
return codes, {"ms": int((time.time() - t0) * 1000), "err": None, "missing": missing}
except urllib.error.HTTPError as e:
if e.code in (429, 500, 502, 503):
wait = float(e.headers.get("retry-after", 0)) or min(2 ** attempt, 30)
time.sleep(wait)
continue
return ["?"] * len(batch), {"ms": int((time.time() - t0) * 1000), "err": f"HTTP {e.code}", "missing": len(batch)}
except Exception as e: # noqa: BLE001 — degradare gratioasa, batch-ul devine '?'
if attempt < max_attempts - 1:
time.sleep(min(2 ** attempt, 20))
continue
return ["?"] * len(batch), {"ms": int((time.time() - t0) * 1000), "err": type(e).__name__, "missing": len(batch)}
return ["?"] * len(batch), {"ms": int((time.time() - t0) * 1000), "err": "max_attempts", "missing": len(batch)}
if __name__ == "__main__":
# Sanity-check manual: 1 batch mic pe backend-ul configurat (default lmstudio).
import sys
probe = sys.argv[1:] or ["13 X ITP", "INLOCUIT PLACUTE FRANA FATA", "SCHIMB ULEI MOTOR SI FILTRE"]
b = get_backend()
print(f"backend={b.name} url={b.url} model={b.model}")
codes, meta = call(probe, b)
for op, c in zip(probe, codes):
print(f" {c:6} {op}")
print("meta:", meta)

View File

@@ -0,0 +1,346 @@
"""Generare seed etichetat operatie->cod (US-003, PRD 5.18).
Produce artefactul `app/data/operatii-etichetate.json` (comis in repo), consumat de
seeder (US-004) si de corpusul embeddings (US-005). NU cheama LLM la runtime — o
singura data, offline, pe LM Studio (backend implicit, D4).
Pipeline dedup OBLIGATORIU, in ordine, INAINTE de orice apel LLM (D5):
1. Agrega cele N CSV-uri -> freq pe denumire RAW (NR ne-numeric -> skip rand, F9).
2. `cheie = normalize_for_match(denumire)` (ACEEASI functie ca DB/k-NN, NU strip exact).
Arunca randurile cu `cheie == ""` inainte de dedup (coliziune pe slot UNIQUE gol, F6).
3. Dedup pe cheie: un reprezentant per cheie, `freq = suma NR`.
4. Harta `cheie -> cod` din TOATE etichetele existente: `labels-groq-partial.json` (cheiat
brut) + seedul comis anterior (cheiat normalizat). Conflict (acelasi cheie, coduri diferite
pe variante raw) -> castiga codul cu freq-max, tie-break pe cod sortat (F3).
5. `de_etichetat = corpus(in prag) - harta`. Sortat desc pe freq = SINGURUL input la LLM.
Idempotenta cross-run (F2/F7): seedul comis = cache de etichete -> re-run = 0 apeluri LLM.
"""
from __future__ import annotations
import argparse
import csv
import glob
import importlib.util
import json
import os
import sys
from collections import Counter, defaultdict
# Functia de normalizare = sursa unica de adevar (consistenta cu DB/k-NN).
_APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _APP_ROOT not in sys.path:
sys.path.insert(0, _APP_ROOT)
from app.mapping import normalize_for_match # noqa: E402
def _load_eticheteaza():
path = os.path.join(os.path.dirname(__file__), "eticheteaza.py")
spec = importlib.util.spec_from_file_location("eticheteaza", path)
mod = importlib.util.module_from_spec(spec)
sys.modules.setdefault("eticheteaza", mod)
spec.loader.exec_module(mod)
return mod
# Cai implicite (relative la repo).
DEFAULT_CSV_GLOB = os.path.join(_APP_ROOT, "docs", "operatii-service", "*.csv")
DEFAULT_LABELS = os.path.join(_APP_ROOT, "tools", "mapare-llm", "labels-groq-partial.json")
DEFAULT_SEED = os.path.join(_APP_ROOT, "app", "data", "operatii-etichetate.json")
NUL_LABEL = "NUL"
DEFAULT_CONFIDENCE = 0.7
DEFAULT_SOURCE = "llm_seed"
# --------------------------------------------------------------------------- #
# Pasul 1-3: corpus agregat pe cheie normalizata #
# --------------------------------------------------------------------------- #
def _freq_raw(csv_paths: list[str]) -> Counter:
"""Counter denumire_raw -> suma NR. NR ne-numeric -> skip rand (F9), nu zero-weight."""
freq: Counter = Counter()
for f in csv_paths:
with open(f, encoding="utf-8", errors="replace") as fh:
for r in list(csv.reader(fh, delimiter=";"))[1:]:
if len(r) <= 2:
continue
den = r[1].strip()
if not den:
continue
nr_raw = (r[2] or "").strip()
try:
nr = int(nr_raw)
except ValueError:
continue # F9: skip rand cu NR ne-numeric
freq[den] += nr
return freq
def _corpus_din_freq(freq_raw: Counter) -> dict[str, dict]:
"""{cheie_normalizata -> {denumire, freq}}. Arunca cheile vide (F6).
`denumire` = varianta raw cu freq individual maxim (tie-break: raw sortat asc),
folosita ca text trimis la LLM si stocata in seed.
"""
grup: dict[str, list[tuple[str, int]]] = defaultdict(list)
for raw, n in freq_raw.items():
cheie = normalize_for_match(raw)
if not cheie:
continue # F6
grup[cheie].append((raw, n))
corpus: dict[str, dict] = {}
for cheie, variante in grup.items():
freq = sum(n for _, n in variante)
# reprezentant determinist: freq max, tie-break raw sortat.
denumire = sorted(variante, key=lambda rn: (-rn[1], rn[0]))[0][0]
corpus[cheie] = {"denumire": denumire, "freq": freq}
return corpus
def agrega_corpus(csv_paths: list[str]) -> dict[str, dict]:
"""{cheie_normalizata -> {denumire, freq}} din CSV-uri (pasii 1-3)."""
return _corpus_din_freq(_freq_raw(csv_paths))
# --------------------------------------------------------------------------- #
# Pasul 4: harta cheie -> cod din etichetele existente (reuse + conflict) #
# --------------------------------------------------------------------------- #
def _incarca_seed(seed_path: str | None) -> list[dict]:
if not seed_path or not os.path.exists(seed_path):
return []
try:
with open(seed_path, encoding="utf-8") as fh:
return json.loads(fh.read())
except (ValueError, OSError):
return []
def construieste_harta_etichete(
freq_raw: Counter,
corpus: dict[str, dict],
labels_path: str | None,
seed_existent: list[dict],
) -> dict[str, str]:
"""Harta cheie_normalizata -> eticheta (cod RAR sau 'NUL'), reuse in spatiu normalizat.
Voturi ponderate pe freq; conflict pe acelasi cheie -> freq-max, tie-break cod sortat (F3).
"""
votes: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
# labels-groq-partial.json: cheiat pe text BRUT.
if labels_path and os.path.exists(labels_path):
with open(labels_path, encoding="utf-8") as fh:
labels = json.loads(fh.read())
for raw, cod in labels.items():
cheie = normalize_for_match(raw)
if not cheie:
continue
cod = str(cod or "").strip().upper()
if not cod:
continue
votes[cheie][cod] += freq_raw.get(raw, 0)
# seed comis anterior: cheiat normalizat (cache cross-run).
for e in seed_existent:
cheie = e.get("denumire_normalizata")
if not cheie:
continue
eticheta = NUL_LABEL if e.get("is_nul") else str(e.get("cod") or "").strip().upper()
if not eticheta:
continue
votes[cheie][eticheta] += corpus.get(cheie, {}).get("freq", 0)
harta: dict[str, str] = {}
for cheie, codmap in votes.items():
# freq desc, apoi cod asc -> determinist.
harta[cheie] = sorted(codmap.items(), key=lambda kv: (-kv[1], kv[0]))[0][0]
return harta
# --------------------------------------------------------------------------- #
# Pasul 5: selectie de_etichetat (prag de volum) + orchestrare #
# --------------------------------------------------------------------------- #
def selecteaza_de_etichetat(
corpus: dict[str, dict],
harta: dict[str, str],
*,
target_volum: float,
etichetare_all: bool,
) -> list[str]:
"""Cheile ne-etichetate, sortate desc pe freq, in interiorul pragului de volum."""
ordered = sorted(corpus, key=lambda k: (-corpus[k]["freq"], k))
if etichetare_all:
in_prag = ordered
else:
total = sum(c["freq"] for c in corpus.values()) or 1
in_prag = []
cum = 0
for k in ordered:
in_prag.append(k)
cum += corpus[k]["freq"]
if cum / total >= target_volum:
break
return [k for k in in_prag if k not in harta]
def genereaza(
csv_paths: list[str],
*,
labels_path: str | None = DEFAULT_LABELS,
seed_path: str = DEFAULT_SEED,
target_volum: float = 0.9,
etichetare_all: bool = False,
clasifica=None,
batch: int = 32,
confidence: float = DEFAULT_CONFIDENCE,
source: str = DEFAULT_SOURCE,
progres=None,
checkpoint_every: int = 1,
pauza: float = 0.0,
) -> dict:
"""Genereaza/actualizeaza seedul. Intoarce statistici. Scrie `seed_path`.
`clasifica(batch_denumiri) -> list[cod]` e injectabil (teste); default = LM Studio.
`progres(mesaj)` e un callback optional de logare.
Checkpointing (`checkpoint_every` batch-uri): seedul se scrie pe disc periodic in
timpul rularii, NU doar la final. Esential pe GPU-box-ul instabil (shutdown termic
sub sarcina, memorie lmstudio-gpu-etichetare): un crash la batch-ul 80/104 pastreaza
progresul, iar re-run-ul continua din cache (idempotenta cross-run). 0 = doar la final.
"""
freq_raw = _freq_raw(csv_paths)
corpus = _corpus_din_freq(freq_raw)
seed_existent = _incarca_seed(seed_path)
harta = construieste_harta_etichete(freq_raw, corpus, labels_path, seed_existent)
de_etichetat = selecteaza_de_etichetat(
corpus, harta, target_volum=target_volum, etichetare_all=etichetare_all
)
reused = len(harta)
brute = int(sum(freq_raw.values()))
if progres:
progres(f"{len(freq_raw)} randuri brute distincte -> {len(corpus)} dupa normalizare "
f"-> {len(de_etichetat)} trimise la LLM (deja: {len(harta)})")
clasif = clasifica
if clasif is None:
et = _load_eticheteaza()
backend = et.get_backend()
if progres:
progres(f"backend={backend.name} url={backend.url} model={backend.model}")
def clasif(batch_denumiri):
return et.call(batch_denumiri, backend)[0]
apeluri = 0
valide = _valid_labels()
nr_batch = (len(de_etichetat) + batch - 1) // batch
for k in range(0, len(de_etichetat), batch):
chunk = de_etichetat[k:k + batch]
denumiri = [corpus[c]["denumire"] for c in chunk]
codes = clasif(denumiri)
apeluri += 1
for cheie, cod in zip(chunk, codes):
cod = str(cod or "").strip().upper()
if cod in valide: # '?' / cod invalid -> ramane ne-etichetat (retry la urmatorul run)
harta[cheie] = cod
if progres:
progres(f" batch {apeluri}/{nr_batch} "
f"-> total etichetat {sum(1 for c in harta if c in corpus)}")
# Checkpoint periodic: protejeaza progresul pe GPU-box instabil.
if checkpoint_every and apeluri % checkpoint_every == 0:
_scrie_seed(seed_path, _construieste_seed(corpus, harta, confidence=confidence, source=source))
# Pauza intre batch-uri: ragaz termic pentru GPU-box (shutdown sub sarcina sustinuta).
if pauza and k + batch < len(de_etichetat):
import time as _t
_t.sleep(pauza)
seed = _construieste_seed(corpus, harta, confidence=confidence, source=source)
_scrie_seed(seed_path, seed)
return {
"brute": brute,
"distincte": len(corpus),
"deja_etichetate": reused,
"de_etichetat": len(de_etichetat),
"apeluri_llm": apeluri,
"seed": len(seed),
}
def _valid_labels() -> set[str]:
et = _load_eticheteaza()
return set(et.ALL_LABELS)
def _construieste_seed(corpus, harta, *, confidence, source) -> list[dict]:
"""Seed ordonat determinist (pe cheie) -> byte-stabil intre rulari."""
out = []
for cheie in sorted(harta):
if cheie not in corpus:
continue # eticheta fara corespondent in corpusul curent
eticheta = harta[cheie]
is_nul = eticheta == NUL_LABEL
out.append({
"denumire": corpus[cheie]["denumire"],
"denumire_normalizata": cheie,
"cod": None if is_nul else eticheta,
"is_nul": is_nul,
"source": source,
"confidence": confidence,
})
return out
def _scrie_seed(seed_path: str, seed: list[dict]) -> None:
os.makedirs(os.path.dirname(os.path.abspath(seed_path)), exist_ok=True)
with open(seed_path, "w", encoding="utf-8") as fh:
json.dump(seed, fh, ensure_ascii=False, indent=2)
fh.write("\n")
# --------------------------------------------------------------------------- #
# CLI #
# --------------------------------------------------------------------------- #
def main(argv=None):
ap = argparse.ArgumentParser(description="Genereaza seed etichetat operatie->cod (LM Studio).")
ap.add_argument("--target-volum", type=float, default=0.9,
help="prag de acoperire pe volum (default 0.9 = D1)")
ap.add_argument("--all", action="store_true", help="eticheteaza tot corpusul, ignora pragul")
ap.add_argument("--batch", type=int, default=32, help="dimensiune batch (conservator: 32-40)")
ap.add_argument("--pauza", type=float, default=1.5,
help="secunde de pauza intre batch-uri (ragaz termic GPU); 0 = fara")
ap.add_argument("--checkpoint-every", type=int, default=1,
help="scrie seedul la fiecare N batch-uri (1 = dupa fiecare, crash-safe)")
ap.add_argument("--confidence", type=float, default=DEFAULT_CONFIDENCE)
ap.add_argument("--csv-glob", default=DEFAULT_CSV_GLOB)
ap.add_argument("--labels", default=DEFAULT_LABELS)
ap.add_argument("--seed", default=DEFAULT_SEED)
args = ap.parse_args(argv)
csv_paths = sorted(glob.glob(args.csv_glob))
if not csv_paths:
ap.error(f"niciun CSV gasit la {args.csv_glob}")
stats = genereaza(
csv_paths,
labels_path=args.labels,
seed_path=args.seed,
target_volum=args.target_volum,
etichetare_all=args.all,
batch=args.batch,
pauza=args.pauza,
checkpoint_every=args.checkpoint_every,
confidence=args.confidence,
progres=lambda m: print(m, flush=True),
)
print("GATA:", json.dumps(stats, ensure_ascii=False))
if __name__ == "__main__":
main()