Compare commits

...

72 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
Claude Agent
3fc53534e2 feat(5.15+5.14): CLOSE — fix-uri code-review + embeddings functional
5.15 (propagare design + dashboard editare) si 5.14 (mapare LLM distilata)
inchise dupa /code-review high. 8 buguri reparate TDD:

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:48:34 +00:00
Claude Agent
9e42e7ed6f docs(5.15): criterii design din mockup-uri + revizie eng-review
Criterii noi (din docs/mockups/prd-5.15-mockups.html), facute AC testabile:
- antet: referinta vizuala obligatorie la mockup-uri
- US-002: doar tokeni CSS, fara hex hardcodat (AA pe teme luminoase)
- US-003: layout exact strip D6 (glife/copy/last-auth) + stari goale + ierarhie all-time
- US-004: 'Eroare VIN' ilustrativ; pills din labels.py
- US-007: referinta picker op<->cod (2 stari) + reveal odometru

Include si revizia /plan-eng-review care era in working tree necomisa
(E1-E8, US-011 authz, US-012 analytics, Val 0, raport eng CLEARED).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:34:01 +00:00
Claude Agent
19f89ecd70 docs(5.15): mockup-uri HTML pentru piesele fara design (D6 strip, E4 picker, odo reveal)
Tema grafit, tokenuri identice cu landing.html. Acopera cele 3 piese pe
care mockup-urile existente nu le aratau si rezolva contradictiile
mockup<->PRD: VIN unic (fara Confirma VIN), contor Trimise all-time ca
cifra principala, culori prin tokeni (nu hex hardcodat), picker prestatii
pe operatie (op<->cod) in loc de chips plate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:26:45 +00:00
Claude Agent
9031f81908 feat(mapare-llm): pivot PRD 5.14 + tooling etichetare OpenRouter
PRD 5.14 rescris cu pivotul arhitectural: LLM doar etichetator OFFLINE,
runtime = clasificator local fara API (fuzzy + embeddings), baza de
cunostinte GOLD partajata cross-account (validarea unui service ajuta
toate). Decizia 8 (corpus per-cont) SUPERSEDED.

Tooling nou OpenRouter (free, familia NVIDIA Nemotron): or_common.py
(client + corpus pe frecventa, cheie din .env) + or_modeltest.py
(comparatie modele, acord ensemble vs Groq). Masurat: super-120b +
nano-9b fiabile, 3/3 unanim pe 87% volum; ultra-550b aruncat.

Corpus real (4 CSV service, coloana NR=frecventa) + etichete Groq
bootstrap incluse ca date de masurare.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:10:10 +00:00
Claude Agent
4caf055c53 docs(prd): 5.15 revizuit prin /plan-ceo-review (SELECTIVE EXPANSION)
Review CEO + spec-review independent (scor 7/10). Scope 8 -> 10 stories / 6 valuri.

Decizii incorporate (D6-D10):
- D6 strip sanatate mereu-vizibil deasupra contoarelor (zero-silent-failures)
- D7 operatia ramane in op_service + copiata in obs (nu se muta)
- D8 obs EXCLUS din idempotenta (idempotency.py:98) - AC US-005 corectat
- D9 secventiere 5.15 inainte de 5.14
- D10 4 extinderi acceptate: US-009 salvare mapare din chip, US-010 bulk-fix,
  require dinamic odometruInitial + keyboard-first (US-007)

Remedieri din spec-review independent:
- #1 contradictie prestatii: itemii pastreaza op_service/denumire, idPrezentare
  in payload.py (rupea D7/US-009 in forma initiala)
- #2 sent_today/month: status='sent' AND date(updated_at), fara migrare
- #3 US-006 numeste liniile de rewrite din handler-e (nu "fara schimbare logica")
- #5/#6 nota suprafata JS + click target "De corectat"

TODOS += premisa mobil nevalidata, dedup teme grafit~dark, optiune PRD separat US-009/010.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:31:41 +00:00
Claude Agent
822185e138 docs(prd): PRD 5.15 — propagare design landing in aplicatie
Dashboard compact (carduri-contor + lista slim), formular editare slim cu
VIN unic, Observatii (text liber = operatii service) si prestatii ca chips
multi-select. Propaga sistemul de design al landing-ului (teme/culori/carduri)
in controalele aplicatiei.

Decizii confirmate cu userul: D1 contoarele inlocuiesc bara de status; D2 teme
aditive (light/dark/petrol/Auto + grafit/cobalt/cupru/hartie); D3 chips reale
multi-cod; D4 contor Trimise all-time + luna + azi; D5 obs = denumirea
operatiilor de service (in payload, fara coloana noua; concatenat la import).

8 stories / 5 valuri, backend separat de UI. Draft, asteapta poarta de aprobare.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:04:29 +00:00
Claude Agent
41aa385644 feat(landing): sincronizare design v2 + aliniere latime sectiuni
Re-importa design-ul actualizat din claude.ai/design:
- sectiune noua PRIVACY ("Datele clientilor tai nu devin marfa")
- SOLVE rescris ("Nu trebuie sa fii bun cu calculatorul", text condensat)
- subtitlu preturi: "Premium gratuit 30 de zile, apoi automat pe Gratuit"
- butoane preturi uniformizate la "Creeaza cont gratuit"

Fix aliniere: wrapperele de sectiune aveau max-width inconsistent
(980/1040/1120/none) -> continutul nu se alinia intre sectiuni (unele benzi
pareau mai late). Scoatem capacele structurale ca tot continutul sa umple
acelasi gutter; capacele tipografice (text centrat) raman.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 02:04:51 +00:00
Claude Agent
865c208821 feat(landing): pagina comerciala la / pentru vizitatori neautentificati
Importa design-ul "Gateway RAR AUTOPASS Landing" din claude.ai/design si il
implementeaza ca pagina responsiva single-page (app/web/templates/landing.html):
hero + mockup dashboard, problema, calculator interactiv, avertisment legal
(L.142/2023, OMTI 210/2024), pasi, integrare API, preturi (4 planuri), formular
inregistrare/autentificare cu tab-uri, CTA final, footer. 4 teme comutabile
(Grafit/Cobalt/Cupru/Hartie) persistate in localStorage, fonturi self-hostate,
logo /static/romfast_logo.png (fara CDN extern).

"/" serveste landing-ul pentru vizitatorul neautentificat (except LoginRequired)
si dashboard-ul pentru cel logat; formularele posteaza real la /signup si /login
cu token CSRF. Rutele protejate raman redirect /login.

test_dashboard_scope: anonim pe / -> landing 200 (nu redirect); ruta protejata
ramane 303 /login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 01:39:54 +00:00
Claude Agent
670019361c curatare 2026-06-27 23:39:54 +00:00
Claude Agent
8d4ff3400e feat(5.13): carduri compacte mobil/tableta + fix editare preview (OOB tr) + toast
Dogfood pe import + Trimiteri (mobil/tableta <1024px), pur CSS + markup, backend
trimitere neatins:

- Card compact real pentru .tabel-trimiteri (preview + Trimiteri): vehicul=titlu,
  stare=pill dreapta-sus, operatie+cod, meta data/km muted, nota mica. Inlocuieste
  stiva generica eticheta+valoare (carduri de ~450px -> ~135px). Anuleaza regula
  desktop tr.trimitere-row > td{padding:11px} in blocul compact.
- FIX editare preview: OOB swap pe <tr> esua tacit in htmx 1.9 (un <tr> brut se
  pierde la parsarea unui fragment fara context de tabel) -> randul ramanea cu
  starea veche dupa salvare. Inlocuit cu reload complet al preview-ului prin
  HX-Trigger:reincarcaPreview + detalii randSalvat. /editeaza si /confirma-review
  folosesc helper-ul _raspuns_rand_salvat.
- Feedback post-salvare: toast global "Randul N actualizat · <stare>" + scroll +
  flash pe randul actualizat (base.html window.arataToast + listener randSalvat).
- Modal editare: Salveaza + Anuleaza pe acelasi rand (sistem .act): desktop text,
  mobil doua iconite Lucide 44px alaturate (save/x). Macro icon('x') + .act-primary.
- Randuri deja-trimise/duplicate colapsate implicit in preview + toggle "Arata N".
- Select "Operatii de mapat" full-width pe mobil (nu mai iese din viewport).
- Bara de filtre Trimiteri adaptata mobil: pills pe banda cu scroll orizontal,
  cautare vehicul proeminenta (nu 8 butoane full-width stivuite).
- Nota preview = culoarea camp-fix (accent) ca sa atraga atentia; hint-urile
  camp-fix per-camp scoase (campul Note e self-explanatory).
- Confirmare trimitere: scos campul email (Declarant); text mai clar
  ("Confirma numarul din N gata de trimis"). Backend confirmed_by ramane optional.

Teste: contractul OOB (rupt in browser) inlocuit cu noul contract
(reincarcaPreview + randSalvat) in test_web_preview_edit / test_preview_edit_ui /
test_import_review. Suita: 992 passed (exclus live).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:34:33 +00:00
Claude Agent
bafaf05e83 docs: prompt landing page comerciala pentru claude.ai/design
Referinta pentru generarea unui mockup de homepage public: plan gratuit
(sub 100 prezentari/luna), beneficii interfata, integrare API. Tokeni de
culoare/tipografie preluati din design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:39:21 +00:00
Claude Agent
b26dbb79e1 feat(5.12): modal editare + cont obligatoriu la import; design.md + PRD 5.13 revizuit (/autoplan)
5.12 (livrat): editare in modal a randurilor de preview, cont obligatoriu inainte de
import, formular editare extras (_form_editare, _editare_preview_modal), plus suita de
teste aferenta (preview edit/compact, mapare op, form editare, signup, admin panel).

Design + planificare:
- docs/design.md: sistem de design (tokeni, breakpoints, scara control, componente, a11y).
- docs/prd/prd-5.12-* si prd-5.13-* (5.13 cu raport /autoplan: CEO+Design+Eng, audit trail).

Curatare: sterse PNG-urile de test/mockup temporare din radacina.

Nota: implementarea CSS 5.13 (responsive compact + sistem butoane) NU e inca facuta —
planul revizuit cere refactorul testelor fragile din test_web_responsive.py INAINTE de CSS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:52:20 +00:00
Claude Agent
283299ff20 feat(ux): import compact + preview format Trimiteri + navigatie + scoatere auto_send (5.11)
8 stories TDD (echipa Sonnet, lead orchestreaza). US-001 scoate hold-ul auto_send din mapare
(has_no_auto_send->False, simbol pastrat; cod rezolvat->queued). US-002 scoate bifa auto_send
din UI. US-003 preview pas 3 in format .tabel-trimiteri (STARI_PREVIEW + nota_umana_preview,
fara repr Python; view-model prez). US-004 filtre layout/stil ca referinta + buton Custom.
US-005 navigatie Trimiteri/Mapari sub contoare pe toate paginile. US-006 import <details> nativ
colapsabil. US-007 post-commit reveal (OOB _coada/_status + HX-Trigger). US-008 auto-refresh
dupa actiuni (nudge eliminat).

VERIFY context curat PASS (8/8). /code-review high: 3 buguri reparate (tab nav la self-refresh,
pill Custom valori stale, nota_umana_preview precedenta needs_mapping). 934 passed, 1 skipped.
Backend trimitere + schema NEATINSE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:16:28 +00:00
Claude Agent
412102b9b1 fix(deploy): adauga itsdangerous in requirements (SessionMiddleware crash in container) 2026-06-25 22:02:15 +00:00
Claude Agent
a4531acd69 readme 2026-06-25 22:00:22 +00:00
Claude Agent
d487afad73 readme 2026-06-25 21:58:23 +00:00
Claude Agent
c31a1e254c chore(deploy): docker-compose api pe prod + worker send activat
- api: AUTOPASS_RAR_ENV test -> prod; scos maparea de port 8010:8010 (acces prin reverse proxy)
- worker: AUTOPASS_WORKER_SEND_ENABLED false -> true (trimitere efectiva la RAR)

Modificari facute de utilizator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:49:43 +00:00
Claude Agent
4a2afc68bf chore: curatare agresiva comentarii — scoatere referinte US/PRD din cod si template-uri
Eliminat zgomotul de trasabilitate (US-xxx, PRD x.x, Rn, OV-x, Tn, decizii/naratiune
istorica) din 41 fisiere app/ + template-uri. Pastrate comentariile care documenteaza
invarianti si logica ne-evidenta (idempotenta/hash, reconciliere anti-duplicat, RAR 500
esec definitiv, creds per cont, WAF User-Agent, 422 fara echo de parola, scope NULL->1),
curatate doar de tokeni.

Verificare: pentru cele 27 module .py curatate, structura de cod (tokeni non-comentariu/
non-string) e IDENTICA fata de HEAD -> doar comentarii/docstring-uri schimbate. Singura
schimbare de cod e in tests/test_web_responsive.py (scos 3 assert pe markeri US-006/007/008,
inlocuite de asertiunile structurale alaturate). 0 tokeni US/PRD reziduali in app/.
Regresie: 896 passed, 1 deselected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:44:24 +00:00
Claude Agent
f05fe5b221 fix(5.11): tabel trimiteri stabil — bug status=None, pills in bara de filtre, nudge "Date noi" in loc de poll 15s, logo ROMFAST marit
- Fix bug: campul hidden de filtru randa literal "None" (status_filtru None +
  Jinja default('')) -> poll-ul trimitea status=None -> tabel gol. status or "".
- Pills de stare mutate din bara de status in bara de filtre (filtreazaStare scrie
  campul hidden + re-trimite form-ul; filtrul persista la reincarcari). Re-randate
  OOB cu contoare proaspete la fiecare reincarcare a tabelului.
- Polling redesign: tabelul nu se mai reincarca singur (fara every 15s). Poller usor
  JSON (/_fragments/trimiteri-versiune) detecteaza schimbari -> nudge "Date noi —
  Reincarca". Reincarcarea (nudge / actiune) pastreaza filtrul+pagina. Scroll/selectia
  nu se mai pierd. Poll-guard eliminat (nu mai exista poll periodic de pauzat).
- Logo ROMFAST 32px -> 60px (ca pe romfast.ro), header min-height 92px, 44px pe mobil.

Regresie: 896 passed, 1 deselected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:13:42 +00:00
Claude Agent
074b6e7c8a fix(5.10): logo ROMFAST in stanga header (ca romfast.ro) + tooltip tema doar numele temei
- US-012c: logo .brand-logo mutat in header-left (32px, aliniat stanga); env badge mutat sub titlu in header-center; titlul ramane centrat; responsiv pastrat.
- US-014b: title-ul butonului de tema = doar numele temei curente (Light/Dark/Petrol/Auto), fara enumerarea ciclului; aria-label informativ + aria-live pastrate (a11y).

Regresie 896 passed, 1 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:37:00 +00:00
Claude Agent
5a964a1a8d feat(5.10): UX trimiteri (pill filtre, paginare, editare) + Mapari in meniu + branding ROMFAST
14 stories TDD prin echipa de workeri (lead orchestreaza, 3 teammates pe valuri cu fisiere disjuncte; routes.py + base.html serializate ca fisiere fierbinti).

- US-001 fix filtrare data (_iso_date_prefix pe garda+comparatie, prinde timestamp cu ora)
- US-002/007 operatie service distincta in payload_view + afisare in detaliu
- US-003 pill-uri categorii (button/aria-pressed; needs_mapping --warn, needs_data/error --err); fara lista ID-uri/dropdown
- US-004 paginare numerotata 25/pag (total ramificat SQL-COUNT vs fetch-all+slice, clamp page, poll pastreaza pagina)
- US-005 VIN block-level sub nr
- US-006/006b editare cod RAR + validare nomenclator + recalcul idempotency (needs_data/needs_mapping via /corecteaza, error via /repune)
- US-008 card eroare 3-niveluri doar pe read-only + rezumat top-of-form
- US-009 Mapari in meniu hamburger; scoatere tab-bar + role=tablist orfan
- US-010/011 pagina Mapari consolidata + butoane icon SVG + dirty-state (fara kebab/emoji)
- US-012/012b header centrat + logo ROMFAST (/static/romfast_logo.png) in header
- US-013 paleta azur ROMFAST (#2E74D6/#1F66C9) + IBM Plex Sans/Mono self-host (woff2 reale)
- US-014 selector tema ciclic Light/Dark/Petrol/Auto + anti-FOUC pe 4 stari

Backend trimitere (worker/masina stari/idempotenta/mapping) + schema NEATINSE (UI/UX pur + 1 fix de filtrare).
VERIFY context curat PASS; /code-review high: 1 finding material reparat (US-006b). Regresie 896 passed, 1 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:20:58 +00:00
Claude Agent
3bc0825e0b docs(5.9): raport VERIFY - PASS pe teste + E2E browser (R1-R12)
Suita completa 843 passed/1 skipped. E2E real in browser confirma:
modal/focus-trap/poll-guard/responsive si fluxul live de corectie.
Fara leak de cod brut pe rand (R1/D2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:02:26 +00:00
Claude Agent
74ac16f456 feat(5.9): US-005 - poll-guard modal/bife pe trigger periodic
- base.html: listener htmx:beforeRequest scopat la #submissions-wrap care
  anuleaza (preventDefault) DOAR poll-ul periodic (fara requestConfig.triggeringEvent)
  cat timp modalul de detaliu e deschis SAU exista checkbox de bulk bifat.
- F5/R6: trimiteriChanged si submit-ul de filtru au triggeringEvent -> trec mereu,
  deci pauza nu ramane lipita permanent daca randul bifat paraseste filtrul.
- Resume automat (anularea nu opreste timer-ul htmx) + resume explicit pe checkbox
  change via delegare pe body -> trimiteriChanged from:body (pastreaza filtrul).
- Vechea pauza pe „rand expandat" (5.8) era deja inlocuita de modalul global (US-003).
- 3 teste noi in tests/test_web_modal.py; suita 843 passed, 1 deselected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:30:10 +00:00
Claude Agent
35e97faae5 feat(5.9): US-008 - responsive Acasa (upload/status/filtre) + login/signup
- base.html: bloc @media (max-width:767px) US-008, scopat pe id-urile de pe Acasa
- upload (#import-section): drop-zone pe coloana, buton alegere full-width >=44px
- filtre (#filtre-trimiteri): o coloana, inputuri/buton full-width >=44px (!important pe latimile inline)
- status (#status-bar): randuri aliniate, fara scroll orizontal
- login.html/signup.html: clasa .auth-card centrata, max-width:100% pe mobil
- tabelul de trimiteri 5.8 neatins (doar verificat intact)
- teste noi: test_acasa_fara_scroll_orizontal_mobil, test_login_signup_full_width_mobil
- suita: 840 passed, 1 deselected

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:23:37 +00:00
Claude Agent
d3433015ad feat(5.9): US-007 - responsive pagini de continut (card Mapari, scroll contained Jurnal/Nomenclator/Admin, formulare stivate)
- base.html @media(max-width:767px): clasa .tabel-card (card per rand) scopata
  SEPARAT de .tabel-trimiteri 5.8; reguli formular full-width + butoane >=44px
  scopate pe #card-cont / #form-test-cheie / #filtre-jurnal (marker US-007)
- _mapari.html: tabel-card + data-eticheta pe toate 4 tabelele; override select
  in card local (evita batalia de specificitate cu stilul inline)
- _integrare.html: id=form-test-cheie (ancora de scope pe formularul de test)
- R12 per-tabel: Mapari=card; Jurnal/Nomenclator/Admin=.tablewrap scroll contained;
  Cont/Integrare=fara tabele (doar formulare)
- tests: +3 (tabele clasa responsive, formulare full-width, regresie carduri 5.8)
- suita: 838 passed, 1 deselected
- prd.json US-007 passes=true + notes; progress.txt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:17:47 +00:00
Claude Agent
141949dc95 feat(5.9): US-004 - detaliu editabil in-place + butoane consolidate in modal
- _trimitere_detaliu.html rescris pe ordinea verticala R10: header+motiv,
  eroare blocanta, mapare inline, formular editabil/context read-only, actiuni,
  <details> Detalii tehnice colapsat
- zero dublare: campurile editabile apar O SINGURA DATA (nr rand propriu, VIN
  dedesubt, restul in grila); blocul read-only de grila contopit cu formularul
- R9: operatie+cod read-only deasupra campurilor cu prez.cod_rar (fallback
  nemapat), fara eticheta separata Cod RAR
- R2 (fix F7): buton primar conditionat de stare - error->Re-pune(/repune),
  needs_data/needs_mapping->Salveaza si retrimite(/corecteaza); duplicatul gol eliminat
- R11: un singur Sterge outline var(--err) pe rand separat, hx-confirm specific,
  full-width pe mobil (.detaliu-actiuni-jos in @media 767px, base.html)
- R5: hx-disabled-elt pe toate formele; inchidere pe succes prin inchideModal
- R4: script modal-appropriate pastrat (curatat de US-003)
- 6 teste noi in test_web_corectie.py; rutele + _detaliu_ctx NESCHIMBATE
- suita 835 passed (-m 'not live'); prd.json/progress.txt US-004 passes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:07:18 +00:00
Claude Agent
45f6fbb726 feat(5.9): US-006 - fundatie responsive (viewport, nav, modal full-screen mobil)
- base.html: conventie breakpoint unic 767px documentata + bloc @media mobil extins
- modal full-screen pe mobil (100vw/100vh, fara backdrop lateral, x >=44px, scroll intern)
- header/nav colapsat sub 768px + tinte touch >=44px (.icon-btn/.tab-link/.cont-menu)
- tests/test_web_responsive.py NOU (3 teste) + prd.json/progress.txt US-006 passes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:57:52 +00:00
Claude Agent
878e319ac5 feat(5.9): US-002 - tabel trimiteri: eticheta umana sub stare, cod RAR simplu, rand->modal
- Sub pill-ul de Stare apare eticheta umana scurta (`eticheta_problema` din US-001),
  text mic `s-error`, doar cand e ne-goala — stare transmisa prin text, nu doar culoare.
- Coloana Operatie linia 2: codul RAR ca chip discret FARA prefixul "cod RAR:";
  cand nemapat ramane "nemapat" muted (comportament 5.8 pastrat).
- R8: regula touch 44px (min-height + padding) pe `tr.trimitere-row` + afordanta hover/focus;
  chevron inexistent in cod (randul declanseaza deja modalul din US-003, fara aria-expanded).
- Teste: 7 teste noi US-002 + actualizate test_operatie_contine_cod_rar / test_tabel_nu_are_coloana_motiv;
  suita completa 826 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:52:33 +00:00
Claude Agent
fd4a05436d feat(5.9): US-003 - modal reutilizabil (overlay, focus-trap, a11y) + cleanup inline-expand 5.8
- base.html: #modal-detaliu (role=dialog, aria-modal) + #detaliu-modal-body swap target;
  focus-trap, inert+aria-hidden pe <main>, Esc/backdrop/x inchid, listener trimiteriChanged (R5/R7)
- _coada.html: ancora modal in afara #submissions-wrap; sters #trimitere-detaliu inert vechi
- _submissions.html: randul declanseaza modalul; sters tr.detaliu-rand sibling (R3)
- _trimitere_detaliu.html: script rescris pentru modal, fara marcheazaDetaliuDeschis/scrollIntoView (R4)
- teste: test_web_modal.py nou (3); test_web_detaliu_inline.py sters; test_acasa_trimiteri.py curatat (R3)
- gates: pytest PASS (suita completa 819). Browser E2E + design-review deferate la VERIFY.

Salvat manual: iteratiile Ralph 2-12 au ramas fara turns (30) inainte de commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:48:42 +00:00
Claude Agent
6d10f92452 feat(5.9): US-001 - eticheta umana scurta pe rand + cod brut pentru modal (R1)
- _submission_row_view expune eticheta_problema (motiv || eticheta_scurta), gol pe queued/sending/sent, fara decoder nou (R1 DRY)
- parse_erori expune cheia `cod` (cod brut catalog) pe ramurile imbogatite, pentru derivare in modal
- 5 teste US-001 in tests/test_web_submissions.py
- gates: tests PASS (819), /review (backend) PASS

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:17:53 +00:00
Claude Agent
0ba728cab5 docs(5.9): PRD corectie modal mobil + scaffold Ralph (prd.json cu R1-R12)
- docs/prd/prd-5.9-ux-corectie-modal-mobil.md: PRD aprobat (8 stories, raport AUTOPLAN)
- scripts/ralph/: ralph.sh + prompt.md (smart-gates) + progress.txt
- scripts/ralph/prd.json: 8 user stories cu reviziile obligatorii R1-R12 incorporate
  in acceptance criteria; priority encodeaza valurile de dependente
- .gitignore: runtime Ralph (logs/screenshots/archive/.last-branch/usage.jsonl)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:09:08 +00:00
Claude Agent
32408ed3b5 docs(exemple): payload-uri de test CSV (import) + JSON (API) cu randuri valide si erori
Doua fisiere, unul per canal: prezentari_test.csv (import web) si
prezentari_test.json (API). Fiecare contine randuri valide + randuri care
declanseaza erorile de validare (VIN/nr/data/odometru, prestatii goale),
plus README cu cele 3 niveluri de eroare (shape 422, needs_mapping,
needs_data) si comenzi curl. Operatiile folosesc coduri/denumiri proprii
de service, nu coduri RAR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:34:24 +00:00
Claude Agent
51dc504f1d feat(5.8): reguli mapare pe text (substring/cont) + UX tabel trimiteri (detaliu inline, fara scroll, cod RAR)
Reguli text per cont (operation_text_rules), resolve_prestatii cu param aditiv
text_rules + precedenta stricta, threadat pe toate cele 6 callsite-uri + valid_codes
+ seam classify_prezentare. UI Mapari: sectiune reguli + preview pre-salvare + overlap
+ telemetrie text_rule_hit. UX tabel: cod_rar sub operatie, pill eticheta scurta, fara
scroll orizontal (scopat .tabel-trimiteri + carduri <768px), detaliu inline expandabil
(a11y + pauza poll). code-review: reparat regula auto_send=0 care trimitea automat la RAR
in loc sa tina randul pentru review. 814 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:47:37 +00:00
Claude Agent
c80c79462c test(5.7): test live opt-in mapare inline -> RAR test + writeback proba
Adauga tests/test_live_rar.py: reproduce automat proba live a maparii inline
(needs_mapping -> mapare inline web cu sesiune+CSRF -> queued -> worker real
login RAR + postPrezentare -> sent -> verificare in finalizate RAR). Skip
implicit (marker `live`), opt-in cu AUTOPASS_LIVE_RAR=1 + creds <test>.

- conftest.py: inregistreaza markerul `live` (excludere -m "not live")
- ROADMAP/CLAUDE.md: 5.7 NEPROBAT -> PROBAT (manual idPrezentare=68827,
  automatizat idPrezentare=68828) + comenzi rulare test live

pytest -q: 765 passed, 1 skipped (live). Test live verde pe RAR test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:14:53 +00:00
Claude Agent
ac57b9250a feat(5.7): raspuns API onest la blocaje + mapare inline din detaliu
Raportat din client VFP: POST /v1/prezentari raspundea submission_id+status
fara motiv pe randuri blocate (erori se popula doar pe on_unmapped_error=True),
deci un needs_data/needs_mapping parea succes.

API (aditiv): SubmissionResult += nemapate + motiv. create_prezentari
populeaza erori (validare continut, 3 niveluri) / nemapate (coduri fara
mapare, COD_NEMAPAT) / motiv (rezumat uman) pe TOATE caile non-queued —
enqueue, respins (on_unmapped_error=True) si reactivare dedup peste error,
prin helperele _rezultat_enqueue / _rezultat_respins / _motiv_clasificare.
on_unmapped_error=True pastreaza erori=COD_NEMAPAT (compat clienti vechi).

Web: mapare inline in panoul de detaliu trimitere — ruta
POST /trimitere/{id}/mapeaza (reuse save_mapping + reresolve_account, scoped
sesiune + CSRF, re-rezolva pe batch_id-ul randului), helper
_nemapate_pentru_submission + context in _detaliu_ctx, sectiune in
_trimitere_detaliu.html (selector cod RAR cu sugestie fuzzy preselectata).
Apare doar pe operatii nemapate reale (nu pe auto_send=0).

/code-review high: reparat raspuns neonest la reactivare + dublu
load_nomenclator in _detaliu_ctx.

Teste: pytest -q 765 passed. Backend trimitere (worker/masina stari/
idempotenta) si schema NEATINSE. PRD: docs/prd/prd-5.7-*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:51:16 +00:00
Claude Agent
6bad6bc01e feat(api): validare cod_prestatie la nomenclator + optiune on_unmapped_error
Cod_prestatie necunoscut in nomenclator nu se mai trimite raw la RAR (HTTP 500
ORA-12899 + record partial FINALIZATA pe care reconcilierea il marca fals sent):
e promovat la cod_op_service si tratat ca operatie de mapat.

Optiune top-level boolean on_unmapped_error pe POST /v1/prezentari + /valideaza:
  - false (default) -> submission needs_mapping (intra in editor)
  - true            -> respinge fara enqueue (status error, submission_id=null, erori)
  - None            -> default per-cont accounts.on_unmapped_error_default (implicit 0)
Inlocuieste enum-ul anterior on_unmapped (needs_mapping/error) cu un boolean mai
simplu; coloana de cont migrata aditiv la INTEGER on_unmapped_error_default.

Izolare teste de .env-ul de dezvoltare: tests/conftest.py fixeaza default sigur
pe AUTOPASS_REQUIRE_API_KEY / AUTOPASS_WORKER_USE_TEST_CREDS (precedenta peste
.env in pydantic-settings) + fixturile env din test_creds_delivery/test_t1 pineaza
explicit aceste flag-uri, ca fallback-ul creds pe cont sa fie atins.

Teste: 752 passed (fara flag pe CLI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:35:47 +00:00
Claude Agent
c842e3352a feat(5.6): observabilitate + jurnal aplicatie + lifecycle trimiteri blocate
Implementeaza PRD 5.6 complet (14 stories, TDD). Doua axe:

Lifecycle trimiteri blocate (Val A):
- submissions_admin.py: sterge/repune scoped (404 cross-account inaintea lui 409 stare)
- reactivare dedup peste `error` cu CAS (WHERE id=? AND status='error'), creds noi in
  submissions + accounts.rar_creds_enc; worker invalideaza sesiunea RAR la creds proaspete
  (JWT 30h vechi nu mai trimite cu parola gresita); camp aditiv `reactivated:true`
- retentie randuri blocate 30z; purge_expired exclude queued/sending; purge_after curatat
  la reactivare/requeue
- API DELETE /v1/prezentari/{id} + /repune (200+JSON); UI butoane + bulk + banner actionabil

Observabilitate:
- app/observ.py log_event: dublu canal app_events (DB) + RotatingFileHandler per-proces,
  redactare creds/PII la scriere (redact_pii/vin_partial)
- request_id middleware + X-Request-ID pe toate raspunsurile
- handler global excepții -> 500 envelope 6-chei + request_id (traceback doar in jurnal)
- audit cerere API (api_prezentari/api_auth_esuat) + audit worker (rar_login/tranzitii)
- tab "Jurnal" filtrabil scoped (non-admin doar contul sau); retentie jurnal 90z
- rar_error expus in GET /v1/prezentari/{id} (recovery observabil)

pytest -q: 741 passed, 0 failed. Docs: PRD raport VERIFY, contract endpointuri noi, ROADMAP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:45:39 +00:00
Claude Agent
f48346de5c docs: PRD 5.6 observabilitate + jurnal + lifecycle trimiteri blocate (APROBAT)
Nascut din incidentul 500 (client VFP). 14 stories: observabilitate
(handler global 500->3 niveluri, request_id, jurnal app_events DB+fisier,
audit API + login RAR, redactare PII, retentie), lifecycle trimiteri
blocate (sterge/re-pune in coada UI+API, dedup nemaiblocat de un rand
error, purjare blocate) si banner "Necesita atentia ta" actionabil.
Decizii §5 rezolvate cu user. ROADMAP: rand 5.6 APROBAT + hotfix in
"Ultima actualizare".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:55:12 +00:00
Claude Agent
0b288b90d7 fix(web): mesaj corect pentru starea error (nu se reincearca automat)
Dashboard-ul afisa pentru randurile `error` subtextul "se reincearca
automat sau necesita corectie", dar `error` e stare TERMINALA: worker-ul
nu o reincearca niciodata (backoff pastreaza `queued`; la creds gresite
nu exista retry). Text corectat sa reflecte realitatea + indiciu spre
credentialele RAR. Testele verifica doar titlul + clasa CSS, nu subtextul.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:55:12 +00:00
Claude Agent
90603609a1 fix(crypto): validare fail-fast a cheii Fernet la startup
O cheie AUTOPASS_CREDS_KEY setata dar invalida (format Fernet gresit)
arunca ValueError abia la primul encrypt_creds -> 500 brut pe
POST /v1/prezentari, fara mesaj util (cazul reprodus din client VFP).

crypto.validate_creds_key() valideaza cheia, apelata in main.lifespan:
o cheie invalida opreste pornirea cu mesaj clar + comanda de generare,
in loc sa explodeze la prima cerere. Cheie nesetata = OK (model efemer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:55:12 +00:00
Claude Agent
5dc963a02c feat(api): rar_credentials optional pe POST /v1/prezentari
Cand `rar_credentials` lipseste din cerere, submission-ul intra fara creds
efemere, iar worker-ul cade pe creds-urile RAR durabile ale contului
(accounts.rar_creds_enc). Identificarea contului ramane pe cheia API.
Trimiterea explicita a creds-urilor suprascrie creds-urile contului pe acea
cerere (back-compat: fluxul vechi ROAAUTO merge identic).

- models.py: rar_credentials: RarCredentials | None = None
- router.py: cripteaza creds doar daca exista (altfel creds_enc=NULL)
- worker NEATINS: avea deja fallback _creds_for(...) or _creds_from_account(...)

Pagina /integrare aliniata: exemplele cod (7 limbaje) + export Postman nu mai
includ rar_credentials in payload; nota noua explica modelul (creds pe cont,
optional in payload). README rescris compact + reflecta optionalitatea.

Test nou: enqueue fara creds -> submission fara creds efemere -> fallback pe
contul cu creds salvate. Suita: 673 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:39:53 +00:00
Claude Agent
0517ae59fb feat(web): cautare + paginare client-side pe tabelele din Mapari
Maparile pot creste la sute de randuri. Enhancer reutilizabil (data-dt) in
base.html filtreaza si pagineaza DOM-ul deja randat, fara cereri server; re-init
la full load si dupa swap-urile HTMX. Aplicat pe cele 3 tabele (De rezolvat /
operatii salvate / formate coloane).

Randurile cu <select> expun haystack explicit prin data-dt-row (cod_op + cod_rar
+ denumire): altfel optiunile selectului ar pune tot nomenclatorul in textContent
si orice cautare ar potrivi orice rand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:48:05 +00:00
Claude Agent
e3f295f912 fix(web): kebab anti-clipping partajat + panou admin redenumit + tabel mapari compact
- "Panou admin" -> "Conturi clienti" (titlu, antet, link meniu hamburger)
- Kebab actiuni mutat in component partajat (base.html) cu position:fixed
  pozitionat din JS: .tablewrap{overflow-x:auto} inducea overflow-y:auto care
  taia dropdown-ul pe ultimul rand (meniul admin nu se vedea). Sters CSS local.
- Mapari salvate: Salveaza/Sterge mutate in kebab (legate prin form=); coloana
  "In coada" doar checkbox (macro autosend_toggle compact, semantica de prezenta
  pastrata); select cod RAR limitat la 240px -> tabelul incape fara scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:30:56 +00:00
Claude Agent
36ec50d667 docs: 5.5 LIVRAT in ROADMAP (uniformizare UI/UX + lifecycle conturi)
Inchide randul 5.5 (DONE) + Ultima actualizare, dupa commit 1fbd894.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:56:55 +00:00
Claude Agent
1fbd894329 feat(web): uniformizare/standardizare UI/UX + lifecycle conturi (PRD 5.5)
Aduce toate suprafetele dashboard-ului la grila tabelului Trimiteri, muta
navigarea intr-un meniu de cont (hamburger) si da panoului admin actiuni
reale de ciclu de viata. 9 stories, 3 valuri. UI pur (reskin + reasezare)
cu O SINGURA exceptie backend: modelul de stare a contului.

- US-001 sectiunea "Ajutor" eliminata din Acasa (wayfinding redundant).
- US-002 Nomenclator la grila standard (_submissions.html ca referinta).
- US-003 macro autosend compact (Manual<->Auto). Semantica de PREZENTA
  `auto_send` (bifat->true, absent->false) NEALTERATA — compatibil cu ambele
  parsere (Form(bool) la /mapari, bool(form.get()) la import). Zero backend.
- US-004 accounts.status (pending/active/blocked/archived/deleted), migrare
  defensiva idempotenta derivata din `active`, gate worker claim_one pe
  status='active' (echivalenta active=1 <=> status='active' pastrata).
- US-005 tabel Mapari compact + panou Ajutor (<details>, proza o singura data),
  coloana "In coada".
- US-006 meniu hamburger dropdown (Cont/Integrare/Nomenclator/Admin/logout) +
  context is_authenticated/is_admin/csrf_token defensiv in base.html.
- US-007 tab-bar redus la Acasa+Mapari; rutele /_fragments/{cont,integrare,
  nomenclator} + deep-link ?tab= raman valide.
- US-008 rute admin block/archive/delete + bulk pe lista account_id,
  require_admin + CSRF + PRG, dev id=1 sarit in bulk.
- US-009 admin UI: selectie bife + master + bara bulk + kebab per-rand,
  grupare pe stare (bloc nou blocate/arhivate), nota "cont dev implicit" scoasa.

Stergere = SOFT: tombstone (status='deleted'), dar PII purjata IMEDIAT
(rar_creds_enc + chei API revocate + CUI eliberat pentru re-inregistrare),
GDPR/L.142.

VERIFY: 671 teste pass (+40). E2E browser (Playwright) a prins 2 bug-uri
invizibile la TestClient: bara bulk cu display:flex inline invingea [hidden]
(mutat in CSS .bulk-bar[hidden]); conturi arhivate cadeau sub "in asteptare"
(grupare pe status). /code-review high a prins 2 bug-uri reale: soft delete
pastra creds RAR + CUI la nesfarsit fara purjare accounts (GDPR neonorat);
apostrof in numele firmei rupea confirm() inline din kebab — ambele reparate,
plus cleanup boilerplate rute (_lifecycle_route).

Backend trimitere (worker masina stari/idempotenta/mapping) neatins, cu
exceptia gate-ului de cont. Design: docs/design/5.5-uniformizare-ui.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:56:05 +00:00
Claude Agent
14e1c463f0 feat(errors): erori pe 3 niveluri (problema+cauza+fix) pe API si UI (PRD 5.4)
Catalog central pur app/errors.py ca sursa unica cod->{problema,fix},
consumat de API+UI+worker. Aditiv (field/message pastrate la octet) +
rar_error stocat superset. Scope: fluxul de declarare; login/signup/CSRF
neatinse. labels.parse_erori degradeaza gratios; UI progresiv AA light+dark.
631 teste.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:28:09 +00:00
Claude Agent
b48501d8e4 feat(web): light/dark mode cu comutator persistat + anti-FOUC (PRD 5.3)
Tema light ca bloc [data-theme="light"] peste variabilele :root (dark
nemodificat la octet). Comutator soare/luna in header pe toate paginile,
default OS-aware (prefers-color-scheme, fallback dark), persistenta in
localStorage doar la comutare explicita, script anti-FOUC in <head>
pre-paint. Suprafetele de stare hardcodate convertite la color-mix in
base.html + 7 fragmente _*.html (light lizibil, contrast WCAG AA).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:39:12 +00:00
Claude Agent
ae7960294f feat(api): endpoint dry-run POST /v1/prezentari/valideaza (PRD 5.2)
Valideaza payload + mapare si intoarce verdictul real (status_estimat
queued/needs_data/needs_mapping + erori [{field,message}] + coduri nemapate
+ prestatii rezolvate) FARA enqueue, fara creds, zero scriere DB. "Magical
moment" pentru integratori (ROAAUTO / soft propriu / punte VFP).

Cheia de design: helper pur partajat classify_prezentare (mapping.py) folosit
de AMBELE rute, ca dry-run-ul sa nu poata diverge de trimiterea reala
(invariant de corectitudine). create_prezentari refactorizat pe el cu
comportament identic (test_api.py verde).

Scope minim (decizie user): doar validare+mapare, fara idempotency/duplicat
(idempotency.py neatins); descoperibilitate in hub /integrare amanata.

VERIFY context curat PASS (577 teste; E2E API cu cele 3 verdicte + COUNT(*)=0
dupa dry-run). /code-review high: 0 findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:54:50 +00:00
Claude Agent
f0786051f5 feat(web): hub integrare /integrare — exemple cod + retetar VFP + ping + export (PRD 5.1)
Pagina /integrare (tab autentificat, scoped pe cont): exemple cod multi-limbaj
(curl/Python/PHP/C#/Node) + retetar Visual FoxPro (MSXML2 + WinHttp) pe ambele
canale (prezentari JSON + import fisier), export Postman/OpenAPI/Swagger si buton
"Testeaza conexiunea".

- US-001: GET /v1/ping (readiness: account_id/mediu/autentificat_cu_cheie/
  are_creds_rar/ts) + GET /v1/integrare/postman.json (v2.1.0, allowlist 3 rute)
- US-002: app/web/integrare_examples.py pur (7 limbaje x 2 canale, drift-test
  is_required(), JSON compact pentru C#/VFP)
- US-003: tab "Integrare" IA pe 2 niveluri (limbaj->canal, VFP cu dialecte),
  copy din <pre><code>, empty-state CTA, export .cardlink, script scoped
- US-004: POST /integrare/test-cheie (account_for_key direct, scoped sesiune,
  no-echo cheie)

Backend trimitere (worker/masina stari/idempotenta/mapping) si schema neatinse.
568 teste pass. VERIFY context curat + E2E browser (Playwright) + code-review high.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:16:41 +00:00
Claude Agent
be36c2c53b docs: Etapa 5 (ergonomie & integrare) + PRD 5.1 hub integrare aprobat
Directie noua: prioritate pe usurinta de integrare/ergonomie peste Etapa 4
(deprioritizata). ROADMAP: Etapa 5 cu 4 livrabile (5.1 hub integrare, 5.2
dry-run, 5.3 light/dark, 5.4 erori 3 niveluri).

PRD 5.1 (hub /integrare: exemple multi-limbaj + retetar VFP MSXML2/WinHttp +
GET /v1/ping readiness + export Postman/OpenAPI + test conexiune): 4 stories
atomice in 2 valuri, fundamentat cu lentila DX gstack pe codul real. 3 review-uri
de plan rulate (CEO/eng/design, toate APROB CU MODIFICARI) si incorporate.
Stare: aprobat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:16:44 +00:00
Claude Agent
35f35d03cc fix(web): protejeaza decriptarea override_json in preview + inchide 3.6 (CLOSE)
decrypt_creds(override_json) era in afara try/except-ului care protejeaza
raw_json in preview_import (import_router) si _web_compute_preview (routes).
La rotatie cheie Fernet (risc acceptat R4) sau token corupt, raw_json degrada
gratios la {} dar override_json arunca 500 pe tot batch-ul. Acum ambalat
identic (fallback None -> {}).

Prins de /code-review high la CLOSE. Writeback: ROADMAP 3.6 -> DONE,
PRD -> inchis + Raport CLOSE. Duplicare _override_of/canonicalize notata
ca cleanup viitor (disciplina backend-neatins). 523 teste pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:09:03 +00:00
Claude Agent
178bc87006 docs: PRD 3.6 verify-pass + actualizare ROADMAP (E2E + LIVE RAR test)
VERIFY PASS pentru 3.6: suita 523 teste, E2E browser pe `/` (toate US-001..007),
si trimitere LIVE pe RAR test — import fara coloana data -> editarea completeaza
data (override_json) -> commit -> worker login RAR test -> postPrezentare -> sent
idPrezentare=68696 (confirmat independent in tools.rar_finalizate).

- ROADMAP: rand 3.6 = VERIFY-PASS + "Ultima actualizare".
- PRD 3.6: Stare -> verify-pass + sectiunea "## Raport VERIFY" completata
  (PASS per story, dovezi, cele 3 bug-uri JS prinse la E2E si reparate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:00:41 +00:00
Claude Agent
6f6b163867 feat(web): editare celule in preview + Acasa unificata (PRD 3.6)
Implementeaza PRD 3.6 (US-001..007), pe canalul de import + stratul web;
worker / masina stari / idempotenta / mapare raman neatinse.

- US-003/004: tab-ul "Trimiteri" eliminat; Trimiterile devin sectiune
  permanenta sub upload pe Acasa ("Trimiterile tale"); upload comprimat la
  bara slim (hero pastrat la first-run); ?tab=coada si /_fragments/coada
  servesc Acasa (fara fragment orfan); poll gated pe visibilityState.
- US-001: coloana noua import_rows.override_json (nullable, Fernet, Approach B)
  + _migrate defensiv; ruta v1 + alias web .../rand/{i}/editeaza aplica patch
  canonic ULTIMUL in _resolve_row_for_preview si commit_import (mutatie pura,
  status rederivat, fara drift). Scoping JOIN -> 404, guard committed -> 409,
  semantica empty=clear, decrypt fail -> no-op.
- US-002: buton "Editeaza" pe rand; swap pe <tr> + OOB contoare (nu pe sectiune);
  form propriu (confirm dezactivat la editare); refoloseste grila responsiva +
  error-map din _trimitere_detaliu.html; mutual-exclusion intre randuri.
- US-005/006: "De rezolvat", "Operatii salvate" si "Formate de coloane" ca
  tabele (.tablewrap); H4: comutatorul reflecta auto_send STOCAT.
- US-007: bifa "auto-send" devine comutator etichetat pe COADA ("Pune automat
  in coada" / "Tine pentru verificare"), scoped pe operatie; name="auto_send"
  pastrat (semantica de prezenta -> bool corect cu ambele parsere, zero backend).

Fix-uri gasite la verificarea E2E in browser (htmx 1.9.12, JS — invizibile la
TestClient): useTemplateFragments=true (raspuns <tr>+OOB era parsat in context
de tabel -> swapError + contoare pierdute); re-activarea confirm-btn dupa salvare
deferita pe tick (evita editing=true tranzitoriu); n-hint actualizat de updateN.

Teste: 523 passed. E2E browser: Acasa unificata, upload slim, editare rand
(needs_data -> ok, swap pe rand, contoare OOB), Mapari tabelar + comutator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:52:17 +00:00
285 changed files with 213039 additions and 2058 deletions

View File

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

8
.gitignore vendored
View File

@@ -81,3 +81,11 @@ venv/
# --- Playwright MCP: artefacte sesiune browser (snapshot-uri, stare locala) --- # --- Playwright MCP: artefacte sesiune browser (snapshot-uri, stare locala) ---
.playwright-mcp/ .playwright-mcp/
# --- Ralph: runtime loop autonom (stare locala, nu artefact de proiect) ---
scripts/ralph/logs/
scripts/ralph/screenshots/
scripts/ralph/archive/
scripts/ralph/.last-branch
scripts/ralph/.ralph.pid
scripts/ralph/usage.jsonl

View File

@@ -29,10 +29,16 @@ python3 -m app.worker # worker (necesar doar pentru a proce
./start.sh stop # opreste procesele pornite cu "both" ./start.sh stop # opreste procesele pornite cu "both"
./start-test.sh / ./start-prod.sh # fixeaza mediul, forwardeaza rolul ./start-test.sh / ./start-prod.sh # fixeaza mediul, forwardeaza rolul
# Teste (pytest, fara config special; folosesc FastAPI TestClient + SQLite temporar) # Teste (pytest; folosesc FastAPI TestClient + SQLite temporar). Testele live RAR sunt
# skip implicit (marker `live`) — `pytest -q` nu atinge endpointul real.
python3 -m pytest -q python3 -m pytest -q
python3 -m pytest tests/test_worker_reconcile.py -q # un fisier python3 -m pytest tests/test_worker_reconcile.py -q # un fisier
python3 -m pytest tests/test_worker_reconcile.py::test_x -q # un singur test python3 -m pytest tests/test_worker_reconcile.py::test_x -q # un singur test
python3 -m pytest -q -m "not live" # exclude explicit testele live
# Test LIVE pe RAR test (opt-in, skip implicit; atinge endpointul real -> creeaza FINALIZATA):
# reproduce lantul mapare inline -> queued -> worker -> sent -> verificare in finalizate.
AUTOPASS_LIVE_RAR=1 python3 -m pytest tests/test_live_rar.py -q # necesita settings.xml cu creds <test>
# Lifecycle chei API (admin, doar din CLI — nu exista suprafata HTTP) # Lifecycle chei API (admin, doar din CLI — nu exista suprafata HTTP)
python3 -m tools.apikey create --account 2 # cheie afisata O SINGURA DATA (rfak_...) python3 -m tools.apikey create --account 2 # cheie afisata O SINGURA DATA (rfak_...)
@@ -59,18 +65,19 @@ Flux: validare (`validation.py`) → mapare operatie→cod (`mapping.py`) → en
- **`AUTOPASS_CREDS_KEY` trebuie sa fie ACEEASI intre API si worker.** API cripteaza creds RAR (Fernet), worker le decripteaza. Chei diferite → worker nu poate decripta → trimiterile esueaza. `start.sh both` genereaza o cheie efemera partajata; pentru prod pune una persistenta in `.env`. (`crypto.py`) - **`AUTOPASS_CREDS_KEY` trebuie sa fie ACEEASI intre API si worker.** API cripteaza creds RAR (Fernet), worker le decripteaza. Chei diferite → worker nu poate decripta → trimiterile esueaza. `start.sh both` genereaza o cheie efemera partajata; pentru prod pune una persistenta in `.env`. (`crypto.py`)
- **Idempotenta = hash de continut canonic** server-side (`idempotency.py`), pentru ca RAR accepta duplicate si nu are nr. comanda. `build_key` normalizeaza INTOTDEAUNA `account_id` la `account_or_default` (None == 1) INAINTE de hash — altfel acelasi rand logic primeste chei diferite pe canalele API vs import (OV-2). `canonicalize_row` normeaza VIN/nr/odometru (strip ".0" din coercion Excel) inainte de validare si de cheie. - **Idempotenta = hash de continut canonic** server-side (`idempotency.py`), pentru ca RAR accepta duplicate si nu are nr. comanda. `build_key` normalizeaza INTOTDEAUNA `account_id` la `account_or_default` (None == 1) INAINTE de hash — altfel acelasi rand logic primeste chei diferite pe canalele API vs import (OV-2). `canonicalize_row` normeaza VIN/nr/odometru (strip ".0" din coercion Excel) inainte de validare si de cheie.
- **`FINALIZATA` e terminal la RAR** — fara anulare/corectie prin API. De aceea reconcilierea anti-duplicat: pe eroare tranzitorie sau rand `sending` orfan, worker-ul cauta in finalizate (match pe vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara a re-trimite (`reconcile.py`). - **`FINALIZATA` e terminal la RAR** — fara anulare/corectie prin API. De aceea reconcilierea anti-duplicat: pe eroare **ambigua** (timeout / TransportError / 502/503/504 / 429 / 408) sau rand `sending` orfan, worker-ul cauta in finalizate (match pe vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara a re-trimite (`reconcile.py`). **EXCEPTIE: un RAR 500 cu mesaj** (`RarError.rar_message`, ex. `ORA-12899`) e un esec DEFINITIV (RAR a raspuns „am esuat", nu o pierdere de raspuns) → worker-ul NU reconciliaza si NU reincearca, marcheaza `error` cu mesajul RAR (`RAR_EROARE_SERVER`). Altfel ar marca fals `sent` pe un record PARTIAL pe care RAR (ne-tranzactional) il lasa la esec.
- **Creds RAR per cont**: durabile in `accounts.rar_creds_enc` (canal web, fallback re-login) SAU efemere in `submissions.rar_creds_enc` (canal API, sterse dupa primul login reusit). Worker incearca submission-ul intai, apoi fallback la cont. Purjarea sterge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_enc`. - **Creds RAR per cont**: durabile in `accounts.rar_creds_enc` (canal web, fallback re-login) SAU efemere in `submissions.rar_creds_enc` (canal API, sterse dupa primul login reusit). Worker incearca submission-ul intai, apoi fallback la cont. Purjarea sterge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_enc`.
- **Auth API-key** (`auth.py`): identifica CONTUL ROAAUTO, separat de credentialele RAR. Stocam doar SHA-256 al cheii. Enforcement prin `AUTOPASS_REQUIRE_API_KEY`: `false` (dev) → fara cheie merge pe cont id=1, cheie invalida → 401; `true` (prod) → cheie obligatorie pe `/v1/*` protejat. POST-urile + rutele de import sunt account-scoped; GET-urile de listare sunt momentan globale + neprotejate (de remediat — vezi ROADMAP). - **Auth API-key** (`auth.py`): identifica CONTUL ROAAUTO, separat de credentialele RAR. Stocam doar SHA-256 al cheii. Enforcement prin `AUTOPASS_REQUIRE_API_KEY`: `false` (dev) → fara cheie merge pe cont id=1, cheie invalida → 401; `true` (prod) → cheie obligatorie pe `/v1/*` protejat. POST-urile + rutele de import sunt account-scoped; GET-urile de listare sunt si ele account-scoped (5.15/US-011: fragmentele `_fragments/submissions|trimitere|mapari|status|jurnal|nomenclator|trimiteri-versiune` sub `require_login` + scope, 404-before-leak pe id strain; `GET /v1/prezentari(/{id})`/`/v1/mapari`/`/v1/audit/export` filtrate pe cont). `GET /v1/nomenclator` ramane public intentionat (coduri RAR publice, fara PII).
- **Mapare coloane retinuta per `(account_id, signature_coloane)`** (`column_mappings`): la urmatorul fisier cu aceleasi coloane, pentru acelasi cont, maparea se reaplica automat. Un cont poate avea mai multe formate memorate simultan. - **Mapare coloane retinuta per `(account_id, signature_coloane)`** (`column_mappings`): la urmatorul fisier cu aceleasi coloane, pentru acelasi cont, maparea se reaplica automat. Un cont poate avea mai multe formate memorate simultan.
- **Mapare operatie→cod**: prestatie poate veni cu `cod_prestatie` (cod RAR direct) sau `cod_op_service` (cod intern) + `denumire`. Nerezolvat → submission `needs_mapping` (nu se trimite), apare in editorul web cu sugestie fuzzy; la salvarea maparii se re-rezolva automat submission-urile blocate. - **Mapare operatie→cod**: prestatie poate veni cu `cod_prestatie` (cod RAR direct) sau `cod_op_service` (cod intern) + `denumire`. Nerezolvat → submission `needs_mapping` (nu se trimite), apare in editorul web cu sugestie fuzzy; la salvarea maparii se re-rezolva automat submission-urile blocate.
- **`cod_prestatie` e VALIDAT fata de nomenclator la ingestie** (`resolve_prestatii(..., valid_codes)`): un cod direct NECUNOSCUT in nomenclator NU se mai trimite raw — e promovat la `cod_op_service` (denumire=cod) si tratat ca operatie de mapat. Motiv (confirmat live 2026-06-23): RAR accepta NUMAI coduri din nomenclator (coloana `COD_PRESTATIE` max 5 car.); un cod necunoscut da **HTTP 500** (`ORA-12899`), iar RAR **NU e tranzactional** → lasa un record PARTIAL `FINALIZATA` (terminal) chiar pe esec, pe care reconcilierea worker-ului l-ar marca fals `sent`. Comportamentul la cod necunoscut/nemapat: `on_unmapped_error` (camp boolean top-level pe `POST /v1/prezentari` + `/valideaza`) = `false` (intra in editor, `needs_mapping`) sau `true` (respinge fara enqueue → `submission_id=null` + `erori`). Default = `accounts.on_unmapped_error_default` (implicit `false`/`0`); precedenta cerere > cont > `false`.
- **WAF RAR da 403 fara User-Agent de browser** — toate apelurile httpx trimit `User-Agent: Mozilla/5.0` (`config.py`, confirmat live). - **WAF RAR da 403 fara User-Agent de browser** — toate apelurile httpx trimit `User-Agent: Mozilla/5.0` (`config.py`, confirmat live).
- **422 fara echo de credentiale**: handler-ul global de validare in `main.py` pastreaza type/loc/msg dar DROP-a `input`/`ctx` (altfel ar reflecta `rar_credentials.password`). - **422 fara echo de credentiale**: handler-ul global de validare in `main.py` pastreaza type/loc/msg dar DROP-a `input`/`ctx` (altfel ar reflecta `rar_credentials.password`).
- **Retentie**: `submissions` sent + `import_batches` primesc `purge_after = now + 90 zile`; worker-ul purjeaza odata pe ora (T16, GDPR/L.142). - **Retentie**: `submissions` sent + `import_batches` primesc `purge_after = now + 90 zile`; worker-ul purjeaza odata pe ora (T16, GDPR/L.142).
### Masina de stari submissions ### Masina de stari submissions
`queued → sending → sent` (succes, cu `id_prezentare` de la RAR). Ramuri: `needs_mapping` (cod nerezolvat), `needs_data` (RAR 400, validare continut), `error` (max retries / 4xx nerecuperabil / creds invalide / login 401 — NU se face retry pe creds gresite). Backoff: `next_attempt_at = now + base*2^retry`, plafonat. Schema completa: `app/schema.sql`. `queued → sending → sent` (succes, cu `id_prezentare` de la RAR). Ramuri: `needs_mapping` (cod nerezolvat), `needs_data` (RAR 400, validare continut), `error` (max retries / 4xx nerecuperabil / **RAR 500 cu mesaj — esec definitiv** / creds invalide / login 401 — NU se face retry pe creds gresite). Backoff: `next_attempt_at = now + base*2^retry`, plafonat. Schema completa: `app/schema.sql`.
## Mod non-interactiv ## Mod non-interactiv

341
DESIGN.md Normal file
View File

@@ -0,0 +1,341 @@
# DESIGN.md — Sistem de design AutoPass (by ROMFAST)
> Sursa de adevar pentru identitatea vizuala a dashboard-ului. Implementarea concreta sta in
> `app/web/templates/base.html` (variabile CSS `:root` + `[data-theme="light"]`). Acest fisier
> spune *ce* si *de ce*; base.html spune *cum*.
## Lucrul de retinut
> „Software serios pentru o obligatie legala serioasa — dar parte din familia ROMFAST/ROA, nu un
> tool anonim." Operatorul de service trebuie sa simta ca declara la stat printr-un instrument de
> incredere, cu identitatea producatorului (ROMFAST) prezenta discret, nu griul generic de azi.
## Context produs
Gateway web care declara prezentari de service-auto la RAR AUTOPASS (L.142/2023). Utilizatori:
operatori de service-auto si integratori ROAAUTO. Face parte din familia **ROA — Romfast
Applications** (ERP romanesc, modul Service Auto). Referinta de brand: **romfast.ro** — alb curat,
accent albastru azur, pill-uri rotunjite, comutator de tema, logo rosu+albastru.
## Decizie cromatica
Accentul functional = **albastrul ROMFAST** (acelasi cu „FAST" din logo si cu accentul de pe
romfast.ro), nu albastrul generic SaaS de pana acum. Rosul apare DOAR in wordmark-ul „ROM" — nu ca
accent de UI, fiindca rosul e rezervat starilor de eroare. Un singur accent, restul neutre, ca
sistemul sa ramana discret.
### Paleta — Dark (default)
```
--bg: #0f1218 fundal aplicatie
--card: #181c24 suprafete (carduri, modal, inputuri pe fundal)
--card2: #0f1218 fundal input slim / carduri-contor (= --bg, nivelul cel mai adanc)
--ink: #e6e9ef text principal
--muted: #8b93a7 text secundar (label-uri, coduri, „by")
--line: #262b36 borduri, separatoare
--line2: #1f2530 separator subtire lista slim (mai subtil decat --line)
--accent:#2E74D6 azur ROMFAST — butoane primare, pill activ, linkuri, focus
--ok: #2FBF8F sent / succes
--warn: #E0A93B sending / atentie / Lipsa cod
--err: #E05D5D error / needs_data / Date incomplete
```
### Paleta — Light (`[data-theme="light"]`)
```
--bg: #f5f7fa fundal (alb-rece ca romfast.ro)
--card: #ffffff suprafete
--card2: #f5f7fa fundal input slim / carduri-contor (= --bg)
--ink: #1a1d24 text principal
--muted: #5c6473 text secundar
--line: #e2e5ea borduri
--line2: #eaedf2 separator subtire lista slim (mai subtil decat --line)
--accent:#1F66C9 azur, varianta mai inchisa pentru contrast AA pe alb
--ok: #15803d verde AA pe alb
--warn: #b45309 chihlimbar AA pe alb
--err: #dc2626 rosu AA pe alb
```
### Paleta — Petrol (`[data-theme="petrol"]`, tema selectabila)
Tema intunecata alternativa, cu accent petrol-teal (directia initiala aleasa, pastrata ca optiune).
Aceleasi neutre-calde inchise; doar accentul difera de azur.
```
--bg: #0e1416 fundal petrol-inchis
--card: #161e20 suprafete
--card2: #0e1416 fundal input/contor (= --bg)
--ink: #e6e9ef text principal
--muted: #8b93a7 text secundar
--line: #232c2e borduri
--line2: #1c2426 separator subtire (intre --bg si --line)
--accent:#0E7C7B teal petrol — butoane, pill activ, linkuri, focus
--ok: #2FBF8F sent
--warn: #E0A93B atentie
--err: #E05D5D eroare
```
### Paleta — Grafit (`[data-theme="grafit"]`, tema selectabila — adaugata PRD 5.15)
Similara cu dark, accent azur deschis (preluat din landing, `--infot`). Distinta de dark la cererea
userului (D2). Mapare landing->app: `--text->--ink`, `--sub->--muted`, `--okt->--ok`,
`--errt->--err`, `--infot->--accent`.
```
--bg: #0f1218 fundal (identic cu dark)
--card: #181c24 suprafete
--card2: #0f1218 fundal input/contor (= --bg)
--ink: #e6e9ef text principal
--muted: #8b93a7 text secundar
--line: #262b36 borduri
--line2: #1f2530 separator subtire
--accent:#6ea2ec azur deschis (landing --infot) — linkuri, focus, pill activ
--ok: #2FBF8F sent / succes
--warn: #E0A93B atentie
--err: #E05D5D eroare
```
### Paleta — Cobalt (`[data-theme="cobalt"]`, tema selectabila — adaugata PRD 5.15)
Fundal bleumarin adanc, accent albastru viu. Atmosfera tehnica/corporatista rece.
```
--bg: #080d1c fundal bleumarin adanc
--card: #111a33 suprafete
--card2: #0b1226 fundal input/contor
--ink: #e9ecfb text principal (usor albastrat)
--muted: #8a93b8 text secundar
--line: #1d2747 borduri
--line2: #161f3a separator subtire
--accent:#8aa0ff albastru viu (landing --infot)
--ok: #2fd0a6 sent / succes (teal mai saturat)
--warn: #E0A93B atentie
--err: #f06a7a eroare (roz saturat pe bleumarin)
```
### Paleta — Cupru (`[data-theme="cupru"]`, tema selectabila — adaugata PRD 5.15)
Fundal cald ciocolata, accent chihlimbar. Atmosfera artizanala/calda.
```
--bg: #15110b fundal maro inchis-cald
--card: #211a12 suprafete
--card2: #15110b fundal input/contor (= --bg)
--ink: #efe6d6 text principal (crem cald)
--muted: #a89a85 text secundar
--line: #36291c borduri
--line2: #281e14 separator subtire
--accent:#dfa45c chihlimbar cald (landing --infot)
--ok: #67b98c sent / succes (verde muted-cald)
--warn: #c97d2e atentie (chihlimbar mai inchis)
--err: #e2685a eroare (coral pe maro)
```
### Paleta — Hartie (`[data-theme="hartie"]`, tema selectabila — adaugata PRD 5.15)
Fundal crem cald (hartie vintage), accent albastru clasic. Similara cu light, distinta la cererea
userului. Ambele teme luminoase (hartie + light) respecta contrast AA.
```
--bg: #f3efe6 fundal crem cald
--card: #fffdf7 suprafete (crem-alb)
--card2: #f3efe6 fundal input/contor (= --bg)
--ink: #1e1a13 text principal (maro-inchis, AA pe crem)
--muted: #6a6052 text secundar
--line: #e2dccc borduri
--line2: #ece6d9 separator subtire (mai deschis decat line)
--accent:#1F5FBF albastru clasic (landing --infot = --accent) — 6.5:1 pe --bg, AA
--ok: #1c7d5d sent / succes (verde AA pe crem)
--warn: #b45309 atentie (chihlimbar AA pe crem)
--err: #bd463c eroare (rosu AA pe crem)
```
### Tokeni noi adaugati la PRD 5.15 (in toate cele 7 teme)
```
--card2 fundal input slim si carduri-contor (US-001/002); pe dark = --bg (cel mai adanc nivel)
--line2 separator subtire intre randuri lista slim (US-001/002); mai subtil decat --line
```
### Culori de brand (doar wordmark, NU variabile de UI)
```
ROM: #D1342F rosu logo
FAST: #2E74D6 albastru logo (= accentul de UI in dark)
```
Contrast: textul principal pe fundal ramane AA in ambele teme; accentul pe alb foloseste varianta
mai inchisa (`#1F66C9`) ca text/linkul sa treaca AA.
## Tipografie
- **UI / titluri**: **IBM Plex Sans** — sans-serif cu caracter ingineresc, open-source, potrivit
pentru „software serios", parte din limbajul vizual tehnic. Fallback: `system-ui, sans-serif`.
- **Coduri / monospace**: **IBM Plex Mono** — pentru coduri RAR (REV2), VIN, numar inmatriculare,
detalii tehnice. Inlocuieste `ui-monospace/Menlo` actual cu o familie coerenta cu UI-ul.
- **Incarcare**: self-host `woff2` (subset latin + latin-ext pentru diacritice romanesti) in
`app/web/static/fonts/`, `font-display: swap`. Fara CDN extern (gateway intern, fara dependente
de retea la runtime). Pana la self-host, fallback la stiva de sistem nu strica layout-ul.
## Header & branding
- Titlul „Gateway RAR AUTOPASS" **centrat** pe header.
- Sub titlu, mic: **logo-ul ROMFAST** (`/static/romfast_logo.png`, ~28px inaltime). Decizie user
(2026-06-25, US-012b): se foloseste PNG-ul real al logo-ului (ROM rosu + FAST albastru, fundal
transparent — lizibil pe light/dark/petrol), NU wordmark-ul text. Wordmark-ul text (`by ROM FAST`
cu `ROM #D1342F` / `FAST #2E74D6`) ramane documentat ca alternativa, dar livrabila finala
foloseste imaginea.
- Controalele (comutator tema, versiune, hamburger ☰) raman la **dreapta**, fara a strica
centrarea optica a titlului (ex. grila 3 coloane: stanga goala/echilibru, centru titlu, dreapta
controale).
- Responsiv: pe mobil, wordmark-ul ramane sub titlu; controalele nu se suprapun (degrada elegant,
eventual titlu mai mic).
## Selector de tema
Inlocuieste comutatorul binar soare/luna cu un **buton ciclic** (pattern ca demoanaf.ro): un
singur buton care roteste la fiecare click prin setul de teme, cu iconita + tooltip/`aria-label`
care arata tema curenta („Tema: Light" etc.).
Ordinea ciclului (PRD 5.15 — teme aditive D2):
**Light → Dark → Petrol → Grafit → Cobalt → Cupru → Hartie → Auto → (inapoi la Light)**.
- `Light``data-theme="light"` (azur pe alb) — ☀
- `Dark``data-theme="dark"` (azur pe inchis, comportamentul implicit actual) — ☾
- `Petrol``data-theme="petrol"` (teal pe petrol-inchis) — ◐
- `Grafit``data-theme="grafit"` (azur deschis pe negru-grafit, similar dark) — ◑
- `Cobalt``data-theme="cobalt"` (albastru viu pe bleumarin adanc) — ◆
- `Cupru``data-theme="cupru"` (chihlimbar pe maro cald) — ◇
- `Hartie``data-theme="hartie"` (albastru clasic pe crem cald, similar light) — ○
- `Auto` → urmeaza `prefers-color-scheme`; rezolva la `light` (OS light) sau `dark` (OS dark). — ◉
Persistenta: preferinta explicita (inclusiv „Auto") in `localStorage`, doar la click. Scriptul
anti-FOUC din `<head>` cunoaste toate cele 7+1 stari; valori vechi (light/dark/petrol) raman
valide fara migrare fortata; valoare lipsa/necunoscuta → auto (fallback sigur, fara blink).
Implementare DRY (E2 PRD 5.15): configuratia temelor traieste intr-o singura structura JS
`var THEMES = [...]` (sursa de adevar), din care se DERIVA `CYCLE`/`VALID`/`ICONS`/`LABELS`/`NEXT`.
Adaugarea unei teme noi = O singura intrare in `THEMES`.
Default la prima vizita = Auto (OS-aware), ca inainte.
## Componente — note de aplicare
- **Pill-uri de stare/filtru**: rotunjite (`border-radius:99px`), ca badge-ul „ROA" de pe
romfast.ro. Pill activ = fundal accent discret (`color-mix(in srgb, var(--accent) ...)`), text
pe accent. Categoriile de problema isi pastreaza registrul: Date incomplete/Eroare = `--err`,
Lipsa cod = `--warn`.
- **Butoane primare**: fundal `--accent`, text alb (neschimbat ca structura, doar culoarea noua).
- **Linkuri / sugestii**: `--accent`.
- **Focus**: `outline:2px solid var(--accent)` (deja folosit pe randuri).
- **Suprafete de stare** (banner, flash, eroare-3n): raman pe `color-mix` peste `--err/--warn/--ok`,
deci se adapteaza automat la noua paleta si la light/dark.
## Componente slim (PRD 5.15 US-002)
Adaugate in `base.html` (sectiunea `SENTINEL-COMPONENTE-SLIM`). Toate culorile exclusiv prin
`var(--token)` — zero hex hardcodat. Consumate de US-003 (dashboard), US-004 (lista), US-007 (formular).
### `.contor-card`
Card cifra-contor compact: fundal `--card2`, bordura `--line`, `border-radius:8px`, padding 10-12px.
```html
<div class="contor-card">
<div class="contor-cifra s-ok">847</div> <!-- variante de culoare prin .s-ok/.s-err/.s-queued -->
<div class="contor-label">Trimise (total)</div>
<div class="contor-sub">luna 124 · azi 9</div> <!-- optional: sub-linie mono -->
</div>
```
Sub-elemente:
- `.contor-cifra``font-size:22px; font-weight:700`; culoare prin `.s-*` existente
- `.contor-label``font-size:11px; color:var(--muted)`
- `.contor-sub` — IBM Plex Mono, `font-size:10px; color:var(--muted)`
### `.lista-trimiteri-slim` + `.trimitere-slim`
Lista compacta cu separator `--line2`. Randul este clickabil (rol button), tinta `min-height:44px`.
```html
<ul class="lista-trimiteri-slim">
<li class="trimitere-slim" role="button" tabindex="0">
<div>
<div class="slim-vin">WBA8E9...K7F2</div>
<div class="slim-meta">Inspectie tehnica · 09:42</div>
</div>
<span class="pill s-sent">Trimis</span>
</li>
</ul>
```
Sub-elemente:
- `.slim-vin` — IBM Plex Mono, `font-size:13px; font-weight:500; color:var(--ink)`
- `.slim-meta``font-size:11px; color:var(--muted)` (operatie + ora)
### `.camp-slim` + macro `camp(slim=True)`
Varianta compacta de camp formular: label 11px muted deasupra, input `height:30px`, fundal `--card2`.
Integrata in macro-ul `camp` din `_macros.html` prin flagul `slim=False` (default — randarea
actuala ramane neschimbata).
```jinja2
{{ camp('vin', 'VIN (serie sasiu)', vin, slim=True) }}
```
Pentru campuri mono (VIN, odometru, nr. inmatriculare): adauga clasa `camp-mono` pe input
(via `style=""` sau atribut `class=""` direct — macro-ul nu il pune automat, consumatorul decide).
### `.chips` + `.chip` + `.chip-del`
Prestatii multi-select: container `.chips` (fundal `--card2`), item `.chip` (accent 18%, IBM Plex
Mono 11px), buton de stergere `.chip-del` (accesibil cu `aria-label`).
```html
<div class="chips" role="group" aria-label="Prestatii selectate">
<span class="chip">
<button class="chip-del" aria-label="Sterge codul REV2" type="button">&times;</button>
REV2
</span>
<span class="chip chip-warn"> <!-- varianta warn pentru R-ODO/I-ODO -->
<button class="chip-del" aria-label="Sterge codul R-ODO" type="button">&times;</button>
R-ODO
</span>
</div>
```
Clase aditionale:
- `.chip-warn` — fundal `--warn` 22% (pentru coduri R-ODO/I-ODO care cer odometruInitial)
### `.add-code` + `.op-row` (picker E4)
Buton dashed pentru adaugare cod (`.add-code`) si randul operatie<->cod (`.op-row`, `.op-row-name`,
`.op-row-warn`). Folosite de picker-ul E4 din US-007 (formular editare).
```html
<div class="op-row">
<span class="op-row-name">REVIZIE PERIODICA</span>
<span class="chip">REV2 <button class="chip-del" ...>&times;</button></span>
<button class="add-code" type="button">+ alt cod</button>
</div>
<div class="op-row op-row-warn"> <!-- bordura warn: lipsa cod -->
<span class="op-row-name">SCHIMBARE PLACUTE FRANA</span>
<button class="add-code" type="button">alege cod RAR</button>
</div>
```
## Ce NU schimbam
- Mecanismul light/dark existent (anti-FOUC, persistenta `localStorage`, comutator) — il pastram,
doar reimprospatam variabilele.
- Nu introducem rosu ca accent de UI (conflict cu eroare).
- ~~Nu folosim PNG-ul logo cu efect 3D in interfata (wordmark redat ca text).~~ REVIZUIT
(decizie user 2026-06-25): logo-ul PNG real e folosit in header (US-012b). Fundal transparent +
culori proprii il fac lizibil pe toate temele; nu aplicam filtre.
- Nu adaugam un al doilea accent — sistemul ramane monocrom-accent + neutre.
## Legatura cu implementarea (PRD 5.10)
US-012 (header „by ROMFAST" + titlu centrat) si US-013 (paleta) din
`docs/prd/prd-5.10-ux-filtre-pill-paginare-mapari-meniu.md` implementeaza acest sistem. Valorile de
mai sus sunt sursa pentru variabilele din `base.html`.

View File

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

477
README.md
View File

@@ -1,288 +1,111 @@
# Gateway RAR AUTOPASS # Gateway RAR AUTOPASS
Gateway web (Python / FastAPI) care preia prezentarile de service si le declara la Gateway web (Python / FastAPI) care preia prezentarile de service-auto si le declara la
**RAR AUTOPASS** (Legea 142/2023, OM 210/2024). Inlocuieste integrarea Visual FoxPro **RAR AUTOPASS** (Legea 142/2023, OM 210/2024). Inlocuieste integrarea Visual FoxPro (ROAAUTO).
existenta (ROAAUTO). Sursa de adevar pentru contractul RAR este
[`docs/api-rar-contract.md`](docs/api-rar-contract.md).
## Arhitectura pe scurt Doua procese peste acelasi SQLite, care comunica prin tabela `submissions`:
Doua procese peste acelasi SQLite persistent: - **API** (`app.main:app`) — dashboard web, API v1, signup/login, panou admin, `/healthz`, `/metrics`.
- **Worker** (`app.worker`) — login RAR, trimite prezentarile din coada, retry/backoff, heartbeat.
| Proces | Rol | Pornire | Trimiterea catre RAR e **dezactivata implicit** (`AUTOPASS_WORKER_SEND_ENABLED=false`) — sigur pentru probe.
|--------|-----|---------|
| **API** (`app.main:app`) | API v1 (`/v1/*`), dashboard web (`/`), `/healthz`, `/metrics`, import fisiere (`/v1/import/*`) | `uvicorn app.main:app` |
| **Worker** (`app.worker`) | login RAR + JWT, refresh nomenclator, trimite prezentarile din coada, retry/backoff, heartbeat | `python3 -m app.worker` |
Worker-ul ruleaza ca **proces separat** (nu task in API) — un worker mort nu trebuie sa Sursa de adevar pentru contractul RAR: [`docs/api-rar-contract.md`](docs/api-rar-contract.md).
lase containerul "sanatos". Comunicarea API <-> worker se face exclusiv prin tabela Progres + proces: [`docs/ROADMAP.md`](docs/ROADMAP.md).
`submissions` din SQLite. Send-ul catre RAR este **dezactivat implicit**
(`AUTOPASS_WORKER_SEND_ENABLED=false`) — sigur pentru probe.
## Cerinte ## Pornire rapida
- Python 3.12+
- Dependintele din `requirements.txt`
```bash ```bash
pip3 install -r requirements.txt pip3 install -r requirements.txt # Python 3.12+
uvicorn app.main:app --reload --port 8010 # API (dashboard /, Swagger /docs)
python3 -m app.worker # worker (doar daca vrei sa procesezi coada)
``` ```
(Optional, pentru deploy: Docker + Docker Compose — vezi sectiunea Docker.) La prima pornire se creeaza schema SQLite si se face seed la nomenclatorul RAR — dashboard-ul si
maparile merg imediat, offline. Pentru testarea UI-ului si a importului **nu** ai nevoie de worker.
## Configurare Dev rapid fara login: porneste cu `AUTOPASS_WEB_AUTH_REQUIRED=false` (dashboard pe contul implicit id=1).
Variabilele de mediu folosesc prefixul `AUTOPASS_`. Pentru dev local valorile implicite ### Cu `start.sh` (ambaleaza mediu + rol)
sunt suficiente — **nu** ai nevoie de `.env` sau de credentiale RAR ca sa testezi UI-ul si
API-ul. Copiaza `.env.example` -> `.env` doar cand vrei sa rulezi end-to-end.
| Variabila | Implicit | Rol |
|-----------|----------|-----|
| `AUTOPASS_DB_PATH` | `./data/autopass.db` | calea fisierului SQLite |
| `AUTOPASS_RAR_ENV` | `test` | `test` sau `prod` |
| `AUTOPASS_REQUIRE_API_KEY` | `false` | `false` = dev (fara cheie -> cont id=1); `true` = prod (cere cheie) |
| `AUTOPASS_CREDS_KEY` | (efemera) | cheie Fernet pt criptarea creds RAR. **Trebuie partajata intre API si worker.** Genereaza: `python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` |
| `AUTOPASS_WORKER_SEND_ENABLED` | `false` | `true` = trimite efectiv la RAR (proba end-to-end) |
| `AUTOPASS_WORKER_USE_TEST_CREDS` | `false` | dev: foloseste blocul `<test>` din `settings.xml` pt login worker |
Pentru proba reala cu RAR: copiaza `settings.xml.example` -> `settings.xml` si completeaza
credentialele de test (fisierul **nu** se comite).
## Rulare locala (dezvoltare)
### 1. Porneste API-ul
```bash ```bash
uvicorn app.main:app --reload --port 8010 ./start.sh test both --send # API + worker, trimite la RAR test (loguri in .run/)
# sau, daca uvicorn nu e pe PATH: ./start.sh test finalizate # listeaza prezentarile inregistrate la RAR (verificare independenta)
python3 -m uvicorn app.main:app --reload --port 8010 ./start.sh status # stare procese + /healthz
./start.sh stop # opreste procesele pornite cu "both"
./start-test.sh / ./start-prod.sh # fixeaza mediul (test/prod), forwardeaza rolul
``` ```
La prima pornire se creeaza schema SQLite si se face seed la nomenclatorul RAR (18 coduri ## Pagini web
din contract), astfel incat dashboard-ul si maparile functioneaza imediat, offline.
### 2. (Optional) Porneste worker-ul
Necesar doar pentru a procesa coada / a trimite la RAR. Pentru testarea UI-ului si a
import-ului **nu** e necesar.
```bash
python3 -m app.worker
```
### Pornire rapida cu `start.sh`
`start.sh` ambaleaza pornirea pe mediu (`test` / `prod`) si rol (`api` / `worker` / `both`):
```bash
./start.sh test api # API pe :8010, mediu test
./start.sh test worker --send # worker care TRIMITE la RAR test
./start.sh test both --send # API + worker impreuna (dev end-to-end, loguri in .run/)
./start.sh prod api --port 8010 # API mediu prod
./start.sh prod worker --send # worker prod (NU foloseste creds de test)
./start.sh status # stare procese + /healthz
./start.sh stop # opreste procesele pornite cu "both"
./start.sh test finalizate # ce prezentari sunt inregistrate la RAR (vezi mai jos)
```
Optiuni: `--port N`, `--host H`, `--reload` (dev), `--send` (activeaza trimiterea la RAR),
`--test-creds` / `--no-test-creds` (forteaza folosirea creds `<test>` din `settings.xml`).
Pe `test` cu `--send`, creds `<test>` se folosesc automat. Pentru productie reala foloseste
`docker compose` (vezi sectiunea Docker).
Doua wrappere fixeaza mediul si forwardeaza rolul + optiunile:
```bash
./start-test.sh # = start.sh test both --send (API + worker, trimite la RAR test)
./start-test.sh worker --send # = start.sh test worker --send
./start-test.sh finalizate # = start.sh test finalizate
./start-prod.sh both --send # = start.sh prod both --send
./start-prod.sh api # = start.sh prod api
```
Pe test, `./start-test.sh` fara argumente porneste end-to-end (sandbox RAR e sigur). Pe prod,
`./start-prod.sh` cere rolul explicit si trimiterea trebuie ceruta cu `--send` (evita trimiteri
accidentale in productie).
## Testare in browser
Cu API-ul pornit, deschide in browser:
| URL | Ce vezi | | URL | Ce vezi |
|-----|---------| |-----|---------|
| `http://localhost:8010/` | **Dashboard** — stare coada, banner prezentari blocate, stare worker / ultim login RAR, editor mapari operatii, browser nomenclator, sectiune **import fisier** | | `/` | Dashboard: coada, prezentari blocate, stare worker, import fisier, mapari, nomenclator |
| `http://localhost:8010/docs` | **Swagger UI** — API v1 interactiv (incearca endpointurile direct din browser) | | `/signup` · `/login` | Inregistrare cont (emite cheia API o data) · autentificare |
| `http://localhost:8010/healthz` | JSON sanatate: worker viu, ultim login RAR, adancime coada | | `/admin` | Panou admin: conturi pe stari, activare/blocare/arhivare (doar admini) |
| `http://localhost:8010/metrics` | metrici text (submissions pe status) | | `/integrare` | Exemple cod (Python/C#/Node/VFP), export Postman/OpenAPI, testeaza conexiunea |
| `/docs` | Swagger UI — API v1 interactiv |
| `/healthz` · `/metrics` | sanatate JSON · metrici text |
### Fluxul de import fisier (xlsx / csv) din browser ## Import fisier (xlsx / csv)
Pe dashboard, in sectiunea de import: Pe dashboard: **incarca** fisierul → **mapeaza coloanele** (sugerate automat fuzzy; maparea se retine
pe semnatura coloanelor, per cont) → **preview** (fiecare rand: `ok` / `needs_mapping` / `needs_data` /
`already_sent` / ...) → **confirma** (retastezi numarul de randuri `ok`). Randurile intra in coada.
1. **Incarca** un fisier `.xlsx` sau `.csv` (drag & drop sau selectare). Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, `Odometru final`,
2. **Mapeaza coloanele** — gateway-ul sugereaza automat (fuzzy) maparea coloana fisier -> `Odometru initial`, `Operatie`, `Observatii`. Fiecare cont poate avea mai multe formate memorate.
camp canonic (VIN, data prestatie, odometru, operatie etc.). Maparea se retine pe
semnatura coloanelor: la urmatorul fisier cu aceleasi coloane se aplica automat.
3. **Preview** — fiecare rand primeste o stare: `ok`, `needs_mapping`, `needs_data`,
`needs_review`, `already_sent`, `duplicate_in_file`.
4. **Confirma** — gate dur: retastezi numarul exact de randuri `ok` de trimis. Randurile
confirmate intra in coada (`submissions`), apoi le urmaresti in tabelul de jos.
Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, ## API v1 (curl)
`Odometru final`, `Odometru initial`, `Operatie`, `Observatii`.
### Genereaza un fisier de test pentru import Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`.
Repo-ul nu contine fisiere sample. Creeaza unul rapid:
```bash ```bash
python3 - <<'PY' curl -s http://localhost:8010/healthz | python3 -m json.tool # sanatate
import openpyxl curl -s http://localhost:8010/v1/nomenclator # coduri RAR (cache local)
wb = openpyxl.Workbook() curl -s http://localhost:8010/v1/prezentari # coada
ws = wb.active
ws.append(["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]) # Trimite o prezentare. `rar_credentials` e OPTIONAL: daca lipseste, worker-ul
ws.append(["WAUZZZ8K0AA000001", "B123ABC", "2026-06-15", 120000, "REVIZIE PERIODICA"]) # foloseste creds-urile RAR salvate pe cont (POST /v1/conturi/rar-creds). Trimite-le
ws.append(["WAUZZZ8K0AA000002", "B456DEF", "2026-06-16", 85000, "REPARATIE"]) # explicit doar cand vrei sa le suprascrii pe acea cerere.
wb.save("sample_import.xlsx") curl -s -X POST http://localhost:8010/v1/prezentari \
print("scris sample_import.xlsx") -H 'X-API-Key: rfak_...' -H 'Content-Type: application/json' \
PY -d '{
"prezentari": [{
"vin": "WAUZZZ8K0AA000001", "nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-06-15", "odometru_final": "120000",
"prestatii": [{"cod_op_service": "REVIZIE PERIODICA", "denumire": "REVIZIE PERIODICA"}]
}]
}'
# Dry-run: valideaza payload + mapare, FARA enqueue, FARA creds
curl -s -X POST http://localhost:8010/v1/prezentari/valideaza \
-H 'X-API-Key: rfak_...' -H 'Content-Type: application/json' -d '{ "prezentari": [ ... ] }'
# Import fisier
curl -s -X POST http://localhost:8010/v1/import -H 'X-API-Key: rfak_...' -F 'file=@import.xlsx'
``` ```
Sau un CSV echivalent: Toate endpointurile sunt in `/docs`. Exemple gata facute + Postman/OpenAPI: hub-ul `/integrare`.
## Conturi si chei API
Fiecare service = un **cont** (`accounts`) cu lifecycle (`pending → active → blocked / archived / deleted`).
Worker-ul trimite doar pentru conturi `active`. Web-ul se autentifica prin **sesiune** (login email+parola),
API-ul prin **cheie API** (`X-API-Key`). Cheia identifica contul, e separata de credentialele RAR.
```bash ```bash
printf 'VIN,Nr inmatriculare,Data prestatie,Odometru final,Operatie\nWAUZZZ8K0AA000001,B123ABC,2026-06-15,120000,REVIZIE PERIODICA\n' > sample_import.csv # Self-onboarding: service-ul deschide /signup → primeste cheia o data. Primul cont = admin.
# Sau din CLI (admin, pe masina gateway-ului):
python3 -m tools.account create --name "Service Auto SRL" --cui RO12345678 --with-key
python3 -m tools.account list [--pending] | activate --account N | set-admin --account N
python3 -m tools.apikey create|list|rotate|revoke --account N # cheie afisata O SINGURA DATA
``` ```
Incarca apoi fisierul prin sectiunea de import a dashboard-ului. **Creds RAR per cont** (ca worker-ul sa trimita fara parola in fiecare cerere) — criptate Fernet at-rest:
## Proba trimitere la RAR (mediu test) + verificare ca au ajuns
Implicit worker-ul **nu** trimite (`AUTOPASS_WORKER_SEND_ENABLED=false`). Pentru proba
end-to-end pe contul de test RAR:
1. Pune credentialele de test in `settings.xml` (copiaza din `settings.xml.example`,
completeaza blocul `<test>`). Acestea **nu** se comit.
2. Bag-a prezentari in coada — fie prin import fisier din dashboard, fie prin API
(`POST /v1/prezentari`, vezi mai jos).
3. Porneste worker-ul cu trimiterea activa:
```bash
./start.sh test worker --send
```
Worker-ul face login la RAR test, ia randurile `queued`, trimite si trece fiecare rand
in `sent` cu `id_prezentare` (id-ul intors de RAR — dovada ca a ajuns) sau in
`needs_data` / `error` cu motivul.
4. **Vizualizeaza prezentarile trimise** — trei feluri:
- **Dashboard** (`http://localhost:8010/`) — tabelul de jos arata fiecare submission cu
status (`sent`/`error`/...), `id_prezentare`, cod RAR si eroare. Se actualizeaza singur.
- **API**: `curl -s http://localhost:8010/v1/prezentari` — coada locala cu statusuri.
- **Direct de la RAR** (confirmare independenta ca au ajuns):
```bash
./start.sh test finalizate
```
Face login la RAR test si listeaza prezentarile inregistrate acolo (id, VIN, data,
odometru). Compari `id`-urile cu `id_prezentare` din coada locala: daca se regasesc,
prezentarea a ajuns la RAR.
> Status `sent` + `id_prezentare` completat = RAR a acceptat prezentarea. Worker-ul are si
> reconciliere anti-duplicat: daca raspunsul RAR se pierde, la urmatorul ciclu cauta
> prezentarea in finalizate si o marcheaza `sent` fara a o re-trimite.
## Import fisier pentru mai multi utilizatori (service-uri) cu formate diferite
Da — fiecare service auto poate avea propriul format de fisier (alte denumiri de coloane,
alta ordine, alt format de data). Sistemul **tine minte maparea per cont**, deci nu o refaci
la fiecare upload:
- **Cont (`account_id`)** — fiecare service e un cont. In productie contul se identifica prin
**cheia API** (`X-API-Key`) trimisa la upload/cerere (`AUTOPASS_REQUIRE_API_KEY=true`). In
dev, fara cheie, totul merge pe contul implicit `id=1`.
- **Semnatura coloanelor** — la upload, gateway-ul calculeaza o semnatura din lista (sortata)
a denumirilor de coloane din fisier. Maparea coloana-fisier -> camp-canonic se salveaza in
tabela `column_mappings`, cheie unica `(account_id, signature_coloane)`, impreuna cu
formatul de data.
- **Re-aplicare automata** — la urmatorul fisier cu **aceleasi coloane** (aceeasi semnatura),
pentru **acelasi cont**, maparea retinuta se aplica automat si sari direct la preview. Daca
un service schimba formatul (alte coloane) se creeaza o semnatura noua, deci o mapare noua —
fara sa o strice pe cea veche. Astfel un cont poate avea mai multe formate memorate simultan.
Pe scurt: **cine** = `account_id` (din cheia API), **care format** = `signature_coloane`
(setul de coloane al fisierului). Combinatia lor selecteaza maparea corecta.
## Conturi (service-uri) si chei API
Un **cont** (`accounts`) = un service auto care foloseste gateway-ul. Cererile `/v1/*` se
autentifica printr-o **cheie API** (header `X-API-Key: <cheie>` sau `Authorization: Bearer
<cheie>`) care identifica contul. Cheia e separata de credentialele RAR ale service-ului.
Enforcement-ul e controlat de `AUTOPASS_REQUIRE_API_KEY`:
- `false` (dev/test, implicit): cerere fara cheie -> contul implicit `id=1`; o cheie prezenta
dar invalida -> `401`.
- `true` (productie): orice `/v1/*` **protejat** cere o cheie valida, altfel `401`.
Auth-ul se aplica pe endpointurile care scriu/sunt legate de cont (au dependinta de cheie):
`POST /v1/prezentari`, `POST /v1/mapari`, `POST|DELETE /v1/conturi/rar-creds` si toate rutele
de import (`POST /v1/import`, `.../column-mapping`, `.../preview`, `.../commit`,
`.../export-failed`) — acestea ruleaza pe `account_id`-ul cheii. GET-urile de **monitorizare**
(`/v1/prezentari`, `/v1/prezentari/{id}`, `/v1/nomenclator`, `/v1/mapari`, `/v1/audit/export`)
sunt momentan **neprotejate si globale** (nu filtreaza pe cont). Filtrarea pe cont a listarilor
+ protejarea lor raman de adaugat (vezi tabelul de mai jos).
### Stare implementare
| Capabilitate | Stare | Cum |
|--------------|-------|-----|
| Emitere / rotire / revocare / listare chei API | **Implementat** | CLI `python3 -m tools.apikey` |
| Auth pe cheie (X-API-Key / Bearer) pe POST-uri + import | **Implementat** | `app/auth.py` + flag `AUTOPASS_REQUIRE_API_KEY` |
| Ingestie + import account-scoped (din cheie) | **Implementat** | `POST /v1/prezentari`, `POST /v1/import` |
| Creds RAR durabile per cont | **Implementat** | `POST /v1/conturi/rar-creds` |
| Creare cont nou (service) | **De facut / manual** | momentan prin `INSERT` SQL (vezi mai jos); nu exista tool/endpoint dedicat |
| Protejare + filtrare pe cont a GET-urilor de listare | **De facut** | `GET /v1/prezentari`, `/v1/nomenclator`, `/v1/audit/export` sunt globale acum |
| Self-onboarding web (login email+parola -> emite cheie) | **De facut** | `docs/ROADMAP.md` (Etapa 3.3) — neimplementat |
> Lifecycle-ul cheilor se face DOAR din CLI, pe masina gateway-ului (admin) — nu exista
> suprafata HTTP de administrare de securizat. Cheia in clar se afiseaza **o singura data**
> la creare/rotire; in DB se pastreaza doar hash-ul SHA-256.
### Creare cont + cheie pentru un service nou
Pana la onboarding-ul web, un cont nou se creeaza direct in DB, apoi i se emite o cheie:
```bash
# 1. Creeaza contul (numele + CUI sunt informative)
python3 -c "
from app.db import get_connection, init_db
init_db()
c = get_connection()
cur = c.execute(\"INSERT INTO accounts (name, cui) VALUES ('Service Auto SRL', 'RO12345678')\")
print('account_id nou =', cur.lastrowid); c.commit(); c.close()
"
# 2. Emite o cheie API pentru cont (afisata O SINGURA DATA)
python3 -m tools.apikey create --account 2
# -> rfak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Alte operatii
python3 -m tools.apikey list # toate cheile
python3 -m tools.apikey list --account 2 # cheile unui cont
python3 -m tools.apikey rotate --account 2 # revoca cele active + emite una noua
python3 -m tools.apikey revoke --key-id 3 # revoca o cheie dupa id
```
### Creds RAR per cont
Ca worker-ul sa poata trimite pentru un service fara ca fiecare cerere sa-i poarte parola
RAR, seteaza credentialele RAR durabile pe cont (criptate Fernet at-rest):
```bash ```bash
curl -s -X POST http://localhost:8010/v1/conturi/rar-creds \ curl -s -X POST http://localhost:8010/v1/conturi/rar-creds \
@@ -290,126 +113,66 @@ curl -s -X POST http://localhost:8010/v1/conturi/rar-creds \
-d '{"email": "service@exemplu.ro", "password": "parola-rar"}' -d '{"email": "service@exemplu.ro", "password": "parola-rar"}'
``` ```
## Testare prin API (curl) > GET-urile de listare (`/v1/prezentari`, `/v1/nomenclator`, `/v1/audit/export`) sunt momentan
> **globale si neprotejate** — filtrarea pe cont ramane de adaugat.
Exemplele de mai jos arata atat varianta **dev** (fara cheie -> cont id=1), cat si varianta ## Proba reala la RAR (mediu test)
**service cu cheie API** (header `X-API-Key`). Cand `AUTOPASS_REQUIRE_API_KEY=true`, cheia e
obligatorie. 1. Pune creds de test in `settings.xml` (copiaza din `settings.xml.example`, bloc `<test>`; **nu** se comite).
`settings.xml` tine un singur cont RAR doar pentru dev/test — creds-urile conturilor reale stau criptate in DB.
2. Baga prezentari in coada (import sau API).
3. `./start.sh test worker --send` — worker-ul trimite si trece fiecare rand in `sent` (cu `id_prezentare`),
`needs_data` sau `error`.
4. Verifica: dashboard, `curl /v1/prezentari`, sau `./start.sh test finalizate` (listeaza direct de la RAR).
> `sent` + `id_prezentare` = RAR a acceptat. La raspuns pierdut, worker-ul reconciliaza anti-duplicat
> (cauta in finalizate, marcheaza `sent` fara re-trimitere). `FINALIZATA` e terminal la RAR.
## Configurare (`AUTOPASS_*`)
| Variabila | Implicit | Rol |
|-----------|----------|-----|
| `DB_PATH` | `./data/autopass.db` | calea SQLite |
| `RAR_ENV` | `test` | `test` / `prod` |
| `REQUIRE_API_KEY` | `false` | `true` = cere cheie pe `/v1/*` (prod) |
| `WEB_AUTH_REQUIRED` | `true` | `false` = dashboard fara login, cont id=1 (dev) |
| `CREDS_KEY` | (efemera) | **cheie Fernet creds RAR — trebuie PARTAJATA intre API si worker** |
| `SESSION_SECRET` | (efemer) | secret cookie sesiune; persistent in prod |
| `WORKER_SEND_ENABLED` | `false` | `true` = trimite efectiv la RAR |
| `SMTP_HOST` (+ `_PORT`/`_USER`/`_PASSWORD`/`_FROM`) | (none) | notificare admin la signup (best-effort) |
Genereaza chei: `python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
(CREDS_KEY) si `python3 -c "import secrets; print(secrets.token_hex(32))"` (SESSION_SECRET).
## Teste
```bash ```bash
# Sanatate (neprotejat) python3 -m pytest -q # toata suita
curl -s http://localhost:8010/healthz | python3 -m json.tool python3 -m pytest tests/test_x.py -q # un fisier
# Nomenclator RAR (cache local)
curl -s http://localhost:8010/v1/nomenclator
# Coada de prezentari (monitorizare; momentan globala + neprotejata, vezi nota de mai sus)
curl -s http://localhost:8010/v1/prezentari
# Trimite o prezentare -- dev (fara cheie API -> cont id=1)
curl -s -X POST http://localhost:8010/v1/prezentari \
-H 'Content-Type: application/json' \
-d '{
"rar_credentials": {"email": "test@example.ro", "password": "secret"},
"prezentari": [{
"vin": "WAUZZZ8K0AA000001",
"nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-06-15",
"odometru_final": "120000",
"prestatii": [{"cod_op_service": "REVIZIE PERIODICA", "denumire": "REVIZIE PERIODICA"}]
}]
}'
# Trimite o prezentare -- service cu cheie API (account_id curge din cheie)
curl -s -X POST http://localhost:8010/v1/prezentari \
-H 'X-API-Key: rfak_...' -H 'Content-Type: application/json' \
-d '{
"rar_credentials": {"email": "service@exemplu.ro", "password": "parola-rar"},
"prezentari": [{
"vin": "WAUZZZ8K0AA000002",
"nr_inmatriculare": "B456DEF",
"data_prestatie": "2026-06-16",
"odometru_final": "85000",
"prestatii": [{"cod_op_service": "REPARATIE", "denumire": "REPARATIE"}]
}]
}'
# Import fisier prin API pentru un service (multi-tenant: contul vine din cheie)
curl -s -X POST http://localhost:8010/v1/import \
-H 'X-API-Key: rfak_...' -F 'file=@sample_import.xlsx'
``` ```
Endpointurile complete sunt vizibile si testabile in `/docs` (Swagger UI). In Swagger,
pune cheia prin butonul "Authorize" sau adauga header-ul `X-API-Key`.
```bash
# Sanatate
curl -s http://localhost:8010/healthz | python3 -m json.tool
# Nomenclator RAR (cache local)
curl -s http://localhost:8010/v1/nomenclator
# Coada de prezentari
curl -s http://localhost:8010/v1/prezentari
# Trimite o prezentare (dev: fara cheie API -> cont id=1)
curl -s -X POST http://localhost:8010/v1/prezentari \
-H 'Content-Type: application/json' \
-d '{
"rar_credentials": {"email": "test@example.ro", "password": "secret"},
"prezentari": [{
"vin": "WAUZZZ8K0AA000001",
"nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-06-15",
"odometru_final": "120000",
"prestatii": [{"cod_op_service": "REVIZIE PERIODICA", "denumire": "REVIZIE PERIODICA"}]
}]
}'
```
Endpointurile complete sunt vizibile si testabile in `/docs` (Swagger UI).
## Rularea testelor
```bash
python3 -m pytest -q
```
Suita acopera fundatia, securitatea, validarea, parserul de import, masina de stari a
worker-ului si fluxul UI de import (E2E cu RAR mock).
## Docker / deploy ## Docker / deploy
```bash ```bash
# 1. Pregateste .env (CRITIC: AUTOPASS_CREDS_KEY partajata intre api si worker) cp .env.example .env # CRITIC: completeaza AUTOPASS_CREDS_KEY (partajata api+worker)
cp .env.example .env docker compose up --build # api (:8010) + worker + autoheal, acelasi image + volum SQLite
# completeaza AUTOPASS_CREDS_KEY (vezi comanda de generare de mai sus)
# 2. Porneste API + worker + autoheal
docker compose up --build
``` ```
`docker-compose.yml` porneste trei containere: `api` (port 8010), `worker` si `autoheal`
(restarteaza worker-ul cand heartbeat-ul devine invechit). Ambele servicii folosesc acelasi
image si acelasi volum SQLite persistent.
## Structura ## Structura
``` ```
app/ app/
main.py # FastAPI: API v1 + dashboard + /healthz + /metrics main.py # FastAPI: API v1 + dashboard + auth + admin
api/v1/ # router.py (prezentari, nomenclator, mapari) + import_router.py api/v1/ # router.py (prezentari, valideaza, nomenclator, mapari, conturi),
web/ # routes.py (dashboard + import UI HTMX) + templates/ + static/ # import_router.py, integrare_router.py (ping, postman/openapi)
web/ # routes.py (dashboard + import HTMX), auth_routes.py, admin_routes.py,
# session.py, csrf.py, labels.py, templates/, static/
worker/ # proces separat: login RAR, send, retry, heartbeat worker/ # proces separat: login RAR, send, retry, heartbeat
rar_client.py # client HTTP RAR (login/JWT, postPrezentare, nomenclator) rar_client.py # client HTTP RAR (login/JWT, postPrezentare, nomenclator)
validation.py # validare continut (T3) auth.py users.py accounts.py # chei API, parole scrypt + admin, lifecycle conturi
mapping.py # mapare operatie -> cod prestatie + fuzzy lookup validation.py mapping.py errors.py crypto.py # validare, mapare cod, erori 3-niveluri, Fernet
crypto.py # criptare Fernet creds RAR efemere (zero-storage at rest)
schema.sql # schema SQLite schema.sql # schema SQLite
docs/ # contract RAR (sursa de adevar) + ROADMAP (progres + proces) tools/ # CLI admin: account, apikey, backup, rar_finalizate, import_dbf
tests/ # suita pytest docs/ # contract RAR + ROADMAP + prd/
legacy-vfp/ # arhiva Visual FoxPro ROAAUTO (legacy, doar referinta/migrare) tests/ legacy-vfp/ # suita pytest · arhiva ROAAUTO (referinta)
``` ```
Contract RAR (sursa de adevar): [`docs/api-rar-contract.md`](docs/api-rar-contract.md).
Roadmap + proces de dezvoltare: [`docs/ROADMAP.md`](docs/ROADMAP.md).

78
TODOS.md Normal file
View File

@@ -0,0 +1,78 @@
# TODOS
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare.
## Din PRD 5.12 (2026-06-26)
- [ ] **Mai multi utilizatori per firma (flux de invitatie / alaturare la cont)** — azi CUI e unic, deci
al doilea email care vrea pe aceeasi firma e respins la signup (nu exista flux de „alatura-te firmei").
`users` permite tehnic mai multe loginuri per `account_id`, dar nu exista UI. Daca apare nevoia reala
(mai multe persoane dintr-o firma), construieste: admin-ul firmei invita un email SAU al doilea cere
acces si admin-ul aproba; membership pe `account_id`. Decizie user (2026-06-26): in 5.12 ramane
**1 firma = 1 cont = 1 login** + mesaj prietenos la CUI duplicat (US-001); acest flux = livrabila separata.
## Din /autoplan PRD 5.11 (2026-06-26)
- [ ] **E2E smoke de first-run ca poarta de release** — codifica scriptul de dogfooding
(import -> mapcoloane -> preview -> commit -> lista apare + contoare) ca test E2E care
trebuie sa treaca inainte de orice release. Motiv: cele 8 bug-uri din 5.11 sunt toate
first-run friction nedogfooded end-to-end; fara o poarta, reapar ca 8 tichete noi.
(CEO F2, severitate high.)
- [ ] **Control compensator optional pe auto-trimitere unattended** — utilizatorul a ales
(2026-06-26) scoaterea completa a hold-ului auto_send. Risc rezidual acceptat: o regula
text gresita poate auto-trimite FINALIZATA (terminal, fara undo) pe randuri pe canalul API /
remapare inline (fara gate de preview). Daca apar integratori reali, evalueaza un throttle
„primele N auto-trimiteri pe o regula text noua cer confirmare" sau un kill-switch per cont.
(CEO F5/F6, severitate critical ca risc, dar pre-launch exposure ~zero acum.)
## Din /autoplan PRD 5.13 (2026-06-27)
- [ ] **Filtre de data 2x2 pe mobil** — Azi/7zile/30zile/Custom stivuiesc full-width (4 randuri)
pe mobil; grid 2x2 ar fi mai compact. Imbunatatire viitoare. (Design, low.)
- [ ] **Sprite `<use href="#...">` pentru iconitele Lucide**`act_btn` randeaza SVG inline pe
fiecare rand (bloat DOM pe toate viewporturile, ascuns pe desktop). Optimizare deferata; inline
acum (P5 simplu > optim prematur). (Eng §1, medium.)
- [ ] **"Eroare/Eroare" la nivel routes.py/labels.py** — guard-ul de template (pill-only cand
eticheta==stare) acopera cazul vizibil; curatarea logicii de continut ramane debt. (Design §2.)
- [ ] **Validare premisa "utilizare mobil reala"** — inainte de orice extindere responsive viitoare,
confirma device-mix-ul (analytics/cerere user). Daca ~95% desktop, nu mai investi in cardificare
mobil. (CEO F1, high — premisa nedovedita acum.)
## Din /plan-ceo-review PRD 5.15 (2026-06-28)
- [ ] **Validare premisa "utilizare mobil reala" (reluare F1 din 5.13)** — partea slim/compact a lui
5.15 presupune utilizare reala pe mobil. Inainte de orice rafinare responsive viitoare, confirma
device-mix-ul (analytics / cerere user). Daca ~95% desktop, nu mai investi in cardificare mobil.
(CEO, high — premisa nedovedita.)
- [ ] **Deduparea/etichetarea temelor grafit~dark si hartie~light** — 5.15 adauga 4 teme peste cele 3
existente (7 + Auto). grafit e ~ identic cu dark, hartie ~ identic cu light. Daca selectorul devine
confuz sau matricea de test apasa, dedupica sau eticheteaza-le clar. (CEO, low — simplificare optionala.)
- [ ] **US-009/US-010 ca PRD separat daca propagarea design e urgenta** — salvarea mapare-din-chip si
bulk-fix sunt adiacente FUNCTIONALE (acceptate via SELECTIVE EXPANSION), dincolo de obiectivul pur de
propagare design. Daca vrei sa livrezi designul rapid, pot fi scoase intr-un PRD propriu. (CEO, low.)
## Din raport comparatie mockup 5.16 (2026-06-29)
> Restul task-urilor din `docs/raport-comparatie-mockup-5.16.md` au fost livrate (T-1..T-9).
> Cele de mai jos raman explicit in coada la cererea userului.
- [ ] **Stare de eroare HTMX la incarcarea listei (D-4)** — cand `/_fragments/submissions`
da 500 sau pica reteaua, `#submissions-wrap` ramane blocat pe spinner ("se incarca…") fara
mesaj. De adaugat un partial de eroare / `hx-on::response-error` cu "nu s-a putut incarca,
reincearca". Robustete pre-existenta (nu introdusa de 5.16), impact functional real —
**candidatul cu cea mai mare valoare** din lista. (Design D-4, medium.)
- [ ] **Retokenizare px completa in template-uri**`_submissions.html` / `_preview_*` folosesc
literali `font-size:13px/12px/11px` in loc de token-urile `--fs-*`. 5.16 a corectat doar
instanta sub-12px (incalca pragul PRD). Restul ramane debt: schimbarea in masa (13px→`--fs-sm`
=13.5px) misca layout-ul, deci necesita o baza de regresie vizuala inainte. (Eng, bounded —
amanat ca scope creep fara baza AC.)
- [ ] **Diacritice in textul vizibil pentru user** — mockup-urile folosesc diacritice complete
("Observații", "Salvează", "Adaugă"); aplicatia le omite in majoritatea label-urilor. Fontul
le randeaza corect (US-001 confirmat). De aplicat pe label-uri/butoane/titluri, pastrand
cod/comentariile fara diacritice. Decizie initiala (poarta de gust T3): nu se aplica acum —
reintrodus in coada la cererea userului (2026-06-29) ca finisaj viitor. (Transversal, low.)

View File

@@ -15,33 +15,69 @@ 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:
"""trim + upper; sir gol -> None (tratat ca „fara CUI").""" """trim + upper; sir gol -> ValueError daca e string gol, None daca e None."""
if cui is None: if cui is None:
return None return None
cui = cui.strip().upper() cui = cui.strip().upper()
return cui or None if cui == "":
raise ValueError("CUI gol (un CUI trebuie sa fie un sir nevid)")
return cui
def _norm_email(email: str | None) -> str | None:
"""trim + lower; sir gol -> ValueError daca e string gol, None daca e None."""
if email is None:
return None
email = email.strip().lower()
if email == "":
raise ValueError("email gol (un email trebuie sa fie un sir nevid)")
return email
def create_account( def create_account(
conn: sqlite3.Connection, name: str, cui: str | None = None, active: bool = True conn: sqlite3.Connection,
name: str,
cui: str | None = None,
email: str | None = None,
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).
`name` gol/whitespace -> ValueError. `cui` se normalizeaza (trim+upper); un CUI `name` gol/whitespace -> ValueError. `cui` se normalizeaza (trim+upper); sir gol -> ValueError.
deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial `email` se normalizeaza (trim+lower); sir gol -> ValueError.
Un CUI deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial
`ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta. `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)
# 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'.
cur = conn.execute( cur = conn.execute(
"INSERT INTO accounts (name, cui, active) VALUES (?, ?, ?)", "INSERT INTO accounts (name, cui, email, active, status, tier, trial_until, "
(name, cui, 1 if active else 0), "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()
@@ -53,18 +89,169 @@ def create_account(
return int(cur.lastrowid or 0) return int(cur.lastrowid or 0)
def account_is_complete(row: sqlite3.Row | dict) -> bool:
"""Returneaza True daca contul are companie (name), email si CUI ne-goale.
Contul de sistem id=1 (default) este EXCEPTAT si returneaza intotdeauna True
(nu are sens sa-l marcam ca incomplet — nu e un cont de client).
"""
acct_id = row["id"] if "id" in row.keys() else None
if acct_id == 1:
return True
name = (row["name"] or "").strip()
cui = (row["cui"] or "").strip()
email_val = (row["email"] or "").strip() if "email" in row.keys() else ""
return bool(name and cui and email_val)
def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None: def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None:
"""Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca). """Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca).
Cont inexistent -> ValueError.""" Cont inexistent -> ValueError.
Mentine invariantul 5.5 active=1 <=> status='active': activarea -> 'active',
dezactivarea -> 'pending' (legacy „in asteptare"). Pentru blocare/arhivare/stergere
foloseste `set_status`/`delete_account`.
"""
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone() row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not row: if not row:
raise ValueError(f"cont inexistent: {account_id}") raise ValueError(f"cont inexistent: {account_id}")
conn.execute("UPDATE accounts SET active=? WHERE id=?", (1 if active else 0, account_id)) conn.execute(
"UPDATE accounts SET active=?, status=? WHERE id=?",
(1 if active else 0, "active" if active else "pending", account_id),
)
# Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de
# retentie); restul sunt reversibile.
VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted")
# Tieruri de cont valide (5.17). Sursa de adevar: app/plans.py#PLANS (nu duplica valorile).
VALID_TIERS = ("free", "standard", "pro", "premium")
# Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b).
_PROTECTED_ACCOUNT_ID = 1
def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None:
"""Seteaza `accounts.status` la una din `VALID_STATUSES`, mentinand mirror-ul `active`
(active=1 doar pentru 'active', altfel 0).
Contul de sistem id=1 NU poate fi mutat din 'active' (cont default) -> ValueError.
Status invalid sau cont inexistent -> ValueError.
"""
if status not in VALID_STATUSES:
raise ValueError(f"status invalid: {status}")
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 and status != "active":
raise ValueError("Contul default (id=1) nu poate fi blocat/arhivat/sters (cont de sistem).")
conn.execute(
"UPDATE accounts SET active=?, status=? WHERE id=?",
(1 if status == "active" else 0, status, account_id),
)
def set_tier(
conn: sqlite3.Connection,
account_id: int,
tier: str,
trial_until: str | None = None,
) -> None:
"""Seteaza planul unui cont (tier + trial_until).
tier invalid -> ValueError cu mesaj clar.
Contul de sistem id=1 e protejat (ca set_status).
Cont inexistent -> ValueError.
Logheaza schimbarea in app_events (reuse observ.log_event, fara PII nou).
trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul).
"""
if tier not in VALID_TIERS:
raise ValueError(
f"tier invalid: {tier!r} (valid: {', '.join(VALID_TIERS)})"
)
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not row:
raise ValueError(f"cont inexistent: {account_id}")
if account_id == _PROTECTED_ACCOUNT_ID:
raise ValueError(
"Contul default (id=1) nu poate fi mutat pe alt plan via CLI "
"(cont de sistem, tratat coerent)."
)
conn.execute(
"UPDATE accounts SET tier=?, trial_until=? WHERE id=?",
(tier, trial_until, account_id),
)
# Audit in app_events (decizie PRD 5.17 US-008, fara PII nou)
try:
from .observ import log_event
log_event(
"plan_schimbare_tier",
account_id=account_id,
mesaj=f"tier -> {tier}",
context={"tier": tier, "trial_until": trial_until},
conn=conn,
)
except Exception: # noqa: BLE001 — jurnal best-effort (ca observ.log_event)
pass
def set_trial(conn: sqlite3.Connection, account_id: int, trial_until: str | None) -> None:
"""Seteaza DOAR `trial_until` (acorda/prelungeste/sterge trial Pro), fara a atinge `tier`.
Trial Pro activ (trial_until in viitor) ridica planul efectiv la 'pro' (vezi
plans.effective_tier), indiferent de tier-ul de baza. Folosit din panoul admin ca sa
acorzi un trial fara a schimba tier-ul de baza (post-trial).
Contul de sistem id=1 e protejat. Cont inexistent -> ValueError.
trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul).
"""
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not row:
raise ValueError(f"cont inexistent: {account_id}")
if account_id == _PROTECTED_ACCOUNT_ID:
raise ValueError("Contul default (id=1) nu poate primi trial (cont de sistem).")
conn.execute(
"UPDATE accounts SET trial_until=? WHERE id=?", (trial_until, account_id)
)
# Audit in app_events (best-effort, fara PII nou — ca set_tier).
try:
from .observ import log_event
log_event(
"plan_trial_setat",
account_id=account_id,
mesaj=f"trial_until -> {trial_until or 'NULL'}",
context={"trial_until": trial_until},
conn=conn,
)
except Exception: # noqa: BLE001 — jurnal best-effort (ca observ.log_event)
pass
def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele
sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API
revocate si CUI-ul eliberat (ca acelasi CUI sa se poata re-inregistra — altfel indexul unic
`ux_accounts_cui` l-ar tine blocat de un cont invizibil). Contul de sistem id=1 e protejat.
Nota: nu facem hard DELETE pe rand din cauza FK-urilor (submissions/api_keys/...); pastram
tombstone-ul pentru audit, dar fara PII. Jobul de retentie T16 purjeaza `submissions`/batches,
NU acest tombstone — de aceea purjam PII aici, la momentul stergerii."""
set_status(conn, account_id, "deleted") # valideaza existenta + protejeaza id=1; seteaza status+active=0
conn.execute(
"UPDATE accounts SET rar_creds_enc=NULL, cui=NULL WHERE id=?", (account_id,)
)
conn.execute(
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE account_id=? AND active=1",
(account_id,),
)
def list_accounts(conn: sqlite3.Connection) -> list[dict]: def list_accounts(conn: sqlite3.Connection) -> list[dict]:
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id.""" """Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
(stergere soft -> invizibile in panou)."""
rows = conn.execute( rows = conn.execute(
"SELECT id, name, cui, active, created_at FROM accounts ORDER BY id" "SELECT id, name, cui, email, active, status, tier, trial_until, "
"requested_plan, consent_at, created_at FROM accounts "
"WHERE status != 'deleted' ORDER BY id"
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]

View File

@@ -8,14 +8,13 @@ Endpointuri:
POST /v1/import/{id}/commit — gate HARD + enqueue randuri ok + log atestare POST /v1/import/{id}/commit — gate HARD + enqueue randuri ok + log atestare
GET /v1/import/{id}/export-failed — CSV cu randuri esuate (needs_data/needs_mapping/needs_review) GET /v1/import/{id}/export-failed — CSV cu randuri esuate (needs_data/needs_mapping/needs_review)
Reguli cheie (plan §3.1-3.4, §12): Reguli cheie:
- Issue 6: scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany. - Scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany.
- Eng#5: already_sent lookup BATCH (IN chunk ~900), nu N+1. - already_sent lookup BATCH (IN chunk ~900), nu N+1.
- OV-3: duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker. - duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker.
- Issue 1 (TOCTOU): commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING. - TOCTOU: commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
- Issue 5a: import_rows.raw_json CRIPTAT Fernet. - import_rows.raw_json CRIPTAT Fernet.
- Issue 5b: fuzzy coloane refoloseste mapping.normalize_for_match (DRY). - Drift semnatura coloane -> NU aplica orb, cere re-confirmare.
- T4/D3: drift semnatura coloane -> NU aplica orb, cere re-confirmare.
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,7 +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 ...auth import resolve_account_id from datetime import datetime, timezone
from ... import errors
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
@@ -43,9 +45,12 @@ from ...import_parse import (
parse_file, parse_file,
) )
from ...mapping import ( from ...mapping import (
_emite_text_rule_hits,
account_or_default, account_or_default,
has_no_auto_send, has_no_auto_send,
load_mapping_meta, load_mapping_meta,
load_nomenclator_codes,
load_text_rules,
normalize_for_match, normalize_for_match,
resolve_prestatii, resolve_prestatii,
) )
@@ -56,7 +61,7 @@ router = APIRouter(prefix="/v1/import", tags=["import"])
# Marimea maxima a unui chunk pentru IN(...) SQLite (limite SQLite ~999) # Marimea maxima a unui chunk pentru IN(...) SQLite (limite SQLite ~999)
_IN_CHUNK = 900 _IN_CHUNK = 900
# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane (Issue 5b/Eng#4) # Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane
_CANONICAL_SYNONYMS: dict[str, list[str]] = { _CANONICAL_SYNONYMS: dict[str, list[str]] = {
"vin": ["VIN", "Serie sasiu", "Sasiu", "Serie", "Numar sasiu", "Nr sasiu", "Chassis"], "vin": ["VIN", "Serie sasiu", "Sasiu", "Serie", "Numar sasiu", "Nr sasiu", "Chassis"],
"nr_inmatriculare": ["Nr inmatriculare", "Numar inmatriculare", "Numar auto", "Nr auto", "Numar", "Nr"], "nr_inmatriculare": ["Nr inmatriculare", "Numar inmatriculare", "Numar auto", "Nr auto", "Numar", "Nr"],
@@ -89,7 +94,7 @@ def _fuzzy_suggest_column(
) -> list[dict]: ) -> list[dict]:
"""Sugereaza campuri canonice pentru o coloana din fisier. """Sugereaza campuri canonice pentru o coloana din fisier.
Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio (Issue 5b/Eng#4). Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio.
Intoarce [{camp_canonic, score}] sortat descrescator. Intoarce [{camp_canonic, score}] sortat descrescator.
""" """
from rapidfuzz import fuzz, process from rapidfuzz import fuzz, process
@@ -124,6 +129,10 @@ def _resolve_row_for_preview(
mapping: dict[str, str], mapping: dict[str, str],
mapping_meta: dict[str, dict], mapping_meta: dict[str, dict],
formula_columns: list[str], formula_columns: list[str],
override: dict[str, Any] | None = None,
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
reviewed: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Rezolva un rand din import pentru preview: aplica mapare coloane + validare. """Rezolva un rand din import pentru preview: aplica mapare coloane + validare.
@@ -132,6 +141,11 @@ def _resolve_row_for_preview(
resolved: valorile finale rezolvate (VIN, data, km, prestatii) resolved: valorile finale rezolvate (VIN, data, km, prestatii)
errors: lista erori validare errors: lista erori validare
flags: motive needs_review flags: motive needs_review
`override`: patch CANONIC editat in preview, aplicat ULTIMUL peste valorile
mapate (dupa `json_mapare` si canonicalizare). Permite corectarea unei valori
sau completarea unui camp a carui coloana LIPSESTE din fisier, fara sa atinga
`raw_json`/idempotency.
""" """
# Aplica maparea de coloane # Aplica maparea de coloane
mapped: dict[str, Any] = {} mapped: dict[str, Any] = {}
@@ -139,7 +153,7 @@ def _resolve_row_for_preview(
if col_fisier in raw_row and camp_canonic: if col_fisier in raw_row and camp_canonic:
mapped[camp_canonic] = raw_row[col_fisier] mapped[camp_canonic] = raw_row[col_fisier]
# Detectie coloane cu formule (Issue 3) — nu blocheaza, dar adauga flag # Detectie coloane cu formule — nu blocheaza, dar adauga flag
formula_flag: list[str] = [] formula_flag: list[str] = []
for col_fisier, camp_canonic in json_mapare.items(): for col_fisier, camp_canonic in json_mapare.items():
if col_fisier in formula_columns: if col_fisier in formula_columns:
@@ -174,7 +188,15 @@ def _resolve_row_for_preview(
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val) denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}] mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
# Canonicalizare (T9): normalizeaza VIN/nr/odometru # obs derive-on-empty (D7/E3 PRD 5.15): daca obs e gol si avem operatie,
# copiem denumirea operatiei in obs (nu o mutam — op_service ramane neatins).
# DERIVE-ON-EMPTY: doar cand obs e gol, ca sa fie idempotent la re-preview/re-editare.
obs_curent = str(mapped.get("obs") or "").strip()
if not obs_curent and operatie_val:
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["obs"] = obs_denumire
# Canonicalizare: normalizeaza VIN/nr/odometru
canon = canonicalize_row(mapped) canon = canonicalize_row(mapped)
mapped.update({ mapped.update({
"vin": canon["vin"], "vin": canon["vin"],
@@ -182,6 +204,11 @@ def _resolve_row_for_preview(
"odometru_final": canon["odometru_final"], "odometru_final": canon["odometru_final"],
}) })
# Override editat in preview — aplicat ULTIMUL, peste valorile mapate +
# canonicalizate. Valorile din override sunt deja canonice (vezi _merge_override).
if override:
mapped.update(override)
# Flags needs_review acumulate # Flags needs_review acumulate
all_flags = list(coercion_flags) + formula_flag all_flags = list(coercion_flags) + formula_flag
if is_ambiguous_date: if is_ambiguous_date:
@@ -189,7 +216,7 @@ def _resolve_row_for_preview(
# Rezolvare prestatii # Rezolvare prestatii
prestatii = mapped.get("prestatii") or [] prestatii = mapped.get("prestatii") or []
resolved, unmapped = resolve_prestatii(prestatii, mapping) resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
mapped["prestatii"] = resolved mapped["prestatii"] = resolved
# Determinare stare # Determinare stare
@@ -204,8 +231,10 @@ def _resolve_row_for_preview(
# Validare continut # Validare continut
errors = validate_prezentare(mapped) errors = validate_prezentare(mapped)
if all_flags: if all_flags and not reviewed:
# needs_review: chiar daca validarea trece, flagurile blocheaza auto-send # needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala.
# Daca reviewed=True (operatorul a confirmat explicit valorile in modal), sarim
# acest return si continuam spre ok/needs_data (US-007, PRD 5.12).
return { return {
"resolved_status": "needs_review", "resolved_status": "needs_review",
"resolved": mapped, "resolved": mapped,
@@ -213,14 +242,7 @@ def _resolve_row_for_preview(
"flags": all_flags, "flags": all_flags,
} }
# auto_send gate (T6/OV-1) # US-001 (PRD 5.11): ramura auto_send eliminata din preview.
if has_no_auto_send(resolved, mapping_meta):
return {
"resolved_status": "needs_mapping",
"resolved": mapped,
"errors": [{"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"}],
"flags": all_flags,
}
if errors: if errors:
return { return {
@@ -244,8 +266,108 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) ->
return build_key(account_id, canon) return build_key(account_id, canon)
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
# aici (raman in panoul de mapare). obs = text liber, se trateaza ca non-canonic
# (doar .strip(), fara canonicalize_row) — urmeaza ramura `else` din _merge_override.
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs")
def _merge_override(current: dict[str, Any], fields: dict[str, str | None]) -> dict[str, Any]:
"""Aplica campurile editate peste override-ul curent (mutatie pura).
Semantica:
- valoare None -> camp ne-trimis in cerere -> neschimbat.
- valoare "" -> STERGE cheia din override (revine la valoarea din fisier).
- valoare negoala -> set valoare CANONICA (vin/nr upper, odometru_final fara ".0").
`odometru_initial`/`data_prestatie` se pastreaza stripped (canonicalize_row normeaza
doar `_final`; validarea le verifica direct).
"""
out = dict(current)
raw: dict[str, str] = {}
for camp in EDIT_FIELDS:
val = fields.get(camp)
if val is None:
continue
s = str(val).strip()
if s == "":
if camp == "obs":
# obs e camp DERIVAT (copiaza denumirea operatiei cand e gol). Empty =
# STERGERE EXPLICITA a userului -> pastram obs='' in override ca
# derive-on-empty sa NU il re-deriveze (override aplicat ULTIMUL
# suprascrie derivarea, in preview si la commit). Un pop ar fi pierdut
# semnalul "sters explicit" -> obs re-derivat silentios din denumire.
out["obs"] = ""
else:
out.pop(camp, None) # empty = clear (revine la valoarea din fisier)
else:
raw[camp] = s
if raw:
canon = canonicalize_row(raw)
for camp in raw:
if camp in ("vin", "nr_inmatriculare", "odometru_final"):
out[camp] = canon[camp]
else:
out[camp] = raw[camp]
return out
def apply_row_override(
conn,
*,
import_id: int,
account_id: int | None,
row_index: int,
fields: dict[str, str | None],
) -> dict[str, Any]:
"""Persista override-ul canonic pentru un rand de preview (mutatie PURA de stocare).
NU recalculeaza statusul si NU atinge `submissions` — preview-ul rederiva statusul
prin `_resolve_row_for_preview` (un singur clasificator, fara drift).
Ridica HTTPException: 404 (rand/batch inexistent sau alt cont — scoping JOIN),
409 (batch deja comis), 422 (override curent corupt -> no-op defensiv, fara scriere goala).
Intoarce noul dict de override (gol = override sters).
"""
acct = account_or_default(account_id)
# Scoping intr-o singura interogare JOIN -> 404 pe gol (alt cont / batch / row_index).
row = conn.execute(
"SELECT r.id AS rid, r.override_json AS oj, b.status AS bstatus "
"FROM import_rows r JOIN import_batches b ON b.id = r.batch_id "
"WHERE b.id=? AND b.account_id=? AND r.row_index=?",
(import_id, acct, row_index),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="rand de import inexistent")
if row["bstatus"] == "committed":
raise HTTPException(status_code=409, detail="batch deja comis; editarea nu mai are efect")
current: dict[str, Any] = {}
if row["oj"]:
dec = decrypt_creds(row["oj"])
if dec is None:
# Decrypt fail (cheie schimbata / token corupt): no-op defensiv, NICIODATA scriere goala.
_oi_msg = "override curent ilizibil; editare anulata"
raise HTTPException(
status_code=422,
detail={
"error": "override_ilizibil",
"message": _oi_msg,
**errors.eroare("IMPORT_OVERRIDE_ILIZIBIL", cauza=_oi_msg),
},
)
current = dec
new_override = _merge_override(current, fields)
enc = encrypt_creds(new_override) if new_override else None
# D#9 (PRD 5.12): resetam reviewed=0 la orice schimbare de valoare — operatorul
# trebuie sa reconfirme dupa editare. NU conditionam pe reviewed curent: orice override
# (chiar si revert la valoarea initiala) anuleaza confirmarea implicita.
conn.execute("UPDATE import_rows SET override_json=?, reviewed=0 WHERE id=?", (enc, row["rid"]))
return new_override
def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, dict]: def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, dict]:
"""Cauta cheile de idempotenta in submissions (batch, nu N+1 — Eng#5). """Cauta cheile de idempotenta in submissions (batch, nu N+1).
Intoarce {idempotency_key: {id, id_prezentare, created_at}} pentru cheile gasite. Intoarce {idempotency_key: {id, id_prezentare, created_at}} pentru cheile gasite.
""" """
@@ -266,7 +388,7 @@ def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, di
"id_prezentare": r["id_prezentare"], "id_prezentare": r["id_prezentare"],
"created_at": r["created_at"], "created_at": r["created_at"],
} }
# Dual-lookup pentru chei legacy (OV-2: chei vechi cu account_id=None) # Dual-lookup pentru chei legacy (chei vechi cu account_id=None)
legacy_keys_needed = [k for k in chunk if k not in found] legacy_keys_needed = [k for k in chunk if k not in found]
if legacy_keys_needed: if legacy_keys_needed:
lph = ",".join("?" * len(legacy_keys_needed)) lph = ",".join("?" * len(legacy_keys_needed))
@@ -293,13 +415,12 @@ 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.
Nu trimite nimic la RAR. Intoarce {import_id, columns, sample_rows, sheets?}. Nu trimite nimic la RAR. Intoarce {import_id, columns, sample_rows, sheets?}.
PII (raw_json) criptat Fernet la rest (Issue 5a). PII (raw_json) criptat Fernet la rest. Scrieri bulk in tranzactie explicita.
Scrieri bulk in tranzactie explicita (Issue 6).
""" """
acct = account_or_default(account_id) acct = account_or_default(account_id)
data = await file.read() data = await file.read()
@@ -315,6 +436,7 @@ async def upload_import(
"error": "multiple_sheets", "error": "multiple_sheets",
"message": str(ms), "message": str(ms),
"sheets": ms.sheet_names, "sheets": ms.sheet_names,
**errors.eroare("IMPORT_MULTIPLE_SHEETS", cauza=str(ms)),
}, },
) )
except FileTooLarge as e: except FileTooLarge as e:
@@ -323,6 +445,7 @@ async def upload_import(
detail={ detail={
"error": "file_too_large", "error": "file_too_large",
"message": str(e), "message": str(e),
**errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e)),
}, },
) )
except HeaderError as e: except HeaderError as e:
@@ -332,23 +455,28 @@ async def upload_import(
"error": "header_error", "error": "header_error",
"message": str(e), "message": str(e),
"found": e.found, "found": e.found,
**errors.eroare("IMPORT_ANTET_NECLAR", cauza=str(e)),
}, },
) )
except UnicodeDecodeError as e: except UnicodeDecodeError as e:
_enc_msg = f"Encoding nesuportat: {e.reason}"
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail={ detail={
"error": "encoding_error", "error": "encoding_error",
"message": f"Encoding nesuportat: {e.reason}", "message": _enc_msg,
**errors.eroare("IMPORT_ENCODING", cauza=_enc_msg),
}, },
) )
except Exception as e: except Exception as e:
# Fisier corupt (BadZipFile, InvalidFileException, etc.) # Fisier corupt (BadZipFile, InvalidFileException, etc.)
_inv_msg = f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail={ detail={
"error": "invalid_file", "error": "invalid_file",
"message": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", "message": _inv_msg,
**errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=_inv_msg),
}, },
) )
@@ -356,7 +484,7 @@ async def upload_import(
try: try:
sig = _signature(parsed.columns) sig = _signature(parsed.columns)
# Issue 6: tranzactie explicita BEGIN IMMEDIATE + executemany # Tranzactie explicita BEGIN IMMEDIATE + executemany
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
try: try:
# Insert import_batches # Insert import_batches
@@ -370,7 +498,7 @@ async def upload_import(
# Insert import_rows bulk (executemany) cu PII criptat # Insert import_rows bulk (executemany) cu PII criptat
row_params = [] row_params = []
for i, row_dict in enumerate(parsed.rows): for i, row_dict in enumerate(parsed.rows):
raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet (Issue 5a) raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet
row_params.append((batch_id, i, raw_json_enc, "pending", None)) row_params.append((batch_id, i, raw_json_enc, "pending", None))
conn.executemany( conn.executemany(
@@ -394,11 +522,8 @@ async def upload_import(
# Sample rows (primele 3, fara PII) # Sample rows (primele 3, fara PII)
sample = parsed.rows[:3] sample = parsed.rows[:3]
# Persistam metadata parsedata (coercion_flags, date_col_format, formula_columns) # Metadata parsata (coercion_flags etc.) se intoarce in raspuns; preview-ul
# in import_batches pentru refolosire la preview (stocam ca JSON in 'status' nu e OK, # o recalculeaza din raw_json deja stocat.
# ci ca metadate suplimentare — le stocam intr-un rand separat sau returnam direct)
# Solutie: le returnam in raspuns; preview-ul le va recalcula din raw_json deja stocat
# SAU le stocam ca un camp extra. Cel mai simplu: stocam coloanele in batch.
conn.execute( conn.execute(
"UPDATE import_batches SET ok=?, needs_review=? WHERE id=?", "UPDATE import_batches SET ok=?, needs_review=? WHERE id=?",
(0, len(parsed.coercion_flags), batch_id), (0, len(parsed.coercion_flags), batch_id),
@@ -420,7 +545,7 @@ async def upload_import(
result["column_mapping"] = json.loads(existing_mapping["json_mapare"]) result["column_mapping"] = json.loads(existing_mapping["json_mapare"])
result["format_data"] = existing_mapping["format_data"] result["format_data"] = existing_mapping["format_data"]
else: else:
# Sugestii fuzzy per coloana (Issue 5b: refoloseste normalize_for_match) # Sugestii fuzzy per coloana
suggestions: dict[str, list[dict]] = {} suggestions: dict[str, list[dict]] = {}
for col in parsed.columns: for col in parsed.columns:
sugg = _fuzzy_suggest_column(col, limit=3) sugg = _fuzzy_suggest_column(col, limit=3)
@@ -564,7 +689,7 @@ def save_column_mapping(
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# GET /v1/import/{id}/preview — 6 stari per rand (T2 + T11) # # GET /v1/import/{id}/preview — 6 stari per rand #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@router.get("/{import_id}/preview") @router.get("/{import_id}/preview")
@@ -574,8 +699,8 @@ def preview_import(
) -> dict: ) -> dict:
"""Preview 6 stari per rand: ok/needs_mapping/needs_data/needs_review/already_sent/duplicate_in_file. """Preview 6 stari per rand: ok/needs_mapping/needs_data/needs_review/already_sent/duplicate_in_file.
Nu enqueue-aza nimic. Already_sent = lookup batch (Eng#5). Duplicate_in_file = intra-batch Nu enqueue-aza nimic. Already_sent = lookup batch. Duplicate_in_file = intra-batch
collision (OV-3: EXCLUSIV aici, NU in reconcile.py/worker). collision (EXCLUSIV aici, NU in reconcile.py/worker).
""" """
acct = account_or_default(account_id) acct = account_or_default(account_id)
conn = get_connection() conn = get_connection()
@@ -589,21 +714,27 @@ def preview_import(
# Incarca toate randurile # Incarca toate randurile
raw_rows_db = conn.execute( raw_rows_db = conn.execute(
"SELECT row_index, raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index", "SELECT row_index, raw_json, override_json FROM import_rows WHERE batch_id=? ORDER BY row_index",
(import_id,), (import_id,),
).fetchall() ).fetchall()
if not raw_rows_db: if not raw_rows_db:
return {"rows": [], "summary": {}} return {"rows": [], "summary": {}}
# Decripteaza si reconstruieste randurile # Decripteaza si reconstruieste randurile + override-urile editate
rows: list[dict] = [] rows: list[dict] = []
overrides: list[dict] = []
for r in raw_rows_db: for r in raw_rows_db:
try: try:
row_data = decrypt_creds(r["raw_json"]) row_data = decrypt_creds(r["raw_json"])
rows.append(row_data or {}) rows.append(row_data or {})
except Exception: except Exception:
rows.append({}) rows.append({})
try:
ov = decrypt_creds(r["override_json"]) if r["override_json"] else None
except Exception:
ov = None
overrides.append(ov or {})
# Obtine coloanele # Obtine coloanele
col_names = list(rows[0].keys()) if rows else [] col_names = list(rows[0].keys()) if rows else []
@@ -616,30 +747,31 @@ def preview_import(
).fetchone() ).fetchone()
if not mapping_row: if not mapping_row:
_ncm_msg = "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea."
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail={ detail={
"error": "no_column_mapping", "error": "no_column_mapping",
"message": "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea.", "message": _ncm_msg,
**errors.eroare("IMPORT_FARA_MAPARE_COLOANE", cauza=_ncm_msg),
}, },
) )
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"]) json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
format_data = mapping_row["format_data"] format_data = mapping_row["format_data"]
# Incarca maparea de operatii o singura data (Eng#5: load_mapping o singura data) # Incarca maparea de operatii o singura data
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
# Reconstruieste parsed info (coercion_flags si date_col_format) din datele stocate # Recalculam coercion_flags din valorile stocate (nu sunt persistate separat):
# Nota: import_rows stocheaza raw_json dupa coercion (din parse_file) # detectie simpla de VIN numeric.
# Recalculam flags din valorile stocate (coercion_flags nu e stocat separat)
# Vom folosi o detectie simpla: VIN-uri care par numerice si odometru float
coercion_flags_map: dict[int, list[str]] = {} coercion_flags_map: dict[int, list[str]] = {}
# Detectam din valorile stocate
for i, row_dict in enumerate(rows): for i, row_dict in enumerate(rows):
flags = [] flags = []
# Detectam VIN numeric: daca valoarea a fost stocata si arata ca numar
for col_f, camp_c in json_mapare.items(): for col_f, camp_c in json_mapare.items():
if camp_c == "vin": if camp_c == "vin":
vin_val = row_dict.get(col_f) vin_val = row_dict.get(col_f)
@@ -681,6 +813,9 @@ def preview_import(
mapping=mapping, mapping=mapping,
mapping_meta=mapping_meta, mapping_meta=mapping_meta,
formula_columns=formula_columns, formula_columns=formula_columns,
override=overrides[i] or None,
valid_codes=valid_codes,
text_rules=text_rules,
) )
# Calculeaza cheia de idempotenta pentru randurile ok/needs_review # Calculeaza cheia de idempotenta pentru randurile ok/needs_review
@@ -704,11 +839,11 @@ def preview_import(
"idempotency_key": key, "idempotency_key": key,
}) })
# Already_sent: batch lookup (Eng#5 — nu N+1) # Already_sent: batch lookup (nu N+1)
unique_keys = list(set(keys_for_lookup)) unique_keys = list(set(keys_for_lookup))
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys) already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
# Duplicate_in_file (OV-3): detectie coliziuni intra-batch # Duplicate_in_file: detectie coliziuni intra-batch.
# Grupam pe cheie de idempotenta: >1 rand cu aceeasi cheie = duplicate # Grupam pe cheie de idempotenta: >1 rand cu aceeasi cheie = duplicate
key_to_indices: dict[str, list[int]] = {} key_to_indices: dict[str, list[int]] = {}
for row in preview_rows: for row in preview_rows:
@@ -731,7 +866,7 @@ def preview_import(
row["already_sent_info"] = sent_info row["already_sent_info"] = sent_info
continue continue
# Duplicate_in_file (OV-3): >1 rand cu aceeasi cheie in ACELASI fisier # Duplicate_in_file: >1 rand cu aceeasi cheie in ACELASI fisier
indices_with_same_key = key_to_indices.get(k, []) indices_with_same_key = key_to_indices.get(k, [])
if len(indices_with_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"): if len(indices_with_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
others = [idx for idx in indices_with_same_key if idx != row["row_index"]] others = [idx for idx in indices_with_same_key if idx != row["row_index"]]
@@ -785,7 +920,7 @@ def preview_import(
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare (T5+T12) # # POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class CommitIn(BaseModel): class CommitIn(BaseModel):
@@ -801,11 +936,11 @@ 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 (T5+T12). """Gate HARD confirmare + enqueue randuri ok + log atestare.
TOCTOU (Issue 1): INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING. TOCTOU: INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
Randuri colidante = reclasificate already_sent in rezultatul commit-ului. Randuri colidante = reclasificate already_sent in rezultatul commit-ului.
rows_hash + n_confirmed acopera DOAR randurile efectiv puse in coada. rows_hash + n_confirmed acopera DOAR randurile efectiv puse in coada.
""" """
@@ -822,20 +957,41 @@ def commit_import(
if batch["status"] == "committed": if batch["status"] == "committed":
raise HTTPException(status_code=409, detail="batch deja comis") raise HTTPException(status_code=409, detail="batch deja comis")
# Incarca randurile cu stare ok sau needs_review # D#8 (PRD 5.12): gate commit derivat din DB `reviewed` pe AMBELE canale.
# API: reviewed_rows pastrat (contract stabil) dar seteaza reviewed=1 in DB inainte
# de interogare. Randurile needs_review cu reviewed=1 sunt incluse in comit.
if req.reviewed_rows:
conn.execute("BEGIN IMMEDIATE")
try:
for idx in req.reviewed_rows:
conn.execute(
"UPDATE import_rows SET reviewed=1 "
"WHERE batch_id=? AND row_index=? AND resolved_status='needs_review'",
(import_id, idx),
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
# Incarca randurile ok + needs_review confirmate (reviewed=1)
ok_rows_db = conn.execute( ok_rows_db = conn.execute(
"SELECT row_index, raw_json, resolved_status FROM import_rows " "SELECT row_index, raw_json, override_json, resolved_status, reviewed "
"WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index", "FROM import_rows "
"WHERE batch_id=? AND (resolved_status='ok' OR "
"(resolved_status='needs_review' AND reviewed=1)) "
"ORDER BY row_index",
(import_id,), (import_id,),
).fetchall() ).fetchall()
if not ok_rows_db: if not ok_rows_db:
raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat in acest batch.") raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat in acest batch.")
def _override_of(r) -> dict:
return (decrypt_creds(r["override_json"]) if r["override_json"] else None) or {}
# Decripteaza randurile ok # Decripteaza randurile ok
ok_rows: list[dict] = [] ok_rows: list[dict] = []
ok_indices: list[int] = []
review_indices: set[int] = set()
for r in ok_rows_db: for r in ok_rows_db:
try: try:
@@ -844,42 +1000,72 @@ def commit_import(
continue continue
except Exception: except Exception:
continue continue
ok_rows.append({
if r["resolved_status"] == "ok": "row_index": r["row_index"],
ok_rows.append({"row_index": r["row_index"], "data": row_data, "status": "ok"}) "data": row_data,
ok_indices.append(r["row_index"]) "override": _override_of(r),
elif r["resolved_status"] == "needs_review": "status": r["resolved_status"],
review_indices.add(r["row_index"]) })
# needs_review bifate explicit (Voce#1 — atestare pe valori)
confirmed_review = [idx for idx in req.reviewed_rows if idx in review_indices]
for idx in confirmed_review:
# Gaseste randul needs_review si il adauga la ok_rows
for r in ok_rows_db:
if r["row_index"] == idx and r["resolved_status"] == "needs_review":
try:
row_data = decrypt_creds(r["raw_json"])
if row_data:
ok_rows.append({"row_index": idx, "data": row_data, "status": "needs_review"})
ok_indices.append(idx)
except Exception:
pass
# Gate HARD: n_confirmat trebuie sa fie EXACT egal cu numarul de randuri ok # Gate HARD: n_confirmat trebuie sa fie EXACT egal cu numarul de randuri ok
n_total_ok = len(ok_rows) n_total_ok = len(ok_rows)
if req.n_confirmat != n_total_ok: if req.n_confirmat != n_total_ok:
_cg_msg = f"Ai confirmat {req.n_confirmat} dar sunt {n_total_ok} randuri gata de trimis. Verifica preview-ul."
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail={ detail={
"error": "confirmare_gresita", "error": "confirmare_gresita",
"message": f"Ai confirmat {req.n_confirmat} dar sunt {n_total_ok} randuri gata de trimis. Verifica preview-ul.", "message": _cg_msg,
"n_ok": n_total_ok, "n_ok": n_total_ok,
**errors.eroare("IMPORT_CONFIRMARE_GRESITA", cauza=_cg_msg),
}, },
) )
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",
@@ -907,16 +1093,18 @@ def commit_import(
# Incarca maparea de operatii # Incarca maparea de operatii
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
# Construieste payload-urile submissions # Construieste payload-urile submissions
enqueued: list[dict] = [] enqueued: list[dict] = []
toctou_collisions: list[int] = [] toctou_collisions: list[int] = []
rows_for_hash: list[str] = [] rows_for_hash: list[str] = []
# Enqueue in tranzactie explicita (Issue 6) # Enqueue in tranzactie explicita
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
try: try:
# purge_after pentru submissions noi (T16)
purge_after_sql = "datetime('now', '+90 days')" purge_after_sql = "datetime('now', '+90 days')"
for ok_row in ok_rows: for ok_row in ok_rows:
@@ -951,9 +1139,16 @@ def commit_import(
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val) denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}] mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
# obs derive-on-empty (D7/E3 PRD 5.15): copiere denumire in obs daca obs e gol.
# Identic cu logica din _resolve_row_for_preview (override aplicat tot ultimul).
obs_curent = str(mapped.get("obs") or "").strip()
if not obs_curent and operatie_val:
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
mapped["obs"] = obs_denumire
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview) # Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
prestatii = mapped.get("prestatii") or [] prestatii = mapped.get("prestatii") or []
resolved, _ = resolve_prestatii(prestatii, mapping) resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
mapped["prestatii"] = resolved mapped["prestatii"] = resolved
# Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie) # Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie)
@@ -964,6 +1159,19 @@ def commit_import(
"odometru_final": canon["odometru_final"], "odometru_final": canon["odometru_final"],
}) })
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
override = ok_row.get("override") or {}
if override:
mapped.update(override)
# Re-canonicalizeaza pentru a obtine cheia IDENTICA cu cea din preview
# (_build_idempotency_key = canonicalize_row + build_key peste mapped).
canon = canonicalize_row(mapped)
mapped.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
"odometru_final": canon["odometru_final"],
})
# Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine) # Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine)
key = build_key(account_id, canon) key = build_key(account_id, canon)
@@ -978,7 +1186,7 @@ def commit_import(
payload_json = json.dumps(mapped, ensure_ascii=False) payload_json = json.dumps(mapped, ensure_ascii=False)
# INSERT ON CONFLICT DO NOTHING (TOCTOU — Issue 1) # INSERT ON CONFLICT DO NOTHING (TOCTOU)
cur = conn.execute( cur = conn.execute(
"INSERT OR IGNORE INTO submissions " "INSERT OR IGNORE INTO submissions "
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) " "(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
@@ -991,6 +1199,7 @@ def commit_import(
toctou_collisions.append(row_index) toctou_collisions.append(row_index)
else: else:
sub_id = cur.lastrowid sub_id = cur.lastrowid
_emite_text_rule_hits(conn, acct, int(sub_id), resolved)
enqueued.append({ enqueued.append({
"submission_id": sub_id, "submission_id": sub_id,
"row_index": row_index, "row_index": row_index,
@@ -1004,7 +1213,7 @@ def commit_import(
n_enqueued = len(enqueued) n_enqueued = len(enqueued)
# Log atestare (Voce#9): rows_hash + n_confirmed acopera DOAR randurile puse in coada # Log atestare: rows_hash + n_confirmed acopera DOAR randurile puse in coada
rows_hash = hashlib.sha256( rows_hash = hashlib.sha256(
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8") json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
).hexdigest() if rows_for_hash else "" ).hexdigest() if rows_for_hash else ""
@@ -1034,7 +1243,49 @@ def commit_import(
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# GET /v1/import/{id}/export-failed — CSV randuri esuate (T8) # # POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview #
# --------------------------------------------------------------------------- #
class RandEditIn(BaseModel):
"""Campuri de continut editabile in preview. None = ne-trimis (neschimbat);
"" = sterge override-ul (revine la valoarea din fisier).
obs = text liber fara validare de continut (US-005 PRD 5.15)."""
vin: str | None = None
nr_inmatriculare: str | None = None
data_prestatie: str | None = None
odometru_initial: str | None = None
odometru_final: str | None = None
obs: str | None = None
@router.post("/{import_id}/rand/{row_index}/editeaza")
def editeaza_rand(
import_id: int,
row_index: int,
req: RandEditIn,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Persista editarea unui rand de preview (mutatie pura).
NU recalculeaza statusul si NU atinge `submissions`; preview-ul rederiva statusul
prin `_resolve_row_for_preview` cu override aplicat ultimul.
"""
conn = get_connection()
try:
override = apply_row_override(
conn,
import_id=import_id,
account_id=account_id,
row_index=row_index,
fields=req.model_dump(),
)
return {"ok": True, "import_id": import_id, "row_index": row_index, "override": override}
finally:
conn.close()
# --------------------------------------------------------------------------- #
# GET /v1/import/{id}/export-failed — CSV randuri esuate #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
_EXPORT_FAILED_COLUMNS = [ _EXPORT_FAILED_COLUMNS = [

View File

@@ -0,0 +1,180 @@
"""Router integrare — endpoint-uri de integrare externe.
Endpointuri:
GET /v1/ping — readiness check per cont (autentificat sau dev fallback)
GET /v1/integrare/postman.json — export colectie Postman v2.1.0
Ruta /v1/ping foloseste `resolve_account_id` (dependinta standard) pentru 401
pe cheie invalida / prod fara cheie. Flag-ul `autentificat_cu_cheie` e derivat
separat citind header-ele brute si verificand cheia real-time (fara sa dubleze
logica de 401 — aceea ramane in `resolve_account_id`).
"""
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Header
from fastapi.responses import JSONResponse
from ...auth import _extract_key, account_for_key, resolve_account_id
from ...config import get_settings
from ...db import get_connection
from ...mapping import account_or_default
router = APIRouter(prefix="/v1", tags=["integrare"])
@router.get("/ping")
def ping(
account_id: int = Depends(resolve_account_id),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
authorization: str | None = Header(default=None),
) -> JSONResponse:
"""Readiness check per cont.
Intoarce:
account_id — contul rezolvat din cheie (sau 1 in dev fara cheie)
mediu — "test" / "prod" (settings.rar_env)
autentificat_cu_cheie — True daca cererea a venit cu o cheie API reala valida
are_creds_rar — True daca contul are rar_creds_enc stocat
ts — timestamp ISO UTC al cererii
"""
settings = get_settings()
# Detectam daca s-a folosit o cheie reala (nu fallback dev).
# `resolve_account_id` a garantat deja ca nu e cheie invalida (ar fi dat 401).
# Acum verificam doar daca exista o cheie extrasa si daca e valida pentru cont.
cheie_bruta = _extract_key(x_api_key, authorization)
autentificat_cu_cheie = False
if cheie_bruta:
conn = get_connection()
try:
acct = account_for_key(conn, cheie_bruta)
finally:
conn.close()
autentificat_cu_cheie = acct is not None
# Verificam daca contul are creds RAR stocate.
aid = account_or_default(account_id)
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (aid,)
).fetchone()
finally:
conn.close()
are_creds_rar = bool(row and row["rar_creds_enc"])
return JSONResponse({
"account_id": aid,
"mediu": settings.rar_env,
"autentificat_cu_cheie": autentificat_cu_cheie,
"are_creds_rar": are_creds_rar,
"ts": datetime.now(timezone.utc).isoformat(),
})
# Allowlist hardcodat (NU derivat din app.routes) — cele 3 rute de integrare expuse extern.
_POSTMAN_ITEMS = [
{
"name": "Trimite prezentari",
"request": {
"method": "POST",
"header": [
{"key": "X-API-Key", "value": "{{api_key}}"},
{"key": "Content-Type", "value": "application/json"},
],
"url": {
"raw": "{{base_url}}/v1/prezentari",
"host": ["{{base_url}}"],
"path": ["v1", "prezentari"],
},
"body": {
"mode": "raw",
"options": {"raw": {"language": "json"}},
# rar_credentials e optional: cererea trimite doar cheia API + datele
# prezentarii; worker-ul foloseste creds-urile RAR salvate pe cont.
"raw": (
'{\n'
' "prezentari": [\n'
' {\n'
' "vin": "WVWZZZ1KZAW000123",\n'
' "nr_inmatriculare": "B999TST",\n'
' "data_prestatie": "2026-06-15",\n'
' "odometru_final": "123456",\n'
' "prestatii": [\n'
' {"cod_prestatie": "OE-1"}\n'
' ]\n'
' }\n'
' ]\n'
'}'
),
},
},
},
{
"name": "Import fisier (xlsx/csv)",
"request": {
"method": "POST",
"header": [
{"key": "X-API-Key", "value": "{{api_key}}"},
],
"url": {
"raw": "{{base_url}}/v1/import",
"host": ["{{base_url}}"],
"path": ["v1", "import"],
},
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"description": "Fisier xlsx sau csv cu prezentarile de importat",
}
],
},
},
},
{
"name": "Ping (readiness check)",
"request": {
"method": "GET",
"header": [
{"key": "X-API-Key", "value": "{{api_key}}"},
],
"url": {
"raw": "{{base_url}}/v1/ping",
"host": ["{{base_url}}"],
"path": ["v1", "ping"],
},
},
},
]
@router.get("/integrare/postman.json")
def postman_export() -> JSONResponse:
"""Export colectie Postman v2.1.0 cu cele 3 rute de integrare.
Allowlist hardcodat — NU deriva din app.routes pentru a nu expune
rute interne (ex. /v1/conturi/rar-creds, rutele web etc.).
"""
colectie = {
"info": {
"name": "RAR AUTOPASS Gateway",
"description": (
"Colectie de integrare pentru gateway-ul RAR AUTOPASS (Legea 142/2023, OM 210/2024). "
"Seteaza variabilele `base_url` si `api_key` inainte de utilizare."
),
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
},
"variable": [
{"key": "base_url", "value": "http://localhost:8010", "type": "string"},
{"key": "api_key", "value": "", "type": "string"},
],
"item": _POSTMAN_ITEMS,
}
return JSONResponse(content=colectie, media_type="application/json")

View File

@@ -1,12 +1,10 @@
"""API v1 — suprafata gateway (schelet). """API v1 — suprafata gateway.
Endpointuri din plan.md sect. 4. In schelet: Endpointuri:
- POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE). - POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE).
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada. - GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
- GET /v1/nomenclator: cache local. - GET /v1/nomenclator: cache local.
- GET /v1/mapari: listare mapari cont. - GET /v1/mapari: listare mapari cont.
Validarea completa (T3), maparea op->cod, auth API-key, redactarea creds in
middleware (CORE) si exportul CSV vin ulterior — marcate TODO unde lipsesc.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,65 +13,208 @@ 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 ...idempotency import build_key, canonicalize_row, idempotency_key from ...errors import eroare as err_eroare
from ...idempotency import build_key, canonicalize_row
from ...mapping import ( from ...mapping import (
_emite_text_rule_hits,
account_or_default, account_or_default,
account_scope_clause, account_scope_clause,
has_no_auto_send, classify_prezentare,
load_mapping_meta, load_mapping_meta,
load_nomenclator_codes,
load_text_rules,
pending_unmapped, pending_unmapped,
reresolve_account, reresolve_account,
resolve_prestatii,
save_mapping, save_mapping,
) )
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult from ...models import (
PrezentareRequest,
PrezentariResponse,
SubmissionResult,
ValidarePrezentariRequest,
ValidareResponse,
ValidareResult,
)
from ...observ import log_event
from ...payload_view import prezentare_din_payload from ...payload_view import prezentare_din_payload
from ...validation import validate_prezentare from ...submissions_admin import (
SubmissionNotFound,
SubmissionStateConflict,
delete_submission,
requeue_submission,
)
router = APIRouter(prefix="/v1", tags=["v1"]) router = APIRouter(prefix="/v1", tags=["v1"])
def _effective_on_unmapped_error(conn, acct: int, req_value: bool | None) -> bool:
"""Modul efectiv la cod necunoscut/nemapat (True => respinge cererea, False => needs_mapping).
Precedenta: override per-cerere > default cont (on_unmapped_error_default) > False.
"""
if req_value is not None:
return req_value
row = conn.execute("SELECT on_unmapped_error_default FROM accounts WHERE id=?", (acct,)).fetchone()
return bool(row["on_unmapped_error_default"]) if row else False
def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules=None) -> dict:
"""classify_prezentare + aplicarea modului on_unmapped_error.
Cand exista coduri nemapate si error_mode=True, marcheaza outcome-ul ca respingere
(blocked_error=True): rutele NU mai fac enqueue, ci intorc o eroare per-element.
"""
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, text_rules)
cl["blocked_error"] = bool(cl["unmapped"]) and error_mode
return cl
def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
"""Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT)."""
return [
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")}
for u in unmapped
]
def _motiv_clasificare(cl: dict) -> str | None:
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
None cand status='queued'. Acopera ramurile de blocaj: erori de continut
(needs_data) si coduri fara mapare RAR (needs_mapping).
Dupa US-001: needs_mapping apare EXCLUSIV cand unmapped e non-gol
(ramura auto_send_oprit era inaccesibila si a fost eliminata).
"""
if cl["status"] == "queued":
return None
if cl["errors"]:
return "; ".join(
(e.get("problema") or e.get("message") or "") for e in cl["errors"]
).strip("; ") or "Date incomplete (respinse de RAR)."
if cl["unmapped"]:
coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"])
return f"Coduri fara mapare RAR: {coduri}"
return None
def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult:
"""SubmissionResult onest dintr-un rezultat de clasificare.
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None.
"""
return SubmissionResult(
submission_id=submission_id,
status=cl["status"],
erori=list(cl["errors"]),
nemapate=_erori_nemapate(cl["unmapped"]),
motiv=_motiv_clasificare(cl),
**extra,
)
def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult:
"""Rezultat pentru on_unmapped_error=True: status='error', fara enqueue/reactivare.
`erori` pastreaza COD_NEMAPAT (compat clienti vechi); `nemapate` + `motiv` adaugate.
"""
nem = _erori_nemapate(cl["unmapped"])
return SubmissionResult(
submission_id=submission_id, status="error",
erori=nem, nemapate=nem, motiv=_motiv_clasificare(cl),
)
@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.
Validarea de continut (T3, app.validation) ruleaza inainte de enqueue: Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU
esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv resping cererea, ci enqueue-aza cu status `needs_data` + motiv. JSON malformat ->
(plan.md sect. 3). JSON malformat -> 422 din Pydantic (validare de shape). 422 din Pydantic (validare de shape).
account_id vine din cheia API (resolve_account_id): cont real cu cheie, account_id vine din cheia API (resolve_account_id): cont real cu cheie,
implicit id=1 in dev fara cheie, 401 fara cheie valida in prod. implicit id=1 in dev fara cheie, 401 fara cheie valida in prod.
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea. cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`).
""" """
acct = account_or_default(account_id) acct = account_or_default(account_id)
# Creds RAR efemere: criptate si lipite de fiecare submission nou pana la # Creds RAR efemere: criptate si lipite de fiecare submission nou pana la
# primul login reusit pentru cont (worker le sterge atunci). Zero-storage at # primul login reusit pentru cont (worker le sterge atunci). Zero-storage at
# rest — niciodata in clar in DB/loguri (plan sect. 5). # rest — niciodata in clar in DB/loguri. Optional: cand lipsesc,
creds_enc = encrypt_creds(req.rar_credentials.model_dump()) # creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului.
creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None
conn = get_connection() conn = get_connection()
results: list[SubmissionResult] = [] results: list[SubmissionResult] = []
try: try:
# T6/OV-1: load_mapping_meta include auto_send per op (gate pentru coduri noi). # load_mapping_meta incarca maparea op->cod RAR; dupa US-001, auto_send nu mai tine randuri.
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
valid_codes = load_nomenclator_codes(conn) or None
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
text_rules = load_text_rules(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
# T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
from ...config import get_settings as _get_settings
from ...plans import PLANS, effective_tier, monthly_usage
_settings = _get_settings()
if _settings.enforce_plans:
_acct_row = conn.execute(
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
).fetchone()
_now = datetime.now(timezone.utc)
_et = effective_tier(_acct_row, _now)
_plan_limit = PLANS[_et].get("monthly_limit")
if _plan_limit is not None:
_usage = monthly_usage(conn, acct, _now)
_nr_cerut = len(req.prezentari)
if _usage + _nr_cerut > _plan_limit:
_remaining = max(0, _plan_limit - _usage)
log_event(
"plan_limita_lunara_atinsa",
account_id=acct,
nivel="WARNING",
mesaj=f"Lot de {_nr_cerut} respins (usage={_usage}, limita={_plan_limit})",
context={
"nr_cerut": _nr_cerut, "usage": _usage,
"plan_limit": _plan_limit, "tier": _et,
},
conn=conn,
)
raise HTTPException(
status_code=422,
detail=err_eroare(
"PLAN_LIMITA_LUNARA",
cauza=(
f"Ai trimis {_usage}/{_plan_limit} prezentari luna aceasta;"
f" mai poti trimite {_remaining}."
),
),
)
for prez in req.prezentari: for prez in req.prezentari:
content = prez.model_dump() content = prez.model_dump()
# T9/OV-2: canonicalize_row inaintea build_key (odometru strip ".0", VIN upper). # canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
# build_key aplica account_or_default(account_id) inainte de hash: # build_key aplica account_or_default(account_id) inainte de hash:
# None si 1 colapseaza la aceeasi cheie (canal API + canal import). # None si 1 colapseaza la aceeasi cheie (canal API + canal import).
canon = canonicalize_row(content) canon = canonicalize_row(content)
key = build_key(account_id, canon) key = build_key(account_id, canon)
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare, §3.4bis) # Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
content.update({ content.update({
"vin": canon["vin"], "vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"], "nr_inmatriculare": canon["nr_inmatriculare"],
@@ -84,6 +225,42 @@ def create_prezentari(
(key,), (key,),
).fetchone() ).fetchone()
if existing: if existing:
# Un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
if existing["status"] == "error":
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
if cl["blocked_error"]:
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
results.append(_rezultat_respins(existing["id"], cl))
continue
cur = conn.execute(
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, "
"updated_at=datetime('now') WHERE id=? AND status='error'",
(cl["status"], json.dumps(cl["content"], ensure_ascii=False),
cl["rar_error"], creds_enc, existing["id"]),
)
if cur.rowcount == 1:
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc)
# — ambele canale converg pe parola corectata.
if req.rar_credentials is not None:
conn.execute(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(encrypt_creds(req.rar_credentials.model_dump()), acct),
)
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
# needs_data/needs_mapping, expune motivul (nu doar status).
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True))
continue
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
existing = conn.execute(
"SELECT id, status, id_prezentare FROM submissions WHERE id=?",
(existing["id"],),
).fetchone()
results.append( results.append(
SubmissionResult( SubmissionResult(
submission_id=existing["id"], submission_id=existing["id"],
@@ -94,43 +271,90 @@ def create_prezentari(
) )
continue continue
# Mapare op->cod RAR (hibrid): codul RAR direct trece neatins; codul # Helper pur partajat cu dry-run: reproduce EXACT clasificarea
# intern ROAAUTO se traduce. Op nemapata -> needs_mapping (nu se trimite), # (canonicalize + mapare op->cod + validare; auto_send gate eliminat dupa US-001).
# apare in editorul web. Codul rezolvat se scrie inapoi in payload, deci cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
# validarea T3 + payload builder + worker raman code-driven. if cl["blocked_error"]:
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping) # on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
content["prestatii"] = resolved results.append(_rezultat_respins(None, cl))
continue
if unmapped:
status = "needs_mapping"
rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False)
else:
# T3: validare de continut -> queued daca e curat, altfel needs_data + motiv.
errors = validate_prezentare(content)
if errors:
status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False)
elif has_no_auto_send(resolved, mapping_meta):
# T6/OV-1: cod rezolvat cu auto_send=0 -> nu trimite automat.
# Randul ramane 'needs_mapping' pana userul confirma manual (sau comuta auto_send=1).
status = "needs_mapping"
rar_error = json.dumps(
{"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"},
ensure_ascii=False,
)
else:
status, rar_error = "queued", None
cur = conn.execute( cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) " "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
"VALUES (?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?)",
(key, acct, status, json.dumps(content, ensure_ascii=False), rar_error, creds_enc), (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
) )
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status)) sub_id = int(cur.lastrowid)
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
results.append(_rezultat_enqueue(sub_id, cl))
# Audit cerere API per cont. Doar metadate (count + distributie status),
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
dist: dict[str, int] = {}
for r in results:
if r.reactivated:
cheie = "reactivated"
elif r.deduped:
cheie = "deduped"
else:
cheie = r.status
dist[cheie] = dist.get(cheie, 0) + 1
log_event(
"api_prezentari",
account_id=acct,
mesaj=f"{len(results)} prezentari procesate",
context={"count": len(results), "distributie": dist},
conn=conn,
)
finally: finally:
conn.close() conn.close()
return PrezentariResponse(results=results) return PrezentariResponse(results=results)
@router.post("/prezentari/valideaza", response_model=ValidareResponse)
def valideaza_prezentari(
req: ValidarePrezentariRequest,
account_id: int = Depends(resolve_account_id),
) -> ValidareResponse:
"""Dry-run: valideaza payload exact ca POST /prezentari, fara enqueue si fara efecte secundare.
Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de
continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi
payload + aceeasi mapare de cont. rar_credentials ignorat complet.
"""
acct = account_or_default(account_id)
conn = get_connection()
results: list[ValidareResult] = []
try:
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
text_rules = load_text_rules(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
for i, prez in enumerate(req.prezentari):
content = prez.model_dump()
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
if res["blocked_error"]:
res = {**res, "status": "error"}
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
nemapate = [
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")}
for u in res["unmapped"]
]
results.append(ValidareResult(
index=i,
valid=(res["status"] == "queued"),
status_estimat=res["status"],
erori=res["errors"],
nemapate=nemapate,
prestatii_rezolvate=res["resolved"],
))
finally:
conn.close()
return ValidareResponse(results=results)
@router.get("/prezentari") @router.get("/prezentari")
def list_prezentari( def list_prezentari(
status: str | None = None, status: str | None = None,
@@ -141,7 +365,7 @@ def list_prezentari(
try: try:
scope_sql, scope_params = account_scope_clause(account_id) scope_sql, scope_params = account_scope_clause(account_id)
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca # payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
# sa derivam campurile afisabile prin helper-ul partajat (US-003, DRY), nu il expunem. # sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
cols = ( cols = (
"id, status, id_prezentare, rar_status_code, retry_count, " "id, status, id_prezentare, rar_status_code, retry_count, "
"created_at, updated_at, payload_json" "created_at, updated_at, payload_json"
@@ -169,12 +393,16 @@ def list_prezentari(
conn.close() conn.close()
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4). # Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita.
# Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since. # Exclude: rar_creds_enc, payload_json, idempotency_key, sending_since.
_PREZENTARE_FIELDS = frozenset({ _PREZENTARE_FIELDS = frozenset({
"id", "status", "id_prezentare", "rar_status_code", "retry_count", "id", "status", "id_prezentare", "rar_status_code", "retry_count",
"next_attempt_at", "created_at", "updated_at", "account_id", "next_attempt_at", "created_at", "updated_at", "account_id",
"batch_id", "row_index", "purge_after", "batch_id", "row_index", "purge_after",
# rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
"rar_error",
}) })
@@ -191,7 +419,7 @@ def get_prezentare(
[submission_id] + scope_params, [submission_id] + scope_params,
).fetchone() ).fetchone()
if not row: if not row:
# B3: acelasi mesaj indiferent daca randul exista dar apartine altui cont # Acelasi mesaj indiferent daca randul exista dar apartine altui cont
# sau nu exista deloc — nu confirmam existenta. # sau nu exista deloc — nu confirmam existenta.
raise HTTPException(status_code=404, detail="submission inexistent") raise HTTPException(status_code=404, detail="submission inexistent")
row_dict = dict(row) row_dict = dict(row)
@@ -200,6 +428,59 @@ def get_prezentare(
conn.close() conn.close()
@router.delete("/prezentari/{submission_id}")
def delete_prezentare(
submission_id: int,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Sterge o trimitere blocata a contului cheii API.
Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat
INAINTEA starii: cross-account / inexistent -> 404 (acelasi mesaj);
own-account `sent`/`sending` -> 409 (conflict de stare).
"""
conn = get_connection()
try:
try:
res = delete_submission(conn, account_id, submission_id)
except SubmissionNotFound:
raise HTTPException(status_code=404, detail="submission inexistent")
except SubmissionStateConflict as exc:
raise HTTPException(
status_code=409,
detail=f"trimiterea nu se poate sterge in starea '{exc.status}'",
)
return {"ok": True, **res}
finally:
conn.close()
@router.post("/prezentari/{submission_id}/repune")
def repune_prezentare(
submission_id: int,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Re-pune in coada o trimitere blocata a contului cheii API.
`error -> queued`, re-ruleaza classify. Acelasi oracol de scope/stare ca DELETE
(404 cross-account/inexistent, 409 sent/sending).
"""
conn = get_connection()
try:
try:
res = requeue_submission(conn, account_id, submission_id)
except SubmissionNotFound:
raise HTTPException(status_code=404, detail="submission inexistent")
except SubmissionStateConflict as exc:
raise HTTPException(
status_code=409,
detail=f"trimiterea nu se poate re-pune in starea '{exc.status}'",
)
return {"ok": True, **res}
finally:
conn.close()
@router.get("/nomenclator") @router.get("/nomenclator")
def get_nomenclator() -> dict: def get_nomenclator() -> dict:
conn = get_connection() conn = get_connection()
@@ -233,8 +514,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
"""Randuri audit filtrate pe cont + data(updated_at) in [from, to]. """Randuri audit filtrate pe cont + data(updated_at) in [from, to].
account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu
account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in account_id IS NULL apartin contului 1. b64_image NU intra in CSV.
schelet; b64_image NU intra in CSV.
""" """
scope_sql, scope_params = account_scope_clause(account_id) scope_sql, scope_params = account_scope_clause(account_id)
sql = ( sql = (
@@ -269,7 +549,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
"submission_id": r["id"], "submission_id": r["id"],
"status": r["status"], "status": r["status"],
"id_prezentare": r["id_prezentare"] or "", "id_prezentare": r["id_prezentare"] or "",
# NULL→cont 1 (OV-2): coloana reflecta invariantul de scope, nu "" ambiguu. # NULL→cont 1: coloana reflecta invariantul de scope, nu "" ambiguu.
"account_id": account_or_default(r["account_id"]), "account_id": account_or_default(r["account_id"]),
"vin": p.get("vin") or "", "vin": p.get("vin") or "",
"nr_inmatriculare": p.get("nr_inmatriculare") or "", "nr_inmatriculare": p.get("nr_inmatriculare") or "",
@@ -294,7 +574,7 @@ def audit_export(
pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR); pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR);
`status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana `status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana
`purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta. `purge_after`. b64_image nu se exporta.
""" """
conn = get_connection() conn = get_connection()
try: try:
@@ -323,7 +603,7 @@ def get_mapari(
"""Maparile operatie->cod ale contului curent. """Maparile operatie->cod ale contului curent.
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul
efectiv vine MEREU din cheia API (TD-3.2). Daca e prezent si difera -> 400. efectiv vine MEREU din cheia API. Daca e prezent si difera -> 400.
""" """
if account_id is not None and account_id != key_account: if account_id is not None and account_id != key_account:
raise HTTPException( raise HTTPException(
@@ -390,7 +670,7 @@ def create_mapare(
class RarCredsIn(BaseModel): class RarCredsIn(BaseModel):
"""Creds RAR durabile per-cont (D4). Stocate criptate (Fernet) in accounts.rar_creds_enc.""" """Creds RAR durabile per-cont. Stocate criptate (Fernet) in accounts.rar_creds_enc."""
email: str = Field(..., min_length=1) email: str = Field(..., min_length=1)
password: str = Field(..., min_length=1, repr=False) password: str = Field(..., min_length=1, repr=False)
@@ -401,7 +681,7 @@ def set_rar_creds(
req: RarCredsIn, req: RarCredsIn,
account_id: int = Depends(resolve_account_id), account_id: int = Depends(resolve_account_id),
) -> dict: ) -> dict:
"""Seteaza creds RAR durabile per-cont (D4/T1). """Seteaza creds RAR durabile per-cont.
Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback
cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker). cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker).

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 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
@@ -111,7 +112,28 @@ def _extract_key(x_api_key: str | None, authorization: str | None) -> str | None
return None return None
def _log_auth_esuat(request: Request | None, plaintext: str | None, motiv: str) -> None:
"""Eveniment de jurnal pentru un esec de auth: IP + prefix cheie, NU cheia.
Best-effort (log_event inghite erorile). Import local: evita cuplarea la import-time
(observ -> db; auth -> db) si pastreaza auth.py importabil din CLI fara efecte.
"""
from .observ import log_event
ip = None
if request is not None and request.client is not None:
ip = request.client.host
prefix = (plaintext[:8] + "") if plaintext else None
log_event(
"api_auth_esuat",
nivel="WARNING",
cod="RAR_CREDS_INVALIDE" if plaintext else None,
mesaj=motiv,
context={"ip": ip, "key_prefix": prefix},
)
def resolve_account_id( def resolve_account_id(
request: Request,
x_api_key: str | None = Header(default=None, alias="X-API-Key"), x_api_key: str | None = Header(default=None, alias="X-API-Key"),
authorization: str | None = Header(default=None), authorization: str | None = Header(default=None),
) -> int: ) -> int:
@@ -121,12 +143,14 @@ def resolve_account_id(
- cheie invalida (prezenta) -> 401 (mereu, indiferent de flag) - cheie invalida (prezenta) -> 401 (mereu, indiferent de flag)
- fara cheie + flag off -> cont implicit (id=1), back-compat - fara cheie + flag off -> cont implicit (id=1), back-compat
- fara cheie + flag on -> 401 - fara cheie + flag on -> 401
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie.
""" """
settings = get_settings() settings = get_settings()
plaintext = _extract_key(x_api_key, authorization) plaintext = _extract_key(x_api_key, authorization)
if plaintext is None: if plaintext is None:
if settings.require_api_key: if settings.require_api_key:
_log_auth_esuat(request, None, "cheie API lipsa (prod)")
raise HTTPException(status_code=401, detail="cheie API lipsa") raise HTTPException(status_code=401, detail="cheie API lipsa")
return DEFAULT_ACCOUNT_ID return DEFAULT_ACCOUNT_ID
@@ -136,5 +160,62 @@ def resolve_account_id(
finally: finally:
conn.close() conn.close()
if account_id is None: if account_id is None:
_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

@@ -1,8 +1,8 @@
"""Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite. """Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite.
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO.
(vezi plan.md sect. 5). Helper-ul `load_test_credentials` citeste blocul Helper-ul `load_test_credentials` citeste blocul <test> din settings.xml DOAR
<test> din settings.xml DOAR pentru dev local / probe pe mediul de test. pentru dev local / probe pe mediul de test.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,7 +22,21 @@ class Settings(BaseSettings):
# --- Bază de date --- # --- Bază de date ---
db_path: Path = ROOT / "data" / "autopass.db" db_path: Path = ROOT / "data" / "autopass.db"
# --- Securitate (CORE) --- # --- Observabilitate / jurnal aplicatie ---
# Nivel minim al evenimentelor scrise in app_events + log text. Sub el, evenimentul
# e ignorat (best-effort). DEBUG|INFO|WARNING|ERROR|CRITICAL.
log_level: str = "INFO"
log_retention_days: int = 90
# Director pentru log-ul text rotativ (RotatingFileHandler in aplicatie).
# Fisier per-proces (app-api.log / app-worker.log) — rotatia nu e multiproces-safe.
log_dir: Path = ROOT / ".run"
log_file_max_bytes: int = 5_000_000
log_file_backup_count: int = 5
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
# ale `sent` — un blocat n-are valoare de audit.
blocked_retention_days: int = 30
# --- Securitate ---
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie -> # Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->
# cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA # cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA
# dar invalida da 401 indiferent de flag. # dar invalida da 401 indiferent de flag.
@@ -34,29 +48,33 @@ class Settings(BaseSettings):
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
creds_key: str | None = None creds_key: str | None = None
# --- Sesiuni web (US-002, PRD 3.3) --- # --- Sesiuni web ---
# Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok; # Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza # in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))" # la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
session_secret: str | None = None session_secret: str | None = None
# True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login; # True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login;
# CSRF enforce. Pentru dev rapid pe contul implicit id=1 (back-compat C12/§5 Q5), # CSRF enforce. Pentru dev rapid pe contul implicit id=1,
# seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false. # seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false.
web_auth_required: bool = True web_auth_required: bool = True
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag (C4). # True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag.
# False (dev): cookie fara Secure, functioneaza pe HTTP. # False (dev): cookie fara Secure, functioneaza pe HTTP.
session_https_only: bool = False session_https_only: bool = False
# --- Notificare email admin la signup (US-012, PRD 3.3b) --- # --- Contact suport (US-001, PRD 5.12) ---
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP); # Email/canal de contact afisat in mesaje catre utilizatori (ex. CUI duplicat la signup).
# follow-up cand exista SMTP real configurat in .env. # Nesetat -> fallback la formularea generica fara canal concret.
support_email: str | None = None
# --- Notificare email admin la signup ---
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP).
smtp_host: str | None = None smtp_host: str | None = None
smtp_port: int = 587 smtp_port: int = 587
smtp_user: str | None = None smtp_user: str | None = None
smtp_password: str | None = None smtp_password: str | None = None
smtp_from: str | None = None smtp_from: str | None = None
# --- Rate-limit signup + login (US-009, PRD 3.3 C5) --- # --- Rate-limit signup + login ---
# Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua). # Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua).
signup_rate_max: int = 5 signup_rate_max: int = 5
signup_rate_window_s: int = 3600 signup_rate_window_s: int = 3600
@@ -68,25 +86,52 @@ class Settings(BaseSettings):
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass" rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"
rar_base_url_prod: str = "https://apps.rarom.ro/rar-autopass" rar_base_url_prod: str = "https://apps.rarom.ro/rar-autopass"
# WAF-ul RAR da 403 fara User-Agent de browser (confirmat live, vezi # WAF-ul RAR da 403 fara User-Agent de browser. Toate apelurile httpx il trimit.
# docs/api-rar-contract.md). Toate apelurile httpx il trimit.
http_user_agent: str = "Mozilla/5.0" http_user_agent: str = "Mozilla/5.0"
http_timeout_s: float = 30.0 http_timeout_s: float = 30.0
# --- Worker --- # --- Worker ---
worker_poll_interval_s: float = 5.0 worker_poll_interval_s: float = 5.0
worker_heartbeat_stale_s: int = 30 # /healthz considera worker-ul mort peste atat worker_heartbeat_stale_s: int = 30 # /healthz considera worker-ul mort peste atat
# In schelet send-ul e DEZACTIVAT (nu trimite la RAR). Activeaza-l explicit # Send DEZACTIVAT implicit (nu trimite la RAR). Activeaza-l explicit pentru
# pentru proba end-to-end. Reconcilierea/retry-ul complet = T2. # proba end-to-end.
worker_send_enabled: bool = False worker_send_enabled: bool = False
# 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 (T2) — lasa False. # creds vin per-cerere de la ROAAUTO — lasa False.
worker_use_test_creds: bool = False worker_use_test_creds: bool = False
# T2 — recuperare orfane + retry/backoff: # 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 (pana persistenta) worker_max_retries: int = 8 # peste atat -> error + banner
# --- Planuri de cont (PRD 5.17) ---
# Enforcement DUR al limitelor de plan (volum + acces API). True (implicit) = activ.
# False = kill-switch de operare: sare toate gate-urile de plan (util pentru debugging
# sau rollback rapid fara revert de cod). Enforcement DUR e activ implicit de la deploy
# (decizie user 2026-06-28, decizia #22 autoplan): nu exista conturi legacy, produs in TESTE.
enforce_plans: bool = True
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
# ACTIVAT implicit: editorul de mapari ofera sugestii semantice (model fastembed/ONNX).
# Cost: prima folosire lazy-load-eaza modelul (~230MB pe disc) sincron in thread-ul de
# cerere -> prima cerere /mapari poate dura 30-120s pana modelul intra in memorie; cererile
# urmatoare sunt instant. SUGGESTION-ONLY: nu intra in resolve_prestatii (nu auto-trimite).
# Pune-l pe False (start.sh/Docker/.env: AUTOPASS_EMBEDDINGS_ENABLED=false) cand vrei
# /mapari instant la prima cerere sau suita de teste rapida (cade pe GOLD/SILVER+fuzzy).
embeddings_enabled: bool = True
# --- Seed corpus operatii etichetate (SILVER, PRD 5.18 US-004) ---
# ACTIVAT implicit: la init_db, populeaza mapping_suggestions din artefactul comis
# `app/data/operatii-etichetate.json` (INSERT OR IGNORE). Asa SILVER nu mai e gol in
# productie -> sugestii exact-match + corpus k-NN reale. SUGGESTION-ONLY.
# Pune-l pe False (AUTOPASS_SEED_OPERATII_ENABLED=false) cand vrei SILVER gol —
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
seed_operatii_enabled: bool = True
@property @property
def rar_base_url(self) -> str: def rar_base_url(self) -> str:

View File

@@ -39,6 +39,28 @@ def reset_cache() -> None:
_fernet.cache_clear() _fernet.cache_clear()
def validate_creds_key() -> None:
"""Fail-fast la startup: o cheie `creds_key` setata DAR invalida trebuie sa
opreasca pornirea, nu sa explodeze abia la primul POST /v1/prezentari (500
brut, fara mesaj util pentru client — cazul real reprodus din ROAAUTO/VFP).
Cheie nesetata = OK (modelul efemer, vezi _fernet). Cheie setata si invalida
(lungime/padding gresit) -> RuntimeError cu instructiunea de generare.
"""
key = get_settings().creds_key
if not key:
return
try:
Fernet(key.encode() if isinstance(key, str) else key)
except (ValueError, TypeError) as exc:
raise RuntimeError(
"AUTOPASS_CREDS_KEY este setata dar invalida (Fernet cere 32 bytes "
"url-safe base64, 44 caractere terminate in '='). Genereaza una cu:\n"
" python3 -c \"from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())\""
) from exc
def encrypt_creds(creds: dict) -> str: def encrypt_creds(creds: dict) -> str:
"""Cripteaza un dict de creds -> token Fernet (str). Compact, fara spatii.""" """Cripteaza un dict de creds -> token Fernet (str). Compact, fara spatii."""
blob = json.dumps(creds, separators=(",", ":"), ensure_ascii=False).encode("utf-8") blob = json.dumps(creds, separators=(",", ":"), ensure_ascii=False).encode("utf-8")

File diff suppressed because it is too large Load Diff

247
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,14 +71,67 @@ 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). Lifecycle consumat de 3.3. # 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")
acc_cols.add("active")
if "status" not in acc_cols:
# Stare de ciclu de viata. Default 'active' (trece CHECK pe randurile existente),
# apoi derivam din `active`: active=0 -> 'pending'.
# Invariant: active=1 <=> status='active'.
conn.execute(
"ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' "
"CHECK (status IN ('pending','active','blocked','archived','deleted'))"
)
conn.execute(
"UPDATE accounts SET status='pending' WHERE active=0 AND status='active'"
)
if "on_unmapped_error_default" not in acc_cols:
# Comportament la cod necunoscut/nemapat pe canalul API (default non-distructiv = 0).
conn.execute(
"ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 "
"CHECK (on_unmapped_error_default IN (0, 1))"
)
if "email" not in acc_cols:
# Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy.
conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT")
if "tier" not in acc_cols:
# Plan de cont (US-001, PRD 5.17). Legacy -> 'free' fara trial (enforcement DUR la deploy).
conn.execute(
"ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT 'free' "
"CHECK (tier IN ('free','standard','pro','premium'))"
)
if "trial_until" not in acc_cols:
# Trial Pro activ daca != NULL si > now. Nullable (NULL = fara trial).
conn.execute("ALTER TABLE accounts ADD COLUMN trial_until TEXT")
if "requested_plan" not in acc_cols:
# Planul cerut la signup (integrare plati). NU acorda drepturi; `tier` ramane sursa
# de adevar pt API/volum. Nullable. ALTER nu poate adauga CHECK pe coloana noua in
# SQLite -> validarea valorilor se face in cod (signup, fata de VALID_TIERS).
conn.execute("ALTER TABLE accounts ADD COLUMN requested_plan TEXT")
if "consent_at" not in acc_cols:
# Marca temporala consimtamant Termeni+GDPR (proba). Nullable (NULL = CLI/legacy).
conn.execute("ALTER TABLE accounts ADD COLUMN consent_at TEXT")
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu. # 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"
@@ -79,6 +148,21 @@ def _migrate(conn: sqlite3.Connection) -> None:
if "email_verified" not in user_cols: if "email_verified" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0") conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0")
# Coloana import_rows.override_json: patch canonic editat in preview, criptat Fernet.
irows_tbl = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='import_rows'"
).fetchone()
if irows_tbl:
irows_cols = {r["name"] for r in conn.execute("PRAGMA table_info(import_rows)").fetchall()}
if "override_json" not in irows_cols:
conn.execute("ALTER TABLE import_rows ADD COLUMN override_json TEXT")
if "reviewed" not in irows_cols:
# Marcaj confirmare umana (US-007, PRD 5.12). NU intra in payload/idempotenta.
# NOT NULL DEFAULT 0: valoare clara (0=neconfirmat), fara ambiguitate NULL vs 0.
conn.execute(
"ALTER TABLE import_rows ADD COLUMN reviewed INTEGER NOT NULL DEFAULT 0"
)
# Index batch_id pe submissions (poate lipsi pe DB veche) # Index batch_id pe submissions (poate lipsi pe DB veche)
existing_idx = {r["name"] for r in conn.execute( existing_idx = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'" "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'"
@@ -95,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")
@@ -120,3 +299,69 @@ def read_heartbeat(conn: sqlite3.Connection) -> sqlite3.Row | None:
def queue_depth(conn: sqlite3.Connection) -> int: def queue_depth(conn: sqlite3.Connection) -> int:
row = conn.execute("SELECT COUNT(*) AS n FROM submissions WHERE status='queued'").fetchone() row = conn.execute("SELECT COUNT(*) AS n FROM submissions WHERE status='queued'").fetchone()
return int(row["n"]) if row else 0 return int(row["n"]) if row else 0
# --- Jurnal de aplicatie (app_events) ---
def insert_app_event(
conn: sqlite3.Connection,
*,
request_id: str | None,
account_id: int | None,
sursa: str,
tip: str,
nivel: str,
cod: str | None,
mesaj: str | None,
context_json: str | None,
purge_after: str | None,
) -> None:
"""Insert minimal intr-un rand app_events. Apelat DOAR prin observ.log_event
(care a redactat deja toate valorile). Nu redacteaza aici — separarea de
responsabilitati: db.py persista, observ.py/security.py curata."""
conn.execute(
"INSERT INTO app_events (request_id, account_id, sursa, tip, nivel, cod, mesaj, "
"context_json, purge_after) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(request_id, account_id, sursa, tip, nivel, cod, mesaj, context_json, purge_after),
)
def read_app_events(
conn: sqlite3.Connection,
*,
account_id: int | None = None,
tip: str | None = None,
nivel: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
limit: int = 100,
offset: int = 0,
) -> list[sqlite3.Row]:
"""Citire paginata din app_events, ordine descrescatoare dupa id (cele mai noi intai).
account_id=None -> toate conturile (admin). account_id=int -> scoped pe cont
(NULL apartine contului 1, ca restul UI-ului). Filtrele tip/nivel/data sunt optionale.
"""
where: list[str] = []
params: list = []
if account_id is not None:
where.append("(account_id = ? OR (account_id IS NULL AND ? = 1))")
params.extend([account_id, account_id])
if tip:
where.append("tip = ?")
params.append(tip)
if nivel:
where.append("nivel = ?")
params.append(nivel)
if date_from:
where.append("date(ts) >= date(?)")
params.append(date_from)
if date_to:
where.append("date(ts) <= date(?)")
params.append(date_to)
sql = "SELECT id, ts, request_id, account_id, sursa, tip, nivel, cod, mesaj, context_json FROM app_events"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY id DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
return conn.execute(sql, params).fetchall()

View File

@@ -1,4 +1,4 @@
"""Helper notificare email admin la signup (US-012, PRD 3.3b). """Helper notificare email admin la signup.
Livrare DEGRADATA: daca smtp_host nu e configurat, functia e no-op (log doar). Livrare DEGRADATA: daca smtp_host nu e configurat, functia e no-op (log doar).
Orice eroare SMTP e prinsa si logata — signup-ul NU e blocat niciodata. Orice eroare SMTP e prinsa si logata — signup-ul NU e blocat niciodata.

249
app/embeddings.py Normal file
View File

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

235
app/errors.py Normal file
View File

@@ -0,0 +1,235 @@
"""Catalog central de erori AutoPass.
Singura sursa de adevar care mapeaza fiecare cod de eroare la (problema, fix),
cu un helper care construieste obiectul de eroare pe 3 niveluri:
- nivel 1 (tehnic): `cod` + `cauza` — ce s-a intamplat exact
- nivel 2 (utilizator): `problema` — descriere scurta, inteligibila
- nivel 3 (actiune): `fix` — ce trebuie facut pentru a remedia
Modul PUR — fara import DB sau HTTP.
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# CATALOG
# cheie = cod (string), valoare = {"problema": str, "fix": str}
# ---------------------------------------------------------------------------
CATALOG: dict[str, dict[str, str]] = {
"VIN_FORMAT": {
"problema": "VIN invalid",
"fix": (
"Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere"
" majuscule, fara spatii si fara literele O, I, Q."
),
},
"NR_INMATRICULARE_FORMAT": {
"problema": "Numar de inmatriculare invalid",
"fix": (
"Foloseste doar litere si cifre majuscule, maxim 10 caractere, fara spatii"
" sau cratima (ex. B123ABC)."
),
},
"DATA_FORMAT": {
"problema": "Data prestatiei in format gresit",
"fix": "Scrie data ca AAAA-LL-ZZ (ex. 2026-06-22).",
},
"DATA_PREA_VECHE": {
"problema": "Data prestatiei prea veche",
"fix": (
"RAR accepta prestatii doar incepand cu 01.12.2024;"
" verifica data prestatiei."
),
},
"DATA_VIITOR": {
"problema": "Data prestatiei in viitor",
"fix": "Data prestatiei nu poate fi dupa ziua de azi; corecteaza data.",
},
"ODOMETRU_FINAL_FORMAT": {
"problema": "Odometru final invalid",
"fix": (
"Scrie kilometrajul final ca numar intreg, fara zecimale sau text"
" (ex. 145000)."
),
},
"ODOMETRU_INITIAL_LIPSA": {
"problema": "Lipseste odometrul initial",
"fix": (
"Prestatiile R-ODO / I-ODO cer kilometrajul initial; completeaza-l."
),
},
"ODOMETRU_INITIAL_FORMAT": {
"problema": "Odometru initial invalid",
"fix": (
"Scrie kilometrajul initial ca numar intreg, fara zecimale sau text."
),
},
"ODOMETRU_INITIAL_ORDINE": {
"problema": "Odometru initial mai mare decat finalul",
"fix": (
"Kilometrajul initial trebuie sa fie mai mic sau egal cu cel final;"
" verifica cele doua valori."
),
},
"PRESTATII_GOALE": {
"problema": "Nicio prestatie",
"fix": "Adauga cel putin o prestatie cu cod RAR valid.",
},
"B64_INVALID": {
"problema": "Imaginea nu este base64 valid",
"fix": (
"Trimite imaginea codata base64 corect, sau omite campul daca nu ai imagine."
),
},
"COD_NEMAPAT": {
"problema": "Lipseste codul RAR al operatiei",
"fix": (
"Alege codul RAR pentru aceasta operatie in tab-ul Mapari"
" (ai sugestii automate)."
),
},
"AUTO_SEND_OPRIT": {
"problema": "Necesita confirmare manuala",
"fix": (
"Codul e mapat cu trimitere automata oprita; verifica randul si"
" pune-l manual in coada."
),
},
"RAR_VALIDARE": {
"problema": "RAR a respins prezentarea",
"fix": (
"Corecteaza campul semnalat de RAR (vezi cauza) si reincearca;"
" detaliile exacte sunt in mesajul tehnic RAR."
),
},
"RAR_EROARE_SERVER": {
"problema": "RAR a esuat la inregistrarea prezentarii",
"fix": (
"RAR a raspuns cu o eroare de server (vezi cauza). Trimiterea NU se"
" reincearca automat si NU a fost confirmata — verifica datele (in special"
" codul prestatiei) si re-trimite dupa corectare."
),
},
"RAR_CREDS_INVALIDE": {
"problema": "Credentiale RAR invalide",
"fix": (
"Verifica email-ul si parola contului RAR in tab-ul Cont;"
" trimiterea nu se reincearca automat la credentiale gresite."
),
},
"IMPORT_FISIER_PREA_MARE": {
"problema": "Fisier prea mare",
"fix": (
"Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand."
),
},
"IMPORT_ANTET_NECLAR": {
"problema": "Antet de coloane neclar",
"fix": (
"Asigura-te ca primul rand contine numele coloanelor"
" (ex. VIN, Numar, Data)."
),
},
"IMPORT_ENCODING": {
"problema": "Codare de caractere nesuportata",
"fix": "Salveaza fisierul ca CSV UTF-8 (sau xlsx) si reincarca.",
},
"IMPORT_FISIER_NERECUNOSCUT": {
"problema": "Fisier nerecunoscut",
"fix": "Incarca un fisier .xlsx sau .csv valid.",
},
"IMPORT_MULTIPLE_SHEETS": {
"problema": "Mai multe foi in fisier",
"fix": "Pastreaza datele intr-o singura foaie sau alege foaia de import.",
},
"IMPORT_FARA_MAPARE_COLOANE": {
"problema": "Coloanele nu sunt mapate",
"fix": (
"Mapeaza intai coloanele fisierului la campurile cerute, apoi continua."
),
},
"IMPORT_CONFIRMARE_GRESITA": {
"problema": "Numar confirmat gresit",
"fix": (
"Numarul confirmat difera de randurile gata de trimis;"
" verifica preview-ul si reconfirma."
),
},
"IMPORT_OVERRIDE_ILIZIBIL": {
"problema": "Editarea anterioara nu se poate citi",
"fix": (
"Editarea salvata este ilizibila (probabil cheia s-a schimbat);"
" reediteaza randul."
),
},
"COLOANE_FORMAT_JSON": {
"problema": "Format de coloane (JSON) invalid",
"fix": (
"Verifica sintaxa JSON a maparii de coloane"
" (ghilimele duble, acolade inchise corect)."
),
},
"EROARE_INTERNA": {
"problema": "Eroare interna a gateway-ului",
"fix": (
"Nu e o problema de date trimise de tine. Reincearca peste cateva"
" momente; daca persista, contacteaza administratorul cu identificatorul"
" 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."
),
},
}
# ---------------------------------------------------------------------------
# eroare()
# ---------------------------------------------------------------------------
def eroare(
cod: str,
*,
field: str | None = None,
cauza: str | None = None,
) -> dict:
"""Construieste un obiect de eroare pe 3 niveluri din CATALOG.
Parametri
---------
cod: Codul de eroare (cheie in CATALOG). Ridica KeyError daca absent.
field: Campul care a generat eroarea (optional, pentru context).
cauza: Descrierea tehnica a erorii concrete (optional).
Daca lipseste, `cauza` si `message` preiau valoarea `problema` din catalog.
Returneaza
----------
dict cu exact cheile: field, cod, problema, cauza, fix, message.
"""
entry = CATALOG[cod] # ridica KeyError daca cod absent
problema = entry["problema"]
fix = entry["fix"]
cauza_efectiva = cauza if cauza is not None else problema
message = cauza if cauza is not None else problema
return {
"field": field,
"cod": cod,
"problema": problema,
"cauza": cauza_efectiva,
"fix": fix,
"message": message,
}

View File

@@ -1,24 +1,18 @@
"""Cheie de idempotenta = hash de continut canonic. """Cheie de idempotenta = hash de continut canonic.
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra.
(plan.md sect. 14). Hash stabil peste o reprezentare canonica a prezentarii. Hash stabil peste o reprezentare canonica a prezentarii.
Treapta 2 (T9 + OV-2): extrage canonicalize_row + build_key ca helpere publice canonicalize_row + build_key sunt helpere publice partajate intre canalul API si
partajate intre canalul API si canalul import. canalul import:
- canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE - canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE
de validare (§3.4bis) si INAINTE de cheie. de validare si INAINTE de cheie.
- build_key: aplica account_or_default INAINTE de hash (None si 1 => o cheie). - build_key: aplica account_or_default INAINTE de hash (None si 1 => o cheie).
Altfel acelasi rand logic din canale diferite (account_id None pe canalul API,
1 pe import) ar primi chei diferite -> al doilea FINALIZATA duplicat.
OV-2 — skew account_id: routerul vechi pasa account_id AS-PASSED (None pe canal API Randuri vechi cu cheie-None nu sunt gasite de build_key nou: dual-lookup la
fara auth). Randurile se stocau sub account_or_default=1, dar cheia includea None. already_sent (cheia noua, apoi build_key_legacy) sau recompute-keys o singura data.
Acelasi rand logic din import (account_id=1) dadea cheie diferita -> already_sent
rata -> al doilea FINALIZATA. Fix: build_key normalizeaza INTOTDEAUNA la
account_or_default inainte de hash.
Migrare DB productie (OV-2): randurile existente cu cheie-None nu mai sunt gasite de
build_key nou. Strategie documentata: dual-lookup la already_sent (incearca cheia
noua, apoi cheia legacy). In dev nu exista date reale; la first-deploy productie
se poate face recompute-keys o singura data.
""" """
from __future__ import annotations from __future__ import annotations
@@ -46,10 +40,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
- data_prestatie: strip (normalizarea la YYYY-MM-DD se face in parser). - data_prestatie: strip (normalizarea la YYYY-MM-DD se face in parser).
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii). - prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
""" """
# VIN
vin = (raw.get("vin") or "").strip().upper() vin = (raw.get("vin") or "").strip().upper()
# Nr. inmatriculare
nr = (raw.get("nr_inmatriculare") or "").strip().upper() nr = (raw.get("nr_inmatriculare") or "").strip().upper()
# Odometru: strip ".0" Excel float coercion # Odometru: strip ".0" Excel float coercion
@@ -79,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 (OV-2): None si 1 colapseaza la Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
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"),
@@ -100,25 +97,25 @@ 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).
NOTA: dupa OV-2, account_id=None si account_id=1 produc ACEEASI cheie NOTA: account_id=None si account_id=1 produc ACEEASI cheie (via
(via account_or_default in build_key). Randuri vechi cu cheie-None nu sunt account_or_default in build_key). Randuri vechi cu cheie-None nu sunt
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:
"""Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize). """Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize).
Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi.
(dinainte de T9). Nu folosi pentru randuri noi. Nu folosi pentru randuri noi.
""" """
canonic = { canonic = {
"account_id": account_id, "account_id": account_id,

View File

@@ -1,6 +1,6 @@
"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2, U1). """Parser fisiere xlsx/csv pentru import prezentari (Treapta 2).
Arhitectura 2-treceri (Issue 2, consens cross-model): Arhitectura 2-treceri:
Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet. Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet.
Trecerea 2 — normal-mode: header + merged cells + body. Trecerea 2 — normal-mode: header + merged cells + body.
Aceasta separare e necesara deoarece openpyxl read_only=True nu vede celule imbinate. Aceasta separare e necesara deoarece openpyxl read_only=True nu vede celule imbinate.
@@ -29,7 +29,7 @@ from typing import Any, NamedTuple
MAX_ROWS = 5_000 MAX_ROWS = 5_000
MAX_BYTES = 5 * 1024 * 1024 # 5 MB MAX_BYTES = 5 * 1024 * 1024 # 5 MB
# Prag rata None pe o coloana obligatorie -> mesaj formule necalculate (Issue 3) # Prag rata None pe o coloana obligatorie -> mesaj formule necalculate
FORMULA_NONE_RATE = 0.6 FORMULA_NONE_RATE = 0.6
# Coloane cheie pentru detectia footer-ului (trim structural) # Coloane cheie pentru detectia footer-ului (trim structural)
@@ -82,7 +82,7 @@ class ParsedFile(NamedTuple):
columns: list[str] # Numele coloanelor detectate (din header) columns: list[str] # Numele coloanelor detectate (din header)
rows: list[dict[str, Any]] # Fiecare rand: {coloana: valoare_bruta} rows: list[dict[str, Any]] # Fiecare rand: {coloana: valoare_bruta}
coercion_flags: dict[int, list[str]] # {row_index: [motive needs_review]} coercion_flags: dict[int, list[str]] # {row_index: [motive needs_review]}
formula_columns: list[str] # Coloane cu rata None ridicata (Issue 3) formula_columns: list[str] # Coloane cu rata None ridicata
date_col_format: dict[str, str] # {coloana: "DD.MM.YYYY" | "YYYY-MM-DD" | "native" | "ambiguous"} date_col_format: dict[str, str] # {coloana: "DD.MM.YYYY" | "YYYY-MM-DD" | "native" | "ambiguous"}
@@ -230,13 +230,13 @@ def _xlsx_parse_sheet(ws, sheet_name: str) -> ParsedFile:
# Trim footer: elimina randuri trailing unde coloanele cheie sunt goale # Trim footer: elimina randuri trailing unde coloanele cheie sunt goale
raw_rows = _trim_footer(raw_rows, col_names) raw_rows = _trim_footer(raw_rows, col_names)
# Detectie coloane cu formule (rata None, Issue 3) # Detectie coloane cu formule (rata None ridicata)
formula_columns = _detect_formula_columns(col_values, len(raw_rows)) formula_columns = _detect_formula_columns(col_values, len(raw_rows))
# Detectie format data la nivel de coloana (T10/OV-8) # Detectie format data la nivel de coloana
date_col_format = _detect_date_formats(col_values, col_names) date_col_format = _detect_date_formats(col_values, col_names)
# Coercion + flags needs_review (T3) # Coercion + flags needs_review
coercion_flags: dict[int, list[str]] = {} coercion_flags: dict[int, list[str]] = {}
processed_rows: list[dict[str, Any]] = [] processed_rows: list[dict[str, Any]] = []
for i, row_dict in enumerate(raw_rows): for i, row_dict in enumerate(raw_rows):
@@ -289,7 +289,7 @@ def _trim_footer(rows: list[dict[str, Any]], col_names: list[str]) -> list[dict[
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Detectie coloane formule (Issue 3) # # Detectie coloane formule #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> list[str]: def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> list[str]:
@@ -306,7 +306,7 @@ def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> li
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Dezambiguizare data la nivel de coloana (T10 / OV-8) # # Dezambiguizare data la nivel de coloana #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str]) -> dict[str, str]: def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str]) -> dict[str, str]:
@@ -344,7 +344,7 @@ def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str])
result[col_name] = "mixed" result[col_name] = "mixed"
continue continue
# Toate string — detectie format la nivel de coloana (OV-8) # Toate string — detectie format la nivel de coloana
fmt = _infer_date_format_from_column(str_vals) fmt = _infer_date_format_from_column(str_vals)
result[col_name] = fmt result[col_name] = fmt
@@ -354,7 +354,7 @@ def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str])
def _infer_date_format_from_column(str_vals: list[str]) -> str: def _infer_date_format_from_column(str_vals: list[str]) -> str:
"""Detecteaza formatul datei dintr-o lista de valori string. """Detecteaza formatul datei dintr-o lista de valori string.
Logica OV-8: daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first. Daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first.
Daca toti zi <= 12 -> ambiguu. Daca toti zi <= 12 -> ambiguu.
""" """
dd_first_evidence = False dd_first_evidence = False
@@ -421,7 +421,7 @@ def _split_date(s: str) -> list[str] | None:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Coercion per rand (T3) # # Coercion per rand #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _coerce_row(row_dict: dict[str, Any], col_names: list[str]) -> tuple[dict[str, Any], list[str]]: def _coerce_row(row_dict: dict[str, Any], col_names: list[str]) -> tuple[dict[str, Any], list[str]]:
@@ -682,7 +682,7 @@ def parse_csv(data: bytes) -> ParsedFile:
def parse_xlsx(data: bytes, *, sheet_name: str | None = None) -> ParsedFile: def parse_xlsx(data: bytes, *, sheet_name: str | None = None) -> ParsedFile:
"""Parseaza un fisier XLSX. """Parseaza un fisier XLSX.
Arhitectura 2-treceri (Issue 2): Arhitectura 2-treceri:
1. read_only=True: dim-check + detectie multi-sheet 1. read_only=True: dim-check + detectie multi-sheet
2. normal-mode: header + merged cells + body 2. normal-mode: header + merged cells + body

View File

@@ -1,9 +1,7 @@
"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics. """Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics.
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici:
(plan.md sect. 4: un worker mort nu trebuie sa lase containerul "sanatos"). un worker mort nu trebuie sa lase containerul "sanatos".
Pornire dev: uvicorn app.main:app --reload
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,12 +18,19 @@ from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
import traceback
from . import __version__ from . import __version__
from . import errors
from .api.v1.import_router import router as import_v1_router from .api.v1.import_router import router as import_v1_router
from .api.v1.integrare_router import router as integrare_v1_router
from .api.v1.router import router as api_v1_router from .api.v1.router import router as api_v1_router
from .config import get_settings from .config import get_settings
from .crypto import validate_creds_key
from .db import get_connection, init_db, queue_depth, read_heartbeat from .db import get_connection, init_db, queue_depth, read_heartbeat
from .security import install_log_redaction from .observ import log_event, request_id_var
from .security import install_log_redaction, scrub_text
from .web.middleware import RequestIDMiddleware
from .web.routes import router as web_router from .web.routes import router as web_router
from .web.auth_routes import router as auth_router from .web.auth_routes import router as auth_router
from .web.admin_routes import router as admin_router from .web.admin_routes import router as admin_router
@@ -36,6 +41,9 @@ from .web.session import AdminRequired, LoginRequired
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
install_log_redaction() install_log_redaction()
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
# in loc de 500 brut la primul POST /v1/prezentari.
validate_creds_key()
init_db() init_db()
yield yield
@@ -51,6 +59,10 @@ app.add_middleware(
https_only=settings.session_https_only, https_only=settings.session_https_only,
same_site="strict", same_site="strict",
) )
# request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
# inclusiv 401/404/422/500 produse mai in interior.
app.add_middleware(RequestIDMiddleware)
@app.exception_handler(LoginRequired) @app.exception_handler(LoginRequired)
@@ -81,6 +93,34 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
return JSONResponse(status_code=422, content={"detail": cleaned}) return JSONResponse(status_code=422, content={"detail": cleaned})
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Orice excepție neprinsa -> 500 STRUCTURAT din catalog in loc de 500 brut.
Body = envelope-ul standard din catalog (6 chei: field/cod/problema/cauza/fix/message)
+ `request_id` — fara traceback, fara mesaj de excepție brut, fara creds. Traceback-ul
complet + ruta + cont + request_id se scriu DOAR in jurnal (redactate prin scrub_text).
"""
request_id = getattr(request.state, "request_id", None) or request_id_var.get()
try:
account_id = request.session.get("account_id")
except (AssertionError, KeyError, AttributeError):
account_id = None
tb = scrub_text("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
log_event(
"eroare_interna",
nivel="ERROR",
account_id=account_id,
cod="EROARE_INTERNA",
mesaj=f"{request.method} {request.url.path}: {type(exc).__name__}",
context={"path": request.url.path, "method": request.method, "traceback": tb},
request_id=request_id,
)
body = errors.eroare("EROARE_INTERNA")
body["request_id"] = request_id
return JSONResponse(status_code=500, content=body, headers={"X-Request-ID": request_id or ""})
# Assets servite local (htmx vendorizat), NU din CDN: gateway-ul ruleaza # Assets servite local (htmx vendorizat), NU din CDN: gateway-ul ruleaza
# offline (LXC/VPS, Cloudflare Tunnel). Fara asta, dashboard-ul ramane static # offline (LXC/VPS, Cloudflare Tunnel). Fara asta, dashboard-ul ramane static
# (zero polling banner/coada) cand unpkg e blocat/inaccesibil. Aceeasi decizie # (zero polling banner/coada) cand unpkg e blocat/inaccesibil. Aceeasi decizie
@@ -90,6 +130,7 @@ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
app.include_router(api_v1_router) app.include_router(api_v1_router)
app.include_router(import_v1_router) app.include_router(import_v1_router)
app.include_router(integrare_v1_router)
app.include_router(web_router) app.include_router(web_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
@@ -99,9 +140,8 @@ app.include_router(admin_router)
def healthz() -> dict: def healthz() -> dict:
"""Sanatate: worker viu + ultimul login RAR reusit + adancime coada. """Sanatate: worker viu + ultimul login RAR reusit + adancime coada.
Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort Intoarce 200 mereu cu detalii; orchestratorul decide restartul pe campul
-> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii; `worker_alive`.
orchestratorul decide pe campul `worker_alive`.
""" """
settings = get_settings() settings = get_settings()
conn = get_connection() conn = get_connection()

View File

@@ -1,7 +1,7 @@
"""Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor. """Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor.
Contract (varianta hibrida, decis 2026-06-15): un item de prestatie poate veni Contract (varianta hibrida): un item de prestatie poate veni
fie cu `cod_prestatie` (cod RAR direct, ca pana acum), fie cu `cod_op_service` fie cu `cod_prestatie` (cod RAR direct), fie cu `cod_op_service`
(cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern (cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern
prin `operations_mapping`; daca nu exista mapare -> submission `needs_mapping` prin `operations_mapping`; daca nu exista mapare -> submission `needs_mapping`
(nu se trimite la RAR), iar operatia apare in editorul web unde userul o mapeaza (nu se trimite la RAR), iar operatia apare in editorul web unde userul o mapeaza
@@ -14,12 +14,15 @@ unit-testabile direct. Cele cu `conn` sunt helpere de persistenta.
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import re
import unicodedata import unicodedata
from typing import Any from typing import Any
from rapidfuzz import fuzz, process from rapidfuzz import fuzz, process
from . import errors as err_mod
from .nomenclator_seed import FALLBACK_NOMENCLATOR from .nomenclator_seed import FALLBACK_NOMENCLATOR
from .validation import validate_prezentare from .validation import validate_prezentare
@@ -47,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],
@@ -86,16 +143,126 @@ def suggest_codes(
] ]
# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text.
# Forma: "text_rule:<pattern original al regulii castigatoare>". Payload-harmless —
# RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect.
COD_SURSA_TEXT_RULE_PREFIX = "text_rule:"
def _rezolva_din_reguli_text(
item: dict,
text_rules: list[dict] | None,
valid_codes: set[str] | None,
) -> tuple[str | None, str | None, bool | None]:
"""Cauta prima regula text (in ordinea data) al carei pattern e substring al
textului operatiei. Intoarce (cod uppercase, pattern original, auto_send) daca e
valid, altfel (None, None, None).
Textul operatiei = `denumire` daca exista, altfel `cod_op_service`. Ambele parti
(text si pattern) se normalizeaza cu `normalize_for_match` (fara diacritice,
uppercase, spatii colapsate) -> match insensibil la caz/diacritice.
`text_rules` e deja ordonata (priority ASC, id ASC) de `load_text_rules`, deci
prima regula care da match castiga. Daca regula castigatoare are un cod absent din
`valid_codes` (cand `valid_codes` e setat), nu intoarcem un cod invalid ->
(None, None, None) (operatia ramane nemapata), coerent cu garda din `resolve_prestatii`.
Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie), nu cel
normalizat folosit la match. `auto_send` = flagul regulii castigatoare: cand e
falsy (DEFAULT 0, de siguranta) randul trebuie TINUT pentru verificare umana, nu
trimis automat la RAR (blast radius substring + FINALIZATA ireversibil).
"""
if not text_rules:
return None, None, None
text = normalize_for_match(item.get("denumire") or item.get("cod_op_service"))
if not text:
return None, None, None
for rule in text_rules:
pat = normalize_for_match(rule.get("pattern"))
if not pat or pat not in text:
continue
# Prima regula care da match castiga.
cod = (rule.get("cod_prestatie") or "").strip().upper()
if not cod:
return None, None, None
if valid_codes is not None and cod not in valid_codes:
return None, None, None # cod invalid in nomenclator -> nu il punem; ramane nemapat
return cod, rule.get("pattern"), bool(rule.get("auto_send"))
return None, None, None
def text_rule_hits(resolved: list[dict] | None) -> list[dict]:
"""Extrage din itemii rezolvati cei care au primit cod dintr-o regula text.
Intoarce [{pattern, cod_prestatie}] pentru fiecare item al carui `cod_sursa`
incepe cu `COD_SURSA_TEXT_RULE_PREFIX`. Pur (fara DB); apelantii cu `conn` il
folosesc ca sa emita `log_event("text_rule_hit", ...)`.
"""
hits: list[dict] = []
for item in resolved or []:
sursa = item.get("cod_sursa")
if isinstance(sursa, str) and sursa.startswith(COD_SURSA_TEXT_RULE_PREFIX):
hits.append({
"pattern": sursa[len(COD_SURSA_TEXT_RULE_PREFIX):],
"cod_prestatie": item.get("cod_prestatie"),
})
return hits
def text_rules_overlap(pattern: str, existing_rules: list[dict] | None) -> list[dict]:
"""Reguli text existente care se SUPRAPUN cu `pattern` (avertisment neblocant).
Overlap = pattern-ul nou normalizat (`normalize_for_match`) e substring al unei
reguli existente SAU invers (oricare directie). Pur, determinist, fara DB.
Un pattern IDENTIC dupa normalizare NU e overlap: e un upsert (update al codului),
nu o suprapunere care merita avertisment. Intoarce dict-urile originale din
`existing_rules` care se suprapun (in ordinea data).
"""
pat = normalize_for_match(pattern)
if not pat:
return []
hits: list[dict] = []
for rule in existing_rules or []:
other = normalize_for_match(rule.get("pattern"))
if not other or other == pat:
continue # gol sau identic -> nu e overlap
if pat in other or other in pat:
hits.append(rule)
return hits
def resolve_prestatii( def resolve_prestatii(
prestatii: list[dict] | None, prestatii: list[dict] | None,
mapping: dict[str, str], mapping: dict[str, str],
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
) -> tuple[list[dict], list[dict]]: ) -> tuple[list[dict], list[dict]]:
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste. """Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
Reguli (hibrid): Reguli (hibrid):
- item cu `cod_prestatie` -> pastrat ca atare (cod RAR direct). - item cu `cod_prestatie` valid (in nomenclator) -> pastrat ca atare.
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie. - item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
- item fara cod si fara mapare -> ramane nemapat. - item fara cod, nemapat exact, dar al carui text da match pe o regula text
(substring) -> umplem cod_prestatie din prima regula care potriveste.
- item fara cod, fara mapare si fara regula text -> ramane nemapat.
- item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de
mapat: il promovam la `cod_op_service` (daca nu exista deja) ca sa intre in
fluxul needs_mapping. RAR accepta NUMAI coduri din nomenclator (coloana
COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
la RAR (terminal) -> nu-l trimitem niciodata raw.
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service`
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista
cod valid SI op nu e in `mapping`.
`valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e
None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece
neatins"); rutele API il paseaza intotdeauna.
`text_rules` = lista de dict-uri ca cea intoarsa de `load_text_rules`
([{pattern, cod_prestatie, auto_send, priority}], ordonata priority ASC, id ASC).
Default None = comportament actual neschimbat (fara reguli text).
Intoarce (prestatii_rezolvate, nemapate). `prestatii_rezolvate` pastreaza Intoarce (prestatii_rezolvate, nemapate). `prestatii_rezolvate` pastreaza
si campurile originale (cod_op_service/denumire) ca re-rezolvarea sa aiba si campurile originale (cod_op_service/denumire) ca re-rezolvarea sa aiba
@@ -106,17 +273,45 @@ def resolve_prestatii(
unmapped: list[dict] = [] unmapped: list[dict] = []
for item in prestatii or []: for item in prestatii or []:
it = dict(item) it = dict(item)
# Curata adnotarile aditive ale rezolvarii (cod_sursa + flagul de hold pe
# regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare.
# Altfel, un item re-rezolvat acum prin alta cale (ex. mapare exacta) ar pastra
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
it.pop("cod_sursa", None)
it.pop("regula_fara_autosend", None)
cod = (it.get("cod_prestatie") or "").strip().upper() cod = (it.get("cod_prestatie") or "").strip().upper()
op = (it.get("cod_op_service") or "").strip() op = (it.get("cod_op_service") or "").strip()
if cod: cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes)
if cod_valid:
it["cod_prestatie"] = cod it["cod_prestatie"] = cod
elif op and op in mapping: else:
it["cod_prestatie"] = mapping[op] # cod lipsa SAU necunoscut in nomenclator -> ruta de mapare.
elif op: if cod and not op:
it["cod_prestatie"] = None # Promovam codul direct necunoscut la cod_op_service ca sa-l poti mapa
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")}) # in editor (cu denumire = codul, pentru sugestia fuzzy) si sa se retina.
# item fara cod si fara op: il lasam asa; validarea de continut prinde op = cod
# "prestatii goale"/cod lipsa. it["cod_op_service"] = op
if not it.get("denumire"):
it["denumire"] = cod
if op and op in mapping:
it["cod_prestatie"] = mapping[op]
elif op:
# Mapare exacta absenta -> incearca regulile text (substring).
cod_regula, pattern_regula, auto_send_regula = _rezolva_din_reguli_text(
it, text_rules, valid_codes
)
if cod_regula is not None:
it["cod_prestatie"] = cod_regula
# Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul
# sursa. Payload-harmless (RAR citeste doar cod_prestatie).
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
# US-001 (PRD 5.11): regula_fara_autosend nu se mai seteaza;
# auto_send nu mai tine randul (has_no_auto_send neutralizat).
else:
it["cod_prestatie"] = None
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")})
# item fara cod si fara op: il lasam asa; validarea de continut prinde
# "prestatii goale"/cod lipsa.
resolved.append(it) resolved.append(it)
return resolved, unmapped return resolved, unmapped
@@ -132,7 +327,7 @@ def account_or_default(account_id: int | None) -> int:
def account_scope_clause(account_id: int) -> tuple[str, list]: def account_scope_clause(account_id: int) -> tuple[str, list]:
"""Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable. """Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable.
Aplica regula: NULL apartine contului 1 (legacy/OV-2). Aplica regula: NULL apartine contului 1 (legacy).
Foloseste DOAR pe submissions (account_id NULLABLE). Foloseste DOAR pe submissions (account_id NULLABLE).
NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu. NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu.
""" """
@@ -191,6 +386,17 @@ def load_nomenclator(conn) -> list[dict]:
return [dict(r) for r in rows] return [dict(r) for r in rows]
def load_nomenclator_codes(conn) -> set[str]:
"""Setul de coduri RAR valide (uppercase) pentru validarea cod_prestatie la ingestie.
Intoarce set() daca nomenclatorul e gol -> apelantul trebuie sa NU valideze in
acel caz (altfel ar bloca totul). In practica nomenclatorul e mereu populat:
seed fallback (18 coduri) la boot + upsert live de la worker la fiecare login.
"""
rows = conn.execute("SELECT cod_prestatie FROM nomenclator_rar").fetchall()
return {(r["cod_prestatie"] or "").strip().upper() for r in rows if (r["cod_prestatie"] or "").strip()}
def load_mapping(conn, account_id: int | None) -> dict[str, str]: def load_mapping(conn, account_id: int | None) -> dict[str, str]:
"""{cod_op_service -> cod_prestatie} pentru un cont.""" """{cod_op_service -> cod_prestatie} pentru un cont."""
acct = account_or_default(account_id) acct = account_or_default(account_id)
@@ -204,7 +410,7 @@ def load_mapping(conn, account_id: int | None) -> dict[str, str]:
def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]: def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont. """{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
T6/OV-1: varianta extinsa care include si flagul auto_send per operatie. Varianta extinsa care include si flagul auto_send per operatie.
""" """
acct = account_or_default(account_id) acct = account_or_default(account_id)
rows = conn.execute( rows = conn.execute(
@@ -217,16 +423,72 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
} }
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool: def classify_prezentare(
"""Verifica daca vreun item rezolvat via mapping are auto_send=0. content: dict,
mapping: dict[str, str],
mapping_meta: dict[str, dict],
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
) -> dict:
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
T6/OV-1: un cod nou-mapat cu auto_send=0 nu trebuie trimis automat. Apelat de AMBELE rute (POST /v1/prezentari si POST /v1/prezentari/valideaza) pentru
Items cu cod_prestatie direct (nu via cod_op_service) nu sunt afectate. a garanta acelasi verdict — invariantul de corectitudine dry-run.
Intoarce {"status", "rar_error", "resolved", "unmapped", "errors", "content"}.
"content" = copia actualizata (VIN/nr canonicalizat + prestatii rezolvate).
"""
from .idempotency import canonicalize_row # import local: evita circular (mapping <- idempotency)
c = dict(content)
canon = canonicalize_row(c)
c.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
"odometru_final": canon["odometru_final"],
})
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules)
c["prestatii"] = resolved
if unmapped:
status = "needs_mapping"
coduri = ", ".join((u.get("cod_op_service") or "") for u in unmapped)
rar_error = json.dumps(
{"unmapped": unmapped, **err_mod.eroare("COD_NEMAPAT", cauza=f"Coduri fara mapare RAR: {coduri}")},
ensure_ascii=False,
)
errors: list[dict] = []
else:
errors = validate_prezentare(c)
if errors:
status = "needs_data"
rar_error = json.dumps(errors, ensure_ascii=False)
else:
# US-001 (PRD 5.11): ramura AUTO_SEND_OPRIT eliminata.
# Un cod rezolvat (mapare exacta sau regula text) -> queued direct.
status = "queued"
rar_error = None
return {
"status": status,
"rar_error": rar_error,
"resolved": resolved,
"unmapped": unmapped,
"errors": errors,
"content": c,
}
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
"""Neutralizat dupa US-001 (PRD 5.11): auto_send nu mai tine randuri in needs_mapping.
Simbolul este PASTRAT (importat in routes.py si import_router.py); stergerea
ar produce ImportError la boot. Functia intoarce mereu False — codul rezolvat
intra direct in queued, indiferent de valoarea auto_send din mapping_meta.
Coloanele DB raman cu default=1 (migrare non-distructiva).
""" """
for item in resolved:
op = (item.get("cod_op_service") or "").strip()
if op and op in mapping_meta and not mapping_meta[op]["auto_send"]:
return True
return False return False
@@ -238,7 +500,7 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern. footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern.
account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL
apartine contului 1, OV-2). Filtrarea in SQL, nu post-hoc in Python. apartine contului 1). Filtrarea in SQL, nu post-hoc in Python.
""" """
nomenclator = load_nomenclator(conn) nomenclator = load_nomenclator(conn)
if account_id is not None: if account_id is not None:
@@ -277,10 +539,18 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
entry["denumire"] = item.get("denumire") entry["denumire"] = item.get("denumire")
entry["_ids"].add(r["id"]) entry["_ids"].add(r["id"])
# Indexeaza corpusul embeddings o data inainte de bucla (no-op cand flagul e off).
ensure_embeddings_corpus(conn, nomenclator)
out: list[dict] = [] out: list[dict] = []
for entry in agg.values(): for entry in agg.values():
entry["blocked"] = len(entry.pop("_ids")) entry["blocked"] = len(entry.pop("_ids"))
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5) entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
# L14-S6: imbogatire sugestii cu GOLD partajat > SILVER > embeddings (Eng-F2).
# SUGGESTION-ONLY: nu intra in resolve_prestatii/load_mapping (#13).
enriched = enrich_suggestions(conn, entry["denumire"])
entry["sugestie_principala"] = enriched["sugestie_principala"]
entry["surse_sugestie"] = enriched["surse"]
out.append(entry) out.append(entry)
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"])) out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
return out return out
@@ -302,26 +572,276 @@ def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestati
) )
def load_text_rules(conn, account_id: int | None) -> list[dict]:
"""Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC.
Fiecare element: {pattern, cod_prestatie, auto_send, priority}.
Aplica account_or_default (None == 1).
"""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT pattern, cod_prestatie, auto_send, priority "
"FROM operation_text_rules "
"WHERE account_id=? "
"ORDER BY priority ASC, id ASC",
(acct,),
).fetchall()
return [dict(r) for r in rows]
def save_text_rule(
conn,
account_id: int | None,
pattern: str,
cod_prestatie: str,
auto_send: bool,
) -> None:
"""Upsert o regula text pe (account_id, pattern).
auto_send boolean -> 0/1. Daca regula exista deja (acelasi cont + pattern),
actualizeaza cod_prestatie si auto_send.
"""
acct = account_or_default(account_id)
pat = (pattern or "").strip()
cod = (cod_prestatie or "").strip().upper()
if not pat or not cod:
raise ValueError("pattern si cod_prestatie sunt obligatorii")
conn.execute(
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(account_id, pattern) DO UPDATE SET "
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send",
(acct, pat, cod, 1 if auto_send else 0),
)
def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
"""Sterge regula cu (account_id, pattern) daca exista."""
acct = account_or_default(account_id)
pat = (pattern or "").strip()
conn.execute(
"DELETE FROM operation_text_rules WHERE account_id=? AND pattern=?",
(acct, pat),
)
# Prag minim de similaritate cosine pentru sugestia din embeddings NN.
# Sub acest scor, sugestia NN e prea incerta si nu o afisam (previne recomandari
# irelevante cand corpus-ul e mic sau neindexat corect).
EMB_MIN_SIMILARITATE = 0.5
def _corpus_signature_silver(rows: list) -> str:
"""Semnatura stabila a corpusului SILVER (mapping_suggestions) pentru cache.
Hash pe (denumire_normalizata, cod, is_nul) sortat -> se schimba la orice
add/remove/redenumire/relabel, ramane stabila altfel (evita re-embed inutil).
"""
triples = sorted(
(str(r["denumire_normalizata"] or ""), str(r["cod_prestatie"] or ""), int(r["is_nul"] or 0))
for r in rows
)
blob = "".join(f"{d}|{c}|{n}" for d, c, n in triples)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
"""Construieste/actualizeaza corpusul embeddings din corpusul ETICHETAT (PRD 5.18 US-005).
Sursa corpusului = `mapping_suggestions` (SILVER): exemple reale etichetate
{denumire_normalizata -> cod, is_nul}, NU cele 18 categorii generice din
`nomenclator_rar`. k-NN peste exemple reale e net mai precis (94.3% acord LLM).
Parametrul `nomenclator` e pastrat pentru compatibilitatea apelantilor, dar nu mai
e folosit ca sursa.
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default ON; OFF in teste): cand e
dezactivat, e un no-op total -> /mapari instant + suita de teste rapida.
Cand e activat: indexeaza corpusul o singura data (lazy-load modelul ~230MB la
prima chemare), re-indexeaza doar cand semnatura corpusului SILVER s-a schimbat.
Itemii NUL (is_nul=1, cod NULL) raman in corpus: un vecin NUL e semnal de supresie
(US-006). Degradare gratioasa: orice eroare lasa corpusul gol -> enrich cade pe restul.
"""
from .config import get_settings
if not get_settings().embeddings_enabled:
return
try:
from . import embeddings as _emb
rows = conn.execute(
"SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions"
).fetchall()
if not rows:
return
sig = _corpus_signature_silver(rows)
if _emb.corpus_signature() == sig and _emb.has_corpus():
return # deja indexat pe acelasi corpus SILVER -> nimic de facut
items = [
{
"denumire": str(r["denumire_normalizata"]),
"cod": (str(r["cod_prestatie"]) if r["cod_prestatie"] is not None else None),
"is_nul": bool(r["is_nul"]),
}
for r in rows
if r["denumire_normalizata"]
]
_emb.index_corpus(items, signature=sig)
except Exception:
pass # degradare gratioasa (#16b): esecul indexarii nu blocheaza editorul
def enrich_suggestions(
conn,
denumire: str | None,
*,
include_embeddings: bool = True,
) -> dict:
"""Imbogateste sugestiile cu GOLD partajat, SILVER LLM si embeddings NN.
Precedenta Eng-F2 (pentru sugestie-only, nu auto-send):
shared GOLD > SILVER > embeddings
(Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping;
nu apare in needs_mapping, deci nu e in precedenta de sugestie.)
Ordine completa (PRD 5.18 US-006):
pre-filtru NUL determinist -> (daca NUL: fara cod, `surse['nul']=True`)
altfel GOLD partajat > exact (SILVER) > k-NN embeddings.
Returneaza:
{
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None, 'nul': bool}
}
INVARIANTE:
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13).
- Pre-filtru NUL (US-001) ruleaza PRIMUL: gunoiul evident (ITP/plata/discount...) e
marcat non-operatie INAINTE de k-NN, fara sugestie de cod.
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie (#4); vecin k-NN NUL idem.
- Degradare gratioasa pe embeddings (#16b): daca motorul nu e disponibil sau arunca,
returneaza sugestia disponibila din celelalte surse, fara exceptie.
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store
importa normalize_for_match din mapping).
"""
sugestie_principala: dict | None = None
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
if not denumire:
return {"sugestie_principala": sugestie_principala, "surse": surse}
# 0. Pre-filtru NUL determinist (US-001) INAINTE de orice k-NN/lookup: non-operatie
# evidenta -> fara cod, scurtcircuit (nu interogheaza embeddings/SILVER pe gunoi).
if prefiltru_nul(denumire):
surse["nul"] = True
return {"sugestie_principala": None, "surse": surse}
# Colecteaza TOATE sursele (fara short-circuit) in `surse`: editorul le poate afisa
# toate, independent de care castiga ca sugestie principala.
# Precedenta Eng-F2 se aplica DOAR la alegerea sugestiei_principale.
# 1. GOLD partajat cross-account (validat de om, cel mai de incredere)
try:
from .shared_store import lookup_shared_gold
row_gold = lookup_shared_gold(conn, denumire)
if row_gold and row_gold["cod_prestatie"]:
surse["gold_partajat"] = str(row_gold["cod_prestatie"])
except Exception:
pass # degradare gratioasa
# 2. SILVER LLM (bootstrap, nevalidat de om; is_nul = supresie)
try:
from .shared_store import lookup_suggestion
row_silver = lookup_suggestion(conn, denumire)
if row_silver and not row_silver["is_nul"] and row_silver["cod_prestatie"]:
surse["silver"] = str(row_silver["cod_prestatie"])
except Exception:
pass # degradare gratioasa
# 3. Embeddings NN (similaritate semantica, degradare gratioasa #16b)
if include_embeddings:
try:
from . import embeddings as _emb
# Poarta IEFTINA: nu atinge is_available()/suggest_nearest cand corpus-ul
# e gol — `is_available()` lazy-load-eaza modelul de ~230MB (30-120s in
# thread-ul de cerere). Corpusul se construieste de apelant prin
# ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand
# flagul e off, has_corpus() ramane False si calea e un no-op real.
if _emb.has_corpus():
# F1 (US-005): corpusul k-NN e text NORMALIZAT (denumire_normalizata),
# deci query-ul TREBUIE normalizat la fel — altfel cosine degradeaza si
# nu mai e configul sub care s-a masurat 94.3%.
nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=1)
# Prag minim: similaritate prea mica = sugestie inutila.
# Evita recomandari irelevante cand corpus-ul e mic/partial.
if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
if nn[0].get("is_nul"):
# Vecin NUL (non-operatie) = semnal de SUPRESIE, nu cod (US-006).
surse["nul"] = True
elif nn[0].get("cod"):
surse["embedding"] = str(nn[0]["cod"])
except Exception:
pass # degradare gratioasa (#16b): motorul absent nu blocheaza
# Alege sugestia principala in ordinea de precedenta: GOLD > SILVER > embeddings
if surse["gold_partajat"]:
sugestie_principala = {"cod_prestatie": surse["gold_partajat"], "sursa": "gold_partajat"}
elif surse["silver"]:
sugestie_principala = {"cod_prestatie": surse["silver"], "sursa": "silver"}
elif surse["embedding"]:
sugestie_principala = {"cod_prestatie": surse["embedding"], "sursa": "embedding"}
return {"sugestie_principala": sugestie_principala, "surse": surse}
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
Telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event inghite
exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} — fara
PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import.
"""
hits = text_rule_hits(resolved)
if not hits:
return
from .observ import log_event # import local: best-effort, fara ciclu la import-time
for hit in hits:
log_event(
"text_rule_hit",
account_id=account_id,
cod=hit.get("cod_prestatie"),
conn=conn,
context={
"submission_id": submission_id,
"account_id": account_id,
"pattern": hit.get("pattern"),
"cod_prestatie": hit.get("cod_prestatie"),
},
)
def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) -> dict[str, int]: def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) -> dict[str, int]:
"""Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare. """Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare.
Pentru fiecare: aplica maparea curenta; daca nu mai raman op-uri nemapate -> Pentru fiecare: aplica maparea curenta; daca nu mai raman op-uri nemapate ->
ruleaza validarea de continut (T3) si trece pe `queued` (sau `needs_data` cu ruleaza validarea de continut si trece pe `queued` (sau `needs_data` cu
motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu
motivul actualizat. Intoarce {requeued, still_blocked, needs_data, review_manual}. motivul actualizat. Intoarce {requeued, still_blocked, needs_data, review_manual}.
T6/OV-1: auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane 'needs_mapping'
'needs_mapping' cu motiv "review manual"); previne FINALIZATA eronat permanent. cu motiv "review manual"); previne FINALIZATA eronat permanent.
T7: batch_id != None -> scope la seria comitata (NU cross-batch). batch_id != None -> scope la seria comitata (NU cross-batch).
batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus). batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus).
""" """
acct = account_or_default(account_id) acct = account_or_default(account_id)
mapping_meta = load_mapping_meta(conn, acct) mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
# Incarca regulile text O DATA, inainte de bucla pe randuri.
text_rules = load_text_rules(conn, acct)
if batch_id is not None: if batch_id is not None:
# T7: scope la batch-ul specificat (import commit explicit). # Scope la batch-ul specificat (import commit explicit).
# NU atinge randuri din alte batches sau din feed API. # NU atinge randuri din alte batches sau din feed API.
rows = conn.execute( rows = conn.execute(
"SELECT id, payload_json FROM submissions " "SELECT id, payload_json FROM submissions "
@@ -330,8 +850,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
).fetchall() ).fetchall()
else: else:
# POST /v1/mapari (save manual): re-rezolva EXCLUSIV canalul API (batch_id IS NULL). # POST /v1/mapari (save manual): re-rezolva EXCLUSIV canalul API (batch_id IS NULL).
# T7/R1 INCHIS: salvarea unei mapari NU re-queues randuri din batches de import # Salvarea unei mapari NU re-queues randuri din batches de import (cross-batch /
# (cross-batch / cross-feed). Batches de import sunt re-rezolvate doar la commit explicit. # cross-feed). Batches de import sunt re-rezolvate doar la commit explicit.
rows = conn.execute( rows = conn.execute(
"SELECT id, payload_json FROM submissions " "SELECT id, payload_json FROM submissions "
"WHERE status='needs_mapping' AND account_id=? AND batch_id IS NULL", "WHERE status='needs_mapping' AND account_id=? AND batch_id IS NULL",
@@ -344,10 +864,13 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
content = json.loads(r["payload_json"]) content = json.loads(r["payload_json"])
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping) resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
content["prestatii"] = resolved content["prestatii"] = resolved
payload_json = json.dumps(content, ensure_ascii=False) payload_json = json.dumps(content, ensure_ascii=False)
# Telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, r["id"], resolved)
if unmapped: if unmapped:
conn.execute( conn.execute(
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?", "UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",
@@ -356,18 +879,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
stats["still_blocked"] += 1 stats["still_blocked"] += 1
continue continue
# T6/OV-1: verifica auto_send inainte de re-queuing # US-001 (PRD 5.11): ramura auto_send eliminata din reresolve.
if has_no_auto_send(resolved, mapping_meta): # Un cod rezolvat -> queued direct (review_manual ramane 0).
conn.execute(
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",
(
payload_json,
json.dumps({"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"}, ensure_ascii=False),
r["id"],
),
)
stats["review_manual"] += 1
continue
errors = validate_prezentare(content) errors = validate_prezentare(content)
if errors: if errors:

View File

@@ -1,9 +1,8 @@
"""Modele Pydantic pentru suprafata API. """Modele Pydantic pentru suprafata API.
ATENTIE: validarea completa (regex VIN ^[A-HJ-NPR-Z0-9]{17}$, nrInmatriculare, Aici sunt doar formele de baza + normalizare strip/upper. Validarea completa de
dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti, R-ODO/I-ODO -> odometruInitial continut (regex VIN, interval data, R-ODO/I-ODO -> odometruInitial, ordine
obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este odometru) este in app.validation.
**T3** — aici sunt doar formele de baza. Vezi plan.md sect. 2 + roadmap T3.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,7 +19,7 @@ class RarCredentials(BaseModel):
class PrestatieItem(BaseModel): class PrestatieItem(BaseModel):
"""O operatie de declarat. Contract hibrid (decis 2026-06-15): """O operatie de declarat. Contract hibrid:
ROAAUTO poate trimite FIE `cod_prestatie` (cod RAR direct, ex. OE-1), FIE ROAAUTO poate trimite FIE `cod_prestatie` (cod RAR direct, ex. OE-1), FIE
`cod_op_service` (cod intern ROAAUTO) + `denumire` — pe care gateway-ul le `cod_op_service` (cod intern ROAAUTO) + `denumire` — pe care gateway-ul le
@@ -55,7 +54,7 @@ class PrezentareIn(BaseModel):
Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de
continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in
app.validation.validate_prezentare si NU resping cererea — marcheaza app.validation.validate_prezentare si NU resping cererea — marcheaza
`needs_data` (plan.md sect. 3). `needs_data`.
""" """
vin: str vin: str
@@ -80,18 +79,65 @@ class PrezentareIn(BaseModel):
class PrezentareRequest(BaseModel): class PrezentareRequest(BaseModel):
"""Body pentru POST /v1/prezentari — una sau mai multe prezentari + creds RAR.""" """Body pentru POST /v1/prezentari — una sau mai multe prezentari + creds RAR.
rar_credentials: RarCredentials `rar_credentials` e OPTIONAL: daca lipseste, worker-ul foloseste creds-urile RAR
durabile salvate pe cont (`accounts.rar_creds_enc`, via POST /v1/conturi/rar-creds).
Trimite-le explicit doar cand vrei sa suprascrii creds-urile contului pe acea cerere.
"""
rar_credentials: RarCredentials | None = None
prezentari: list[PrezentareIn] = Field(..., min_length=1) prezentari: list[PrezentareIn] = Field(..., min_length=1)
# Optional: override per-cerere al comportamentului la cod necunoscut/nemapat.
# True -> respinge cererea fara enqueue (status 'error');
# False -> submission 'needs_mapping' (intra in editorul de mapare);
# None -> se foloseste accounts.on_unmapped_error_default (implicit False).
on_unmapped_error: bool | None = None
class SubmissionResult(BaseModel): class SubmissionResult(BaseModel):
submission_id: int # submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True).
submission_id: int | None = None
status: str status: str
id_prezentare: int | None = None id_prezentare: int | None = None
deduped: bool = False # True daca idempotency a intors un submission existent deduped: bool = False # True daca idempotency a intors un submission existent
# Camp aditiv. True cand un rand `error` cu aceeasi cheie de continut a fost
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
reactivated: bool = False
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
# motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes.
# erori = validare de continut (needs_data), 3 niveluri [{field, cod, problema, cauza, fix, message}].
# Pe ramura on_unmapped_error='error' pastreaza COD_NEMAPAT (compat).
# nemapate = coduri fara mapare RAR (needs_mapping / respins), 3 niveluri + cod_op_service/denumire.
# motiv = rezumat uman pe o linie (None cand status='queued').
erori: list[dict] = []
nemapate: list[dict] = []
motiv: str | None = None
class PrezentariResponse(BaseModel): class PrezentariResponse(BaseModel):
results: list[SubmissionResult] results: list[SubmissionResult]
class ValidarePrezentariRequest(BaseModel):
"""Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue."""
rar_credentials: RarCredentials | None = None
prezentari: list[PrezentareIn] = Field(..., min_length=1)
on_unmapped_error: bool | None = None
class ValidareResult(BaseModel):
"""Verdictul dry-run per prezentare."""
index: int
valid: bool
status_estimat: str # "queued" | "needs_data" | "needs_mapping"
erori: list[dict] = []
nemapate: list[dict] = []
prestatii_rezolvate: list[dict] = []
class ValidareResponse(BaseModel):
results: list[ValidareResult]

146
app/observ.py Normal file
View File

@@ -0,0 +1,146 @@
"""Logger structurat central.
Singurul punct prin care se emit evenimente de aplicatie: garanteaza format,
redactare si dublul canal (app_events in DB + log text rotativ) consistente si
imposibil de ocolit. Best-effort: o cadere a jurnalului NU doboara cererea/worker-ul.
Redactare la SCRIERE (nu la afisare): toate valorile trec prin `redact_pii`
(creds/token mascate integral, VIN/nr partial) inainte de persistare.
"""
from __future__ import annotations
import contextvars
import json
import logging
import logging.handlers
from datetime import datetime, timedelta, timezone
from typing import Any
from .config import get_settings
from .db import get_connection, insert_app_event
from .security import redact_pii, scrub_text
# request_id al cererii curente. Setat de middleware-ul HTTP; disponibil in
# handlerul de erori si aici, fara a polua semnaturile de functii.
request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"request_id", default=None
)
_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "WARN": 30, "ERROR": 40, "CRITICAL": 50}
# Sursa implicita a evenimentelor pentru procesul curent. API = 'api' (default);
# worker-ul cheama set_source('worker') la pornire (fisier per-proces).
_DEFAULT_SOURCE = "api"
_loggers: dict[str, logging.Logger] = {}
def set_source(sursa: str) -> None:
"""Fixeaza sursa implicita a evenimentelor (apelata o data de worker la start)."""
global _DEFAULT_SOURCE
_DEFAULT_SOURCE = sursa
def _text_logger(sursa: str) -> logging.Logger:
"""Logger cu RotatingFileHandler pe fisier per-proces (app-<sursa>.log).
Rotatia pe dimensiune e in aplicatie — nu depindem de deploy. Cheia de cache
include calea: la schimbarea log_dir (teste) se creeaza un logger nou, fara a
acumula handlere duplicate pe acelasi fisier.
"""
settings = get_settings()
path = settings.log_dir / f"app-{sursa}.log"
key = str(path)
lg = _loggers.get(key)
if lg is not None:
return lg
lg = logging.getLogger(f"autopass.events::{key}")
lg.setLevel(logging.DEBUG)
lg.propagate = False
try:
settings.log_dir.mkdir(parents=True, exist_ok=True)
handler = logging.handlers.RotatingFileHandler(
path,
maxBytes=settings.log_file_max_bytes,
backupCount=settings.log_file_backup_count,
encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
lg.addHandler(handler)
except Exception: # noqa: BLE001 — fisier indisponibil nu trebuie sa doboare logul DB
pass
_loggers[key] = lg
return lg
def _purge_after(days: int) -> str:
"""now (UTC) + days, in formatul SQLite datetime('now') ('YYYY-MM-DD HH:MM:SS')."""
return (datetime.now(timezone.utc) + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
def log_event(
tip: str,
*,
nivel: str = "INFO",
account_id: int | None = None,
cod: str | None = None,
mesaj: str | None = None,
context: dict | None = None,
sursa: str | None = None,
request_id: str | None = None,
conn: Any = None,
) -> None:
"""Emite un eveniment: un rand `app_events` + o linie in logul text (acelasi continut redactat).
- `tip`: text liber documentat (lista extensibila).
- `nivel`: DEBUG|INFO|WARNING|ERROR|CRITICAL. Sub `AUTOPASS_LOG_LEVEL` -> ignorat.
- `context`: metadate (submission_id, count, status...) — NU payload PII integral.
- `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL);
None -> deschide/inchide o conexiune proprie.
Best-effort: orice exceptie e inghitita (jurnalul nu trebuie sa rupa fluxul).
"""
try:
settings = get_settings()
min_lvl = _LEVELS.get((settings.log_level or "INFO").upper(), 20)
lvl = (nivel or "INFO").upper()
if _LEVELS.get(lvl, 20) < min_lvl:
return
src = sursa or _DEFAULT_SOURCE
rid = request_id if request_id is not None else request_id_var.get()
mesaj_red = scrub_text(mesaj) if isinstance(mesaj, str) else mesaj
ctx_red = redact_pii(context) if context else None
ctx_json = (
json.dumps(ctx_red, ensure_ascii=False, default=str) if ctx_red is not None else None
)
purge_after = _purge_after(int(settings.log_retention_days))
own = conn is None
c = conn or get_connection()
try:
insert_app_event(
c,
request_id=rid,
account_id=account_id,
sursa=src,
tip=tip,
nivel=lvl,
cod=cod,
mesaj=mesaj_red,
context_json=ctx_json,
purge_after=purge_after,
)
finally:
if own:
c.close()
line = (
f"[{src}] tip={tip} nivel={lvl} cont={account_id} cod={cod} "
f"rid={rid} {mesaj_red or ''}"
)
if ctx_json:
line += f" ctx={ctx_json}"
_text_logger(src).info(scrub_text(line))
except Exception: # noqa: BLE001 — jurnal best-effort (ca notify_signup)
pass

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)

View File

@@ -1,4 +1,4 @@
"""Extragere payload submission -> campuri afisabile (US-003, PRD 3.5). """Extragere payload submission -> campuri afisabile.
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie (`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
@@ -35,6 +35,17 @@ def _clean_odometru(value: Any) -> str:
return s return s
def _clean_cod_rar(value: Any) -> str:
"""Cod RAR afisat curat: uppercase + strip '.0' defensiv (coercion Excel 'OE-2.0' -> 'OE-2').
Codurile RAR nu au zecimale, dar fii defensiv ca la odometru.
"""
s = _clean_str(value)
if s.endswith(".0"):
s = s[:-2]
return s.upper() if s else ""
def _vin_scurt(vin: str) -> str: def _vin_scurt(vin: str) -> str:
"""Forma trunchiata a VIN-ului pentru tabel (integral ramane in detaliu). """Forma trunchiata a VIN-ului pentru tabel (integral ramane in detaliu).
@@ -101,6 +112,23 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
denumire = _clean_str(item.get("denumire")) denumire = _clean_str(item.get("denumire"))
operatie = denumire or cod operatie = denumire or cod
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
cod_rar = _clean_cod_rar(item.get("cod_prestatie"))
# Operatia de service originala (codul intern + denumire venita prin API/import),
# distincta de operatia RAR mapata (cod_rar).
# Conventie goala: aceste campuri intorc "" (string gol) cand lipsesc — NU EMPTY="—".
# Motivul: apelantul decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`.
# Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—".
op_service_cod = _clean_str(item.get("cod_op_service"))
# op_service_denumire e relevant doar cand exista un cod de operatie de service;
# altfel ar expune denumirea RAR drept op. de service, ceea ce e semantic incorect.
op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else ""
# obs: text liber observatii (camp RAR, optional). Conventie goala "" (nu EMPTY).
# US-005 PRD 5.15: obs traieste in payload_json (nu coloana separata).
obs = _clean_str(data.get("obs"))
return { return {
"vehicul_nr": nr or EMPTY, "vehicul_nr": nr or EMPTY,
"vin": vin or EMPTY, "vin": vin or EMPTY,
@@ -109,4 +137,9 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
"data_prestatie": data_prest or EMPTY, "data_prestatie": data_prest or EMPTY,
"odometru": odo or EMPTY, "odometru": odo or EMPTY,
"cod": cod or EMPTY, "cod": cod or EMPTY,
"cod_rar": cod_rar or EMPTY,
# Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
"op_service_cod": op_service_cod,
"op_service_denumire": op_service_denumire,
"obs": obs,
} }

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

View File

@@ -19,12 +19,25 @@ from .config import Settings, get_settings
class RarError(Exception): class RarError(Exception):
"""Eroare la apel RAR. `status_code` = HTTP RAR; `field_errors` = lista [{field,message}] la 400.""" """Eroare la apel RAR. `status_code` = HTTP RAR; `field_errors` = lista [{field,message}] la 400.
def __init__(self, message: str, *, status_code: int | None = None, field_errors: list[dict] | None = None): `rar_message` = mesajul din envelope-ul de eroare al RAR (`{statusCode, message, data}`),
cand exista. Prezenta lui pe un 5xx inseamna ca RAR A RASPUNS definitiv „am esuat"
(nu o pierdere de raspuns) -> worker-ul il trateaza ca permanent, nu reconciliaza.
"""
def __init__(
self,
message: str,
*,
status_code: int | None = None,
field_errors: list[dict] | None = None,
rar_message: str | None = None,
):
super().__init__(message) super().__init__(message)
self.status_code = status_code self.status_code = status_code
self.field_errors = field_errors or [] self.field_errors = field_errors or []
self.rar_message = rar_message
class RarAuthError(RarError): class RarAuthError(RarError):
@@ -105,7 +118,14 @@ class RarClient:
errors = body.get("data") if isinstance(body.get("data"), list) else [] errors = body.get("data") if isinstance(body.get("data"), list) else []
msg = body.get("message", "Validare esuata la RAR") msg = body.get("message", "Validare esuata la RAR")
raise RarError(msg, status_code=400, field_errors=errors) raise RarError(msg, status_code=400, field_errors=errors)
raise RarError(f"postPrezentare esuat (HTTP {resp.status_code})", status_code=resp.status_code) # Non-200/non-400: pastram mesajul din envelope-ul RAR daca exista (ex. 500 cu
# `{"statusCode":500,"message":"Eroare la adaugarea prezentarii : ORA-..."}`).
rar_message = body.get("message") if isinstance(body, dict) else None
raise RarError(
f"postPrezentare esuat (HTTP {resp.status_code})",
status_code=resp.status_code,
rar_message=rar_message,
)
def get_finalizate(self, token: str) -> list[dict]: def get_finalizate(self, token: str) -> list[dict]:
"""Lista prezentarilor finalizate (pentru reconciliere — T2). """Lista prezentarilor finalizate (pentru reconciliere — T2).

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

@@ -10,8 +10,45 @@ CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
cui TEXT, cui TEXT,
email TEXT, -- email canonic de contact al firmei (US-001, PRD 5.12); nullable pt. conturi legacy
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3 active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1) -- Stare de ciclu de viata explicita (5.5). Superset al lui `active`: mentinem invariantul
-- active=1 <=> status='active' (vezi accounts.set_status / set_active). Worker gate-uieste pe status.
-- pending=neactivat · active=operational · blocked=suspendat reversibil · archived=scos din liste,
-- date read-only · deleted=stergere soft (tombstone; PII/creds + CUI purjate imediat la stergere,
-- vezi accounts.delete_account — randul ramane doar pentru audit).
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('pending','active','blocked','archived','deleted')),
rar_creds_enc TEXT, -- LEGACY (PRD 5.20 US-013 dropeaza coloana): creds RAR durabile env-less
-- Medii RAR per cont (PRD 5.20 US-001). Fiecare mediu = bifa de activare + slot creds.
-- medii_disponibile = enabled AND creds prezente (app/rar_env.py). Cont client nou =
-- Productie on / Testare off (clientii declara real); contul operator se pune manual pe Testare.
rar_test_enabled INTEGER NOT NULL DEFAULT 0 CHECK (rar_test_enabled IN (0, 1)),
rar_prod_enabled INTEGER NOT NULL DEFAULT 1 CHECK (rar_prod_enabled IN (0, 1)),
rar_creds_test_enc TEXT, -- creds RAR criptate (Fernet) pentru mediul Testare
rar_creds_prod_enc TEXT, -- creds RAR criptate (Fernet) pentru mediul Productie
rar_env_default TEXT NOT NULL DEFAULT 'prod' CHECK (rar_env_default IN ('test', 'prod')),
-- Comportament implicit la cod prestatie necunoscut/nemapat pe canalul API:
-- 0 (default, non-distructiv: submission 'needs_mapping', intra in editorul de mapare) sau
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
CHECK (on_unmapped_error_default IN (0, 1)),
-- Plan de cont (5.17). Tier de baza al contului (admin aloca manual via CLI set-tier).
-- trial_until: daca != NULL si > now -> effective_tier() intoarce 'pro' (trial Pro activ).
-- Cont nou primeste tier='free' + trial_until=now+30z via create_account.
-- Contul implicit id=1 (dev) primeste DEFAULT 'free' + trial_until=NULL (fara trial).
tier TEXT NOT NULL DEFAULT 'free'
CHECK (tier IN ('free','standard','pro','premium')),
trial_until TEXT, -- ISO datetime UTC sau NULL; nullable
-- Planul CERUT de client la signup (separat de `tier`). NU acorda drepturi:
-- `tier` ramane sursa unica de adevar pentru gate-ul API (require_api_access) si volum.
-- Folosit la integrarea platilor: client cere plan -> plateste -> admin/webhook urca `tier`
-- -> API se deblocheaza. NULL = necunoscut (cont creat via CLI / inainte de coloana).
requested_plan TEXT
CHECK (requested_plan IS NULL OR requested_plan IN ('free','standard','pro','premium')),
-- Marca temporala a acceptarii Termenilor + politicii de confidentialitate (GDPR, L.142).
-- Setata la signup (proba de consimtamant). NULL = cont fara flux de consimtamant (CLI/legacy).
consent_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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
@@ -59,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,
@@ -112,6 +153,8 @@ CREATE TABLE IF NOT EXISTS import_rows (
batch_id INTEGER NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE, batch_id INTEGER NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
row_index INTEGER NOT NULL, row_index INTEGER NOT NULL,
raw_json TEXT NOT NULL, -- PII criptat (Fernet, ca submissions) raw_json TEXT NOT NULL, -- PII criptat (Fernet, ca submissions)
override_json TEXT, -- patch CANONIC editat in preview, criptat Fernet (3.6, Approach B); NULL = fara editare
reviewed INTEGER NOT NULL DEFAULT 0, -- US-007 (PRD 5.12): 0=neconfirmat, 1=confirmat de operator; NU intra in payload/idempotenta
resolved_status TEXT NOT NULL DEFAULT 'pending' resolved_status TEXT NOT NULL DEFAULT 'pending'
CHECK (resolved_status IN ( CHECK (resolved_status IN (
'pending','ok','needs_mapping','needs_data', 'pending','ok','needs_mapping','needs_data',
@@ -146,6 +189,81 @@ CREATE TABLE IF NOT EXISTS users (
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- Jurnal de aplicatie la nivel de eveniment (PRD 5.6). Dublu canal: aici (vizibil
-- in tab "Jurnal") + log text rotativ (depanare). `tip` e text liber documentat
-- (lista extensibila, decizie §5) — adaugam tipuri fara migrare. Toate valorile
-- sunt REDACTATE la scriere (app/observ.py via app/security.py): parole/token ->
-- ***REDACTED***, VIN/nr partial. `context_json` = metadate (NU payload PII integral).
CREATE TABLE IF NOT EXISTS app_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL DEFAULT (datetime('now')),
request_id TEXT, -- corelare cu raspunsul clientului (US-002)
account_id INTEGER, -- NULL = eveniment de sistem (fara cont)
sursa TEXT NOT NULL DEFAULT 'api'
CHECK (sursa IN ('api','worker')),
tip TEXT NOT NULL, -- ex. api_prezentari, rar_login, submission_repus
nivel TEXT NOT NULL DEFAULT 'INFO',
cod TEXT, -- cod din catalogul de erori (app/errors.py) daca aplica
mesaj TEXT, -- mesaj scurt redactat
context_json TEXT, -- JSON metadate redactate (submission_id, count, status...)
purge_after TEXT -- ts + log_retention_days (US-008)
);
CREATE INDEX IF NOT EXISTS idx_app_events_ts ON app_events(ts);
CREATE INDEX IF NOT EXISTS idx_app_events_account ON app_events(account_id, ts);
CREATE INDEX IF NOT EXISTS idx_app_events_tip ON app_events(tip);
-- Reguli automate de mapare pe text (substring, per cont). PRD 5.8 US-001.
-- auto_send DEFAULT 0 (decizie CEO 2026-06-24): substring are blast-radius mai mare
-- decat maparea exacta; o regula noua rezolva codul dar tine randul pentru verificare
-- umana pana cand operatorul activeaza explicit "In coada".
CREATE TABLE IF NOT EXISTS operation_text_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
pattern TEXT NOT NULL,
cod_prestatie TEXT NOT NULL,
auto_send INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (account_id, pattern)
);
CREATE INDEX IF NOT EXISTS idx_text_rules_account ON operation_text_rules(account_id);
-- Sugestii de mapare (strat SILVER, L14-S3 PRD 5.14).
-- Etichete LLM/embedding — bootstrap; citita DOAR de suggest_codes/pending_unmapped,
-- NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
-- Cheia = denumire normalizata (fara diacritice, uppercase, spatii colapsate).
-- is_nul=1: non-operatie (ITP, discount, nr. inmatriculare) -> suprima (#4), cod NULL.
-- INSERT OR IGNORE la re-seed: nu suprascrie randuri existente (#2).
CREATE TABLE IF NOT EXISTS mapping_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
denumire_normalizata TEXT NOT NULL UNIQUE,
cod_prestatie TEXT, -- NULL cand is_nul=1 (supresie)
is_nul INTEGER NOT NULL DEFAULT 0 CHECK (is_nul IN (0, 1)),
source TEXT NOT NULL, -- 'llm', 'embedding', etc. (#5)
confidence REAL NOT NULL DEFAULT 0.0 CHECK (confidence >= 0.0 AND confidence <= 1.0),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_mapping_suggestions_cod
ON mapping_suggestions(cod_prestatie) WHERE cod_prestatie IS NOT NULL;
-- Mapari validate de oameni (strat GOLD partajat cross-account, L14-S3 PRD 5.14).
-- Confirmarile umane din ORICE cont contribuie la acest store (#8).
-- cross-account = suggestion-only (pre-completeaza editorul, F-A/#11), NU auto-send.
-- Auto-send DOAR din operations_mapping (GOLD propriu per-cont, Eng-F2).
-- Cheia = denumire_normalizata (NU cod_op_service: spatii de chei diferite, #14).
CREATE TABLE IF NOT EXISTS shared_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
denumire_normalizata TEXT NOT NULL UNIQUE,
cod_prestatie TEXT NOT NULL, -- cod RAR valid (GOLD = validat de om)
source TEXT NOT NULL DEFAULT 'human', -- 'human', 'human_import' (#5)
provenance TEXT, -- detalii: cont, email, batch (#5)
confidence REAL NOT NULL DEFAULT 1.0,
confirmations INTEGER NOT NULL DEFAULT 1, -- contor confirmari din orice cont
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici. -- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
CREATE TABLE IF NOT EXISTS worker_heartbeat ( CREATE TABLE IF NOT EXISTS worker_heartbeat (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),

View File

@@ -39,6 +39,46 @@ SENSITIVE_KEYS = frozenset(
) )
# Chei al caror continut e PII de identificare vehicul/proprietar: se logheaza DOAR
# partial (ultimele 4), niciodata integral (L.142/GDPR).
PII_PARTIAL_KEYS = frozenset({"vin", "nr_inmatriculare", "nr", "numar"})
def vin_partial(value: Any) -> str:
"""VIN/numar mascat partial: pastreaza ultimele 4 caractere, restul `…`.
'WVWZZZ1KZAW000123' -> 'WVW…0123'. Sub 4 caractere -> doar masca. Suficient
pentru a corela un rand fara a expune identificatorul integral in jurnal.
"""
s = str(value if value is not None else "").strip()
if not s:
return ""
if len(s) <= 4:
return ""
return f"{s[:3]}{s[-4:]}" if len(s) > 7 else f"{s[-4:]}"
def redact_pii(obj: Any) -> Any:
"""Ca `scrub`, plus mascare partiala a VIN/numar (PII_PARTIAL_KEYS).
Folosit la scrierea jurnalului (observ.log_event): mai intai mascam credentialele
integral (scrub), apoi reducem VIN/nr la forma partiala. Recursiv pe dict/list.
"""
if isinstance(obj, dict):
out: dict = {}
for k, v in obj.items():
if isinstance(k, str) and k.lower() in SENSITIVE_KEYS:
out[k] = MASK
elif isinstance(k, str) and k.lower() in PII_PARTIAL_KEYS and not isinstance(v, (dict, list)):
out[k] = vin_partial(v)
else:
out[k] = redact_pii(v)
return out
if isinstance(obj, (list, tuple)):
return [redact_pii(v) for v in obj]
return obj
def scrub(obj: Any) -> Any: def scrub(obj: Any) -> Any:
"""Copie a structurii cu valorile cheilor sensibile mascate, recursiv. """Copie a structurii cu valorile cheilor sensibile mascate, recursiv.

142
app/shared_store.py Normal file
View File

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

119
app/submissions_admin.py Normal file
View File

@@ -0,0 +1,119 @@
"""Lifecycle trimiteri blocate: sterge / re-pune in coada.
Inchide lacuna: un rand `error` (creds RAR gresite) ar ramane altfel permanent si
nereparabil. Aceste helpere adauga DOUA tranzitii controlate — stergere de randuri
ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge logica de trimitere
a worker-ului.
Invariante:
- Opereaza DOAR pe `error`/`needs_data`/`needs_mapping`. `sent` (dovada de trimitere
la RAR, audit) si `sending` (lease worker in zbor) sunt INTERZISE.
- Scope-ul (apartenenta la cont) se evalueaza INAINTEA starii: un rand inexistent SAU
al altui cont -> SubmissionNotFound (404, nu confirmam existenta). Doar pe randuri
proprii in stare gresita -> SubmissionStateConflict (409).
- Ambele emit eveniment in jurnal: `submission_sters` / `submission_repus`.
Functii cu `conn` (persistenta).
"""
from __future__ import annotations
import json
from .mapping import (
account_or_default,
account_scope_clause,
classify_prezentare,
load_mapping_meta,
load_nomenclator_codes,
)
from .observ import log_event
# Stari pe care le putem sterge / re-pune in coada (ne-sent, ne-in-zbor).
_GESTIONABILE = ("error", "needs_data", "needs_mapping")
class SubmissionNotFound(Exception):
"""Randul nu exista SAU apartine altui cont (acelasi mesaj — nu confirmam existenta)."""
class SubmissionStateConflict(Exception):
"""Randul exista si e al contului, dar e intr-o stare protejata (sent/sending)."""
def __init__(self, status: str):
super().__init__(f"stare protejata: {status}")
self.status = status
def _fetch_scoped(conn, account_id: int, sid: int):
scope_sql, scope_params = account_scope_clause(account_id)
return conn.execute(
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
[sid] + scope_params,
).fetchone()
def delete_submission(conn, account_id: int, sid: int) -> dict:
"""Sterge un rand ne-sent al contului. Ridica SubmissionNotFound / SubmissionStateConflict.
Intoarce {"submission_id", "status_anterior"} la succes.
"""
row = _fetch_scoped(conn, account_id, sid)
if row is None:
raise SubmissionNotFound()
status = row["status"]
if status not in _GESTIONABILE:
raise SubmissionStateConflict(status)
conn.execute("DELETE FROM submissions WHERE id=?", (sid,))
log_event(
"submission_sters",
account_id=account_or_default(account_id),
mesaj=f"trimitere #{sid} stearsa din {status}",
context={"submission_id": sid, "status_anterior": status},
conn=conn,
)
return {"submission_id": sid, "status_anterior": status}
def requeue_submission(conn, account_id: int, sid: int) -> dict:
"""Re-pune in coada un rand blocat al contului: re-ruleaza classify pe payload.
`error -> queued` (cand continutul e valid) sau ramane `needs_data`/`needs_mapping`
daca clasificarea o cere. Reseteaza retry_count/next_attempt_at/sending_since si
CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare).
Ridica SubmissionNotFound / SubmissionStateConflict. Intoarce
{"submission_id", "status_anterior", "status_nou"}.
"""
row = _fetch_scoped(conn, account_id, sid)
if row is None:
raise SubmissionNotFound()
status = row["status"]
if status not in _GESTIONABILE:
raise SubmissionStateConflict(status)
try:
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
if not isinstance(content, dict):
content = {}
except (ValueError, TypeError):
content = {}
mapping_meta = load_mapping_meta(conn, account_id)
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes)
conn.execute(
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, retry_count=0, "
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, updated_at=datetime('now') "
"WHERE id=?",
(cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], sid),
)
log_event(
"submission_repus",
account_id=account_or_default(account_id),
mesaj=f"trimitere #{sid} re-pusa: {status} -> {cl['status']}",
context={"submission_id": sid, "status_anterior": status, "status_nou": cl["status"]},
conn=conn,
)
return {"submission_id": sid, "status_anterior": status, "status_nou": cl["status"]}

View File

@@ -1,8 +1,8 @@
"""Helper-e utilizatori web (email + parola scrypt). US-001 PRD 3.3. """Helper-e utilizatori web (email + parola scrypt).
Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu
secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru
migrare cost viitoare (C9). migrare cost viitoare.
""" """
from __future__ import annotations from __future__ import annotations
@@ -98,7 +98,7 @@ def set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool = True)
"""Seteaza/sterge rolul admin pe toti userii contului dat. """Seteaza/sterge rolul admin pe toti userii contului dat.
Ridica ValueError daca contul nu exista. Ridica ValueError daca contul nu exista.
Daca contul exista dar nu are useri, e no-op silentios (confom spec US-010). Daca contul exista dar nu are useri, e no-op silentios.
""" """
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone() acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not acct: if not acct:
@@ -119,7 +119,7 @@ def is_account_admin(conn: sqlite3.Connection, account_id: int) -> bool:
def list_admin_emails(conn: sqlite3.Connection) -> list[str]: def list_admin_emails(conn: sqlite3.Connection) -> list[str]:
"""Returneaza emailurile tuturor userilor cu is_admin=1 (folosit de US-012).""" """Returneaza emailurile tuturor userilor cu is_admin=1."""
rows = conn.execute( rows = conn.execute(
"SELECT email FROM users WHERE is_admin=1" "SELECT email FROM users WHERE is_admin=1"
).fetchall() ).fetchall()

View File

@@ -17,6 +17,8 @@ import re
from datetime import date from datetime import date
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from app.errors import eroare as _eroare
# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract). # VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
# Numar inmatriculare: max 10, litere + cifre majuscule. # Numar inmatriculare: max 10, litere + cifre majuscule.
@@ -64,36 +66,54 @@ def validate_prezentare(content: dict) -> list[dict]:
# --- VIN --- # --- VIN ---
vin = _norm(content.get("vin")) vin = _norm(content.get("vin"))
if not VIN_RE.match(vin): if not VIN_RE.match(vin):
errors.append({ errors.append(_eroare(
"field": "vin", "VIN_FORMAT",
"message": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.", field="vin",
}) cauza="VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
))
# --- nrInmatriculare --- # --- nrInmatriculare ---
nrinm = _norm(content.get("nr_inmatriculare")) nrinm = _norm(content.get("nr_inmatriculare"))
if not NRINM_RE.match(nrinm): if not NRINM_RE.match(nrinm):
errors.append({ errors.append(_eroare(
"field": "nr_inmatriculare", "NR_INMATRICULARE_FORMAT",
"message": "Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.", field="nr_inmatriculare",
}) cauza="Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.",
))
# --- dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti --- # --- dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti ---
raw_data = str(content.get("data_prestatie") or "").strip() raw_data = str(content.get("data_prestatie") or "").strip()
try: try:
d = date.fromisoformat(raw_data) d = date.fromisoformat(raw_data)
except ValueError: except ValueError:
errors.append({"field": "data_prestatie", "message": "Format data invalid; foloseste YYYY-MM-DD."}) errors.append(_eroare(
"DATA_FORMAT",
field="data_prestatie",
cauza="Format data invalid; foloseste YYYY-MM-DD.",
))
d = None d = None
if d is not None: if d is not None:
if d < MIN_DATA_PRESTATIE: if d < MIN_DATA_PRESTATIE:
errors.append({"field": "data_prestatie", "message": "Data prestatiei nu poate fi anterioara datei de 01.12.2024."}) errors.append(_eroare(
"DATA_PREA_VECHE",
field="data_prestatie",
cauza="Data prestatiei nu poate fi anterioara datei de 01.12.2024.",
))
elif d > today_bucuresti(): elif d > today_bucuresti():
errors.append({"field": "data_prestatie", "message": "Data prestatiei nu poate fi in viitor."}) errors.append(_eroare(
"DATA_VIITOR",
field="data_prestatie",
cauza="Data prestatiei nu poate fi in viitor.",
))
# --- odometruFinal (string numeric) --- # --- odometruFinal (string numeric) ---
odo_final = _parse_int(content.get("odometru_final")) odo_final = _parse_int(content.get("odometru_final"))
if odo_final is None: if odo_final is None:
errors.append({"field": "odometru_final", "message": "odometruFinal trebuie sa fie un numar intreg (ca string)."}) errors.append(_eroare(
"ODOMETRU_FINAL_FORMAT",
field="odometru_final",
cauza="odometruFinal trebuie sa fie un numar intreg (ca string).",
))
# --- odometruInitial: obligatoriu daca prestatii ∋ R-ODO/I-ODO; <= odometruFinal --- # --- odometruInitial: obligatoriu daca prestatii ∋ R-ODO/I-ODO; <= odometruFinal ---
codes = _codes(content.get("prestatii")) codes = _codes(content.get("prestatii"))
@@ -101,26 +121,43 @@ def validate_prezentare(content: dict) -> list[dict]:
raw_initial = content.get("odometru_initial") raw_initial = content.get("odometru_initial")
has_initial = str(raw_initial or "").strip() != "" has_initial = str(raw_initial or "").strip() != ""
if needs_initial and not has_initial: if needs_initial and not has_initial:
errors.append({ errors.append(_eroare(
"field": "odometru_initial", "ODOMETRU_INITIAL_LIPSA",
"message": "odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.", field="odometru_initial",
}) cauza="odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.",
))
if has_initial: if has_initial:
odo_initial = _parse_int(raw_initial) odo_initial = _parse_int(raw_initial)
if odo_initial is None: if odo_initial is None:
errors.append({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie un numar intreg."}) errors.append(_eroare(
"ODOMETRU_INITIAL_FORMAT",
field="odometru_initial",
cauza="odometruInitial trebuie sa fie un numar intreg.",
))
elif odo_final is not None and odo_initial > odo_final: elif odo_final is not None and odo_initial > odo_final:
errors.append({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie <= odometruFinal."}) errors.append(_eroare(
"ODOMETRU_INITIAL_ORDINE",
field="odometru_initial",
cauza="odometruInitial trebuie sa fie <= odometruFinal.",
))
# --- prestatii nevide --- # --- prestatii nevide ---
if not codes: if not codes:
errors.append({"field": "prestatii", "message": "Lista de prestatii nu poate fi goala."}) errors.append(_eroare(
"PRESTATII_GOALE",
field="prestatii",
cauza="Lista de prestatii nu poate fi goala.",
))
# --- b64Image: optional, dar daca e prezent trebuie base64 valid --- # --- b64Image: optional, dar daca e prezent trebuie base64 valid ---
b64 = content.get("b64_image") b64 = content.get("b64_image")
if b64: if b64:
if not _is_valid_base64(str(b64)): if not _is_valid_base64(str(b64)):
errors.append({"field": "b64_image", "message": "b64Image nu este base64 valid."}) errors.append(_eroare(
"B64_INVALID",
field="b64_image",
cauza="b64Image nu este base64 valid.",
))
return errors return errors

View File

@@ -1,4 +1,4 @@
"""Panou admin web /admin. US-011 PRD 3.3b. """Panou admin web /admin.
Rute: Rute:
GET /admin — listeaza conturi in asteptare + active (require_admin) GET /admin — listeaza conturi in asteptare + active (require_admin)
@@ -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 list_accounts, set_active 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,18 +78,33 @@ 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
acct["is_complete"] = account_is_complete(acct)
acct["email"] = emails.get(acct["id"]) acct["email"] = emails.get(acct["id"])
pending = [a for a in accounts if not a["active"] and a["id"] != 1] # Plan EFECTIV (ce are contul acum): trial Pro activ ridica `free` la `pro`.
active = [a for a in accounts if a["active"] and a["id"] != 1] # `tier` ramane sursa de adevar pentru drepturi; `requested_plan` e doar intentia de la signup.
default = next((a for a in accounts if a["id"] == 1), None) eff = effective_tier(acct, now)
acct["tier_label"] = _plan_label(acct.get("tier")) # tier de baza (post-trial)
acct["tier_efectiv_label"] = _plan_label(eff) # plan efectiv ACUM
acct["trial_activ"] = eff != (acct.get("tier") or "free")
acct["trial_zile"] = _trial_zile_ramase(acct.get("trial_until"), now)
acct["requested_plan_label"] = _plan_label(acct.get("requested_plan"))
# Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0)
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
active = [a for a in accounts if a["status"] == "active" and a["id"] != 1]
suspended = [a for a in accounts if a["status"] in ("blocked", "archived") and a["id"] != 1]
return _TMPL.TemplateResponse(request, "admin.html", _ctx( return _TMPL.TemplateResponse(request, "admin.html", _ctx(
request, request,
csrf_token=get_csrf_token(request), csrf_token=get_csrf_token(request),
pending=pending, pending=pending,
active=active, active=active,
default_account=default, suspended=suspended,
error=error, error=error,
is_authenticated=True,
is_admin=True,
), status_code=status_code) ), status_code=status_code)
@@ -74,20 +120,131 @@ async def admin_get(request: Request):
conn.close() conn.close()
def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
"""Aplica un verb de ciclu de viata pe o lista de conturi. Conturile protejate
(id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul).
`action`: activate | block | archive | delete."""
for aid in ids:
try:
if action == "activate":
# Gate US-002: nu activam conturi fara identitate completa (companie+email+CUI)
acct_row = conn.execute(
"SELECT id, name, cui, email FROM accounts WHERE id=?", (aid,)
).fetchone()
if acct_row and not account_is_complete(acct_row):
continue # sarim activarea — contul incomplet ramane pending
set_status(conn, aid, "active")
elif action == "block":
set_status(conn, aid, "blocked")
elif action == "archive":
set_status(conn, aid, "archived")
elif action == "delete":
delete_account(conn, aid)
except ValueError:
continue # cont de sistem / inexistent -> sarit
def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str):
"""Corp comun pentru rutele de ciclu de viata: auth + CSRF + aplica verbul (bulk) + PRG.
Evita 4 handlere copy-paste care difera doar prin verb."""
require_admin(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
_apply_lifecycle(conn, account_id, action)
conn.commit()
finally:
conn.close()
return RedirectResponse("/admin", status_code=303)
@router.post("/admin/activate", response_class=HTMLResponse) @router.post("/admin/activate", response_class=HTMLResponse)
async def admin_activate( async def admin_activate(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Activeaza unul sau mai multe conturi (bulk). PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "activate")
@router.post("/admin/block", response_class=HTMLResponse)
async def admin_block(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Blocheaza (suspendare reversibila) unul sau mai multe conturi. PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "block")
@router.post("/admin/archive", response_class=HTMLResponse)
async def admin_archive(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Arhiveaza (scos din listele active, date read-only) unul sau mai multe conturi. PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "archive")
@router.post("/admin/delete", response_class=HTMLResponse)
async def admin_delete(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Stergere SOFT (tombstone + purjare PII imediata) a unuia sau mai multor conturi. PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "delete")
@router.post("/admin/set-tier", response_class=HTMLResponse)
async def admin_set_tier(
request: Request, request: Request,
account_id: int = Form(...), account_id: int = Form(...),
tier: str = Form(...),
csrf_token: str = Form(default=""), csrf_token: str = Form(default=""),
): ):
"""Activeaza un cont. PRG: redirect 303 la /admin dupa succes.""" """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) require_admin(request)
verify_csrf(request, csrf_token) verify_csrf(request, csrf_token)
conn = get_connection() conn = get_connection()
try: try:
try: try:
set_active(conn, account_id, True) # 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: except ValueError as exc:
return _render_admin(request, conn, error=str(exc), status_code=422) return _render_admin(request, conn, error=str(exc), status_code=422)
finally: finally:

View File

@@ -1,7 +1,8 @@
"""Rute autentificare web: /signup (US-003), /login + /logout (US-004). PRD 3.3.""" """Rute autentificare web: /signup, /login, /logout."""
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,9 +75,29 @@ 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_norm = cui.strip().upper() if cui else ""
if not cui_norm:
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error="CUI-ul firmei este obligatoriu.",
name=name, cui=cui, email=email, plan=requested_plan,
), status_code=422)
# Consimtamant Termeni + GDPR obligatoriu (proba). Checkbox bifat -> valoare ne-goala.
if not (consent and consent.strip()):
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error="Trebuie sa accepti Termenii si prelucrarea datelor (GDPR) pentru a crea cont.",
name=name, cui=cui, email=email, plan=requested_plan,
), status_code=422)
consent_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE, # 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()
@@ -76,17 +105,53 @@ 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.strip() or None, 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")
except ValueError as exc:
conn.execute("ROLLBACK")
exc_msg = str(exc)
# Ordinea conteaza: verifica EMAIL inainte de CUI (ambele contin 'deja folosit').
# create_user ridica exact "email deja folosit"; create_account ridica "CUI X e deja folosit".
if "email deja folosit" in exc_msg:
# Email duplicat -> mesaj specific emailului (T3, D#14-email)
error_msg = (
"Acest email este deja folosit. "
"Daca ai deja cont, autentifica-te."
)
elif "deja folosit" in exc_msg or "IntegrityError" in exc_msg:
# CUI duplicat -> mesaj prietenos, NU mesajul tehnic cu 'activate --account' (T3, D#14)
settings = get_settings()
if settings.support_email:
error_msg = (
f"Aceasta firma (CUI {cui_norm}) e deja inregistrata. "
f"Cere accesul de la administratorul contului sau contacteaza suportul: "
f"{settings.support_email}"
)
else:
error_msg = (
f"Aceasta firma (CUI {cui_norm}) e deja inregistrata. "
f"Cere accesul de la administratorul contului."
)
else:
error_msg = exc_msg
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error=error_msg,
name=name, cui=cui, email=email, plan=requested_plan,
), status_code=422)
except Exception as exc: except Exception as exc:
conn.execute("ROLLBACK") conn.execute("ROLLBACK")
return _TMPL.TemplateResponse(request, "signup.html", _ctx( return _TMPL.TemplateResponse(request, "signup.html", _ctx(
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

@@ -1,4 +1,4 @@
"""CSRF token per-sesiune + validare. US-009 PRD 3.3. """CSRF token per-sesiune + validare.
Contract pentru rutele POST web: Contract pentru rutele POST web:
- Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> - Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}">

View File

@@ -0,0 +1,375 @@
"""Generator de exemple de cod multi-limbaj pentru integrarea cu AutoPass.
Modul PUR: fara I/O, fara DB, fara stare globala.
Folosit de pagina de documentatie a hub-ului de integrare (Etapa 5).
Campurile obligatorii din payload-ul JSON sunt derivate dinamic din
PrezentareIn.model_fields pentru rezistenta la drift de schema.
"""
from __future__ import annotations
import json
from app.models import PrezentareIn
# Placeholder pentru cheia API — niciodata o cheie reala
_CHEIE_PLACEHOLDER = "rfak_..."
def _campuri_obligatorii() -> list[str]:
"""Intoarce lista campurilor obligatorii din PrezentareIn (is_required())."""
return [camp for camp, field in PrezentareIn.model_fields.items() if field.is_required()]
def _payload_prezentari_dict(account_id: int) -> dict:
"""Construieste un payload JSON exemplu cu toate campurile obligatorii.
Campurile cu default (odometru_initial, obs, b64_image, sistem_reparat) sunt
omise pentru concizie — nu sunt obligatorii.
`rar_credentials` NU e inclus: cererea trimite doar cheia API + datele prezentarii,
iar worker-ul foloseste credentialele RAR salvate pe cont (tab-ul Cont). Trimiterea
lor in payload e optionala (suprascrie creds-urile contului pe acea cerere).
"""
# Construim un dict cu toate campurile obligatorii
campuri = _campuri_obligatorii()
prezentare: dict = {}
# Valori exemplu pentru campuri obligatorii cunoscute
valori_exemplu: dict = {
"vin": "WVWZZZ1JZXW000001",
"nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-06-22",
"odometru_final": "150000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
for camp in campuri:
if camp in valori_exemplu:
prezentare[camp] = valori_exemplu[camp]
else:
# Fallback generic pentru campuri neasteptate adaugate ulterior
prezentare[camp] = f"<{camp}>"
return {"prezentari": [prezentare]}
def _payload_json_str(account_id: int, indent: int = 2) -> str:
"""Payload JSON formatat ca string pentru includere in snippet-uri."""
return json.dumps(_payload_prezentari_dict(account_id), indent=indent, ensure_ascii=False)
def _payload_json_compact(account_id: int) -> str:
"""Payload JSON pe o singura linie (fara newline) pentru string literal C#/VFP.
Foloseste separators=(',', ':') pentru a elimina spatiile si newline-urile.
Rezultatul e un JSON valid pe o singura linie, fara newline in interior.
"""
return json.dumps(_payload_prezentari_dict(account_id), separators=(",", ":"), ensure_ascii=False)
def _snippet_curl_prezentari(base_url: str, account_id: int) -> str:
payload = _payload_json_str(account_id)
return f"""curl -X POST "{base_url}/v1/prezentari" \\
-H "Content-Type: application/json" \\
-H "X-API-Key: {_CHEIE_PLACEHOLDER}" \\
-d '{payload}'"""
def _snippet_curl_import(base_url: str, account_id: int) -> str:
fisier = '"file=@prezentari.xlsx"'
return (
f'curl -X POST "{base_url}/v1/import" \\\n'
f' -H "X-API-Key: {_CHEIE_PLACEHOLDER}" \\\n'
f" -F {fisier}"
)
def _snippet_python_prezentari(base_url: str, account_id: int) -> str:
payload = _payload_json_str(account_id)
return f"""import requests
url = "{base_url}/v1/prezentari"
headers = {{
"X-API-Key": "{_CHEIE_PLACEHOLDER}",
"Content-Type": "application/json",
}}
payload = {payload}
response = requests.post(url, json=payload, headers=headers)
print(response.json())"""
def _snippet_python_import(base_url: str, account_id: int) -> str:
return f"""import requests
url = "{base_url}/v1/import"
headers = {{
"X-API-Key": "{_CHEIE_PLACEHOLDER}",
}}
with open("prezentari.xlsx", "rb") as f:
files = {{"file": ("prezentari.xlsx", f, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}}
response = requests.post(url, headers=headers, files=files)
print(response.json())"""
def _snippet_php_prezentari(base_url: str, account_id: int) -> str:
payload = _payload_json_str(account_id)
# Escapeaza apostrof-urile pentru PHP heredoc
payload_php = payload.replace("'", "\\'")
return f"""<?php
$url = "{base_url}/v1/prezentari";
$payload = '{payload_php}';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/json",
"X-API-Key: {_CHEIE_PLACEHOLDER}",
]);
$response = curl_exec($ch);
curl_close($ch);
echo $response;"""
def _snippet_php_import(base_url: str, account_id: int) -> str:
return f"""<?php
$url = "{base_url}/v1/import";
$fisier = new CURLFile("prezentari.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "prezentari.xlsx");
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, ["file" => $fisier]);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"X-API-Key: {_CHEIE_PLACEHOLDER}",
]);
$response = curl_exec($ch);
curl_close($ch);
echo $response;"""
def _snippet_csharp_prezentari(base_url: str, account_id: int) -> str:
payload = _payload_json_compact(account_id)
# Escape ghilimele duble pentru string C# (literal pe o singura linie)
payload_cs = payload.replace('"', '\\"')
return f"""using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
var client = new HttpClient();
client.DefaultRequestHeaders.Add("X-API-Key", "{_CHEIE_PLACEHOLDER}");
var json = "{payload_cs}";
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("{base_url}/v1/prezentari", content);
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);"""
def _snippet_csharp_import(base_url: str, account_id: int) -> str:
return f"""using System.Net.Http;
using System.Threading.Tasks;
var client = new HttpClient();
client.DefaultRequestHeaders.Add("X-API-Key", "{_CHEIE_PLACEHOLDER}");
using var form = new MultipartFormDataContent();
var fileBytes = File.ReadAllBytes("prezentari.xlsx");
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
form.Add(fileContent, "file", "prezentari.xlsx");
var response = await client.PostAsync("{base_url}/v1/import", form);
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);"""
def _snippet_node_prezentari(base_url: str, account_id: int) -> str:
payload = _payload_json_str(account_id)
return f"""const payload = {payload};
const response = await fetch("{base_url}/v1/prezentari", {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"X-API-Key": "{_CHEIE_PLACEHOLDER}",
}},
body: JSON.stringify(payload),
}});
const data = await response.json();
console.log(data);"""
def _snippet_node_import(base_url: str, account_id: int) -> str:
# FormData si Blob sunt globale in Node 18+ — nu necesita import din node:buffer
return f"""import {{ readFileSync }} from "fs";
const form = new FormData();
const continut = readFileSync("prezentari.xlsx");
form.append("file", new Blob([continut], {{
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}}), "prezentari.xlsx");
const response = await fetch("{base_url}/v1/import", {{
method: "POST",
headers: {{ "X-API-Key": "{_CHEIE_PLACEHOLDER}" }},
body: form,
}});
const data = await response.json();
console.log(data);"""
def _snippet_vfp_msxml_prezentari(base_url: str, account_id: int) -> str:
payload = _payload_json_compact(account_id)
# VFP: ghilimele in string se dubleaza; payload compact = o singura linie
payload_vfp = payload.replace('"', '""')
return f"""* Visual FoxPro — MSXML2.ServerXMLHTTP.6.0
LOCAL oHTTP, cURL, cPayload, cRaspuns
cURL = "{base_url}/v1/prezentari"
cPayload = "{payload_vfp}"
oHTTP = CREATEOBJECT("MSXML2.ServerXMLHTTP.6.0")
oHTTP.open("POST", cURL, .F.)
oHTTP.setRequestHeader("Content-Type", "application/json")
oHTTP.setRequestHeader("X-API-Key", "{_CHEIE_PLACEHOLDER}")
oHTTP.send(cPayload)
cRaspuns = oHTTP.responseText
? cRaspuns"""
def _snippet_vfp_msxml_import(base_url: str, account_id: int) -> str:
return f"""* Visual FoxPro — MSXML2.ServerXMLHTTP.6.0 — upload fisier
* Necesita ADODB.Stream pentru a citi fisierul binar
LOCAL oHTTP, oStream, oBody
LOCAL cURL, cGranita, cCRLF, cDisp, cType
cURL = "{base_url}/v1/import"
cGranita = "----AutoPassBoundary"
cCRLF = CHR(13) + CHR(10)
* Citire fisier in ADODB.Stream
oStream = CREATEOBJECT("ADODB.Stream")
oStream.Type = 1 && adTypeBinary
oStream.Open()
oStream.LoadFromFile("prezentari.xlsx")
* Construire body multipart (simplificat — pentru fisiere mici)
oBody = CREATEOBJECT("ADODB.Stream")
oBody.Type = 1
oBody.Open()
oHTTP = CREATEOBJECT("MSXML2.ServerXMLHTTP.6.0")
oHTTP.open("POST", cURL, .F.)
oHTTP.setRequestHeader("Content-Type", "multipart/form-data; boundary=" + cGranita)
oHTTP.setRequestHeader("X-API-Key", "{_CHEIE_PLACEHOLDER}")
* Nota: pentru upload binar complet folositi un helper COM sau ADODB.
oHTTP.send(oBody)
? oHTTP.responseText"""
def _snippet_vfp_winhttp_prezentari(base_url: str, account_id: int) -> str:
payload = _payload_json_compact(account_id)
# VFP: ghilimele in string se dubleaza; payload compact = o singura linie
payload_vfp = payload.replace('"', '""')
return f"""* Visual FoxPro — WinHttp.WinHttpRequest.5.1
LOCAL oHTTP, cURL, cPayload, cRaspuns
cURL = "{base_url}/v1/prezentari"
cPayload = "{payload_vfp}"
oHTTP = CREATEOBJECT("WinHttp.WinHttpRequest.5.1")
oHTTP.Open("POST", cURL, .F.)
oHTTP.SetRequestHeader("Content-Type", "application/json")
oHTTP.SetRequestHeader("X-API-Key", "{_CHEIE_PLACEHOLDER}")
oHTTP.Send(cPayload)
cRaspuns = oHTTP.ResponseText
? cRaspuns"""
def _snippet_vfp_winhttp_import(base_url: str, account_id: int) -> str:
return f"""* Visual FoxPro — WinHttp.WinHttpRequest.5.1 — upload fisier
* Necesita ADODB.Stream pentru a citi fisierul binar
LOCAL oHTTP, oStream
LOCAL cURL, cGranita, cCRLF
cURL = "{base_url}/v1/import"
cGranita = "----AutoPassBoundary"
cCRLF = CHR(13) + CHR(10)
oStream = CREATEOBJECT("ADODB.Stream")
oStream.Type = 1 && adTypeBinary
oStream.Open()
oStream.LoadFromFile("prezentari.xlsx")
oHTTP = CREATEOBJECT("WinHttp.WinHttpRequest.5.1")
oHTTP.Open("POST", cURL, .F.)
oHTTP.SetRequestHeader("Content-Type", "multipart/form-data; boundary=" + cGranita)
oHTTP.SetRequestHeader("X-API-Key", "{_CHEIE_PLACEHOLDER}")
* Nota: pentru upload binar complet folositi un helper COM sau ADODB.
oHTTP.Send()
? oHTTP.ResponseText"""
def exemple(base_url: str, account_id: int) -> dict:
"""Genereaza snippet-uri de cod multi-limbaj pentru integrarea cu AutoPass.
Parametri:
base_url: URL-ul de baza al gateway-ului (ex. "https://autopass.example.com")
account_id: ID-ul contului (inclus in context, nu in snippet-uri)
Intoarce un dict structurat astfel:
{
"<limbaj>": {
"prezentari": "<snippet string>",
"import": "<snippet string>",
},
...
}
Limbaje: curl, python, php, csharp, node, vfp_msxml, vfp_winhttp.
Functie pura: fara I/O, fara DB, fara stare globala.
"""
return {
"curl": {
"prezentari": _snippet_curl_prezentari(base_url, account_id),
"import": _snippet_curl_import(base_url, account_id),
},
"python": {
"prezentari": _snippet_python_prezentari(base_url, account_id),
"import": _snippet_python_import(base_url, account_id),
},
"php": {
"prezentari": _snippet_php_prezentari(base_url, account_id),
"import": _snippet_php_import(base_url, account_id),
},
"csharp": {
"prezentari": _snippet_csharp_prezentari(base_url, account_id),
"import": _snippet_csharp_import(base_url, account_id),
},
"node": {
"prezentari": _snippet_node_prezentari(base_url, account_id),
"import": _snippet_node_import(base_url, account_id),
},
"vfp_msxml": {
"prezentari": _snippet_vfp_msxml_prezentari(base_url, account_id),
"import": _snippet_vfp_msxml_import(base_url, account_id),
},
"vfp_winhttp": {
"prezentari": _snippet_vfp_winhttp_prezentari(base_url, account_id),
"import": _snippet_vfp_winhttp_import(base_url, account_id),
},
}

View File

@@ -1,9 +1,6 @@
""" """Traducere stari tehnice in text uman + clasa CSS.
labels.py — traducere stari tehnice in text uman + clasa CSS (US-001, PRD 3.4).
Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri. Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri.
Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001.
""" """
import json import json
@@ -51,12 +48,45 @@ STARI_SUBMISSION: dict[str, Eticheta] = {
), ),
"error": ( "error": (
"Eroare la trimitere", "Eroare la trimitere",
"Vezi detaliul randului; se reincearca automat sau necesita corectie.", "Trimiterea a esuat si nu se mai reincearca automat. Vezi detaliul randului; "
"daca tine de credentialele RAR, corecteaza-le in Cont.",
"s-error", "s-error",
), ),
} }
# ---------------------------------------------------------------------------
# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri
# Dict propriu — NU element in tuple Eticheta (ar rupe template-urile care
# despacheteaza 3 elemente). eticheta_stare ramane neatinsa.
# ---------------------------------------------------------------------------
ETICHETE_SCURTE: dict[str, str] = {
"queued": "In coada",
"sending": "Se trimite",
"sent": "Finalizat",
"needs_mapping": "De mapat",
"needs_data": "Date lipsa",
"error": "Eroare",
}
def eticheta_scurta(status: str) -> str:
"""
Returneaza eticheta compacta (pill) pentru o stare de submission.
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
stari noi adaugate in schema fara mapare corespunzatoare.
"""
try:
return ETICHETE_SCURTE[status]
except KeyError:
raise KeyError(
f"Starea de submission {status!r} nu are eticheta scurta in labels.py. "
"Adauga-o in ETICHETE_SCURTE."
)
def eticheta_stare(status: str) -> Eticheta: def eticheta_stare(status: str) -> Eticheta:
""" """
Returneaza (text, subtext, css_class) pentru o stare de submission. Returneaza (text, subtext, css_class) pentru o stare de submission.
@@ -122,7 +152,7 @@ def eticheta_rar(stare: str) -> Eticheta:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Format data RAR (US-001, PRD 3.5) # Format data RAR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def format_data_rar(raw: object) -> str: def format_data_rar(raw: object) -> str:
@@ -148,7 +178,7 @@ def format_data_rar(raw: object) -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Motiv uman din rar_error (US-004, PRD 3.5) # Motiv uman din rar_error
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def motiv_uman(status: str, rar_error: object) -> str: def motiv_uman(status: str, rar_error: object) -> str:
@@ -178,6 +208,8 @@ def motiv_uman(status: str, rar_error: object) -> str:
return f"Cod RAR lipsa pentru: {nume}" if nume else "Cod RAR lipsa" return f"Cod RAR lipsa pentru: {nume}" if nume else "Cod RAR lipsa"
if "auto_send" in data: if "auto_send" in data:
return "Necesita confirmare manuala (auto-send oprit pentru cod)" return "Necesita confirmare manuala (auto-send oprit pentru cod)"
if "problema" in data:
return str(data.get("problema") or "")[:200]
parti = [f"{k}: {v}" for k, v in data.items()] parti = [f"{k}: {v}" for k, v in data.items()]
return "; ".join(parti)[:200] return "; ".join(parti)[:200]
@@ -195,6 +227,180 @@ def motiv_uman(status: str, rar_error: object) -> str:
return str(data)[:160] return str(data)[:160]
# ---------------------------------------------------------------------------
# parse_erori — transforma rar_error in lista 3-niveluri
# ---------------------------------------------------------------------------
def parse_erori(rar_error: object) -> list[dict]:
"""Transforma `rar_error` (JSON stocat) intr-o lista de erori 3-niveluri.
Fiecare element al listei are cheile: problema, cauza, fix, field (sau None).
Functie PURA — nu arunca niciodata exceptii; degradeaza gratios pe orice forma.
Forme recunoscute:
- None / "" / falsy -> lista goala []
- array imbogatit (au cod sau problema) -> un element per eroare
- dict cu cod specific -> 1 element cu cele 3 niveluri din dict
- dict fara cod (forma veche: unmapped / auto_send) -> 1 element cu problema din context
- lista cu {field, message} fara cod -> degradare: problema=message, cauza/fix=""
- string plain -> 1 element cu problema=text, cauza/fix=""
- JSON corupt -> 1 element cu problema=text brut, cauza/fix=""
"""
if not rar_error:
return []
raw = rar_error if isinstance(rar_error, str) else str(rar_error)
# Incercare parsare JSON
try:
data = json.loads(raw)
except (ValueError, TypeError):
# String plain sau JSON corupt: degradare gratuoasa
return [{"problema": raw[:200], "cauza": "", "fix": "", "field": None}]
# --- Forma: array de erori ---
if isinstance(data, list):
rezultat = []
for e in data:
if not isinstance(e, dict):
rezultat.append({"problema": str(e)[:200], "cauza": "", "fix": "", "field": None})
continue
# Eroare imbogatita (are cod sau problema)
if e.get("cod") or e.get("problema"):
rezultat.append({
"problema": e.get("problema") or e.get("cod") or "",
"cauza": e.get("cauza") or e.get("message") or "",
"fix": e.get("fix") or "",
"field": e.get("field"),
# Cod BRUT de catalog (ex. RAR_EROARE_SERVER) — DOAR pentru modal.
"cod": e.get("cod"),
})
else:
# Forma veche: {field, message} fara cod
msg = str(e.get("message") or e.get("msg") or "; ".join(str(v) for v in e.values()))
elem = {
"problema": msg[:200],
"cauza": "",
"fix": "",
"field": e.get("field"),
}
# Filtreaza elementele complet goale (problema/cauza/fix toate vide)
if not (
elem["problema"].strip() == ""
and elem["cauza"].strip() == ""
and elem["fix"].strip() == ""
):
rezultat.append(elem)
return rezultat
# --- Forma: dict ---
if isinstance(data, dict):
# Dict imbogatit cu cod explicit
if data.get("cod") or data.get("problema"):
return [{
"problema": data.get("problema") or data.get("cod") or "",
"cauza": data.get("cauza") or "",
"fix": data.get("fix") or "",
"field": data.get("field"),
# Cod BRUT de catalog (ex. COD_NEMAPAT) — DOAR pentru modal.
"cod": data.get("cod"),
}]
# Dict vechi: unmapped
if "unmapped" in data:
ops = data.get("unmapped") or []
coduri = ", ".join(
(o.get("cod_op_service") or "") for o in ops if isinstance(o, dict)
).strip(", ")
problema = f"Cod RAR lipsa pentru: {coduri}" if coduri else "Cod RAR lipsa"
return [{"problema": problema, "cauza": "", "fix": "", "field": None}]
# Dict vechi: auto_send
if "auto_send" in data:
return [{"problema": "Necesita confirmare manuala (auto-send oprit pentru cod)",
"cauza": "", "fix": "", "field": None}]
# Dict generic necunoscut
parti = "; ".join(f"{k}: {v}" for k, v in data.items())
if not parti.strip():
return []
return [{"problema": parti[:200], "cauza": "", "fix": "", "field": None}]
# Scalar (nr, bool, etc.)
return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}]
# ---------------------------------------------------------------------------
# Etichete stari preview import (vocabular DIFERIT de starile de submission)
#
# Starile de preview (ok/needs_review/already_sent/duplicate_in_file) NU
# exista in STARI_SUBMISSION — reutilizarea directa a eticheta_stare/eticheta_scurta
# ridica KeyError. Acest map este sursa de adevar pentru stratul de adaptare din
# _web_compute_preview (routes.py) si pentru template (_preview_rand.html).
# ---------------------------------------------------------------------------
STARI_PREVIEW: dict[str, tuple[str, str]] = {
"ok": ("Gata de trimis", "s-ok"),
"needs_review": ("Verifica valori", "s-needs_review"),
"needs_mapping": ("Cod RAR lipsa", "s-needs_mapping"),
"needs_data": ("Date incomplete", "s-needs_data"),
"already_sent": ("Deja trimis", "s-already_sent"),
"duplicate_in_file": ("Duplicat in fisier", "s-duplicate_in_file"),
}
def nota_umana_preview(status: str, errors: list, flags: list) -> str:
"""Formateaza mesajul uman pentru coloana Note din tabelul de preview import.
Primeste ``errors`` ca lista Python (nu JSON string) — NU pasa la motiv_uman
sau parse_erori care asteapta un JSON string si ar produce repr Python brut
prin fallback ``raw[:160]`` (bug documentat in PRD 5.11 US-003).
Logica de prioritate:
- already_sent / duplicate_in_file -> "" (template le afiseaza separat)
- needs_mapping -> unmapped INAINTE de flags (codul lipsa e motivul real)
- flags non-goale -> primul flag (needs_review: data ambigua etc.)
- errors cu cheie "unmapped" -> "Cod RAR lipsa pentru: COD1, COD2"
- errors cu field+message (needs_data) -> primul mesaj de validare
- altceva -> ""
Fara exceptii. Trunchiat la 200 caractere.
"""
if status in ("already_sent", "duplicate_in_file"):
return ""
# needs_mapping: codul RAR lipseste — prioritizeaza 'unmapped' inaintea flags,
# altfel un rand cu si un flag (ex. VIN numeric) ar afisa textul flag-ului
# si ascunde motivul real (cod lipsa).
if status == "needs_mapping":
for e in errors:
if not isinstance(e, dict):
continue
if "unmapped" in e:
ops = e.get("unmapped") or []
coduri = ", ".join(
o.get("cod_op_service", "") for o in ops if isinstance(o, dict)
)
return ("Cod RAR lipsa pentru: " + coduri if coduri else "Cod RAR lipsa")
if flags:
return str(flags[0])[:200]
for e in errors:
if not isinstance(e, dict):
continue
if "unmapped" in e:
ops = e.get("unmapped") or []
coduri = ", ".join(
o.get("cod_op_service", "") for o in ops if isinstance(o, dict)
)
return (f"Cod RAR lipsa pentru: {coduri}" if coduri else "Cod RAR lipsa")
msg = (
e.get("message")
or e.get("msg")
or e.get("problema")
or e.get("cauza")
or ""
)
if msg:
return str(msg)[:200]
return ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constante auxiliare (microcopy fix, fara logica) # Constante auxiliare (microcopy fix, fara logica)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

33
app/web/middleware.py Normal file
View File

@@ -0,0 +1,33 @@
"""Middleware HTTP: request_id per cerere.
Fiecare raspuns primeste un header `X-Request-ID` (generat daca clientul nu trimite
unul). Pe durata cererii, id-ul e disponibil prin `observ.request_id_var` (contextvar)
in handlerul de erori si in `log_event` — fara a polua semnaturile.
Format opac, fara PII: `secrets.token_hex(8)` (16 hex). Daca clientul trimite un
`X-Request-ID`, il pastram (corelare end-to-end), dar il scurtam defensiv (max 64).
"""
from __future__ import annotations
import secrets
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from ..observ import request_id_var
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
incoming = request.headers.get("X-Request-ID")
request_id = (incoming.strip()[:64] if incoming and incoming.strip() else secrets.token_hex(8))
token = request_id_var.set(request_id)
# Expune si pe request.state pentru handlerele care prefera accesul explicit.
request.state.request_id = request_id
try:
response = await call_next(request)
finally:
request_id_var.reset(token)
response.headers["X-Request-ID"] = request_id
return response

View File

@@ -1,6 +1,6 @@
"""Rate-limit in-proces cu fereastra glisanta. US-009 PRD 3.3 C5. """Rate-limit in-proces cu fereastra glisanta.
Fara dependinta externa. Folosit de POST /signup (US-003) cu cheia = IP client. Fara dependinta externa. Folosit de POST /signup cu cheia = IP client.
Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py). Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py).
""" """

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
"""Helper-e sesiune web. US-002 PRD 3.3. """Helper-e sesiune web.
Mecanism require_login (C11): NU un dependency FastAPI care intoarce RedirectResponse Mecanism require_login: NU un dependency FastAPI care intoarce RedirectResponse
(acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb: (acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb:
- require_login() RIDICA LoginRequired - require_login() RIDICA LoginRequired
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce - app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce
@@ -31,7 +31,7 @@ def current_account(request: Request) -> int | None:
def current_user_id(request: Request) -> int | None: def current_user_id(request: Request) -> int | None:
"""user_id din sesiune sau None (C19: leaga import_attestations.confirmed_by).""" """user_id din sesiune sau None (leaga import_attestations.confirmed_by)."""
val = request.session.get("user_id") val = request.session.get("user_id")
return int(val) if val is not None else None return int(val) if val is not None else None
@@ -88,7 +88,7 @@ def require_admin(request: Request) -> int:
def set_session(request: Request, account_id: int, user_id: int) -> None: def set_session(request: Request, account_id: int, user_id: int) -> None:
"""Seteaza sesiunea dupa login. Curata mai intai (C3 anti-fixare sesiune).""" """Seteaza sesiunea dupa login. Curata mai intai (anti-fixare sesiune)."""
request.session.clear() request.session.clear()
request.session["account_id"] = account_id request.session["account_id"] = account_id
request.session["user_id"] = user_id request.session["user_id"] = user_id

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,7 +1,29 @@
<div id="acasa-section"> <div id="acasa-section">
{# === Centru de greutate: caseta de upload (importul e operatia principala) === #} {# === Banner ne-blocant: cont incomplet (US-002) ===
{% include '_upload.html' %} Apare cand accounts.name / email / CUI sunt necompletate (conturi legacy sau create din CLI).
NU blocheaza importul sau uploadul — doar orienteaza operatorul sa completeze datele.
Dispare automat dupa ce contul devine complet (re-render la urmatoarea navigare/reload).
#}
{% if cont_incomplet %}
<div class="card banner" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); margin-bottom:14px; padding:10px 14px; font-size:13px;">
<strong>Completeaza datele firmei (email / CUI).</strong>
Contul tau nu are inca email de contact si CUI configurate.
<a href="/?tab=cont" style="margin-left:6px;">Completeaza acum &rarr;</a>
</div>
{% endif %}
{# === Container colapsabil: stepper + upload intr-un singur element <details> (US-006).
Serverul seteaza atributul `open` din are_trimiteri:
are_trimiteri=False (first-run) → open (importul e vizibil imediat, fara JS)
are_trimiteri=True (returning) → colapsat (nu ocupa ecranul, dar e accesibil la click)
Degradare fara JS: corecta pe ambele ramuri.
In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul
intern) → <details> ramane neatins → containerul ramane deschis intre pasi. === #}
<details id="import-details"{% if not are_trimiteri %} open{% endif %}>
<summary>+ Importa fisier (XLSX / CSV)</summary>
{% include '_upload.html' %}
</details>
{# === Subordonat: primii pasi pe un singur rand compact === #} {# === Subordonat: primii pasi pe un singur rand compact === #}
{% set toti_esentiali = are_creds and are_trimiteri %} {% set toti_esentiali = are_creds and are_trimiteri %}
@@ -44,13 +66,14 @@
</div> </div>
{% endif %} {% endif %}
{# === Subordonat: ajutor rapid pe un rand discret === #} {# Sectiunea Trimiteri, permanenta sub upload.
<div style="margin-top:10px; font-size:13px; color:var(--muted); La first-run (zero trimiteri), randam un placeholder <section> gol/ascuns — necesar
display:flex; gap:16px; flex-wrap:wrap; align-items:center;"> ca OOB swap-ul de la confirma sa gaseasca tinta valida in DOM si sa injecteze
<span>Ajutor:</span> _coada.html fara reload complet. Fara placeholder, HTMX ignora silentios OOB-ul. #}
<a href="/?tab=coada">Trimiteri</a> {% if are_trimiteri %}
<a href="/?tab=mapari">Mapari</a> {% include '_coada.html' %}
<a href="/?tab=nomenclator">Coduri RAR</a> {% else %}
</div> <section id="trimiteri-section" hidden></section>
{% endif %}
</div> </div>

View File

@@ -1,5 +1,5 @@
{% if not account_active %} {% if not account_active %}
<div class="card banner" style="border-color:var(--warn); background:#201c0f;" <div class="card banner" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card));"
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML"> hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
<strong>Cont in asteptare de activare.</strong> <strong>Cont in asteptare de activare.</strong>
Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin. Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin.

View File

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

View File

@@ -1,55 +1,98 @@
<div id="coada-section"> {#
_coada.html — sectiunea "Trimiterile tale" inclusa pe Acasa, sub zona de upload.
Filtre + tabel (_submissions.html); detaliul se deschide in modalul global (#modal-detaliu).
#}
<section id="trimiteri-section" aria-labelledby="trimiteri-heading"
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"
{% 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 style="font-size:15px; margin:0;">Trimiteri catre RAR</h2> lista incepe direct sub filtre. Heading pastrat sr-only pentru a11y (section
aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
<h2 id="trimiteri-heading" class="sr-only">Trimiterile tale</h2>
{% if blocate_total %}
<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin:0 0 10px;">
<span class="tab-badge" title="{{ blocate_total }} necesita atentie"
style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ blocate_total }}</span>
<span class="muted" style="font-size:var(--fs-sm);">de rezolvat</span>
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;"> <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 %}
<!-- Filtre (US-009): reincarca tabelul; poll-ul re-trimite filtrul curent prin hx-include --> <!-- 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).
Quick-pills de data apeleaza setDataRange -> seteaza data_de/data_pana + re-submit. -->
<form id="filtre-trimiteri" <form id="filtre-trimiteri"
hx-get="/_fragments/submissions" hx-get="/_fragments/submissions"
hx-target="#submissions-wrap" hx-target="#submissions-wrap"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']" hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;"> style="display:flex; gap:8px 12px; flex-wrap:wrap; align-items:center; margin-bottom:12px;">
<div> <input type="hidden" id="f-status" name="status" value="{{ status_filtru | default('', true) }}">
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label> {# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #}
<select id="f-status" name="status"> <input type="hidden" id="f-page" name="page" value="1">
<option value="">toate</option>
<option value="queued">in asteptare</option> {# === STANGA: Quick-pills de data (preset interval) + buton Custom ===
<option value="sent">declarate la RAR</option> Azi / 7 zile / 30 zile → seteaza interval preset si submitr automat.
<option value="needs_mapping">lipsa cod</option> Custom → dezvaluie #custom-date-fields pentru introducere manuala (fara submit automat). #}
<option value="needs_data">date incomplete</option> <div style="flex:0 0 auto; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<option value="error">eroare</option> <div class="pills-categorii" id="quick-date-pills">
<option value="sending">se trimite</option> <button type="button" class="pill-cat pill-data" data-range="azi"
</select> aria-pressed="false"
onclick="setDataRange(this,'azi')">Azi</button>
<button type="button" class="pill-cat pill-data" data-range="7zile"
aria-pressed="false"
onclick="setDataRange(this,'7zile')">7 zile</button>
<button type="button" class="pill-cat pill-data" data-range="30zile"
aria-pressed="false"
onclick="setDataRange(this,'30zile')">30 zile</button>
<button type="button" class="pill-cat pill-data" data-range="custom"
aria-pressed="false"
onclick="setDataRange(this,'custom')">Custom</button>
</div>
{# Campuri de data pentru modul Custom: ascunse pana la click pe „Custom".
type="date" (nu hidden) permite interactiunea utilizatorului.
Campul change pe form re-incarca automat lista via hx-trigger="change". #}
<div id="custom-date-fields"
style="display:none; gap:4px; align-items:center; flex-wrap:wrap; font-size:13px;">
<label for="f-data-de" class="muted" style="font-size:12px; white-space:nowrap;">De:</label>
<input type="date" id="f-data-de" name="data_de" value=""
style="font-size:13px; max-width:140px;">
<label for="f-data-pana" class="muted" style="font-size:12px; white-space:nowrap;">Pana:</label>
<input type="date" id="f-data-pana" name="data_pana" value=""
style="font-size:13px; max-width:140px;">
</div>
</div> </div>
<div>
<label for="f-vehicul" class="muted" style="display:block; font-size:12px;">Vehicul (nr/VIN)</label> {# === MIJLOC: cautare vehicul (nr/VIN) + buton Filtreaza === #}
<input id="f-vehicul" type="text" name="vehicul" placeholder="ex. B123 sau VIN" style="max-width:180px;"> <div style="display:flex; align-items:center; gap:8px; flex:1 1 auto; min-width:160px; flex-wrap:wrap;">
<input id="f-vehicul" type="text" name="vehicul" placeholder="Vehicul (nr/VIN)"
style="flex:1 1 auto; min-width:120px;">
<button type="submit" style="flex:0 0 auto;">Filtreaza</button>
</div> </div>
<div>
<label for="f-data-de" class="muted" style="display:block; font-size:12px;">Data de la</label> {# === DREAPTA: pill-uri de stare cu contoare; re-randate via OOB la reincarcarea tabelului === #}
<input id="f-data-de" type="date" name="data_de"> <span id="pills-categorii" class="pills-categorii" style="margin-left:auto; flex:0 0 auto;">
</div> {% include '_pills.html' %}
<div> </span>
<label for="f-data-pana" class="muted" style="display:block; font-size:12px;">pana la</label>
<input id="f-data-pana" type="date" name="data_pana">
</div>
<button type="submit">Filtreaza</button>
</form> </form>
<!-- Tabelul se reincarca la: incarcarea paginii, actiunile tale (trimiteriChanged)
si auto-refresh periodic din poller (date noi externe). -->
<div id="submissions-wrap" <div id="submissions-wrap"
hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-get="/_fragments/submissions"
hx-trigger="load, trimiteriChanged from:body, reincarcaTrimiteri"
hx-include="#filtre-trimiteri" hx-swap="innerHTML"> hx-include="#filtre-trimiteri" hx-swap="innerHTML">
<div class="empty">se incarca…</div> <div class="empty">se incarca…</div>
</div> </div>
</div> </div>
<!-- Panou dedicat pentru detaliul trimiterii (NU inline in tabel: poll-ul de 10s </section>
din tabel ar sterge un expand inline). Gol pana la click pe un rand. -->
<div id="trimitere-detaliu"></div>
</div>

View File

@@ -1,6 +1,79 @@
<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) -->
<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>
{% if date_firma_mesaj %}
<div class="flash" style="margin-bottom:12px;">{{ date_firma_mesaj }}</div>
{% endif %}
{% if date_firma_eroare %}
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ date_firma_eroare }}</div>
{% endif %}
<form hx-post="/cont/date-firma"
hx-target="#card-cont"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Companie</label><br>
<input type="text" name="companie" required
value="{{ account_meta.name or '' }}"
style="width:100%; max-width:340px;"
placeholder="Numele firmei (ex. Service Auto SRL)">
</p>
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Email contact</label><br>
<input type="email" name="email" required
value="{{ account_meta.email or '' }}"
style="width:100%; max-width:340px;"
placeholder="contact@firma.ro">
</p>
<p style="margin:0 0 12px;">
<label style="font-size:13px; color:var(--muted);">CUI (cod unic de identificare)</label><br>
<input type="text" name="cui" required
value="{{ account_meta.cui or '' }}"
style="width:100%; max-width:340px;"
placeholder="RO12345678">
</p>
<button type="submit">Salveaza datele firmei</button>
</form>
</div>
<!-- Sectiunea: Cheia mea API --> <!-- Sectiunea: Cheia mea API -->
<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;">Cheia mea API</h3> <h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Cheia mea API</h3>

View File

@@ -0,0 +1,74 @@
{# _editare_preview_modal.html — fragment de editare rand preview in modalul global.
US-006 (PRD 5.12): swap-uit in #detaliu-modal-body de butonul Editeaza din preview.
US-007 (PRD 5.12): butonul 'Confirma valorile' apare DOAR pe randurile needs_review
(T2): trimite CSRF POST la /confirma-review, inchide modalul via HX-Trigger-After-Settle.
Necesita din context:
import_id — id batch import
row_index — index rand (0-based)
csrf_token — token CSRF
vin — VIN pentru titlu
stare_css — clasa CSS pill (ex. "s-ok")
stare_eticheta — text pill (ex. "Gata de trimis")
message — mesaj de eroare general (None daca nu e)
is_needs_review — True daca randul e in starea needs_review (afiseaza butonul Confirma)
+ variabilele pentru _form_editare.html:
form_nr, form_vin, form_data, form_odo_final, form_odo_initial
err_map, fix_map, vin_context, btn_label
#}
<div class="card" style="border:none; padding:0; margin:0;">
{# Header cu heading accesibil (aria-labelledby al dialogului) #}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">
Editare rand {{ row_index + 1 }}
{% if vin %}<span class="muted" style="font-weight:400; font-size:13px;">· {{ vin }}</span>{% endif %}
</h2>
<span class="pill {{ stare_css }}" style="font-size:11px;">{{ stare_eticheta }}</span>
</div>
{% if message %}
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:12px;"
role="alert">{{ message }}</div>
{% endif %}
<form hx-post="/_import/{{ import_id }}/rand/{{ row_index }}/editeaza"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-disabled-elt="find button"
hx-on::response-error="this.querySelector && this.querySelector('.rand-eroare-banner') && (this.querySelector('.rand-eroare-banner').style.display='block');">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div class="rand-eroare-banner" role="alert"
style="display:none; margin-bottom:10px; padding:8px 12px; border:1px solid var(--err);
background:color-mix(in srgb, var(--err) 12%, var(--card)); border-radius:6px; font-size:13px;">
Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
</div>
{# with_cancel=True: _form_editare.html randeaza Salveaza + Anuleaza pe acelasi
rand (sistemul .act: desktop text, mobil iconite Lucide 44px alaturate). #}
{% set with_cancel = true %}
{% include "_form_editare.html" %}
</form>
{% if is_needs_review %}
{# T2 (US-007): Butonul 'Confirma valorile' apare DOAR pe randurile needs_review.
POST separat (form propriu) la /confirma-review cu CSRF. Raspunsul inchide
modalul via HX-Trigger-After-Settle: inchideModal + swap OOB randul si countorii. #}
<form hx-post="/_import/{{ import_id }}/rand/{{ row_index }}/confirma-review"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-disabled-elt="find button"
style="margin-top:12px; border-top:1px solid var(--line); padding-top:12px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<p class="muted" style="font-size:13px; margin:0 0 8px;">
Valorile sunt corecte si doriesti sa includi acest rand la trimitere la RAR?
</p>
<button type="submit"
style="min-height:44px; padding:8px 18px;
background:var(--ok, #2a7); color:#fff; border-color:transparent;">
Confirma valorile
</button>
</form>
{% endif %}
</div>

View File

@@ -0,0 +1,36 @@
{#
_eroare.html — macro card_erori(erori).
Primeste o lista de dict-uri cu cheile: problema, cauza, fix, field (sau None).
Afiseaza 3 niveluri intr-un bloc scannabil:
- "Problema" (bold, --err)
- "De ce" (doar daca ne-gol, --muted)
- "Cum repari" (accentuat, --accent)
Nu hardcodeaza culori — foloseste variabilele CSS din paleta (base.html).
Suporta light + dark din box (variabilele se schimba prin [data-theme]).
#}
{% macro card_erori(erori) %}
{% if erori %}
<div class="eroare-3n">
{% for e in erori %}
<div class="eroare-3n-item{% if not loop.first %} eroare-3n-sep{% endif %}">
<div class="eroare-3n-problema">
{% if e.field %}<span class="eroare-3n-camp">{{ e.field }}</span> {% endif %}{{ e.problema }}
</div>
{% if e.cauza %}
<div class="eroare-3n-cauza">
<span class="eroare-3n-label">De ce:</span> {{ e.cauza }}
</div>
{% endif %}
{% if e.fix %}
<div class="eroare-3n-fix">
<span class="eroare-3n-label">Cum repari:</span> {{ e.fix }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endmacro %}

View File

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

View File

@@ -0,0 +1,350 @@
{# Panoul Integrare: exemple de cod multi-limbaj + export & referinta. #}
{# Variabile context: account_id, base_url, exemple, are_cheie, are_creds, csrf_token #}
<div id="integrare-section">
{# Empty-state: lipsesc cheie API sau credentiale RAR #}
{% if not are_cheie or not are_creds %}
<div class="banner warn" style="margin-bottom:16px;" role="alert" aria-live="polite">
<strong>Configurare incompleta.</strong>
{% if not are_creds and not are_cheie %}
Lipsesc atat credentialele RAR cat si o cheie API activa.
{% elif not are_creds %}
Lipsesc credentialele RAR pentru trimitere.
{% else %}
Lipseste o cheie API activa.
{% endif %}
Mergi la <a href="/?tab=cont">tab-ul Cont</a> pentru a le configura.
</div>
{% endif %}
{# Card: ID cont si endpoint de baza #}
<div class="card" style="margin-bottom:16px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<span class="muted" style="font-size:13px;">Cont ID:</span>
<strong style="font-size:13px;">{{ account_id }}</strong>
<span class="muted" style="font-size:13px; margin-left:16px;">Endpoint:</span>
<code style="font-size:12px; color:var(--accent);">{{ base_url }}</code>
</div>
<p class="muted" style="font-size:12px; margin:10px 0 0;">
Cererile trimit doar cheia API + datele prezentarii. Credentialele RAR se configureaza
o data in <a href="/?tab=cont">Cont</a> si sunt folosite automat la trimitere. Optional,
poti include <code>rar_credentials</code> in payload ca sa le suprascrii pe acea cerere.
</p>
</div>
{# Tab-list PRIMAR: limbaje #}
<div class="card" style="margin-bottom:16px;">
<div role="tablist" class="tab-bar" aria-label="Limbaje de programare" id="tl-limbaje" style="margin-bottom:0; border-bottom:1px solid var(--line);">
<button role="tab" id="tab-curl" aria-selected="true" aria-controls="panel-curl" tabindex="0" class="tab-link tab-activ" style="border:none; background:none; cursor:pointer;">curl</button>
<button role="tab" id="tab-python" aria-selected="false" aria-controls="panel-python" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer;">Python</button>
<button role="tab" id="tab-php" aria-selected="false" aria-controls="panel-php" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer;">PHP</button>
<button role="tab" id="tab-csharp" aria-selected="false" aria-controls="panel-csharp" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer;">C#</button>
<button role="tab" id="tab-node" aria-selected="false" aria-controls="panel-node" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer;">Node</button>
<button role="tab" id="tab-vfp" aria-selected="false" aria-controls="panel-vfp" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer;">VFP</button>
</div>
{# Panel curl #}
<div role="tabpanel" id="panel-curl" aria-labelledby="tab-curl" style="padding:16px 0 0;">
{% set ex = exemple["curl"] %}
<div role="tablist" class="tab-bar" aria-label="Canal curl" id="tl-curl-canal" style="margin-bottom:8px; border-bottom:1px solid var(--line);">
<button role="tab" id="tab-curl-prez" aria-selected="true" aria-controls="panel-curl-prez" tabindex="0" class="tab-link tab-activ" style="border:none; background:none; cursor:pointer; font-size:13px;">Prezentari JSON</button>
<button role="tab" id="tab-curl-import" aria-selected="false" aria-controls="panel-curl-import" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer; font-size:13px;">Import fisier</button>
</div>
<div role="tabpanel" id="panel-curl-prez" aria-labelledby="tab-curl-prez">
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet curl prezentari" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["prezentari"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
<div role="tabpanel" id="panel-curl-import" aria-labelledby="tab-curl-import" hidden>
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet curl import" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["import"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
</div>
{# Panel python #}
<div role="tabpanel" id="panel-python" aria-labelledby="tab-python" hidden style="padding:16px 0 0;">
{% set ex = exemple["python"] %}
<div role="tablist" class="tab-bar" aria-label="Canal python" id="tl-python-canal" style="margin-bottom:8px; border-bottom:1px solid var(--line);">
<button role="tab" id="tab-python-prez" aria-selected="true" aria-controls="panel-python-prez" tabindex="0" class="tab-link tab-activ" style="border:none; background:none; cursor:pointer; font-size:13px;">Prezentari JSON</button>
<button role="tab" id="tab-python-import" aria-selected="false" aria-controls="panel-python-import" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer; font-size:13px;">Import fisier</button>
</div>
<div role="tabpanel" id="panel-python-prez" aria-labelledby="tab-python-prez">
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet python prezentari" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["prezentari"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
<div role="tabpanel" id="panel-python-import" aria-labelledby="tab-python-import" hidden>
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet python import" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["import"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
</div>
{# Panel PHP #}
<div role="tabpanel" id="panel-php" aria-labelledby="tab-php" hidden style="padding:16px 0 0;">
{% set ex = exemple["php"] %}
<div role="tablist" class="tab-bar" aria-label="Canal php" id="tl-php-canal" style="margin-bottom:8px; border-bottom:1px solid var(--line);">
<button role="tab" id="tab-php-prez" aria-selected="true" aria-controls="panel-php-prez" tabindex="0" class="tab-link tab-activ" style="border:none; background:none; cursor:pointer; font-size:13px;">Prezentari JSON</button>
<button role="tab" id="tab-php-import" aria-selected="false" aria-controls="panel-php-import" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer; font-size:13px;">Import fisier</button>
</div>
<div role="tabpanel" id="panel-php-prez" aria-labelledby="tab-php-prez">
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet php prezentari" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["prezentari"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
<div role="tabpanel" id="panel-php-import" aria-labelledby="tab-php-import" hidden>
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet php import" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["import"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
</div>
{# Panel C# #}
<div role="tabpanel" id="panel-csharp" aria-labelledby="tab-csharp" hidden style="padding:16px 0 0;">
{% set ex = exemple["csharp"] %}
<div role="tablist" class="tab-bar" aria-label="Canal csharp" id="tl-csharp-canal" style="margin-bottom:8px; border-bottom:1px solid var(--line);">
<button role="tab" id="tab-csharp-prez" aria-selected="true" aria-controls="panel-csharp-prez" tabindex="0" class="tab-link tab-activ" style="border:none; background:none; cursor:pointer; font-size:13px;">Prezentari JSON</button>
<button role="tab" id="tab-csharp-import" aria-selected="false" aria-controls="panel-csharp-import" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer; font-size:13px;">Import fisier</button>
</div>
<div role="tabpanel" id="panel-csharp-prez" aria-labelledby="tab-csharp-prez">
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet csharp prezentari" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["prezentari"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
<div role="tabpanel" id="panel-csharp-import" aria-labelledby="tab-csharp-import" hidden>
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet csharp import" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["import"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
</div>
{# Panel Node #}
<div role="tabpanel" id="panel-node" aria-labelledby="tab-node" hidden style="padding:16px 0 0;">
{% set ex = exemple["node"] %}
<div role="tablist" class="tab-bar" aria-label="Canal node" id="tl-node-canal" style="margin-bottom:8px; border-bottom:1px solid var(--line);">
<button role="tab" id="tab-node-prez" aria-selected="true" aria-controls="panel-node-prez" tabindex="0" class="tab-link tab-activ" style="border:none; background:none; cursor:pointer; font-size:13px;">Prezentari JSON</button>
<button role="tab" id="tab-node-import" aria-selected="false" aria-controls="panel-node-import" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer; font-size:13px;">Import fisier</button>
</div>
<div role="tabpanel" id="panel-node-prez" aria-labelledby="tab-node-prez">
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet node prezentari" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["prezentari"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
<div role="tabpanel" id="panel-node-import" aria-labelledby="tab-node-import" hidden>
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet node import" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["import"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
</div>
{# Panel VFP: tab-list SECUNDAR pentru dialecte #}
<div role="tabpanel" id="panel-vfp" aria-labelledby="tab-vfp" hidden style="padding:16px 0 0;">
<p class="muted" style="font-size:13px; margin:0 0 8px;">Visual FoxPro — alege dialectul COM:</p>
{# Dialecte VFP #}
<div role="tablist" class="tab-bar" aria-label="Dialecte VFP" id="tl-vfp-dialect" style="margin-bottom:12px; border-bottom:1px solid var(--line);">
<button role="tab" id="tab-vfp-msxml" aria-selected="true" aria-controls="panel-vfp-msxml" tabindex="0" class="tab-link tab-activ" style="border:none; background:none; cursor:pointer; font-size:13px;">MSXML2</button>
<button role="tab" id="tab-vfp-winhttp" aria-selected="false" aria-controls="panel-vfp-winhttp" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer; font-size:13px;">WinHttp</button>
</div>
{# MSXML2 #}
<div role="tabpanel" id="panel-vfp-msxml" aria-labelledby="tab-vfp-msxml">
{% set ex = exemple["vfp_msxml"] %}
<div role="tablist" class="tab-bar" aria-label="Canal vfp msxml" id="tl-vfp-msxml-canal" style="margin-bottom:8px; border-bottom:1px solid var(--line);">
<button role="tab" id="tab-vfp-msxml-prez" aria-selected="true" aria-controls="panel-vfp-msxml-prez" tabindex="0" class="tab-link tab-activ" style="border:none; background:none; cursor:pointer; font-size:12px;">Prezentari JSON</button>
<button role="tab" id="tab-vfp-msxml-import" aria-selected="false" aria-controls="panel-vfp-msxml-import" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer; font-size:12px;">Import fisier</button>
</div>
<div role="tabpanel" id="panel-vfp-msxml-prez" aria-labelledby="tab-vfp-msxml-prez">
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet VFP MSXML2 prezentari" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["prezentari"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
<div role="tabpanel" id="panel-vfp-msxml-import" aria-labelledby="tab-vfp-msxml-import" hidden>
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet VFP MSXML2 import" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["import"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
</div>
{# WinHttp #}
<div role="tabpanel" id="panel-vfp-winhttp" aria-labelledby="tab-vfp-winhttp" hidden>
{% set ex = exemple["vfp_winhttp"] %}
<div role="tablist" class="tab-bar" aria-label="Canal vfp winhttp" id="tl-vfp-winhttp-canal" style="margin-bottom:8px; border-bottom:1px solid var(--line);">
<button role="tab" id="tab-vfp-winhttp-prez" aria-selected="true" aria-controls="panel-vfp-winhttp-prez" tabindex="0" class="tab-link tab-activ" style="border:none; background:none; cursor:pointer; font-size:12px;">Prezentari JSON</button>
<button role="tab" id="tab-vfp-winhttp-import" aria-selected="false" aria-controls="panel-vfp-winhttp-import" tabindex="-1" class="tab-link" style="border:none; background:none; cursor:pointer; font-size:12px;">Import fisier</button>
</div>
<div role="tabpanel" id="panel-vfp-winhttp-prez" aria-labelledby="tab-vfp-winhttp-prez">
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet VFP WinHttp prezentari" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["prezentari"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
<div role="tabpanel" id="panel-vfp-winhttp-import" aria-labelledby="tab-vfp-winhttp-import" hidden>
<div style="position:relative;">
<button class="btn-copiaza" aria-label="Copiaza snippet VFP WinHttp import" style="position:absolute; top:8px; right:8px; font-size:12px; padding:4px 10px; background:var(--line); color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer;">Copiaza</button>
<pre style="background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; margin:0;"><code style="font-size:12px; font-family:ui-monospace,monospace; color:var(--ink);">{{ ex["import"] | e }}</code></pre>
</div>
<div aria-live="polite" class="copiaza-feedback muted" style="font-size:12px; min-height:20px; margin-top:4px;"></div>
</div>
</div>
</div>
</div>
{# Card: Export & referinta #}
<div class="card" style="margin-bottom:16px;">
<h3 style="margin:0 0 12px; font-size:15px;">Export &amp; referinta</h3>
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<a class="cardlink" href="/docs" target="_blank" rel="noopener">Swagger UI — /docs</a>
<a class="cardlink" href="/openapi.json" target="_blank" rel="noopener">Schema OpenAPI — /openapi.json</a>
<a class="cardlink" href="/v1/integrare/postman.json" download>Colectie Postman — /v1/integrare/postman.json</a>
</div>
</div>
{# Formular test conexiune #}
<div class="card" style="margin-bottom:16px;">
<h3 style="margin:0 0 12px; font-size:15px;">Testeaza conexiunea</h3>
<form id="form-test-cheie"
hx-post="/integrare/test-cheie"
hx-target="#integrare-test-rezultat"
hx-swap="innerHTML"
style="display:flex; gap:8px; flex-wrap:wrap; align-items:flex-end;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div>
<label for="test-api-key" style="display:block; font-size:13px; color:var(--muted); margin-bottom:4px;">Cheie API (rfak_...)</label>
<input type="password" id="test-api-key" name="api_key" placeholder="rfak_..."
style="width:280px;" autocomplete="off">
<p class="muted" style="font-size:12px; margin:4px 0 0;">Verificam doar daca cheia e valida. Nu o salvam si nu o memoram — cheia se gestioneaza in Cont.</p>
</div>
<button type="submit">Testeaza</button>
</form>
<div id="integrare-test-rezultat" style="margin-top:8px;"></div>
</div>
</div>
<script>
(function() {
/* Navigare ARIA pentru tab-uri multiple (scoped pe containerul propriu). */
var root = document.getElementById('integrare-section');
if (!root) return;
root.querySelectorAll('[role="tablist"]').forEach(function(tablist) {
var tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
if (!tabs.length) return;
/* Navigare cu sageti, Home, End */
tablist.addEventListener('keydown', function(e) {
var idx = tabs.indexOf(document.activeElement);
if (idx === -1) return;
var next = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (idx + 1) % tabs.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (idx - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
next = 0;
} else if (e.key === 'End') {
next = tabs.length - 1;
}
if (next !== -1) {
e.preventDefault();
tabs[next].focus();
}
});
/* Click pe tab: activeaza panelul corespunzator */
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
var panelId = tab.getAttribute('aria-controls');
if (!panelId) return;
/* Dezactiveaza toate tab-urile din acest tablist */
tabs.forEach(function(t) {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
t.classList.remove('tab-activ');
var pid = t.getAttribute('aria-controls');
if (pid) {
var p = document.getElementById(pid);
if (p) p.hidden = true;
}
});
/* Activeaza tab-ul curent */
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
tab.classList.add('tab-activ');
var panel = document.getElementById(panelId);
if (panel) panel.hidden = false;
});
});
});
/* Buton Copiaza: citeste textul din <pre><code> sibling, nu din data-* */
root.querySelectorAll('.btn-copiaza').forEach(function(btn) {
btn.addEventListener('click', function() {
var wrapper = btn.closest('div[style*="position:relative"]') || btn.parentElement;
var code = wrapper ? wrapper.querySelector('pre code') : null;
if (!code) return;
var text = code.innerText || code.textContent;
var feedback = wrapper.parentElement && wrapper.parentElement.querySelector('.copiaza-feedback');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
btn.textContent = 'Copiat';
setTimeout(function() { btn.textContent = 'Copiaza'; }, 2000);
if (feedback) {
feedback.textContent = 'Copiat!';
setTimeout(function() { feedback.textContent = ''; }, 2000);
}
}).catch(function() {
if (feedback) feedback.textContent = 'Eroare la copiere.';
});
} else {
/* Fallback pentru browsere fara Clipboard API */
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
btn.textContent = 'Copiat';
setTimeout(function() { btn.textContent = 'Copiaza'; }, 2000);
if (feedback) {
feedback.textContent = 'Copiat!';
setTimeout(function() { feedback.textContent = ''; }, 2000);
}
} catch(err) {
if (feedback) feedback.textContent = 'Eroare la copiere.';
}
document.body.removeChild(ta);
}
});
});
})();
</script>

View File

@@ -0,0 +1,9 @@
{% if succes %}
<div class="flash" aria-live="polite" role="status">
{{ mesaj }}
</div>
{% else %}
<div class="banner" aria-live="polite" role="alert">
{{ mesaj }}
</div>
{% endif %}

View File

@@ -0,0 +1,106 @@
{# _jurnal.html — tab Jurnal de aplicatie.
Lista paginata de evenimente (app_events), redactate la scriere. Filtre tip/nivel/
data + (admin) cont. #}
<section id="jurnal-section" aria-labelledby="jurnal-heading">
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 id="jurnal-heading" style="font-size:15px; margin:0;">Jurnal de aplicatie</h2>
{% if is_admin %}
<span class="pill s-sent" style="font-size:11px;">admin: toate conturile</span>
{% else %}
<span class="muted" style="font-size:12px;">doar evenimentele contului tau</span>
{% endif %}
</div>
<form id="filtre-jurnal"
hx-get="/_fragments/jurnal"
hx-target="#jurnal-wrap"
hx-swap="innerHTML"
hx-trigger="submit, change"
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<div>
<label for="j-tip" class="muted" style="display:block; font-size:12px;">Tip eveniment</label>
<select id="j-tip" name="tip">
<option value="">toate</option>
{% for t in tipuri %}
<option value="{{ t }}" {% if f_tip == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="j-nivel" class="muted" style="display:block; font-size:12px;">Nivel</label>
<select id="j-nivel" name="nivel">
<option value="">toate</option>
{% for nv in ("INFO", "WARNING", "ERROR", "CRITICAL", "DEBUG") %}
<option value="{{ nv }}" {% if f_nivel == nv %}selected{% endif %}>{{ nv }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="j-data-de" class="muted" style="display:block; font-size:12px;">Data de la</label>
<input id="j-data-de" type="date" name="data_de" value="{{ f_data_de }}">
</div>
<div>
<label for="j-data-pana" class="muted" style="display:block; font-size:12px;">pana la</label>
<input id="j-data-pana" type="date" name="data_pana" value="{{ f_data_pana }}">
</div>
{% if is_admin %}
<div>
<label for="j-cont" class="muted" style="display:block; font-size:12px;">Cont (id)</label>
<input id="j-cont" type="number" name="cont" value="{{ f_cont }}" placeholder="toate" style="max-width:100px;">
</div>
{% endif %}
<button type="submit">Filtreaza</button>
</form>
<div id="jurnal-wrap">
{% if evenimente %}
<div class="tablewrap">
<table>
<thead><tr>
<th>Cand</th>
<th>Sursa</th>
<th>Tip</th>
<th>Nivel</th>
{% if is_admin %}<th>Cont</th>{% endif %}
<th>Cod</th>
<th>Mesaj</th>
</tr></thead>
<tbody>
{% for e in evenimente %}
<tr>
<td class="muted" style="white-space:nowrap;">{{ e.ts }}</td>
<td>{{ e.sursa }}</td>
<td>{{ e.tip }}</td>
<td>
<span class="{% if e.nivel in ('ERROR','CRITICAL') %}s-error{% elif e.nivel == 'WARNING' %}s-needs_data{% else %}muted{% endif %}">{{ e.nivel }}</span>
</td>
{% if is_admin %}<td class="muted">{{ e.account_id if e.account_id is not none else '—' }}</td>{% endif %}
<td class="muted">{{ e.cod or '—' }}</td>
<td style="white-space:normal; max-width:360px;">{{ e.mesaj or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Paginare: prev/next pe acelasi set de filtre #}
{% if prev_page is not none or next_page is not none %}
<div style="display:flex; gap:10px; margin-top:12px; align-items:center;">
{% if prev_page is not none %}
<a href="#" hx-get="/_fragments/jurnal?page={{ prev_page }}&tip={{ f_tip }}&nivel={{ f_nivel }}&data_de={{ f_data_de }}&data_pana={{ f_data_pana }}&cont={{ f_cont }}"
hx-target="#jurnal-wrap" hx-swap="innerHTML">&lsaquo; mai noi</a>
{% endif %}
<span class="muted" style="font-size:12px;">pagina {{ page + 1 }}</span>
{% if next_page is not none %}
<a href="#" hx-get="/_fragments/jurnal?page={{ next_page }}&tip={{ f_tip }}&nivel={{ f_nivel }}&data_de={{ f_data_de }}&data_pana={{ f_data_pana }}&cont={{ f_cont }}"
hx-target="#jurnal-wrap" hx-swap="innerHTML">mai vechi &rsaquo;</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty">Niciun eveniment pe filtrul curent.</div>
{% endif %}
</div>
</div>
</section>

View File

@@ -0,0 +1,80 @@
{# Macro-uri partajate intre template-urile de import si mapari. #}
{# US-002 (PRD 5.11): autosend_toggle neutralizat — auto_send nu mai tine randuri (US-001).
Simbolul pastrat (apelat in _mapari.html, _preview_import.html, _trimitere_detaliu.html)
dar intoarce string gol. Coloanele DB raman (default 1, ne-citite pentru hold). #}
{% macro autosend_toggle(form_id='', checked=True, label='') -%}{%- endmacro %}
{# US-005 (PRD 5.12): macro `camp` partajat — extras din _trimitere_detaliu.html si
_preview_rand.html. Suporta tip='date' (calendar nativ, D#10/R3) si tip='text' (default).
Parametri:
nome — name="" al input-ului (si cheie in err_map/fix_map)
eticheta — text pentru label
valoare — valoarea curenta (pre-fill)
tip — type="" al input-ului: 'text' (default) sau 'date' (calendar nativ)
err_map — dict {field_name: mesaj_eroare}; default {}
fix_map — dict {field_name: hint_fix}; default {}
vin_context — string VIN pentru aria-label cu context (default '')
id_prefix — prefix pentru id="" al input-ului (default 'c'; preview poate folosi 'e-N')
#}
{% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c', slim=False, mono=False) %}
{# slim=False: randare clasica (neschimbata). slim=True: varianta compacta (.camp-slim) din US-002 PRD 5.15:
label 11px muted deasupra, input ~30px, fundal --card2.
mono=True (valid numai cu slim=True): adauga clasa 'camp-mono' pe input pentru campuri
VIN/odometru/nr (IBM Plex Mono, prin .camp-slim .camp-mono din base.html). #}
<div {% if slim %}class="camp-slim"{% else %}style="margin-bottom:10px;"{% endif %}>
<label for="{{ id_prefix }}-{{ nome }}"{% if not slim %} class="muted" style="font-size:12px; display:block;"{% endif %}>{{ eticheta }}</label>
{% if tip == 'date' %}
{# D#10/R3: degradare grijulie pentru valori ne-YYYY-MM-DD.
Daca valoarea nu e in formatul corect, inputul ramane gol + hint + hidden cu valoarea bruta
(ca sa nu se piarda tacut la submit). #}
{%- set _dp_ok = (valoare and valoare|length == 10 and valoare[4:5] == '-' and valoare[7:8] == '-') -%}
<input id="{{ id_prefix }}-{{ nome }}" type="date" name="{{ nome }}"
value="{{ valoare if _dp_ok else '' }}"
{% if slim and mono %}class="camp-mono"{% endif %}
style="{% if not slim %}width:100%; {% endif %}{% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
aria-label="{{ eticheta }}{% if vin_context %} (VIN: {{ vin_context }}){% endif %}"
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
{% if not _dp_ok and valoare %}
<input type="hidden" name="data_prestatie_raw" value="{{ valoare }}">
<span class="camp-fix" style="font-size:12px;">Valoarea originala: {{ valoare }}</span>
{% endif %}
{% else %}
<input id="{{ id_prefix }}-{{ nome }}" type="{{ tip }}" name="{{ nome }}"
value="{{ valoare or '' }}"
{% if slim and mono %}class="camp-mono"{% endif %}
style="{% if not slim %}width:100%; {% endif %}{% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
{% if vin_context %}aria-label="{{ eticheta }} (VIN: {{ vin_context }})"{% endif %}
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
{% endif %}
{% if err_map.get(nome) %}
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nome) }}</div>
{% endif %}
{% if fix_map.get(nome) %}
<span class="camp-fix" style="font-size:12px;">{{ fix_map.get(nome) }}</span>
{% endif %}
</div>
{% endmacro %}
{# PRD 5.13 — sistem butoane de actiune responsive.
CSS-ul aferent (.act, .act-tx, .act-ic, .act-save, .act-del, .act-group)
este definit in base.html.
Desktop: se afiseaza textul (.act-tx); mobil: se afiseaza iconita (.act-ic). #}
{% macro icon(name) -%}
<svg class="act-ic" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
{%- if name == 'save' -%}<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>
{%- elif name == 'trash' -%}<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>
{%- elif name == 'edit' -%}<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
{%- elif name == 'plus' -%}<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
{%- elif name == 'x' -%}<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
{%- endif -%}
</svg>
{%- endmacro %}
{% macro act_btn(label, ic, kind='', attrs='') -%}
<button class="act{% if kind %} act-{{ kind }}{% endif %}" aria-label="{{ label }}" {{ attrs | safe }}>
<span class="act-tx">{{ label }}</span>{{ icon(ic) }}</button>
{%- endmacro %}

View File

@@ -1,4 +1,14 @@
{% import '_macros.html' as ui %}
<div id="mapari-section"> <div id="mapari-section">
<style>
/* Selectul de cod RAR e principalul vinovat de latimea tabelelor de mapari. Il limitam ca
tabelul sa incapa in card fara scroll orizontal -> coloana Actiuni (kebab) ramane vizibila. */
#mapari-section td select { width:100%; max-width:240px; min-width:150px; }
/* In card per rand (sub 767px) selectul/inputurile umplu cardul. */
@media (max-width:767px) {
#mapari-section td select, #mapari-section td input[type=text] { max-width:none; min-width:0; }
}
</style>
{% if message %} {% if message %}
<div class="flash" style="margin-bottom:12px;">{{ message }}</div> <div class="flash" style="margin-bottom:12px;">{{ message }}</div>
@@ -8,60 +18,81 @@
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) --> <!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
<!-- ============================================================ --> <!-- ============================================================ -->
<div class="card"> <div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">De rezolvat</h2> <h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
{% if not pending %} {% if pending %}
<div class="empty"> <div data-dt="10">
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR. <div class="dt-tools">
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari. <input type="search" data-dt-search class="dt-search"
placeholder="Cauta operatie sau cod..." aria-label="Cauta in operatiile de rezolvat">
</div>
<div class="tablewrap tabel-card">
<table>
<thead><tr>
<th>Operatie</th>
<th>Sugestii</th>
<th>Cod RAR</th>
<th></th>
</tr></thead>
<tbody>
{% for e in pending %}
{% set top = e.suggestions[0] if e.suggestions else None %}
{# L14-S6: pre-selectare din sugestie_principala (GOLD/SILVER/embedding) > fuzzy #}
{% set preselect = e.sugestie_principala.cod_prestatie if e.sugestie_principala else (top.cod_prestatie if (top and top.score >= 60) else '') %}
{# data-dt-row = haystack de cautare (randul contine un <select> cu tot nomenclatorul). #}
<tr data-dt-row="{{ e.cod_op_service }} {{ e.denumire or '' }}
{%- for s in e.suggestions[:3] %} {{ s.cod_prestatie }}{% endfor %}">
<td data-eticheta="Operatie">
<form id="map-rez-{{ loop.index }}" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
{# L14-S6: denumire pt record_human_validation in GOLD partajat #}
<input type="hidden" name="denumire" value="{{ e.denumire or '' }}">
</form>
<div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="submission-uri blocate">{{ e.blocked }} blocate</span></div>
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
</td>
<td class="muted" style="font-size:12px;" data-eticheta="Sugestii">
{# 5.18 US-007: badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
{% if e.sugestie_principala %}
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
<span class="sugg-sursa sugg-sursa--confirmat" title="cod confirmat de un operator">confirmat</span>
{% else %}
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
{% endif %}
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
<span class="sugg-sursa sugg-sursa--nul" title="pare non-operatie (ITP/plata/discount...)">non-operatie</span>
{% endif %}
{% if e.suggestions %}
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
{% elif not e.sugestie_principala and not (e.surse_sugestie and e.surse_sugestie.nul) %}—{% endif %}
</td>
<td data-eticheta="Cod RAR">
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</td>
<td>
<button type="submit" form="map-rez-{{ loop.index }}">Salveaza</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="dt-empty" data-dt-empty style="display:none;">Nicio operatie nu se potriveste cautarii.</div>
<div class="dt-pager" data-dt-pager></div>
</div> </div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>.
Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat.
</p>
{% for e in pending %}
{% set top = e.suggestions[0] if e.suggestions else None %}
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
<form class="maprow" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="mapcol grow">
<div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="submission-uri blocate">{{ e.blocked }} blocate</span></div>
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
{% if e.suggestions %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mapcol">
<select name="cod_prestatie" required>
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
<div class="mapcol">
<label class="chk"><input type="checkbox" name="auto_send" value="true" checked> auto-send</label>
</div>
<div class="mapcol">
<button type="submit">Salveaza</button>
</div>
</form>
{% endfor %}
{% endif %} {% endif %}
</div> </div>
@@ -76,56 +107,151 @@
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand. Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
</div> </div>
{% else %} {% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;"> <div data-dt="10">
Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau auto-send si salveaza; <div class="dt-tools">
la schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat. <input type="search" data-dt-search class="dt-search"
</p> placeholder="Cauta operatie sau cod RAR..." aria-label="Cauta in maparile salvate">
</div>
{% for m in saved_mappings %} <div class="tablewrap tabel-card">
<form class="maprow" hx-post="/mapari/salvate" hx-target="#mapari-section" hx-swap="outerHTML"> <table>
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}"> <thead><tr>
<input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}"> <th>Operatie</th>
<th>Cod RAR</th>
<div class="mapcol grow"> <th>Actiuni</th>
<div><strong>{{ m.cod_op_service }}</strong></div> </tr></thead>
<div class="muted" style="font-size:12px;"> <tbody>
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %} {% for m in saved_mappings %}
</div> {# data-dt-row = haystack de cautare (randul contine un <select> cu tot nomenclatorul). #}
</div> <tr data-dt-row="{{ m.cod_op_service }} {{ m.cod_prestatie }} {{ m.nume_prestatie or '' }}">
<td data-eticheta="Operatie">
<div class="mapcol"> <form id="map-salv-{{ loop.index }}" hx-post="/mapari/salvate" hx-target="#mapari-section" hx-swap="outerHTML">
<select name="cod_prestatie" required aria-label="Cod RAR pentru {{ m.cod_op_service }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
{% for n in nomenclator %} <input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == m.cod_prestatie %}selected{% endif %}> </form>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }} <form id="map-del-{{ loop.index }}" hx-post="/mapari/salvate/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
</option> hx-confirm="Stergi maparea pentru {{ m.cod_op_service }}?">
{% endfor %} <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
</select> <input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
</div> </form>
<div><strong>{{ m.cod_op_service }}</strong></div>
<div class="mapcol"> <div class="muted map-acum" style="font-size:12px;">
<label class="chk"><input type="checkbox" name="auto_send" value="true" acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
{% if m.auto_send %}checked{% endif %}> auto-send</label> </div>
</div> </td>
<td data-eticheta="Cod RAR">
<div class="mapcol" style="display:flex; gap:6px;"> <select name="cod_prestatie" form="map-salv-{{ loop.index }}" required
<button type="submit">Salveaza</button> aria-label="Cod RAR pentru {{ m.cod_op_service }}">
</div> {% for n in nomenclator %}
<div class="mapcol"> <option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
<button type="submit" {{ n.cod_prestatie }} — {{ n.nume_prestatie }}
hx-post="/mapari/salvate/sterge" hx-target="#mapari-section" hx-swap="outerHTML" </option>
hx-confirm="Stergi maparea pentru {{ m.cod_op_service }}?" {% endfor %}
style="background:var(--card); color:var(--err); border-color:var(--err);"> </select>
Sterge </td>
</button> <td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
</div> {# Butoane act_btn (desktop: text; mobil: iconita 44px).
</form> data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
{% endfor %} JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
{{ ui.act_btn('Salveaza', 'save', 'save', 'type="submit" form="map-salv-' ~ loop.index ~ '" data-dirty-form="map-salv-' ~ loop.index ~ '"') }}
{{ ui.act_btn('Sterge', 'trash', 'del', 'type="submit" form="map-del-' ~ loop.index ~ '"') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="dt-empty" data-dt-empty style="display:none;">Nicio mapare nu se potriveste cautarii.</div>
<div class="dt-pager" data-dt-pager></div>
</div>
{% endif %} {% endif %}
</div> </div>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- Sectiunea 3: Formate de coloane salvate (column_mappings) --> <!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2>
<p class="muted" style="margin:0 0 12px; font-size:13px; max-width:680px;">
O regula leaga orice operatie al carei text <strong>contine</strong> (nu egal, ci substring)
un cuvant de un cod RAR. Util pentru operatii fara cod intern: ex. orice operatie care
<em>contine</em> „verificare" primeste codul ales. Match insensibil la majuscule/diacritice.
</p>
{% if not text_rules %}
<div class="empty" style="margin-bottom:12px;">
Inca nu ai reguli. Ex: operatia contine «verificare» &rarr; OE-2.
Mapeaza automat operatii similare fara cod intern. Adauga prima regula mai jos.
</div>
{% endif %}
<div class="tablewrap tabel-card">
<table>
<thead><tr>
<th>Daca operatia contine</th>
<th>Cod RAR</th>
<th>Actiuni</th>
</tr></thead>
<tbody>
{% for r in text_rules %}
<tr>
<td data-eticheta="Daca operatia contine">
<form id="rt-del-{{ loop.index }}" hx-post="/mapari/reguli-text/sterge"
hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi regula «{{ r.pattern }}»?">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="pattern" value="{{ r.pattern }}">
</form>
<div>contine <strong>«{{ r.pattern }}»</strong></div>
</td>
<td class="muted" style="font-size:12px;" data-eticheta="Cod RAR">
{{ r.cod_prestatie }}
</td>
<td style="text-align:right; white-space:nowrap;">
{{ ui.act_btn('Sterge', 'trash', 'del', 'type="submit" form="rt-del-' ~ loop.index ~ '"') }}
</td>
</tr>
{% endfor %}
{# Rand de adaugare (mereu prezent ca placeholder, inclusiv in empty state). #}
<tr>
<td data-eticheta="Daca operatia contine">
<form id="rt-add" hx-post="/mapari/reguli-text" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="text" name="pattern" required
placeholder="ex. verificare"
aria-label="Text continut in operatie"
style="width:100%; max-width:240px;"
hx-post="/mapari/reguli-text/preview"
hx-trigger="keyup delay:400ms"
hx-target="#rt-preview"
hx-swap="innerHTML"
hx-include="#rt-add">
</form>
</td>
<td data-eticheta="Cod RAR">
<select name="cod_prestatie" form="rt-add" required aria-label="Cod RAR pentru regula text">
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
</td>
<td style="text-align:right; white-space:nowrap;">
<button type="submit" form="rt-add">Adauga</button>
</td>
</tr>
{# Preview pre-salvare: cate operatii nemapate potriveste pattern-ul. #}
<tr>
<td colspan="3" style="padding-top:0;">
<div id="rt-preview" aria-live="polite"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ============================================================ -->
<!-- Sectiunea 4: Formate de coloane salvate (column_mappings) -->
<!-- ============================================================ --> <!-- ============================================================ -->
<div class="card"> <div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2> <h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
@@ -137,45 +263,60 @@
</div> </div>
{% else %} {% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;"> <p class="muted" style="margin:0 0 12px; font-size:13px;">
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat (nu suprascrie). Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
</p> </p>
{% for f in column_formats %} <div data-dt="10">
<div class="maprow" style="align-items:flex-start;"> <div class="dt-tools">
<div class="mapcol grow"> <input type="search" data-dt-search class="dt-search"
<div style="font-size:13px; margin-bottom:4px;"> placeholder="Cauta coloana sau camp..." aria-label="Cauta in formatele de coloane">
<strong>{{ f.columns | length }} coloane recunoscute</strong> </div>
{% if f.format_data %} <div class="tablewrap tabel-card">
<span class="pill" title="format data">data: {{ f.format_data }}</span> <table>
{% endif %} <thead><tr>
</div> <th>Coloane</th>
<div class="muted" style="font-size:12px;"> <th>Mapari (coloana &rarr; camp)</th>
{% for col, camp in f.mappings.items() %} <th>Format data</th>
<span class="sugg">{{ col }}</span> &rarr; {{ camp }}{% if not loop.last %}; {% endif %} <th></th>
{% endfor %} </tr></thead>
</div> <tbody>
</div> {% for f in column_formats %}
<tr>
<form class="mapcol" style="display:flex; gap:6px; align-items:center;" <td style="white-space:nowrap;" data-eticheta="Coloane">
hx-post="/formate-coloane/editeaza" hx-target="#mapari-section" hx-swap="outerHTML"> <strong>{{ f.columns | length }} coloane</strong>
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}"> </td>
<input type="hidden" name="format_id" value="{{ f.id }}"> <td class="muted" style="font-size:12px; white-space:normal; max-width:340px;" data-eticheta="Mapari (coloana &rarr; camp)">
<input type="text" name="format_data" value="{{ f.format_data or '' }}" {% for col, camp in f.mappings.items() %}
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;"> <span class="sugg">{{ col }}</span> &rarr; {{ camp }}{% if not loop.last %}; {% endif %}
<button type="submit">Salveaza data</button> {% endfor %}
</form> </td>
<td data-eticheta="Format data">
<form class="mapcol" <form id="fmt-edit-{{ loop.index }}" hx-post="/formate-coloane/editeaza"
hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML" hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi acest format de coloane?"> style="display:flex; gap:6px; align-items:center;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}"> <input type="hidden" name="format_id" value="{{ f.id }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);"> <input type="text" name="format_data" value="{{ f.format_data or '' }}"
Sterge placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
</button> <button type="submit">Salveaza data</button>
</form> </form>
</td>
<td>
<form hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi acest format de coloane?">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
{{ ui.act_btn('Sterge', 'trash', 'del', 'type="submit"') }}
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="dt-empty" data-dt-empty style="display:none;">Niciun format nu se potriveste cautarii.</div>
<div class="dt-pager" data-dt-pager></div>
</div> </div>
{% endfor %}
{% endif %} {% endif %}
</div> </div>

View File

@@ -1,30 +1,75 @@
<div id="import-section"> <div id="import-section">
{% set pas = 2 %}{% include '_stepper.html' %} {% set pas = 2 %}{% include '_stepper.html' %}
{% from '_eroare.html' import card_erori %}
{# prima_inregistrare poate veni din context (web_upload_import) sau derivat din sample_rows #}
{%- set prima_inreg = prima_inregistrare if prima_inregistrare is defined else (sample_rows[0] if sample_rows else none) -%}
<div class="card"> <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>
{% if message %} {% if eroare_mapare %}
<div class="flash" style="{% if error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:12px;" <div style="margin-bottom:12px;">
{{ card_erori([eroare_mapare]) }}
</div>
{% elif message %}
<div class="flash" style="{% if error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:12px;"
{% if error %}role="alert"{% endif %}> {% if error %}role="alert"{% endif %}>
{{ message }} {{ message }}
</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 (compatibilitate teste) #}
<div class="tablewrap" style="margin-bottom:16px;">
<table class="preview-antet" style="border-collapse:collapse; font-size:var(--fs-xs); width:100%; min-width:max-content;">
<thead>
<tr>
{% for col in columns %}
<th style="padding:4px 10px; text-align:left; background:var(--card); border:1px solid var(--line);
white-space:nowrap; font-weight:600; font-size:var(--fs-xs); color:var(--ink);">
{{ col }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% if prima_inreg %}
<tr>
{% for col in columns %}
{%- set val = prima_inreg.get(col, '') | string -%}
<td style="padding:4px 10px; border:1px solid var(--line); white-space:nowrap;
font-size:var(--fs-xs); color:var(--muted); max-width:160px; overflow:hidden; text-overflow:ellipsis;"
title="{{ val }}">
{{ val[:40] }}{% if val | length > 40 %}…{% endif %}
</td>
{% endfor %}
</tr>
{% else %}
<tr>
<td colspan="{{ columns | length }}"
style="padding:6px 10px; border:1px solid var(--line); font-size:var(--fs-xs);
color:var(--muted); font-style:italic; text-align:center;">
Antet fara randuri de date
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<form hx-post="/_import/{{ import_id }}/mapare-coloane" <form hx-post="/_import/{{ import_id }}/mapare-coloane"
hx-target="#import-section" hx-target="#import-section"
hx-swap="outerHTML"> hx-swap="outerHTML">
<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"
@@ -32,67 +77,105 @@
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"
style="min-height:44px; padding:10px 24px; font-size:14px;"> {% if not prima_inreg %}disabled aria-disabled="true"{% 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>
<span class="muted" style="font-size:12px;"> {% if not prima_inreg %}
<span style="font-size:var(--fs-xs); color:var(--err);">
Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare.
</span>
{% else %}
<span class="muted" style="font-size:var(--fs-xs);">
maparea se retine pentru fisiere cu acelasi antet maparea se retine pentru fisiere cu acelasi antet
</span> </span>
{% endif %}
</div> </div>
</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

@@ -1,12 +1,18 @@
{# Aceeasi grila standard ca tabelul Trimiteri: cod in .pill, denumire ca text normal
(singura coloana care se poate rupe pe randuri inguste), empty-state in .empty. #}
{% if rows %} {% if rows %}
<div class="tablewrap"> <div class="tablewrap">
<table> <table>
<thead><tr><th>Cod</th><th>Denumire</th><th>Actualizat</th></tr></thead> <thead><tr>
<th>Cod</th>
<th>Denumire</th>
<th>Actualizat</th>
</tr></thead>
<tbody> <tbody>
{% for r in rows %} {% for r in rows %}
<tr> <tr>
<td><span class="pill">{{ r.cod_prestatie }}</span></td> <td><span class="pill">{{ r.cod_prestatie }}</span></td>
<td>{{ r.nume_prestatie }}</td> <td style="white-space:normal;">{{ r.nume_prestatie }}</td>
<td class="muted">{{ r.updated_at }}</td> <td class="muted">{{ r.updated_at }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -0,0 +1,15 @@
{# Pill-uri de filtrare a starii, randate in bara de filtre (_coada.html) si re-randate
prin OOB la fiecare reincarcare a tabelului (_submissions.html). Stare activa =
status_filtru. "Toate" reseteaza filtrul; categoriile apar doar cand au n>0. #}
<button type="button" class="pill-cat pill-cat-reset" data-status=""
aria-pressed="{{ 'true' if not status_filtru else 'false' }}"
onclick="filtreazaStare(this, '')">Toate</button>
{% for pill in pills_categorii %}
<button type="button" class="pill-cat" data-status="{{ pill.status }}"
aria-pressed="{{ 'true' if status_filtru == pill.status else 'false' }}"
style="color:var({{ pill.color_var }}); border-color:var({{ pill.color_var }});"
onclick="filtreazaStare(this, '{{ pill.status }}')">
{{ pill.label }}
<span class="pill-cat-n" style="background:var({{ pill.color_var }});">{{ pill.n }}</span>
</button>
{% endfor %}

View File

@@ -1,193 +1,186 @@
<div id="import-section"> {% import '_macros.html' as ui %}
{# reincarcaPreview (emis de /editeaza si /confirma-review prin HX-Trigger): preview-ul
se reincarca COMPLET (rand + contoare + colaps deja-trimise corecte) in loc de OOB swap
pe <tr> (fragil in htmx 1.9). Evidentierea + toast-ul randului salvat: base.html. #}
<div id="import-section"
hx-get="/_import/{{ import_id }}/preview"
hx-trigger="reincarcaPreview from:body"
hx-target="#import-section"
hx-swap="outerHTML">
{% set pas = 3 %}{% include '_stepper.html' %} {% 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 %}
<div class="flash" style="{% if error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:12px;" <div class="flash" style="{% if error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:12px;"
{% if error %}role="alert"{% endif %}> {% if error %}role="alert"{% endif %}>
{{ message }} {{ message }}
</div> </div>
{% endif %} {% endif %}
<!-- Rezumat stari --> <!-- Rezumat stari cu etichete umane cu majuscula (id stabil pentru OOB swap) -->
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> {% set status_labels = [
{% set status_labels = [ ('ok', 'Gata de trimis'),
('ok', 'gata de trimis'), ('needs_review', 'Verifica valori'),
('needs_review', 'verifica valori'), ('needs_mapping', 'Cod RAR lipsa'),
('needs_mapping', 'fara cod RAR'), ('needs_data', 'Date incomplete'),
('needs_data', 'date lipsa'), ('already_sent', 'Deja trimis'),
('already_sent', 'deja trimis'), ('duplicate_in_file','Duplicat in fisier'),
('duplicate_in_file','dublicat in fisier'), ] %}
] %} <div id="preview-rezumat" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
{% for status_key, label in status_labels %} {% for status_key, label in status_labels %}
{%- 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 }}</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>
<!-- Butoane filtrare stare --> <!-- Butoane filtrare stare — text uman, data-filter pastreaza codul tehnic -->
<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);">
{{ status_key }} ({{ cnt }}) {{ label }} ({{ cnt }})
</button> </button>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload) --> <!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload).
US-004: un singur <form> cu un select per operatie + un singur buton Salveaza. -->
{% if unmapped_ops %} {% if unmapped_ops %}
<div class="card" style="border-color:var(--err); background:#241a1a; 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.
</p> </p>
{% for e in unmapped_ops %} <form hx-post="/_import/{{ import_id }}/mapare-operatii"
{%- set top = e.suggestions[0] if e.suggestions else None -%} hx-target="#import-section" hx-swap="outerHTML">
{%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%}
<form class="maprow" hx-post="/_import/{{ import_id }}/mapare-operatie"
hx-target="#import-section" hx-swap="outerHTML"
style="align-items:flex-end;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}"> {% for e in unmapped_ops %}
<div class="mapcol grow"> {%- set top = e.suggestions[0] if e.suggestions else None -%}
<div><strong>{{ e.cod_op_service }}</strong> {%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%}
<span class="pill" title="randuri blocate">{{ e.blocked }} randuri</span></div> <div class="maprow" style="align-items:flex-end; margin-bottom:10px;">
{% if e.denumire and e.denumire != e.cod_op_service %} <input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="muted">{{ e.denumire }}</div> <div class="mapcol grow">
{% endif %} <div><strong>{{ e.cod_op_service }}</strong>
{% if e.suggestions %} <span class="pill" title="randuri blocate">{{ e.blocked }} randuri</span></div>
<div class="muted" style="font-size:12px; margin-top:4px;"> {% if e.denumire and e.denumire != e.cod_op_service %}
sugestii: <div class="muted">{{ e.denumire }}</div>
{% for s in e.suggestions[:3] %} {% endif %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %} {% if e.suggestions %}
{% endfor %} <div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mapcol">
<select name="cod_prestatie" aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div> </div>
{% endif %}
</div> </div>
<div class="mapcol"> {% endfor %}
<select name="cod_prestatie" required aria-label="Cod RAR pentru {{ e.cod_op_service }}"> <div style="margin-top:12px;">
<option value="">— alege cod RAR —</option> <button type="submit" style="min-height:44px;">Salveaza maparile</button>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
<div class="mapcol">
<label class="chk"><input type="checkbox" name="auto_send" value="true" checked> auto-send</label>
</div>
<div class="mapcol">
<button type="submit" style="min-height:44px;">Salveaza</button>
</div> </div>
</form> </form>
{% endfor %}
</div> </div>
{% endif %} {% endif %}
<!-- Tabel preview + bara confirmare (un singur form) --> <!-- Banner discoverability (T1, US-007): vizibil cand exista randuri needs_review.
Explica operatorului ca randurile cu 'Verifica valori' nu pleaca la RAR
pana le deschide in modal si apasa 'Confirma valorile'. Dispare via OOB
cand summary.needs_review == 0. -->
<div id="preview-needs-review-banner">
{% if summary.get('needs_review', 0) %}
<div class="banner warn" role="note" aria-live="polite"
style="margin-bottom:12px; padding:8px 14px; border-radius:6px;
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
border:1px solid var(--warn, #e6b34a); font-size:13px;">
Randurile cu <span class="pill s-needs_review" style="font-size:11px;">Verifica valori</span>
nu pleaca la RAR pana le deschizi in modal si confirmi in modal
cu butonul <strong>Confirma valorile</strong>.
</div>
{% endif %}
</div>
<!-- Toggle randuri deja-trimise / duplicate: colapsate implicit (nu ocupa loc).
Click -> comuta clasa .preview-arata-trimise pe tabel (CSS in base.html). -->
{% set _n_trimise = summary.get('already_sent', 0) + summary.get('duplicate_in_file', 0) %}
{% if _n_trimise %}
<div style="margin-bottom:8px;">
<button type="button" class="btn-secondary btn-sm" aria-expanded="false"
onclick="var t=document.getElementById('preview-tabel'); var on=t.classList.toggle('preview-arata-trimise'); this.setAttribute('aria-expanded', on); this.querySelector('.tgl-tx').textContent = on ? 'Ascunde {{ _n_trimise }} deja trimise / duplicate' : 'Arata {{ _n_trimise }} deja trimise / duplicate';">
<span class="tgl-tx">Arata {{ _n_trimise }} deja trimise / duplicate</span>
</button>
</div>
{% endif %}
<!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
5.16 (T-4): densitate redusa la coloanele esentiale — Stare / Vehicul /
Operatie / Data + Actiuni. KM final + mesajul de validare (Note) au iesit
din tabel: KM se editeaza in modal, motivul apare ca tooltip pe pill-ul de
Stare. Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). -->
<div id="preview-tabel" class="tablewrap tabel-trimiteri">
<table>
<thead>
<tr>
<th class="col-stare">Stare</th>
<th class="col-vehicul">Vehicul</th>
<th class="col-operatie">Operatie</th>
<th class="col-data">Data</th>
<th class="col-actiuni">Actiuni</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
{% include '_preview_rand.html' %}
{% endfor %}
</tbody>
</table>
<!-- Mesaj "filtrat la zero": afisat de JS cand filtrul ascunde toate randurile -->
<p id="preview-zero-message" class="muted"
style="display:none; text-align:center; padding:24px 16px; font-size:var(--fs-md);">
Niciun rand nu corespunde filtrului selectat.
</p>
</div>
<!-- Bara confirmare (sticky jos) — singurul formular care trimite la RAR -->
<form id="confirm-form" <form id="confirm-form"
hx-post="/_import/{{ import_id }}/confirma" hx-post="/_import/{{ import_id }}/confirma"
hx-target="#import-section" hx-target="#import-section"
hx-swap="outerHTML"> hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div class="tablewrap">
<table>
<thead>
<tr>
<th>#</th>
<th>VIN</th>
<th>Nr. Inm.</th>
<th>Data</th>
<th>KM final</th>
<th>Operatie</th>
<th>Stare</th>
<th>Note</th>
<th>Verificat?</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
{%- set res = row.resolved -%}
{%- set status = row.resolved_status -%}
{%- set prestatii = res.get('prestatii') or [] -%}
{%- set op = (prestatii[0].get('cod_prestatie') or prestatii[0].get('cod_op_service', '')) if prestatii else '' -%}
<tr data-status="{{ status }}"
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif status in ('already_sent', 'duplicate_in_file') %}opacity:.65;{% endif %}">
<td class="muted">{{ row.row_index + 1 }}</td>
<td>{{ res.get('vin') or '<span class="muted"></span>' | safe }}</td>
<td>{{ res.get('nr_inmatriculare') or '' }}</td>
<td>{{ res.get('data_prestatie') or '' }}</td>
<td>{{ res.get('odometru_final') or '' }}</td>
<td>{{ op or '<span class="muted"></span>' | safe }}</td>
<td>
<span class="pill s-{{ status }}">{{ status }}</span>
</td>
<td class="muted" style="font-size:12px; white-space:normal; max-width:220px;">
{% 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 %}
{% elif row.flags %}
{{ row.flags[0] }}
{% elif row.errors %}
{# US-008: arata MOTIVUL (mesajul de validare), nu numele campului #}
{%- for e in row.errors -%}
{%- if e is mapping -%}
{{ e.get('message') or e.get('msg') or (e.values() | list | first) }}
{%- else -%}
{{ e }}
{%- endif -%}
{%- if not loop.last %}; {% endif -%}
{%- endfor -%}
{% endif %}
</td>
<td style="text-align:center;">
{% if status == 'needs_review' %}
<label class="chk" style="min-height:44px; justify-content:center; cursor:pointer;"
title="Bifat inseamna ca ai verificat valorile si le incluzi in trimitere">
<input type="checkbox" name="reviewed_rows" value="{{ row.row_index }}"
onchange="updateN()"
aria-label="Verificat — randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
verif.
</label>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Bara confirmare (sticky jos) -->
<div class="sticky-bar"> <div class="sticky-bar">
<div style="flex:1; min-width:280px;"> <div style="flex:1; min-width:280px;">
<!-- Banner declarant (D12) — direct deasupra input-ului N --> <!-- Banner declarant — direct deasupra input-ului N -->
<div class="banner warn" style="margin-bottom:10px; padding:8px 12px; border-radius:6px;" <div class="banner warn" style="margin-bottom:10px; padding:8px 12px; border-radius:6px;"
role="note" aria-live="polite"> role="note" aria-live="polite">
Confirmand, TU esti declarantul acestor Confirmand, TU esti declarantul acestor
@@ -195,57 +188,45 @@
prezentari la RAR (ireversibil). prezentari la RAR (ireversibil).
</div> </div>
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;"> <div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<div> <label for="n-confirmat"
<label for="n-confirmat" style="font-size:var(--fs-sm); color:var(--muted);">
style="font-size:13px; color:var(--muted); display:block; margin-bottom:2px;"> Confirma numarul
Numar prezentari de confirmat </label>
</label> <input type="number" id="n-confirmat" name="n_confirmat"
<input type="number" id="n-confirmat" name="n_confirmat" value="{{ summary.get('ok', 0) }}"
value="{{ summary.get('ok', 0) }}" 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:var(--fs-xs);">
<span id="n-hint" class="muted" style="font-size:12px; margin-left:6px;"> din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
({{ summary.get('ok', 0) }} ok </span>
{% if summary.get('needs_review', 0) %}
+ pana la {{ summary.get('needs_review', 0) }} verificate manual
{% endif %})
</span>
</div>
<div>
<label for="confirmed-by"
style="font-size:13px; color:var(--muted); display:block; margin-bottom:2px;">
Declarant (optional)
</label>
<input type="text" id="confirmed-by" name="confirmed_by"
placeholder="email sau nume"
style="max-width:200px;">
</div>
</div> </div>
</div> </div>
<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 %}
</div> </div>
</div> </div>
</form> </form>
<!-- Contor "gata de trimis" citit din DOM (data-ok), ca OOB swap-ul de la editare
sa actualizeze N fara a re-randa sectiunea. -->
<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>
@@ -254,25 +235,44 @@
<script> <script>
(function() { (function() {
var nOk = {{ summary.get('ok', 0) | int }}; /* Un singur sticky bar pe ecran — cat preview-ul de import e activ,
ascunde sectiunea Trimiteri de pe Acasa (se reveleaza la reset/commit din _upload.html). */
var trim = document.getElementById('trimiteri-section');
if (trim) trim.style.display = 'none';
/* Actualizeaza N si bannerul cand se bifeaza needs_review */ /* nOk se citeste din DOM (#preview-ok-count[data-ok]) ca OOB swap-ul de la editare
sa-l poata actualiza fara re-randarea sectiunii. */
function getOk() {
var el = document.getElementById('preview-ok-count');
return el ? parseInt(el.dataset.ok || '0', 10) : 0;
}
/* Actualizeaza N dupa editare/confirmare rand (OOB).
US-007: reviewed_rows (checkboxe) eliminate; N = randurile ok din DB,
actualizate via OOB (#preview-ok-count[data-ok]) dupa /confirma-review sau /editeaza. */
function updateN() { function updateN() {
var checked = document.querySelectorAll('input[name="reviewed_rows"]:checked').length; var total = getOk();
var total = nOk + checked;
var inp = document.getElementById('n-confirmat'); var inp = document.getElementById('n-confirmat');
var disp = document.getElementById('n-display'); var disp = document.getElementById('n-display');
var btn = document.getElementById('confirm-btn'); var btn = document.getElementById('confirm-btn');
if (inp) inp.value = total; if (inp) inp.value = total;
if (disp) disp.textContent = total; if (disp) disp.textContent = total;
var hintOk = document.getElementById('n-hint-ok');
if (hintOk) hintOk.textContent = total;
if (btn) btn.disabled = (total === 0); if (btn) btn.disabled = (total === 0);
} }
/* Filtrare randuri dupa stare */ /* Filtrare randuri dupa stare.
Cand niciun rand nu e vizibil, afiseaza mesajul #preview-zero-message. */
function filterRows(status) { function filterRows(status) {
var visible = 0;
document.querySelectorAll('tbody tr[data-status]').forEach(function(tr) { document.querySelectorAll('tbody tr[data-status]').forEach(function(tr) {
tr.style.display = (status === 'all' || tr.dataset.status === status) ? '' : 'none'; var show = status === 'all' || tr.dataset.status === status;
tr.style.display = show ? '' : 'none';
if (show) visible++;
}); });
var zeroMsg = document.getElementById('preview-zero-message');
if (zeroMsg) zeroMsg.style.display = (visible === 0) ? '' : 'none';
document.querySelectorAll('.filter-btn').forEach(function(b) { document.querySelectorAll('.filter-btn').forEach(function(b) {
var active = b.dataset.filter === status; var active = b.dataset.filter === status;
b.style.background = active ? 'var(--accent)' : ''; b.style.background = active ? 'var(--accent)' : '';
@@ -281,15 +281,25 @@
}); });
} }
/* Expune functiile global pentru onclick inline */ /* Expune functiile global pentru onclick/hx-on inline si OOB swap */
window.updateN = updateN; window.updateN = updateN;
window.filterRows = filterRows; window.filterRows = filterRows;
/* Filtru implicit "Toate" activ la incarcare */ /* Filtru implicit "Toate" activ la incarcare */
filterRows('all'); filterRows('all');
updateN();
/* Focus pe campul N la deschidere (a11y — D12) */ /* Evidentiere rand dupa reincarcarea preview-ului (window.__randSalvat setat de
var ni = document.getElementById('n-confirmat'); listener-ul 'randSalvat' din base.html): scroll + flash, ca userul sa vada CARE
if (ni) { ni.focus(); ni.select(); } rand s-a schimbat si sa nu ramana cu impresia ca "nu s-a intamplat nimic". */
if (window.__randSalvat) {
var d = window.__randSalvat; window.__randSalvat = null;
var r = document.getElementById('preview-row-' + d.rowIndex);
if (r) {
r.scrollIntoView({block:'center', behavior:'smooth'});
void r.offsetWidth;
r.classList.add('rand-actualizat');
}
}
})(); })();
</script> </script>

View File

@@ -0,0 +1,111 @@
{#
_preview_rand.html — un singur rand de preview import.
US-006 (PRD 5.12): editarea inline (tr.preview-edit + mutual-exclusion script)
a fost eliminata. Butonul Editeaza deschide MODALUL global (#detaliu-modal-body).
Parametri:
editing — ELIMINAT (ignorat, pastrat pentru compatibilitate apeluri vechi)
include_oob — True: randeaza OOB rezumat + contor + script recalc (swap dupa save)
oob_tr — True: adauga hx-swap-oob pe <tr> insusi (pentru raspunsul POST succes)
summary — dict cu contoarele per status
Campuri pre-computate de _web_compute_preview (NOT din template raw):
row.prez — prezentare_din_payload(resolved): vehicul_nr, vin_scurt,
operatie, cod_rar, data_prestatie, odometru
row.stare_eticheta — text uman (ex. "Gata de trimis"), din STARI_PREVIEW
row.stare_css — clasa CSS (ex. "s-ok"), din STARI_PREVIEW
row.nota_umana — mesaj uman formatat pentru coloana Note (fara repr Python)
#}
{%- set res = row.resolved -%}
{%- set status = row.resolved_status -%}
{%- set _sent_dup = status in ('already_sent', 'duplicate_in_file') -%}
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}"
{% if _sent_dup %}class="preview-sent-row"{% endif %}
{% if oob_tr %}hx-swap-oob="outerHTML:#preview-row-{{ row.row_index }}"{% endif %}
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif _sent_dup %}opacity:.6;{% endif %}">
{#- Motivul (validare / deja-trimis / duplicat) — fost coloana Note, acum tooltip pe pill.
KM final iese din tabel (se editeaza in modal). -#}
{%- if status == 'already_sent' and row.get('already_sent_info') -%}
{%- set ai = row.already_sent_info -%}
{%- set _nota = 'deja trimis ' ~ ((ai.get('created_at') or '')[:10]) ~ ((' (#' ~ ai.id_prezentare ~ ')') if ai.get('id_prezentare') else '') -%}
{%- elif status == 'duplicate_in_file' and row.get('duplicate_with') -%}
{%- set _dwith = [] -%}
{%- for idx in row.duplicate_with -%}{{ _dwith.append(idx + 1) or '' }}{%- endfor -%}
{%- set _nota = 'dubla cu randul ' ~ (_dwith | join(', ')) -%}
{%- else -%}
{%- set _nota = row.nota_umana or '' -%}
{%- endif -%}
<td class="col-stare" data-eticheta="Stare">
<span class="pill {{ row.stare_css }}" style="display:inline-flex; align-items:center; gap:5px;"
{% if _nota %}title="{{ _nota }}"{% endif %}>
<span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ row.stare_eticheta }}</span>
</td>
<td class="col-vehicul" data-eticheta="Vehicul">
{{ row.prez.vehicul_nr }}
{% if row.prez.vin_scurt and row.prez.vin_scurt != '—' %}
<div class="muted" style="font-size:12px; white-space:nowrap;">{{ row.prez.vin_scurt }}</div>
{% endif %}
</td>
<td class="col-operatie" data-eticheta="Operatie">
<div>{{ row.prez.operatie }}</div>
{% if row.prez.cod_rar and row.prez.cod_rar != '—' %}
<div class="cod-rar-sub"><span class="cod-rar-cod">{{ row.prez.cod_rar }}</span></div>
{% else %}
<div class="muted cod-rar-sub">nemapat</div>
{% endif %}
</td>
<td class="col-data" data-eticheta="Data prestatie">{{ row.prez.data_prestatie }}</td>
<td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
{% if status not in ('already_sent', 'duplicate_in_file') %}
<button type="button" class="btn-editeaza"
style="min-height:36px; padding:6px 14px; font-size:13px;
background:transparent; border-color:var(--line); color:var(--ink);"
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare-modal"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
aria-label="Editeaza randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
Editeaza
</button>
{% endif %}
</td>
</tr>
{% if include_oob %}
{# OOB: actualizeaza rezumatul, contorul, bannerul needs_review dupa save/confirma-review. #}
{% set status_labels = [
('ok','Gata de trimis'), ('needs_review','Verifica valori'), ('needs_mapping','Cod RAR lipsa'),
('needs_data','Date incomplete'), ('already_sent','Deja trimis'), ('duplicate_in_file','Duplicat in fisier')] %}
<div id="preview-rezumat" hx-swap-oob="true"
style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
{% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%}
{% if cnt > 0 %}<span class="pill s-{{ status_key }}" style="display:inline-flex; align-items:center; gap:5px; font-size:var(--fs-xs);"><span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ cnt }} {{ label | lower }}</span>{% endif %}
{% endfor %}
</div>
<span id="preview-ok-count" hx-swap-oob="true" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
{# Banner discoverability: OOB swap dupa confirmare/editare → dispare cand needs_review==0. #}
<div id="preview-needs-review-banner" hx-swap-oob="true">
{% if summary.get('needs_review', 0) %}
<div class="banner warn" role="note" aria-live="polite"
style="margin-bottom:12px; padding:8px 14px; border-radius:6px;
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
border:1px solid var(--warn, #e6b34a); font-size:13px;">
Randurile cu <span class="pill s-needs_review" style="font-size:11px;">Verifica valori</span>
nu pleaca la RAR pana le deschizi in modal si confirmi in modal
cu butonul <strong>Confirma valorile</strong>.
</div>
{% endif %}
</div>
<script>
(function() {
/* Editare incheiata: re-activeaza confirm + butoanele Editeaza, recalculeaza N.
Defer pe tick-ul urmator: la momentul rularii scriptului, swap-ul randului poate
sa nu se fi asezat inca, deci tr[data-editing] ar fi inca prezent si updateN ar
lasa confirm dezactivat (editing=true). Dupa setTimeout(0) randul e in mod display. */
setTimeout(function() {
document.querySelectorAll('.btn-editeaza').forEach(function(b) { b.disabled = false; });
var btn = document.getElementById('confirm-btn');
if (btn) btn.title = '';
if (window.updateN) window.updateN();
}, 0);
})();
</script>
{% endif %}

View File

@@ -1,69 +1,156 @@
<div id="status-bar" class="status-bar card" <div id="status-bar" class="status-bar card"
hx-get="/_fragments/status" hx-get="/_fragments/status?tab={{ tab_activ | default('acasa') }}"
hx-trigger="every 15s" hx-trigger="every 15s, trimiteriChanged from:body"
hx-swap="outerHTML"> hx-swap="outerHTML"
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) --> {# Banner cont in asteptare de activare (mereu vizibil cand contul e inactiv) #}
{% if not account_active %} {% if not account_active %}
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn); <div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn);
background:#201c0f; border-radius:6px; font-size:13px;"> background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px; font-size:13px;">
<strong>Cont in asteptare de activare.</strong> <strong>Cont in asteptare de activare.</strong>
Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR
porneste automat dupa activare de catre administrator. porneste automat dupa activare de catre administrator.
</div> </div>
{% endif %} {% endif %}
<!-- Rand 1: doua bife binare + ultima autentificare --> {# US-006 (5.17) — Banner one-time trial->Gratuit (T-DES-1): afisat la prima incarcare
<div style="display:flex; gap:28px; flex-wrap:wrap; align-items:center; font-size:14px;"> dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage.
Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #}
{# Bifa: glifa (✓/✗) + culoare + text — accesibil (nu doar culoare, design review) #} {% if trial_expirat_recent|default(false) %}
{% macro bifa(ok, text, tip) %} <div id="banner-trial-expirat"
<span title="{{ tip }}" style="display:inline-flex; align-items:center; gap:7px;"> role="status"
{% if ok %} style="margin-bottom:10px; padding:7px 12px;
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span> border-left:3px solid var(--warn);
<span class="s-sent">{{ text }}</span> background:color-mix(in srgb, var(--warn) 10%, var(--card));
{% else %} border-radius:6px; font-size:var(--fs-sm);
<span class="s-error" aria-hidden="true" style="font-weight:bold;">&#10007;</span> display:flex; align-items:center; justify-content:space-between; gap:8px;">
<span class="s-error">{{ text }}</span> <span>Trial Pro expirat — esti pe Gratuit, 60/luna</span>
{% endif %} <button onclick="sessionStorage.setItem('tfx','1'); document.getElementById('banner-trial-expirat').style.display='none';"
</span> style="background:transparent; border:none; color:var(--muted); cursor:pointer;
{% endmacro %} font-size:18px; padding:0 4px; line-height:1; flex-shrink:0;"
aria-label="Inchide bannerul">×</button>
{{ bifa(worker_ok, worker_lbl[0], worker_lbl[1]) }}
{{ bifa(rar_ok, rar_lbl[0], rar_lbl[1]) }}
<span style="display:inline-flex; align-items:center; gap:6px;">
<span class="muted">{{ eticheta_ultima_auth }}:</span>
<span>{{ last_login }}</span>
</span>
</div> </div>
<script>(function(){ if(sessionStorage.getItem('tfx')){ var el=document.getElementById('banner-trial-expirat'); if(el) el.style.display='none'; } })();</script>
{% endif %}
<!-- Rand 2: contoare coada --> {# === US-003 (PRD 5.16): Banda de stare RAR — NUMAI cand BLOCAT (rosu, lat de 100%).
<div style="margin-top:10px; display:flex; gap:20px; flex-wrap:wrap; font-size:14px;"> OK = dot verde in antet (base.html); banda nu mai apare cand totul e ok.
<span><span class="muted">In asteptare:</span> <span class="s-queued">{{ counts_queued }}</span></span> Elementul id="strip-sanatate" ramane in DOM mereu, dar goleste continutul cand OK,
<span><span class="muted">Declarate la RAR:</span> <span class="s-sent">{{ counts_sent }}</span></span> astfel "hidden" + fara continut eroare in sursa = nu pica testele de prezenta id-ului.
<span><span class="muted">Blocate:</span> #}
<span class="{{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</span> {% if sanatate_ok %}
</span> <div id="strip-sanatate" role="status" aria-live="polite" hidden></div>
</div> {% else %}
<div id="strip-sanatate"
<!-- Defalcare blocate pe motiv (doar daca exista) --> role="status"
{% if blocate_defalcat %} aria-live="polite"
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);"> style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;
<div style="font-size:13px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div> padding:10px 14px; border-radius:8px; margin-bottom:14px;
<div style="display:flex; gap:16px; flex-wrap:wrap;"> background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);">
{% for eticheta, n in blocate_defalcat %} <div style="display:flex; align-items:center; gap:9px;">
{% if n > 0 %} <span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--err);">&#10007;</span>
<div> <span style="font-weight:700; font-size:13px;">{{ sanatate_text }}</span>
<span class="{{ eticheta[2] }}" style="font-size:13px;">{{ eticheta[0] }}</span>
<span class="muted" style="font-size:13px; margin-left:4px;">({{ n }})</span>
{% if eticheta[1] %}
<div class="muted" style="font-size:13px; max-width:240px;">{{ eticheta[1] }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div> </div>
<span style="font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted); white-space:nowrap;">
{{ eticheta_ultima_auth }}: {{ last_login }}
</span>
</div>
{% endif %}
{# === US-002 (PRD 5.16): 5 carduri-contor separate (desktop) + bara compacta (mobil <=560px).
Total / Luna asta / Azi / In coada / De corectat.
#}
{# Desktop: 5 carduri side-by-side. display:flex + layout stau in CSS (.contoare-desktop in
base.html), NU inline, ca media query-ul <=560px sa le poata ascunde pe mobil (bara compacta). #}
<div class="contoare-desktop">
{# Total trimise (all-time) #}
<div class="contor-card" style="flex:1; min-width:100px;">
<div class="contor-cifra">{{ counts_sent }}</div>
<div class="contor-label">Total</div>
</div>
{# Luna asta #}
<div class="contor-card" style="flex:1; min-width:100px;">
<div class="contor-cifra s-accent">{{ sent_month }}</div>
<div class="contor-label">Luna asta</div>
</div>
{# Azi #}
<div class="contor-card" style="flex:1; min-width:80px;">
<div class="contor-cifra s-accent">{{ sent_today }}</div>
<div class="contor-label">Azi</div>
</div>
{# In coada #}
<div class="contor-card" style="flex:1; min-width:80px;">
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
<div class="contor-label">In coada</div>
</div>
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
<a href="/" class="contor-card"
style="flex:1; min-width:80px; text-decoration:none; display:block; cursor:pointer;"
aria-label="De corectat: {{ blocate_total }} — click pentru lista de trimiteri">
<div class="contor-cifra {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
<div class="contor-label">De corectat</div>
</a>
</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 ===
Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ.
#}
{% set _tab = tab_activ | default('acasa') %}
<nav class="status-nav" aria-label="Navigatie rapida"
style="display:flex; gap:8px 16px; flex-wrap:wrap; font-size:13px; border-top:1px solid var(--line); padding-top:8px;">
<a href="/"
{% if _tab == 'acasa' or _tab == '' %}aria-current="page"{% endif %}
class="status-nav-link{% if _tab == 'acasa' or _tab == '' %} status-nav-activ{% endif %}">Trimiteri</a>
<a href="/?tab=mapari"
{% if _tab == 'mapari' %}aria-current="page"{% endif %}
class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a>
</nav>
{# US-006 (5.17) + T-6 (5.16): linia de plan in CORP apare DOAR in starea de avertizare
(>=80% -> --warn; limita atinsa -> --err). Consumul normal (N/60) traieste in badge-ul
din antet + linia din meniul burger, nu ca rand permanent in corp (densitate redusa).
Ierarhie: nu concureaza cu stripul de sanatate (zero-silent-failures pastrat). #}
{% if plan_linie and (plan_warn|default(false) or plan_limita_atinsa|default(false)) %}
<div class="plan-status-line"
style="font-size:var(--fs-sm); margin-top:6px; padding-top:6px;
border-top:1px solid var(--line2);
color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--muted){% endif %};
{% if plan_warn|default(false) %}font-weight:600;{% endif %}">
{{ plan_linie }}
{% if plan_limita_atinsa|default(false) or plan_warn|default(false) %}
&nbsp;<a href="/?tab=cont" style="font-size:var(--fs-xs); font-weight:400; color:var(--accent);">Detalii plan</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -1,22 +1,9 @@
{# {#
_stepper.html — Antet wizard import (PUR vizual, fara logica de rutare). _stepper.html — Antet wizard import COMPACT (PUR vizual). PRD 5.13.
Parametru: `pas` (integer 1-4). Clasele .stepper-* sunt definite in base.html.
Parametru: `pas` (integer 1-4) — pasul curent. >=1024px: bara slim orizontala (.stepper-track). <1024px: forma colapsata
Utilizare in template-uri care mostenesc contextul Jinja2: "Pasul N din 4 - Titlu" + bara de progres (.stepper-collapsed).
{% set pas = 1 %}{% include '_stepper.html' %} Utilizare: {% set pas = 1 %}{% include '_stepper.html' %}
sau cu `with`:
{% with pas=2 %}{% include '_stepper.html' %}{% endwith %}
Cei 4 pasi ficsi:
1. Incarca fisier
2. Potriveste coloanele
3. Verifica
4. Confirma trimiterea
Stari vizuale:
- index < pas "facut" (bulina plina, text bifat)
- index == pas "activ" (evidentiat, aria-current="step")
- index > pas → "viitor" (estompat)
#} #}
{%- set _pasi_import = [ {%- set _pasi_import = [
(1, "Incarca fisier", "Trage un fisier xlsx/csv aici sau foloseste butonul de alegere."), (1, "Incarca fisier", "Trage un fisier xlsx/csv aici sau foloseste butonul de alegere."),
@@ -24,73 +11,26 @@
(3, "Verifica", "Verifica randurile inainte sa le trimiti la RAR."), (3, "Verifica", "Verifica randurile inainte sa le trimiti la RAR."),
(4, "Confirma trimiterea", "Confirma numarul de prezentari actiunea e ireversibila."), (4, "Confirma trimiterea", "Confirma numarul de prezentari actiunea e ireversibila."),
] -%} ] -%}
<nav class="stepper-import" aria-label="Pasii importului" style=" {%- set _activ = _pasi_import[pas - 1] -%}
display:flex; <div class="stepper">
gap:0; {# Desktop (>=1024px): bara slim orizontala. #}
align-items:stretch; <nav class="stepper-track" aria-label="Pasii importului">
margin-bottom:20px; {% for nr, titlu, ajutor in _pasi_import %}
border:1px solid var(--line); {%- if nr < pas %}{% set cls = "is-done" %}{% set aria = "" %}
border-radius:8px; {%- elif nr == pas %}{% set cls = "is-active" %}{% set aria = ' aria-current="step"' %}
overflow:hidden; {%- else %}{% set cls = "" %}{% set aria = "" %}{% endif %}
background:var(--card); <div class="stepper-step {{ cls }}"{{ aria | safe }}>
"> <span class="stepper-nr">{% if nr < pas %}&#10003;{% else %}{{ nr }}{% endif %}</span>
{% for nr, titlu, ajutor in _pasi_import %} <span class="stepper-tx">{{ titlu }}</span>
{%- if nr < pas %}
{%- set cls = "facut" -%}
{%- set aria = "" -%}
{%- elif nr == pas %}
{%- set cls = "activ" -%}
{%- set aria = ' aria-current="step"' -%}
{%- else %}
{%- set cls = "viitor" -%}
{%- set aria = "" -%}
{%- endif %}
<div class="stepper-pas stepper-pas--{{ cls }}"{{ aria | safe }}
style="
flex:1;
padding:10px 14px;
border-right:{% if not loop.last %}1px solid var(--line){% else %}none{% endif %};
{% if cls == 'activ' %}
background:rgba(91,141,239,.10);
{% elif cls == 'facut' %}
opacity:1;
{% else %}
opacity:.4;
{% endif %}
">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:2px;">
<span class="stepper-nr" style="
display:inline-flex;
align-items:center;
justify-content:center;
width:20px;
height:20px;
border-radius:50%;
font-size:11px;
font-weight:700;
flex-shrink:0;
{% if cls == 'activ' %}
background:var(--accent);
color:#fff;
{% elif cls == 'facut' %}
background:var(--ok);
color:#fff;
{% else %}
background:var(--line);
color:var(--muted);
{% endif %}
">
{% if cls == 'facut' %}&#10003;{% else %}{{ nr }}{% endif %}
</span>
<span style="
font-size:13px;
font-weight:{% if cls == 'activ' %}600{% else %}400{% endif %};
color:{% if cls == 'activ' %}var(--ink){% elif cls == 'facut' %}var(--ink){% else %}var(--muted){% endif %};
">{{ titlu }}</span>
</div> </div>
{% if cls == 'activ' %} {% endfor %}
<p class="muted" style="margin:0; font-size:12px; padding-left:26px;">{{ ajutor }}</p> </nav>
{% endif %} {# Tableta/mobil (<1024px): colapsat "Pasul N din 4 - Titlu" + progres. #}
<div class="stepper-collapsed">
<div class="stepper-current">Pasul {{ pas }} din 4 <span class="muted">&middot; {{ _activ[1] }}</span></div>
<div class="stepper-progress" role="progressbar" aria-valuenow="{{ pas }}" aria-valuemin="1" aria-valuemax="4"
aria-label="Pasul {{ pas }} din 4"><span style="width:{{ (pas / 4 * 100) | round | int }}%;"></span></div>
</div> </div>
{% endfor %} {# Ajutorul pasului activ — o singura linie, sub bara (valabil pe ambele forme). #}
</nav> <p class="stepper-help">{{ _activ[2] }}</p>
</div>

View File

@@ -1,42 +1,247 @@
{#
OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri.
Reincarcarea (hx-include="#filtre-trimiteri") preia automat pagina curenta.
Elementul OOB e extras din continutul normal de HTMX inainte de swap in #submissions-wrap.
#}
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
{# OOB: re-randeaza pill-urile de stare (in bara de filtre, in afara #submissions-wrap) cu
contoarele si starea activa proaspete la fiecare reincarcare a tabelului. #}
<span hx-swap-oob="innerHTML:#pills-categorii">{% include '_pills.html' %}</span>
{# Versiunea datelor cu care s-a randat tabelul; pollerul "Date noi" o compara. #}
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
{% if bulk_message %}
{# Sumar actiune bulk (US-010 PRD 5.15): afisat dupa bulk-fix, disparut la urmatoarea reincarcare. #}
<div class="bulk-message" role="status" aria-live="polite"
style="font-size:13px; color:var(--ink); background:var(--card2);
border:1px solid var(--line); border-radius:6px;
padding:6px 10px; margin-bottom:8px;">
{{ bulk_message }}
</div>
{% endif %}
{% if rows %} {% if rows %}
<div class="tablewrap"> {# Form bulk cu DOUA actiuni: (1) aplica cod RAR la selectate (bulk-fix, US-010),
<table> (2) sterge selectate (sterge-bulk). Selectia opereaza DOAR pe randuri blocate
<thead><tr> (gestionabil); sent/sending/queued nu au checkbox (read-only).
<th>#</th> Butonul "Aplica cod" foloseste hx-post propriu (override form action).
<th>Stare</th> hx-disinherit="hx-confirm" pe form => butonul aplica-cod NU mosteneste confirmare. #}
<th>Vehicul</th> <form id="bulk-trimiteri"
<th>Operatie</th> hx-post="/trimiteri/sterge-bulk"
<th>Data prestatie</th> hx-target="#submissions-wrap"
<th>Nr. prezentare RAR</th> hx-swap="innerHTML"
<th>Actualizat</th> hx-confirm="Stergi definitiv trimiterile selectate?"
<th>Motiv</th> hx-disinherit="hx-confirm"
</tr></thead> style="margin:0;">
<tbody> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="display:flex; justify-content:flex-end; align-items:center;
gap:6px; margin-bottom:8px; flex-wrap:wrap;">
{# Bulk-fix: input cod + buton aplica (US-010 PRD 5.15) #}
<input type="text" name="cod_prestatie" id="bulk-fix-cod"
placeholder="Cod RAR (ex: OE-1)"
autocomplete="off" autocapitalize="characters"
style="width:120px; font-size:12px; padding:3px 7px;
border:1px solid var(--line); border-radius:5px;
background:var(--card2); color:var(--ink);"
aria-label="Cod RAR de aplicat la randurile selectate">
<button type="button"
hx-post="/trimiteri/bulk-fix"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="background:var(--card); color:var(--accent); border-color:var(--accent);
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;"
aria-label="Aplica codul RAR la randurile blocate selectate">
Aplica cod
</button>
{# Separator vizual #}
<span style="color:var(--muted); font-size:11px; padding:0 2px;" aria-hidden="true">|</span>
{# Bulk-delete: pastreaza exact comportamentul existent #}
<button type="submit" id="bulk-sterge-btn"
style="background:var(--card); color:var(--err); border-color:var(--err);
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;">
Sterge selectate
</button>
</div>
{# Lista slim trimiteri (US-004, PRD 5.15).
Inlocuieste tabelul cu randuri compacte: VIN mono + operatie·ora + pill.
Nr. inmatriculare, data prestatie si nr. prezentare RAR raman accesibile
pe linia meta discreta (linia 3) si in modalul de detaliu. #}
<ul class="lista-trimiteri-slim" role="list"
aria-label="Lista trimiteri">
{% for r in rows %} {% for r in rows %}
<tr id="trimitere-row-{{ r.id }}" {# Randul slim: stanga = VIN mono scurt (L1) + operatie·ora muted (L2) + meta (L3);
dreapta = pill de stare. Click deschide modalul global (#detaliu-modal-body).
Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
<li id="trimitere-row-{{ r.id }}"
class="trimitere-slim"
data-detaliu-id="{{ r.id }}"
hx-get="/_fragments/trimitere/{{ r.id }}" hx-get="/_fragments/trimitere/{{ r.id }}"
hx-target="#trimitere-detaliu" hx-target="#detaliu-modal-body"
hx-swap="innerHTML" hx-swap="innerHTML"
role="button" tabindex="0"
aria-haspopup="dialog"
style="cursor:pointer;" style="cursor:pointer;"
title="Click pentru detaliul complet"> title="Click pentru detaliul complet">
<td class="muted">{{ r.id }}</td>
<td><span class="pill {{ r.stare_css }}">{{ r.stare_text }}</span></td> {# Zona checkbox — nu declanseaza modalul (stopPropagation).
<td> Vizibila DOAR pe randurile gestionabile (error/needs_data/needs_mapping).
{{ r.prez.vehicul_nr }} Latimea fixa previne reflow la prezenta/absenta checkbox-ului. #}
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %} <div style="flex:0 0 22px; display:flex; align-items:center;" onclick="event.stopPropagation();">
<span class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</span> {% if r.gestionabil %}
<input type="checkbox" name="submission_id" value="{{ r.id }}"
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
{% endif %} {% endif %}
</td> </div>
<td>{{ r.prez.operatie }}</td>
<td>{{ r.prez.data_prestatie }}</td> {# Bloc text principal — stanga, ocupa spatiul ramas. Rand de 2 linii (spec 5.16):
<td>{{ r.id_prezentare or '—' }}</td> L1 = placuta (identificator primar); L2 = cod RAR · operatie · data prestatie. #}
<td class="muted">{{ r.updated_at }}</td> <div style="flex:1 1 auto; min-width:0;">
<td class="muted" style="white-space:normal; max-width:280px;">{{ r.motiv }}</td>
</tr> {# Linia 1: nr. inmatriculare (placuta) — identificatorul primar pe care il
scaneaza operatorul. .slim-vin reumplut (acelasi nume de clasa, churn minim).
Fallback cand placuta lipseste ('—'): VIN scurt, apoi mesaj neutru
(nu randa em-dash izolat ca identificator). #}
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
<div class="slim-vin">{{ r.prez.vehicul_nr }}</div>
{% elif r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
<div class="slim-vin muted">{{ r.prez.vin_scurt }}</div>
{% else %}
<div class="slim-vin muted">fara numar</div>
{% endif %}
{# Linia 2: cod RAR (sau 'nemapat') · operatie (ink, ellipsis) · data prestatie.
Separatorul "·" e injectat prin CSS intre celule. Operatia primeste ellipsis
ca randul sa NU treaca pe a 3-a linie nici la 390px.
VIN integral, #id_prezentare si secundele traiesc in modalul de detaliu. #}
<div class="slim-meta slim-rand2">
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
<span class="cod-rar-cod">{{ r.prez.cod_rar }}</span>
{% else %}
<span class="cod-rar-cod cod-rar-sub muted">nemapat</span>
{% endif %}
<span class="slim-op">{{ r.prez.operatie }}</span>
{% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %}
<span class="slim-data muted">{{ r.prez.data_prestatie }}</span>
{% endif %}
</div>
{# Micro-linie umana a problemei — text mic s-error, DOAR pe stari de problema
(loud-on-exception D6). Randul normal/finalizat ramane strict 2 linii.
Token tipografic --fs-xs (>=12px, scala 5.16). #}
{% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
{% endif %}
</div>
{# Pill de stare — dreapta, flex:none #}
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}"
style="flex:0 0 auto; white-space:nowrap;">{{ r.stare_scurt }}</span>
</li>
{% endfor %} {% endfor %}
</tbody> </ul>
</table> </form>
{#
Paginare numerotata.
Afisata doar cand exista mai mult de o pagina.
Fiecare link pastreaza filtrele curente (status, vehicul, data_de, data_pana).
Pagina curenta: aria-current="page" (semantic).
#}
{% if total is defined %}
<div aria-live="polite"
style="font-size:12px; color:var(--muted); text-align:right; margin-top:6px; margin-bottom:2px;">
{% if total == 0 %}
0 trimiteri
{% else %}
{{ page_start }}{{ page_end }} din {{ total }}
{% endif %}
</div> </div>
{% endif %}
{% if pages is defined and pages > 1 %}
{#
Construim param-string pentru filtrele curente (fara page) — refolosit in fiecare link.
Filtrul status vine din pill-uri (nu din form); il pastram in URL.
#}
{% set pq = "" %}
{% if f_status %}{% set pq = pq + "&status=" + f_status %}{% endif %}
{% if f_vehicul %}{% set pq = pq + "&vehicul=" + f_vehicul %}{% endif %}
{% if f_data_de %}{% set pq = pq + "&data_de=" + f_data_de %}{% endif %}
{% if f_data_pana %}{% set pq = pq + "&data_pana=" + f_data_pana %}{% endif %}
<nav aria-label="Paginare trimiteri"
style="display:flex; justify-content:center; gap:4px; flex-wrap:wrap; margin-top:10px;">
{# Buton Anterior #}
{% if page > 1 %}
<button type="button"
hx-get="/_fragments/submissions?page={{ page - 1 }}{{ pq }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
border:1px solid var(--line); background:var(--card); color:var(--fg);"
aria-label="Pagina anterioara">
&laquo;
</button>
{% else %}
<button type="button" disabled
style="padding:3px 10px; border-radius:6px; font-size:13px;
border:1px solid var(--line); background:var(--card); color:var(--muted);
opacity:0.4; cursor:default;"
aria-label="Pagina anterioara (indisponibila)">
&laquo;
</button>
{% endif %}
{# Numerele de pagina #}
{% for p in range(1, pages + 1) %}
{% if p == page %}
<button type="button"
aria-current="page"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:default;
border:1px solid var(--accent); background:var(--accent); color:#fff;
font-weight:700;">
{{ p }}
</button>
{% else %}
<button type="button"
hx-get="/_fragments/submissions?page={{ p }}{{ pq }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
border:1px solid var(--line); background:var(--card); color:var(--fg);">
{{ p }}
</button>
{% endif %}
{% endfor %}
{# Buton Urmator #}
{% if page < pages %}
<button type="button"
hx-get="/_fragments/submissions?page={{ page + 1 }}{{ pq }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
border:1px solid var(--line); background:var(--card); color:var(--fg);"
aria-label="Pagina urmatoare">
&raquo;
</button>
{% else %}
<button type="button" disabled
style="padding:3px 10px; border-radius:6px; font-size:13px;
border:1px solid var(--line); background:var(--card); color:var(--muted);
opacity:0.4; cursor:default;"
aria-label="Pagina urmatoare (indisponibila)">
&raquo;
</button>
{% endif %}
</nav>
{% endif %}
{% elif filtru_activ %} {% elif filtru_activ %}
<div class="empty"> <div class="empty">
Nimic pe filtrul curent. Nimic pe filtrul curent.

View File

@@ -1,98 +1,220 @@
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--accent);"> {% from "_eroare.html" import card_erori %}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;"> {% import '_macros.html' as ui %}
<h2 style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2> {# Detaliu editabil in-place. Fragmentul se swap-uieste in corpul modalului global
<span class="pill {{ stare_css }}">{{ stare_text }}</span> (#detaliu-modal-body). Heading-ul poarta id-ul folosit de aria-labelledby al dialogului.
<button type="button" style="margin-left:auto; background:var(--card); color:var(--muted); border-color:var(--line);" Operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
onclick="document.getElementById('trimitere-detaliu').innerHTML='';"> (fallback „nemapat"), fara eticheta separata „Cod RAR". #}
Inchide {% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
</button> <div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
</div>
{% if stare_subtext %} {# === Header — #id + pill + motiv uman === #}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 8px;">
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
</div>
{% if motiv %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ motiv }}</p>
{% elif stare_subtext %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p> <p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
{% endif %} {% endif %}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:12px 24px;"> {# === Bloc eroare blocanta — DOAR in read-only.
<div><div class="muted" style="font-size:12px;">Numar inmatriculare</div><div>{{ prez.vehicul_nr }}</div></div> In editare, cardul 3-niveluri e inlocuit cu: erori per-camp in macro `camp`
<div><div class="muted" style="font-size:12px;">VIN (serie sasiu)</div><div style="word-break:break-all;">{{ prez.vin }}</div></div> (text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #}
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }}</div></div> {% if not editabil and erori_3n %}
<div><div class="muted" style="font-size:12px;">Cod RAR</div><div>{{ prez.cod }}</div></div> <div style="margin:0 0 14px;">
<div><div class="muted" style="font-size:12px;">Data prestatie</div><div>{{ prez.data_prestatie }}</div></div> {{ card_erori(erori_3n) }}
<div><div class="muted" style="font-size:12px;">Odometru final</div><div>{{ prez.odometru }}</div></div>
<div><div class="muted" style="font-size:12px;">Nr. prezentare RAR</div><div>{{ id_prezentare or '—' }}</div></div>
<div><div class="muted" style="font-size:12px;">Cod HTTP RAR</div><div>{{ rar_status_code or '—' }}</div></div>
<div><div class="muted" style="font-size:12px;">Reincercari</div><div>{{ retry_count }}</div></div>
<div><div class="muted" style="font-size:12px;">Creat</div><div>{{ created_at }}</div></div>
<div><div class="muted" style="font-size:12px;">Actualizat</div><div>{{ updated_at }}</div></div>
<div><div class="muted" style="font-size:12px;">Urmatoarea incercare</div><div>{{ next_attempt_at }}</div></div>
</div>
{% if motiv %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div class="muted" style="font-size:12px;">Motiv</div>
<div>{{ motiv }}</div>
</div> </div>
{% endif %} {% endif %}
{% if rar_error %} {# === Mapare inline — alege cod RAR pentru operatiile nemapate.
<details style="margin-top:10px;"> Cand nemapate_inline, linia „Operatie: X · nemapat" apare in formularul de mai jos
<summary class="muted" style="font-size:12px; cursor:pointer;">Mesaj tehnic RAR (integral)</summary> (cod_afis = nemapat), iar aici e picker-ul; dupa mapare, re-render arata codul rezolvat. === #}
<pre style="white-space:pre-wrap; word-break:break-all; font-size:12px; margin:6px 0 0; color:var(--muted);">{{ rar_error }}</pre> {% if nemapate_inline %}
</details> <div style="margin:0 0 14px; padding-bottom:12px; border-bottom:1px solid var(--line);">
<h3 style="font-size:14px; margin:0 0 4px;">Mapeaza codul operatiei</h3>
<p class="muted" style="margin:0 0 10px; font-size:13px;">
Alege codul RAR pentru fiecare operatie. La salvare, randul se re-rezolva pe loc
(si celelalte randuri cu aceeasi operatie).
</p>
{% for op in nemapate_inline %}
{% set top = op.suggestions[0] if op.suggestions else None %}
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
<form hx-post="/trimitere/{{ id }}/mapeaza" hx-target="#detaliu-modal-body" hx-swap="innerHTML"
hx-disabled-elt="find button"
style="margin:0 0 12px; padding:10px; border:1px solid var(--line); border-radius:8px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="cod_op_service" value="{{ op.cod_op_service }}">
<div style="margin-bottom:6px;">
<strong>{{ op.cod_op_service }}</strong>
{% if op.denumire and op.denumire != op.cod_op_service %}
<span class="muted">— {{ op.denumire }}</span>
{% endif %}
</div>
{% if op.suggestions %}
<div class="muted" style="font-size:12px; margin-bottom:6px;">
Sugestii:
{% for s in op.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<select name="cod_prestatie" required aria-label="Cod RAR pentru {{ op.cod_op_service }}"
style="flex:1; min-width:220px; max-width:380px;">
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
<button type="submit">Salveaza maparea</button>
</div>
</form>
{% endfor %}
</div>
{% endif %} {% endif %}
{# === Corectie inline (US-010): doar randuri ne-trimise blocate === #} {# === Formular editabil (needs_data/needs_mapping) SAU context read-only.
Zero dublare: campurile vehiculului apar O SINGURA DATA — editabile cand randul e
corectabil, altfel read-only. Operatie + cod RAR read-only deasupra campurilor. === #}
{% if editabil %} {% if editabil %}
{% set err_map = {} %} {% set err_map = {} %}
{% for e in corectie_errors %}{% if e.field %}{% set _ = err_map.update({e.field: e.message}) %}{% endif %}{% endfor %} {% for e in corectie_errors %}{% if e.field %}{% set _ = err_map.update({e.field: e.message}) %}{% endif %}{% endfor %}
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);"> {# fix_map gol pentru Trimiteri (fix-hints vin din preview, nu din corectii de trimitere). #}
<h3 style="font-size:14px; margin:0 0 8px;">Corecteaza si re-trimite</h3> {% set fix_map = {} %}
{# vin_context pentru aria-label cu context VIN (D#6). #}
{%- set vin_context = form_vin -%}
{# btn_label pentru butonul primar al partial-ului. #}
{%- set btn_label = 'Salveaza si retrimite' -%}
{% if corectie_msg %} {% if corectie_msg %}
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:10px;" <div class="flash" style="{% if corectie_error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin:0 0 12px;"
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div> {% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% endif %}
{# Erori fara camp (field None) nu dispar silentios in editare —
cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form.
Erori cu camp raman afisate per-camp de macro-ul `camp` din _form_editare.html. #}
{% for e in erori_3n if not e.field %}
<div class="s-error" style="font-size:13px; margin:0 0 10px;" role="alert">{{ e.problema }}</div>
{% endfor %}
<form hx-post="/trimitere/{{ id }}/corecteaza"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# Cleanup B (US-009 PRD 5.15): vechiul <select name="cod_prestatie"> eliminat.
Chips din _form_editare.html (via _chips_prestatii.html) il inlocuiesc complet:
emit hidden inputs name="cod_prestatie" + picker per-operatie (E4, US-007).
post_corectie_trimitere foloseste form.getlist("cod_prestatie") → compatibil. #}
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent.
RAMANE in _trimitere_detaliu.html (D#5). #}
{% if prez.op_service_cod %}
<div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie service</div>
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div>
</div>
{% endif %} {% endif %}
<form hx-post="/trimitere/{{ id }}/corecteaza" {# === Campurile vehicul/data/odo + erori/fix + buton — partial DRY (US-005). === #}
hx-target="#trimitere-detaliu" hx-swap="innerHTML"> {% include "_form_editare.html" %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> </form>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 16px;"> {% else %}
{# Context read-only pentru randuri ne-editabile (sent/sending/queued/error). #}
{% macro camp(nume, eticheta, valoare, tip='text') %} <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:12px 24px;">
<div> <div style="grid-column:1 / -1;">
<label for="c-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label> <div class="muted" style="font-size:12px;">Numar inmatriculare</div><div>{{ prez.vehicul_nr }}</div>
<input id="c-{{ nume }}" type="{{ tip }}" name="{{ nume }}" value="{{ valoare }}" </div>
style="width:100%; {% if err_map.get(nume) %}border-color:var(--err);{% endif %}" <div style="grid-column:1 / -1;">
{% if err_map.get(nume) %}aria-invalid="true"{% endif %}> <div class="muted" style="font-size:12px;">VIN (serie sasiu)</div>
{% if err_map.get(nume) %} <div style="word-break:break-all;">{{ prez.vin }}</div>
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div> </div>
{% endif %} <div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }} &middot; {{ cod_afis }}</div></div>
</div> {# Operatie service (cod intern + denumire), distinct de operatia RAR.
{% endmacro %} op_service_cod="" cand lipseste → randul absent (fara "—"). #}
{% if prez.op_service_cod %}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }} <div><div class="muted" style="font-size:12px;">Operatie service</div>
{{ camp('vin', 'VIN (serie sasiu)', form_vin) }} <div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div></div>
{{ camp('data_prestatie', 'Data prestatie (YYYY-MM-DD)', form_data) }} {% endif %}
{{ camp('odometru_final', 'Odometru final', form_odo_final) }} <div><div class="muted" style="font-size:12px;">Data prestatie</div><div>{{ prez.data_prestatie }}</div></div>
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }} <div><div class="muted" style="font-size:12px;">Odometru final</div><div>{{ prez.odometru }}</div></div>
</div>
<div style="margin-top:10px;">
<button type="submit">Salveaza si re-pune in coada</button>
</div>
</form>
</div> </div>
{% endif %} {% endif %}
</div>
<script> {# === Actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT === #}
(function() { {% if status == 'error' or gestionabil %}
/* Vizibilitate (design review): scroll la panou + evidentiaza randul selectat. */ <div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
var panou = document.getElementById('trimitere-detaliu'); {# Error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
if (panou) panou.scrollIntoView({behavior: 'smooth', block: 'nearest'}); {# campuri vehicul, dar se poate schimba cod_prestatie prin acelasi formular). #}
document.querySelectorAll('tr[id^="trimitere-row-"]').forEach(function(tr) { {% if status == 'error' %}
tr.style.outline = ''; <form hx-post="/trimitere/{{ id }}/repune"
}); hx-target="#detaliu-modal-body" hx-swap="innerHTML"
var rand = document.getElementById('trimitere-row-{{ id }}'); hx-disabled-elt="find button" style="margin:0 0 10px;">
if (rand) rand.style.outline = '2px solid var(--accent)'; <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
})(); {# Select cod_prestatie optional in formularul /repune (doar pentru error). #}
</script> {% if nomenclator_rar %}
<label for="cod-rar-error-{{ id }}" style="display:block; font-size:12px; color:var(--muted); margin-bottom:4px;">
Operatie RAR (optional — schimba codul si re-pune)
</label>
<select id="cod-rar-error-{{ id }}" name="cod_prestatie"
aria-label="Alege operatia RAR din nomenclator"
style="width:100%; margin-bottom:8px; font-size:13px;">
<option value="">— pastrat ({{ cod_prestatie_curent }}) —</option>
{% for item in nomenclator_rar %}
<option value="{{ item.cod_prestatie }}"
{% if item.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
{{ item.cod_prestatie }} — {{ item.nume_prestatie }}
</option>
{% endfor %}
</select>
{% endif %}
<button type="submit">Re-pune in coada</button>
</form>
{% endif %}
{# UN SINGUR Sterge, outline distructiv (var(--err)), pe rand separat, full-width pe mobil. #}
{% if gestionabil %}
<form hx-post="/trimitere/{{ id }}/sterge"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
hx-disabled-elt="find button"
hx-confirm="Stergi definitiv trimiterea #{{ id }}? Nu se poate anula." style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-sterge"
style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</form>
{% endif %}
</div>
{% endif %}
{# === Detalii tehnice — colapsat implicit === #}
<details style="margin-top:14px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">Detalii tehnice</summary>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 24px; margin-top:10px;">
<div><div class="muted" style="font-size:12px;">Nr. prezentare RAR</div><div>{{ id_prezentare or '—' }}</div></div>
<div><div class="muted" style="font-size:12px;">Cod HTTP RAR</div><div>{{ rar_status_code or '—' }}</div></div>
<div><div class="muted" style="font-size:12px;">Reincercari</div><div>{{ retry_count }}</div></div>
<div><div class="muted" style="font-size:12px;">Creat</div><div>{{ created_at }}</div></div>
<div><div class="muted" style="font-size:12px;">Actualizat</div><div>{{ updated_at }}</div></div>
<div><div class="muted" style="font-size:12px;">Urmatoarea incercare</div><div>{{ next_attempt_at }}</div></div>
{% if erori_3n and erori_3n[0].cod %}
<div><div class="muted" style="font-size:12px;">Cod eroare (brut)</div><div>{{ erori_3n[0].cod }}</div></div>
{% endif %}
</div>
{% if rar_error %}
<div style="margin-top:10px;">
<div class="muted" style="font-size:12px;">Mesaj RAR (integral)</div>
<pre style="white-space:pre-wrap; word-break:break-all; font-size:12px; margin:4px 0 0; color:var(--muted);">{{ rar_error }}</pre>
</div>
{% endif %}
</details>
</div>
{# Focus-ul post-swap (incl. re-render corectie/mapare) e gestionat de htmx:afterSettle pe
#detaliu-modal-body din base.html. Inchiderea modalului pe succes (queued/sterge) vine
din HX-Trigger `inchideModal` emis de rute. #}

View File

@@ -1,19 +1,25 @@
<div id="import-section"> <div id="import-section">
{% set pas = 1 %}{% include '_stepper.html' %} {% set pas = 1 %}{% include '_stepper.html' %}
<div class="card"> {# Bara de upload accentuata (border de accent) ca sa ramana punctul
<h2 style="font-size:15px; margin:0 0 12px;">Import fisier (xlsx / csv)</h2> de intrare evident chiar cu tabelul Trimiteri lung dedesubt. #}
{% from '_eroare.html' import card_erori %}
<div class="card" style="border-color:var(--accent);">
{% if message %} {% if message %}
<div class="flash" style="margin-bottom:12px;">{{ message }}</div> <div class="flash" style="margin-bottom:12px;">{{ message }}</div>
{% endif %} {% endif %}
{% if error %} {% if eroare_upload %}
<div class="flash" style="border-color:var(--err); background:#241a1a; margin-bottom:12px;" <div style="margin-bottom:12px;">
{{ card_erori([eroare_upload]) }}
</div>
{% elif error %}
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:12px;"
role="alert">{{ error }}</div> role="alert">{{ error }}</div>
{% endif %} {% endif %}
{% if sheets %} {% if sheets %}
<div class="flash" style="border-color:var(--warn); background:#201c0f; margin-bottom:12px;"> <div class="flash" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); margin-bottom:12px;">
Fisierul are mai multe foi de lucru. Alege foaia de mai jos si incarca din nou. Fisierul are mai multe foi de lucru. Alege foaia de mai jos si incarca din nou.
</div> </div>
{% endif %} {% endif %}
@@ -40,13 +46,33 @@
</div> </div>
{% endif %} {% endif %}
{% if are_trimiteri and not sheets %}
{# === Bara slim (returning user): eticheta + buton + zona de trage, pe un rand === #}
<div class="drop-zone" id="drop-zone"
role="region" aria-label="Zona de incarcare fisier"
style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;
padding:12px 16px; text-align:left;">
<strong style="font-size:var(--fs-md);">Importa:</strong>
<input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv"
style="display:none;" aria-label="Selecteaza fisier xlsx sau csv">
<button type="button" id="upload-btn"
style="min-height:44px; padding:10px 20px; font-size:var(--fs-md);">
Alege fisier (xlsx/csv)
</button>
<span class="muted" style="font-size:var(--fs-sm);">sau trage aici</span>
<span class="muted" style="font-size:var(--fs-xs); margin-left:auto;">
NU se trimite nimic la RAR pana confirmi.
</span>
</div>
{% else %}
{# === Hero first-run (sau re-upload multi-foaie): pastreaza copy-ul de bun venit === #}
<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 %}
@@ -54,17 +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 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 %}
<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>
@@ -77,6 +104,17 @@
var fi = document.getElementById('file-input'); var fi = document.getElementById('file-input');
var dz = document.getElementById('drop-zone'); var dz = document.getElementById('drop-zone');
var frm = document.getElementById('upload-form'); var frm = document.getElementById('upload-form');
/* Un singur sticky bar pe ecran — cand re-apare zona de upload
(reset sau dupa commit), sectiunea Trimiteri redevine vizibila. */
var trim = document.getElementById('trimiteri-section');
if (trim) trim.style.display = '';
/* Dupa un commit reusit (mesaj de succes), du utilizatorul la Trimiteri. */
{% if message and not error %}
if (trim) trim.scrollIntoView({behavior: 'smooth', block: 'start'});
{% endif %}
if (!btn || !fi || !frm) return; if (!btn || !fi || !frm) return;
btn.addEventListener('click', function() { fi.click(); }); btn.addEventListener('click', function() { fi.click(); });

View File

@@ -1,8 +1,142 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %} {% block title %}Conturi clienti — Gateway RAR AUTOPASS{% endblock %}
{% block content %} {% block content %}
{# Metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
{% set VERBS = {
'activate': ('Activeaza', '/admin/activate', ''),
'block': ('Blocheaza', '/admin/block', ''),
'archive': ('Arhiveaza', '/admin/archive', ''),
'delete': ('Sterge', '/admin/delete', 'danger')
} %}
{# Tier-uri selectabile in panou (cod, eticheta). Aliniat cu app/plans.py#PLANS. #}
{% set TIERS = [('free', 'Gratuit'), ('standard', 'Standard'), ('pro', 'Pro'), ('premium', 'Premium')] %}
{% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
<div class="card">
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
{% if rows %}
{# Bara bulk: form propriu (id=bulk-<block>); checkbox-urile randurilor se leaga prin atributul
HTML5 form= (fara form-uri imbricate). Ascunsa pana exista o selectie (JS). #}
<form id="bulk-{{ block_id }}" method="post" class="bulk-form" data-block="{{ block_id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="bulk-bar" hidden>
<span class="bulk-count muted" style="font-size:13px;">0 selectate</span>
{% for v in bulk_verbs %}
{% set label, action, cls = VERBS[v] %}
<button type="submit" formaction="{{ action }}"
{% if v == 'delete' %}onclick="return confirm('Stergi conturile selectate? (stergere soft, datele se purjeaza)');"{% endif %}
style="{% if cls == 'danger' %}background:var(--card); color:var(--err); border-color:var(--err);{% endif %}">{{ label }}</button>
{% endfor %}
</div>
</form>
<div class="tablewrap">
<table>
<thead><tr>
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
aria-label="Selecteaza tot"></th>
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Plan curent</th><th>Plan cerut</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
</tr></thead>
<tbody>
{% for acct in rows %}
<tr>
<td><input type="checkbox" name="account_id" value="{{ acct.id }}" form="bulk-{{ block_id }}"
class="row-check" data-block="{{ block_id }}"
aria-label="Selecteaza contul {{ acct.name }}"></td>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td style="white-space:nowrap;">
{# Plan EFECTIV acum (prominent): trial Pro activ ridica free->pro. #}
<div style="margin-bottom:5px;">
<span class="pill" style="font-weight:600;">{{ acct.tier_efectiv_label }}</span>
{% if acct.trial_activ %}
<span class="muted" style="font-size:11px;">
trial{% if acct.trial_zile %} · {{ acct.trial_zile }} {{ 'zi' if acct.trial_zile == 1 else 'zile' }} ramase{% endif %}
→ apoi {{ acct.tier_label }}
</span>
{% endif %}
</div>
{# Schimbare plan inline: select tier de baza + Aplica. Form propriu (nu imbricat in bulk-form).
Aplica INCHEIE trial-ul si seteaza planul ales ca real, cu efect imediat. #}
<form method="post" action="/admin/set-tier" class="tier-form"
style="display:flex;align-items:center;gap:6px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<select name="tier" aria-label="Plan pentru {{ acct.name }}"
style="padding:4px 8px;min-height:32px;max-width:130px;">
{% for code, label in TIERS %}
<option value="{{ code }}"{% if acct.tier == code %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<button type="submit" class="btn-sm"
title="Aplica planul ales ca plan real (incheie trial-ul daca e activ)">Aplica</button>
</form>
{# Acorda/prelungeste trial Pro de N zile, fara a schimba tier-ul de baza. #}
<form method="post" action="/admin/set-trial" class="trial-form"
style="display:flex;align-items:center;gap:6px;margin-top:5px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<input type="number" name="trial_days" value="30" min="1" max="3650"
aria-label="Zile trial Pro pentru {{ acct.name }}"
style="padding:4px 8px;min-height:32px;width:64px;">
<button type="submit" class="btn-sm"
title="Acorda/prelungeste trial Pro de la acum (nu schimba tier-ul de baza)">Trial Pro</button>
</form>
</td>
<td class="muted">{{ acct.requested_plan_label }}</td>
<td><span class="pill">{{ acct.status }}</span></td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td style="white-space:nowrap;">
<details class="kebab">
<summary aria-label="Actiuni pentru {{ acct.name }}">&#8943;</summary>
<div class="kebab-menu">
{% for v in row_verbs %}
{% set label, action, cls = VERBS[v] %}
{# Confirm fara nume interpolat: un apostrof in numele firmei (free-form) ar rupe
string-ul JS din atributul inline (entitatea &#39; e decodata inainte de parse). #}
<form method="post" action="{{ action }}"
{% if v == 'delete' %}onsubmit="return confirm('Stergi acest cont? (stergere soft)');"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
{% if v == 'activate' and not acct.is_complete %}
<button type="submit"{% if cls == 'danger' %} class="danger"{% endif %}
disabled
title="Completeaza datele firmei (companie + email + CUI) inainte de activare">{{ label }}</button>
{% else %}
<button type="submit"{% if cls == 'danger' %} class="danger"{% endif %}>{{ label }}</button>
{% endif %}
</form>
{% endfor %}
</div>
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont.</p>
{% endif %}
</div>
{% endmacro %}
<style>
/* Bara de actiuni bulk — ascunsa pana exista selectie. `[hidden]` trebuie sa invinga
display-ul, deci stilul sta in CSS (NU inline cu display:flex, care ar invinge [hidden]). */
.bulk-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:10px;
padding:8px 10px; border:1px solid var(--line); border-radius:8px;
background:color-mix(in srgb, var(--accent) 8%, var(--card)); }
.bulk-bar[hidden] { display:none; }
/* Kebab per-rand: stiluri partajate in base.html (position:fixed, anti-clipping tablewrap). */
</style>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;"> <div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
<h2 style="margin:0;">Panou admin</h2> <h2 style="margin:0;">Conturi clienti</h2>
<a href="/" class="cardlink muted">Inapoi la dashboard</a> <a href="/" class="cardlink muted">Inapoi la dashboard</a>
</div> </div>
@@ -10,96 +144,45 @@
<div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div> <div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div>
{% endif %} {% endif %}
<!-- Conturi in asteptare --> {{ lifecycle_block("Conturi in asteptare", pending, "pending",
<div class="card"> ['activate', 'block', 'archive', 'delete'],
<h3 style="margin-top:0;">Conturi in asteptare ({{ pending|length }})</h3> ['activate', 'block', 'archive', 'delete']) }}
{% if pending %}
<div class="tablewrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Companie</th>
<th>CUI</th>
<th>Email</th>
<th>Inregistrat</th>
<th>Actiune</th>
</tr>
</thead>
<tbody>
{% for acct in pending %}
<tr>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td>
<form method="post" action="/admin/activate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit">Activeaza</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont in asteptare.</p>
{% endif %}
</div>
<!-- Conturi active --> {{ lifecycle_block("Conturi active", active, "active",
<div class="card"> ['block', 'archive', 'delete'],
<h3 style="margin-top:0;">Conturi active ({{ active|length }})</h3> ['block', 'archive', 'delete']) }}
{% if active %}
<div class="tablewrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Companie</th>
<th>CUI</th>
<th>Email</th>
<th>Inregistrat</th>
<th>Actiune</th>
</tr>
</thead>
<tbody>
{% for acct in active %}
<tr>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td>
<form method="post" action="/admin/deactivate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit" style="background:var(--err);border-color:var(--err);">Dezactiveaza</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont activ (in afara de contul dev).</p>
{% endif %}
</div>
<!-- Contul dev default (id=1) --> {# Conturi suspendate (blocate/arhivate): reactivare sau stergere. Stare reala in pill. #}
{% if default_account %} {{ lifecycle_block("Conturi blocate / arhivate", suspended, "suspended",
<div class="card" style="border-color:var(--muted);"> ['activate', 'delete'],
<p class="muted" style="margin:0;font-size:13px;"> ['activate', 'delete']) }}
Cont dev implicit (id=1): <strong>{{ default_account.name }}</strong>
— activ={{ default_account.active }} — fara buton de activare/dezactivare (cont de sistem). <script>
</p> (function() {
</div> // Selectie + bara bulk, scoped pe fiecare bloc (pending/active) prin data-block.
{% endif %} document.querySelectorAll('.master-check').forEach(function(master) {
var block = master.getAttribute('data-block');
var rows = Array.prototype.slice.call(
document.querySelectorAll('.row-check[data-block="' + block + '"]'));
var form = document.getElementById('bulk-' + block);
var bar = form ? form.querySelector('.bulk-bar') : null;
var count = form ? form.querySelector('.bulk-count') : null;
function refresh() {
var n = rows.filter(function(r) { return r.checked; }).length;
if (bar) bar.hidden = (n === 0);
if (count) count.textContent = n + ' selectate';
master.checked = (n > 0 && n === rows.length);
master.indeterminate = (n > 0 && n < rows.length);
}
master.addEventListener('change', function() {
rows.forEach(function(r) { r.checked = master.checked; });
refresh();
});
rows.forEach(function(r) { r.addEventListener('change', refresh); });
refresh();
});
})();
</script>
{% endblock %} {% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,93 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<!-- Nav cont: link admin (doar pentru admini) + logout --> <!-- Bara de status: mereu vizibila -->
<div style="display:flex; gap:8px; justify-content:flex-end; margin-bottom:12px; flex-wrap:wrap;">
{% if is_admin %}<a class="cardlink" href="/admin">Panou admin</a>{% endif %}
<form method="post" action="/logout" style="display:inline; margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--muted); border-color:var(--line);">Iesi din cont</button>
</form>
</div>
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
<div id="status-bar" class="status-bar card" <div id="status-bar" class="status-bar card"
hx-get="/_fragments/status" hx-get="/_fragments/status?tab={{ active_tab }}"
hx-trigger="load, every 15s" hx-trigger="load, every 15s, trimiteriChanged from:body"
hx-swap="outerHTML"> hx-swap="outerHTML">
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div> <div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
</div> </div>
<!-- Tab-bar: navigare intre sectiuni --> <!-- Panou activ: randat server-side la full load (Acasa implicit, sau ?tab= prin meniu) -->
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard"> <div id="tab-panel" class="tab-panel">
{% set tabs = [
("acasa", "Acasa", "tab-acasa"),
("coada", "Trimiteri", "tab-coada"),
("mapari", "Mapari", "tab-mapari"),
("cont", "Cont", "tab-cont"),
("nomenclator", "Nomenclator", "tab-nomenclator")
] %}
{% for tab_id, tab_label, tab_elem_id in tabs %}
{% set badge = (badges.get(tab_id, 0) if badges else 0) %}
<a id="{{ tab_elem_id }}"
role="tab"
href="/?tab={{ tab_id }}"
aria-selected="{{ 'true' if active_tab == tab_id else 'false' }}"
aria-controls="tab-panel"
{% if badge %}aria-label="{{ tab_label }}, {{ badge }} necesita atentie"{% endif %}
class="tab-link{% if active_tab == tab_id %} tab-activ{% endif %}"
tabindex="{{ '0' if active_tab == tab_id else '-1' }}"
hx-get="/_fragments/{{ tab_id }}"
hx-target="#tab-panel"
hx-swap="innerHTML"
hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}{% if 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;">{{ badge }}</span>{% endif %}</a>
{% endfor %}
</div>
<!-- Panou activ: randat server-side la full load; HTMX inlocuieste continutul la click pe tab -->
<div id="tab-panel" role="tabpanel" aria-labelledby="tab-{{ active_tab }}" class="tab-panel">
{{ panel_html | safe }} {{ panel_html | safe }}
</div> </div>
<script>
(function() {
/* Navigare cu sageti intre tab-uri (ARIA pattern) */
var tablist = document.querySelector('[role="tablist"]');
if (!tablist) return;
var tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
tablist.addEventListener('keydown', function(e) {
var idx = tabs.indexOf(document.activeElement);
if (idx === -1) return;
var next = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (idx + 1) % tabs.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (idx - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
next = 0;
} else if (e.key === 'End') {
next = tabs.length - 1;
}
if (next !== -1) {
e.preventDefault();
tabs[next].focus();
}
});
/* La click pe tab: actualizeaza aria-selected + tabindex */
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
t.classList.remove('tab-activ');
});
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
tab.classList.add('tab-activ');
});
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,464 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ROA AUTOPASS — declari prestațiile la RAR din câteva click-uri</title>
<meta name="description" content="Încarci fișierul tău cu operațiile service-ului, completezi o dată codurile RAR și le salvezi. ROMFAST trimite prestațiile la RAR AUTOPASS în locul tău, fără tastat manual. Conform Legii 142/2023.">
<style>
/* US-001/US-008 (PRD 5.16): IBM Plex eliminat complet — stive font sistem standard web.
Tokenurile --font-ui / --font-mono definite in :root (sursa unica de adevar). */
:root{--font-ui:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--font-mono:ui-monospace,"SF Mono","Cascadia Code","Segoe UI Mono","Roboto Mono",Menlo,Consolas,monospace;}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{font-family:var(--font-ui);-webkit-font-smoothing:antialiased;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);}
body[data-theme="grafit"]{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--hbg:rgba(15,18,24,.88);--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--mut:#5c6473}
body[data-theme="cobalt"]{--bg:#080d1c;--card:#111a33;--card2:#0b1226;--text:#e9ecfb;--sub:#8a93b8;--line:#1d2747;--line2:#161f3a;--accent:#4068FF;--hbg:rgba(8,13,28,.9);--okt:#2fd0a6;--infot:#8aa0ff;--errt:#f06a7a;--mut:#5a6390}
body[data-theme="cupru"]{--bg:#15110b;--card:#211a12;--card2:#15110b;--text:#efe6d6;--sub:#a89a85;--line:#36291c;--line2:#281e14;--accent:#D98A3D;--hbg:rgba(21,17,11,.9);--okt:#67b98c;--infot:#dfa45c;--errt:#e2685a;--mut:#6d5f4c}
body[data-theme="hartie"]{--bg:#f3efe6;--card:#fffdf7;--card2:#f3efe6;--text:#1e1a13;--sub:#6a6052;--line:#e2dccc;--line2:#ece6d9;--accent:#1F5FBF;--hbg:rgba(255,253,247,.92);--okt:#1c7d5d;--infot:#1F5FBF;--errt:#bd463c;--mut:#9a8f7d}
.page{width:100%;max-width:1280px;margin:0 auto;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);overflow:hidden;}
a{text-decoration:none;}
input[type=range]{-webkit-appearance:none;appearance:none;background:transparent;}
input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:99px;background:var(--line,#262b36);}
input[type=range]::-moz-range-track{height:6px;border-radius:99px;background:var(--line,#262b36);}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;margin-top:-6px;border-radius:99px;background:var(--accent,#2E74D6);cursor:pointer;border:none;}
input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:99px;background:var(--accent,#2E74D6);cursor:pointer;border:none;}
@media (max-width:900px){
.lp-nav{display:none!important;}
.lp-header{padding:0 18px!important;}
.lp-h1{font-size:32px!important;line-height:1.1!important;}
.page [style*="grid-template-columns"]{grid-template-columns:1fr!important;}
.page [style*="padding:80px 40px"]{padding:48px 20px!important;}
.page [style*="padding:0 40px 80px"]{padding:0 20px 48px!important;}
.page [style*="padding:56px 40px 80px"]{padding:36px 20px 48px!important;}
.page [style*="padding:44px"]{padding:28px!important;}
.page [style*="padding:56px 40px"]{padding:40px 22px!important;}
.page [style*="height:68px"]{height:60px!important;}
.page [style*="gap:56px"]{gap:32px!important;}
.page [style*="gap:48px"]{gap:28px!important;}
}
@media (max-width:560px){
.lp-h1{font-size:27px!important;}
.page [style*="padding:10px 40px"]{padding:10px 18px!important;}
.lp-header{padding:0 12px!important;}
#theme-label{display:none!important;}
.lp-hactions{gap:8px!important;}
.lp-hactions button{height:38px!important;padding:0 11px!important;font-size:13px!important;}
}
@media (max-width:430px){
.lp-hactions a.auth-login-link{display:none!important;}
}
</style>
</head>
<body data-theme="grafit">
<script>try{var _t=localStorage.getItem('lp-theme');if(_t&&['grafit','cobalt','cupru','hartie'].indexOf(_t)>=0)document.body.setAttribute('data-theme',_t);}catch(e){}</script>
<main class="page">
<!-- HEADER -->
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
<div style="display:flex;align-items:center;gap:14px;">
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
<div style="display:flex;flex-direction:column;line-height:1.05;">
<span style="font:700 17px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);">ROA AUTOPASS</span>
<span style="font:500 11px var(--font-ui);letter-spacing:.04em;color:var(--sub,#8b93a7);">Gateway RAR</span>
</div>
</div>
<div style="display:flex;align-items:center;gap:28px;">
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
<a href="#cum-functioneaza" style="color:inherit;text-decoration:none;">Cum funcționează</a><a href="#api" style="color:inherit;text-decoration:none;">API</a><a href="#pret" style="color:inherit;text-decoration:none;">Preț</a>
</div>
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
<button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px var(--font-ui);cursor:pointer;">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
<span id="theme-label">Grafit</span>
</button>
<a href="/login" class="auth-login-link" style="display:inline-flex;align-items:center;height:44px;padding:0 18px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;text-decoration:none;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Autentificare</a>
<button data-act="auth" data-tab="register" style="height:44px;padding:0 18px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont</button>
</div>
</div>
</div>
<!-- HERO -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
<div>
<h1 class="lp-h1" style="font:700 50px/1.06 var(--font-ui);letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară prestațiile la RAR AUTOPASS, automat</h1>
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;max-width:480px;">Încarci fișierul tău așa cum îl ai, potrivești o dată operațiile cu codurile RAR, și trimitem prestațiile la RAR AUTOPASS în locul tău. Fără tastat câmp cu câmp.</p>
<div style="margin-bottom:32px;">
<p style="display:flex;align-items:center;gap:8px;font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin:0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg><span><span style="color:#1F9D5C;">Gratuit</span> până la 60 de trimiteri/lună</span></p>
</div>
<div style="display:flex;gap:12px;margin-bottom:22px;">
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
<button style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</button>
</div>
<div style="display:flex;align-items:center;gap:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);flex-wrap:wrap;">
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
<span style="color:var(--line,#262b36);">·</span>
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Datele tale criptate</span>
</div>
</div>
<!-- Dashboard mockup -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);">
<div>
<div style="font:700 14px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri RAR AUTOPASS</div>
<div style="font:400 12px var(--font-mono);color:var(--sub,#8b93a7);margin-top:2px;">Service Auto Vâlcea · 28 iun 2026</div>
</div>
<div style="display:flex;gap:8px;">
<div style="display:flex;align-items:center;gap:5px;padding:4px 9px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 11px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Live</div>
</div>
</div>
<div style="display:flex;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line,#262b36);">
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:var(--text,#e6e9ef);">847</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Trimise luna asta</div></div>
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:var(--accent,#2E74D6);">12</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">În coadă</div></div>
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:#E05D5D;">2</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">De corectat</div></div>
</div>
<div style="padding:6px 0;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">WBA8E9...K7F2</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Inspecție tehnică · 09:42</div></div>
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 12px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">WVWZZZ...3M1</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Revizie periodică · 09:38</div></div>
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,var(--accent,#2E74D6) 14%,transparent);font:500 12px var(--font-ui);color:var(--infot,#6ea2ec);"><span style="width:6px;height:6px;border-radius:99px;background:var(--accent,#2E74D6);"></span>În coadă</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">VF1RFB...A88</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Sistem frânare · 09:31</div></div>
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);font:500 12px var(--font-ui);color:var(--errt,#E05D5D);"><span style="width:6px;height:6px;border-radius:99px;background:#E05D5D;"></span>Eroare VIN</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;">
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">ZAR937...C04</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Schimb ulei · 09:24</div></div>
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 12px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</div>
</div>
</div>
</div>
</div>
<!-- PROBLEM + CALCULATOR (combinat) -->
<div style="padding:80px 40px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
<div style="text-align:center;max-width:760px;margin:0 auto 40px;">
<h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 23 minute pe RAR AUTOPASS.<br><span style="color:var(--errt,#E05D5D);">Minutele acelea sunt bani.</span></h2>
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi. Mută cursorul la volumul service-ului tău și vezi cât te costă.</p>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:stretch;">
<!-- STANGA: formularul RAR AUTOPASS -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:24px;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">RAR AUTOPASS · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px var(--font-mono);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Confirmă VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Data prestației</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">2026-06-22</div></div>
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Număr înmatriculare</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">CT88NOE</div></div>
</div>
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Observații</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-ui);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">REVIZIE; SCHIMBARE PLĂCUȚE FRÂNĂ</div></div>
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Prestații</div><div style="min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);"><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>REVIZIE PERIODICĂ</span><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>ÎNTREȚINERE</span></div></div>
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Valoarea citită a odometrului</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">39000</div></div>
</div>
<button style="margin-top:14px;height:34px;padding:0 14px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--card2,#0f1218));border:none;color:#fff;opacity:.55;font:600 12px var(--font-ui);cursor:not-allowed;align-self:flex-start;">Salvează Prezentarea</button>
<div style="margin-top:auto;padding-top:12px;font:400 12px var(--font-ui);color:var(--sub,#8b93a7);text-align:center;">se repetă pentru fiecare comandă · zi de zi</div>
</div>
<!-- DREAPTA: calculatorul (slidere + cifre) -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:34px;display:flex;flex-direction:column;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;">
<div>
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri/lună</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);" id="out-pres">100</span></div>
<input type="range" min="50" max="1500" step="10" value="100" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
</div>
<div>
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Manoperă</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);"><span id="out-rate">60</span><span style="font:500 12px var(--font-ui);color:var(--sub,#8b93a7);"> lei/h</span></span></div>
<input type="range" min="30" max="200" step="5" value="60" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
</div>
</div>
<div style="background:color-mix(in srgb,#E05D5D 9%,var(--card2,#0f1218));border:1px solid color-mix(in srgb,#E05D5D 28%,var(--line,#262b36));border-radius:10px;padding:22px 24px;">
<div style="font:600 11px var(--font-ui);color:var(--errt,#E05D5D);letter-spacing:.08em;text-transform:uppercase;margin-bottom:14px;">Pierdut pe raportare manuală</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;">
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiMonth">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">lei/lună</div></div>
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="hMonth">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">ore/lună</div></div>
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiYear">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">lei/an</div></div>
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="days">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">zile/an</div></div>
</div>
</div>
<div style="margin-top:20px;padding-top:18px;border-top:1px solid var(--line,#262b36);">
<div style="display:flex;align-items:center;gap:9px;font:600 14px var(--font-ui);color:var(--okt,#2FBF8F);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Cu ROA AUTOPASS: câteva secunde pentru tot lotul</div>
<div style="font:400 13px/1.55 var(--font-ui);color:var(--sub,#8b93a7);margin-top:6px;">Recuperezi ~<span data-calc="leiMonth">0</span> lei/lună și timpul îl pui pe mașini, nu pe formulare.</div>
</div>
<div style="margin-top:14px;display:flex;align-items:center;gap:8px;font:400 12px var(--font-ui);color:var(--mut,#5c6473);"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>Estimat la ~2,5 minute de tastat manual pentru fiecare trimitere.</div>
</div>
</div>
</div>
<!-- LEGE / AMENZI -->
<div style="padding:56px 40px 80px;">
<div style="display:flex;gap:20px;align-items:flex-start;background:color-mix(in srgb,#E0A93B 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E0A93B 34%,var(--line,#262b36));border-radius:12px;padding:26px 28px;">
<div style="width:44px;height:44px;flex-shrink:0;border-radius:8px;background:color-mix(in srgb,#E0A93B 16%,transparent);display:flex;align-items:center;justify-content:center;color:#E0A93B;"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9.5C7.1 20.3 4 16.4 4 12V7l8-4z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></div>
<div>
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Evită riscul amenzilor — transmite automat la RAR Auto-Pass</div>
<p style="font:400 14px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Conform <strong style="color:var(--text,#e6e9ef);font-weight:600;">Legii nr. 142/2023</strong> și <strong style="color:var(--text,#e6e9ef);font-weight:600;">OMTI nr. 210/2024</strong>, service-urile auto autorizate RAR trebuie să transmită, la finalizarea fiecărei prestații, informațiile obligatorii (VIN, kilometraj și, după caz, date privind intervențiile asupra odometrului și reparațiile rezultate din avarii grave). Nerespectarea obligației se sancționează cu amendă între <span style="color:var(--errt,#E05D5D);font-weight:600;">2.000 și 5.000 lei</span>, iar transmiterea unor informații eronate cu amendă între <span style="color:#E0A93B;font-weight:600;">1.000 și 2.000 lei</span>.</p>
</div>
</div>
</div>
<!-- SOLVE -->
<div id="cum-functioneaza" style="padding:80px 40px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
<div style="max-width:780px;margin:0 auto;text-align:center;">
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2>
<p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA AUTOPASS îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
</div>
<div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 var(--font-ui);color:var(--sub,#8b93a7);">
<span style="text-decoration:line-through;text-decoration-color:var(--errt,#E05D5D);text-decoration-thickness:2px;">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>
<!-- API INTEGRATION -->
<div id="api" style="padding:56px 40px 80px;">
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;">
<div>
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu</div>
<h2 style="font:700 30px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Ai deja un soft de service? Conectează-l automat</h2>
<p style="font:400 15px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Softul tău se poate conecta și direct la API-ul RAR Auto-Pass. Cu ROMFAST primești în plus asistență la maparea automată a operațiilor tale (prin mai multe metode) și salvarea mapărilor pentru trimiterile viitoare — totul printr-un singur apel, cu cheie API per cont.</p>
<button style="height:44px;padding:0 20px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi documentația API <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button>
</div>
<div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;">
<div style="display:flex;align-items:center;gap:7px;padding:11px 14px;border-bottom:1px solid #262b36;">
<span style="width:11px;height:11px;border-radius:99px;background:#E05D5D;"></span><span style="width:11px;height:11px;border-radius:99px;background:#E0A93B;"></span><span style="width:11px;height:11px;border-radius:99px;background:#2FBF8F;"></span>
<span style="font:400 12px var(--font-mono);color:#8b93a7;margin-left:8px;">request.sh</span>
</div>
<pre style="margin:0;padding:18px;font:400 13px/1.7 var(--font-mono);color:#e6e9ef;overflow-x:auto;"><span style="color:#2FBF8F;">POST</span> /v1/prezentari
<span style="color:#8b93a7;">Authorization:</span> <span style="color:#E0A93B;">rfak_••••••••</span>
<span style="color:#8b93a7;">Content-Type:</span> application/json
{
<span style="color:#6ea2ec;">"vin"</span>: <span style="color:#2FBF8F;">"WBA8E9C5..."</span>,
<span style="color:#6ea2ec;">"cod_prestatie"</span>: <span style="color:#2FBF8F;">"ITP-01"</span>,
<span style="color:#6ea2ec;">"odometru"</span>: <span style="color:#E0A93B;">142500</span>
}</pre>
</div>
</div>
</div>
<!-- TRIAL BENEFIT -->
<div style="padding:0 40px 80px;">
<div style="display:flex;align-items:center;gap:22px;background:color-mix(in srgb,#2FBF8F 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#2FBF8F 32%,var(--line,#262b36));border-radius:14px;padding:30px 34px;flex-wrap:wrap;">
<div style="width:48px;height:48px;flex-shrink:0;border-radius:10px;background:color-mix(in srgb,#2FBF8F 16%,transparent);display:flex;align-items:center;justify-content:center;color:var(--okt,#2FBF8F);"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/><circle cx="12" cy="12" r="4.5"/></svg></div>
<div style="flex:1;min-width:240px;">
<div style="font:700 19px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);margin-bottom:5px;"><span style="color:var(--okt,#2FBF8F);">30 de zile Pro gratuit</span> la fiecare cont nou</div>
<p style="font:400 14px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Începi direct cu tot ce are planul Pro — import API, categorisire automată și suport rapid. După 30 de zile treci automat pe Gratuit, fără plată și fără întreruperi.</p>
</div>
<button data-act="auth" data-tab="register" data-plan="pro" style="height:48px;padding:0 24px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;white-space:nowrap;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Începe gratuit</button>
</div>
</div>
<!-- PRICING -->
<div id="pret" style="padding:0 40px 80px;">
<div style="text-align:center;margin-bottom:44px;">
<h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
<p style="font:400 15px var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Alege planul potrivit volumului tău. Poți schimba sau anula oricând.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:stretch;">
<!-- Gratuit -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Până la 60 de trimiteri/lună</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Trimiteri nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, în 48h</div>
</div>
<button data-act="auth" data-tab="register" data-plan="free" style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Creează cont gratuit</button>
</div>
<!-- Standard -->
<div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;display:flex;flex-direction:column;">
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Popular</div>
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Standard</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">49 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Trimiteri nelimitate</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 24h</div>
</div>
<button style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="standard">Creează cont gratuit</button>
</div>
<!-- Pro -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Nelimitat + acces API</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 8h</div>
</div>
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="pro">Creează cont gratuit</button>
</div>
<!-- Premium -->
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div>
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></div>
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Integrare în softul tău</div>
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic + onboarding dedicat</div>
</div>
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="premium">Creează cont gratuit</button>
</div>
</div>
</div>
<!-- PRIVACY -->
<div style="padding:80px 40px;border-top:1px solid var(--line,#262b36);">
<div style="margin:0 auto;display:grid;grid-template-columns:minmax(240px,330px) 1fr;gap:48px;align-items:center;">
<h2 style="font:700 30px/1.2 var(--font-ui);letter-spacing:-.02em;margin:0;color:var(--text,#e6e9ef);">Datele clienților tăi nu devin marfă</h2>
<div style="display:flex;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Reținem doar strict necesarul</div>
<div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Doar datele de care e nevoie ca să trimitem la RAR — nimic adunat în plus, nici la conturile gratuite.</div>
</div>
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Doar pentru scopul declarat</div>
<div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Maparea și trimiterea la RAR. Nu le vindem și nu le dăm mai departe.</div>
</div>
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Se șterg la 3 luni</div>
<div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Automat, fără să ceri — sau chiar acum, cu un singur click.</div>
</div>
</div>
</div>
</div>
<!-- AUTH / REGISTER -->
<div id="inregistrare" style="padding:80px 40px;border-top:1px solid var(--line,#262b36);background:color-mix(in srgb,var(--accent,#2E74D6) 5%,var(--bg,#0f1218));">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;margin:0 auto;align-items:center;">
<div>
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Creează cont</div>
<h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2>
<p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit. Imediat poți încărca primul fișier sau conecta softul de service.</p>
<div style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Pro gratuit 30 de zile, apoi automat pe Gratuit</div>
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023 și OMTI 210/2024</div>
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Datele cu caracter personal criptate (GDPR)</div>
</div>
</div>
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:32px;box-shadow:0 20px 50px -24px rgba(0,0,0,.5);">
<div style="display:flex;gap:28px;border-bottom:1px solid var(--line,#262b36);margin-bottom:24px;">
<button type="button" data-act="tab" data-tab="register" class="auth-tab is-active" style="position:relative;background:none;border:none;padding:0 0 12px;font:700 15px var(--font-ui);color:var(--text,#e6e9ef);cursor:pointer;">Creează cont<span class="tab-underline" style="position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--accent,#2E74D6);"></span></button>
<button type="button" data-act="tab" data-tab="login" class="auth-tab" style="position:relative;background:none;border:none;padding:0 0 12px;font:700 15px var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;">Autentificare<span class="tab-underline" style="position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--accent,#2E74D6);"></span></button>
</div>
<form method="post" action="/signup" data-pane="register">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Companie</span><input type="text" name="name" required placeholder="SC Service Auto SRL" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="free" selected>Gratuit — 0 lei/lună</option><option value="standard">Standard — 49 lei/lună</option><option value="pro">Pro — 59 lei/lună</option><option value="premium">Premium — la cerere</option></select></label>
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button>
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div>
</form>
<form method="post" action="/login" data-pane="login" style="display:none;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<label style="display:block;margin-bottom:10px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required placeholder="Parola ta" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
<div style="text-align:right;margin-bottom:18px;"><a href="/login" style="font:400 13px var(--font-ui);color:var(--accent,#2E74D6);cursor:pointer;">Ai uitat parola?</a></div>
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease;">Autentificare</button>
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Nu ai cont? <a data-act="tab" data-tab="register" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Creează unul gratuit</a></div>
</form>
</div>
</div>
</div>
<!-- FOOTER -->
<div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;">
<div style="font:700 18px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div>
<div style="display:flex;gap:26px;font:400 14px var(--font-ui);color:var(--sub,#8b93a7);">
<span>Termeni</span><span>Confidențialitate / GDPR</span><span>Documentație API</span><span>Contact</span>
</div>
<div style="font:400 13px var(--font-ui);color:var(--mut,#5c6473);">© 2026 ROMFAST</div>
</div>
</main>
<script>
(function(){
var THEMES=[['grafit','Grafit'],['cobalt','Cobalt'],['cupru','Cupru'],['hartie','Hârtie']];
var body=document.body;
function curIndex(){var t=body.getAttribute('data-theme');for(var i=0;i<THEMES.length;i++){if(THEMES[i][0]===t)return i;}return 0;}
function applyTheme(i){i=((i%THEMES.length)+THEMES.length)%THEMES.length;body.setAttribute('data-theme',THEMES[i][0]);var l=document.getElementById('theme-label');if(l)l.textContent=THEMES[i][1];try{localStorage.setItem('lp-theme',THEMES[i][0]);}catch(e){}}
applyTheme(curIndex());
// style-hover: framework-ul de design folosea atributul style-hover; il aplicam la hover.
function parseStyle(str){var o={};str.split(';').forEach(function(p){var idx=p.indexOf(':');if(idx>0)o[p.slice(0,idx).trim()]=p.slice(idx+1).trim();});return o;}
document.querySelectorAll('[style-hover]').forEach(function(el){
var hov=parseStyle(el.getAttribute('style-hover'));var keys=Object.keys(hov);var saved={};
el.addEventListener('mouseenter',function(){keys.forEach(function(k){saved[k]=el.style.getPropertyValue(k);el.style.setProperty(k,hov[k]);});});
el.addEventListener('mouseleave',function(){keys.forEach(function(k){el.style.setProperty(k,saved[k]);});});
});
// Calculator: cost raportare manuala (2,5 min/prestatie).
var pres=document.getElementById('calc-pres'),rate=document.getElementById('calc-rate');
var nf=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:0});
var nf1=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:1});
function recalc(){
var p=+pres.value,r=+rate.value,minPer=2.5;
var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
document.getElementById('out-pres').textContent=p;
document.getElementById('out-rate').textContent=r;
var map={leiMonth:nf.format(Math.round(leiMonth)),hMonth:nf.format(Math.round(hMonth)),leiYear:nf.format(Math.round(leiMonth*12)),days:nf.format(Math.round((hMonth*12)/8))};
Object.keys(map).forEach(function(k){document.querySelectorAll('[data-calc="'+k+'"]').forEach(function(n){n.textContent=map[k];});});
}
if(pres&&rate){pres.addEventListener('input',recalc);rate.addEventListener('input',recalc);recalc();}
// Tab-uri autentificare/inregistrare.
function setTab(tab){
document.querySelectorAll('[data-pane]').forEach(function(f){f.style.display=(f.getAttribute('data-pane')===tab)?'':'none';});
document.querySelectorAll('.auth-tab').forEach(function(b){
var on=b.getAttribute('data-tab')===tab;b.classList.toggle('is-active',on);
b.style.color=on?'var(--text,#e6e9ef)':'var(--sub,#8b93a7)';
var u=b.querySelector('.tab-underline');if(u)u.style.display=on?'':'none';
});
}
setTab('register');
document.addEventListener('click',function(e){
var t=e.target.closest('[data-act]');if(!t)return;
var act=t.getAttribute('data-act');
if(act==='theme'){applyTheme(curIndex()+1);}
else if(act==='tab'){e.preventDefault();setTab(t.getAttribute('data-tab'));}
else if(act==='auth'){
e.preventDefault();
setTab(t.getAttribute('data-tab')||'register');
var plan=t.getAttribute('data-plan'),sel=document.getElementById('plan-select');
if(plan&&sel)sel.value=plan;
var a=document.getElementById('inregistrare');if(a)a.scrollIntoView({behavior:'smooth',block:'start'});
}
});
})();
</script>
</body>
</html>

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

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Inregistrare — Gateway RAR AUTOPASS{% endblock %} {% block title %}Inregistrare — Gateway RAR AUTOPASS{% endblock %}
{% block content %} {% block content %}
<div class="card" style="max-width:480px;margin:40px auto;"> <div class="card auth-card" style="max-width:480px;margin:40px auto;">
{% if api_key %} {% if api_key %}
<div class="flash">Contul a fost creat. Salveaza cheia API acum — nu o vei mai putea vedea.</div> <div class="flash">Contul a fost creat. Salveaza cheia API acum — nu o vei mai putea vedea.</div>
@@ -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(--muted);font-size:12px;">(optional)</span></label><br> <label style="display:block;margin-bottom:14px;">
<input type="text" name="cui" value="{{ cui or '' }}" 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

@@ -1,26 +1,23 @@
"""Worker RAR — proces propriu (NU task asyncio in uvicorn; plan.md sect. 4). """Worker RAR — proces propriu (NU task asyncio in uvicorn).
Bucla: heartbeat -> recupereaza orfane -> claim atomic -> login -> postPrezentare -> update. Bucla: heartbeat -> recupereaza orfane -> claim atomic -> login -> postPrezentare -> update.
Ruleaza ca proces separat sub `restart: always` (docker compose). Ruleaza ca proces separat sub `restart: always` (docker compose).
T2 implementat:
- claim atomic anti-race (BEGIN IMMEDIATE), respecta next_attempt_at (backoff). - claim atomic anti-race (BEGIN IMMEDIATE), respecta next_attempt_at (backoff).
- reconciliere anti-duplicat pe raspuns pierdut: pe eroare tranzitorie/timeout SAU pe - reconciliere anti-duplicat pe raspuns pierdut: pe eroare tranzitorie/timeout SAU pe
rand 'sending' orfan (worker mort mid-POST), interogheaza finalizate si match pe rand 'sending' orfan (worker mort mid-POST), interogheaza finalizate si match pe
vin+dataPrestatie+odometruFinal; daca exista -> 'sent' (NU re-trimite). vin+dataPrestatie+odometruFinal; daca exista -> 'sent' (NU re-trimite).
- retry/backoff exponential pe erori tranzitorii; peste worker_max_retries -> 'error' (banner). - retry/backoff exponential pe erori tranzitorii; peste worker_max_retries -> 'error'.
- lease/timeout pe randuri 'sending' orfane. - lease/timeout pe randuri 'sending' orfane.
- re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h. - re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h.
Creds per-cerere (plan sect. 5): fiecare submission poarta creds RAR CRIPTATE Creds per-cerere: fiecare submission poarta creds RAR CRIPTATE (rar_creds_enc).
(rar_creds_enc). Worker-ul face login per CONT cu acele creds, cache-uieste JWT Worker-ul face login per CONT cu acele creds, cache-uieste JWT (30h) in memorie si
(30h) in memorie si STERGE creds-urile contului dupa primul login reusit. Token-ul STERGE creds-urile contului dupa primul login reusit. Token-ul in memorie acopera
in memorie acopera restul trimiterilor; la restart token-ul se pierde si contul restul trimiterilor; la restart token-ul se pierde si contul re-logheaza la urmatorul
re-loghează la urmatorul submission care aduce creds proaspete (degradare acceptata). submission care aduce creds proaspete (degradare acceptata).
Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc. Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc.
Ce NU e inca: criptare PII payload at-rest (P2), b64Image mare pe disc (P2).
Pornire: python -m app.worker Pornire: python -m app.worker
""" """
@@ -34,9 +31,11 @@ from datetime import datetime, timedelta, timezone
import httpx import httpx
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 ..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
from ..reconcile import match_finalizata from ..reconcile import match_finalizata
@@ -58,6 +57,14 @@ def _iso(dt: datetime) -> str:
return dt.isoformat(timespec="seconds") return dt.isoformat(timespec="seconds")
def _wlog(conn, tip: str, mesaj: str, *, nivel: str = "INFO", account_id=None, cod=None, context=None) -> None:
"""Emite evenimentul (sursa=worker, dublu canal DB+fisier) SI pastreaza linia in
stdout (operatorul tailuieste .run/worker.log)."""
print(f"[worker] {mesaj}", flush=True)
log_event(tip, nivel=nivel, account_id=account_id, cod=cod, mesaj=mesaj, context=context,
conn=conn, sursa="worker")
def _backoff_seconds(settings: Settings, retry_count: int) -> int: def _backoff_seconds(settings: Settings, retry_count: int) -> int:
return min(settings.worker_retry_base_s * (2 ** max(0, retry_count - 1)), settings.worker_retry_max_s) return min(settings.worker_retry_base_s * (2 ** max(0, retry_count - 1)), settings.worker_retry_max_s)
@@ -74,13 +81,26 @@ def _is_transient(exc: Exception) -> bool:
# --- Operatii pe submissions --- # --- Operatii pe submissions ---
# Stari blocate ne-sent care primesc retentie proprie. Mai scurta decat cele 90z
# ale `sent`: un blocat n-are valoare de audit ca o trimitere reusita.
_BLOCKED_STATES = ("error", "needs_data", "needs_mapping")
def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None: def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None:
if status == "sent": if status == "sent":
# T16: purge_after = sent + 90 zile (GDPR/L.142 retentie maxima). # purge_after = sent + 90 zile (GDPR/L.142 retentie maxima).
purge_expr = "datetime('now', '+90 days')"
elif status in _BLOCKED_STATES:
# Randurile blocate primesc si ele purge_after (altfel raman permanent).
days = int(get_settings().blocked_retention_days)
purge_expr = f"datetime('now', '+{days} days')"
else:
purge_expr = None
if purge_expr is not None:
conn.execute( conn.execute(
"UPDATE submissions SET status=?, rar_status_code=?, rar_error=?, id_prezentare=?, " "UPDATE submissions SET status=?, rar_status_code=?, rar_error=?, id_prezentare=?, "
"sending_since=NULL, updated_at=datetime('now'), " f"sending_since=NULL, updated_at=datetime('now'), purge_after={purge_expr} WHERE id=?",
"purge_after=datetime('now', '+90 days') WHERE id=?",
(status, rar_status_code, rar_error, id_prezentare, submission_id), (status, rar_status_code, rar_error, id_prezentare, submission_id),
) )
else: else:
@@ -91,26 +111,33 @@ def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_err
) )
# T16: purge interval in secunde (odata pe ora, nu prea agresiv) # Purge interval in secunde (odata pe ora, nu prea agresiv)
_PURGE_INTERVAL_S = 3600 _PURGE_INTERVAL_S = 3600
def purge_expired(conn) -> dict[str, int]: def purge_expired(conn) -> dict[str, int]:
"""Sterge randurile expirate (purge_after < now). """Sterge randurile expirate (purge_after < now).
T16/OV-5: purge_after era exportat dar setat de nimeni si niciun job nu exista. Submissions `sent` SI blocate (error/needs_data/needs_mapping) expirate;
Acum: submissions sent + expirate, import_batches expirate (import_rows via CASCADE). import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal).
Intoarce {submissions_purged, batches_purged}. EXCLUDE explicit `queued`/`sending` (randuri active — nu se purjeaza niciodata, chiar
daca ar avea un purge_after rezidual; reactivarea il curata oricum).
Intoarce {submissions_purged, batches_purged, events_purged}.
""" """
cur_sub = conn.execute( cur_sub = conn.execute(
"DELETE FROM submissions WHERE purge_after IS NOT NULL AND purge_after < datetime('now') AND status='sent'" "DELETE FROM submissions WHERE purge_after IS NOT NULL AND purge_after < datetime('now') "
"AND status IN ('sent','error','needs_data','needs_mapping')"
) )
cur_batch = conn.execute( cur_batch = conn.execute(
"DELETE FROM import_batches WHERE purge_after IS NOT NULL AND purge_after < datetime('now')" "DELETE FROM import_batches WHERE purge_after IS NOT NULL AND purge_after < datetime('now')"
) )
cur_events = conn.execute(
"DELETE FROM app_events WHERE purge_after IS NOT NULL AND purge_after < datetime('now')"
)
return { return {
"submissions_purged": cur_sub.rowcount, "submissions_purged": cur_sub.rowcount,
"batches_purged": cur_batch.rowcount, "batches_purged": cur_batch.rowcount,
"events_purged": cur_events.rowcount,
} }
@@ -144,7 +171,9 @@ def claim_one(conn) -> dict | None:
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id " "FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
"WHERE s.status='queued' " "WHERE s.status='queued' "
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) " "AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
"AND COALESCE(a.active, 1) = 1 " # Gate pe stare de cont: doar 'active' trimite. Derivam defensiv din `active`
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
"AND COALESCE(a.status, CASE WHEN COALESCE(a.active,1)=1 THEN 'active' ELSE 'pending' END) = 'active' "
"ORDER BY s.id LIMIT 1", "ORDER BY s.id LIMIT 1",
(_iso(_now()),), (_iso(_now()),),
).fetchone() ).fetchone()
@@ -183,7 +212,9 @@ def reconcile_and_mark(conn, rar: RarClient, token: str, submission_id: int, con
if found_id is not None: if found_id is not None:
mark(conn, submission_id, "sent", rar_status_code=200, id_prezentare=found_id, mark(conn, submission_id, "sent", rar_status_code=200, id_prezentare=found_id,
rar_error="reconciliat (raspuns pierdut)") rar_error="reconciliat (raspuns pierdut)")
print(f"[worker] submission {submission_id} -> sent prin reconciliere (idPrezentare={found_id})", flush=True) _wlog(conn, "submission_reconciliat",
f"submission {submission_id} -> sent prin reconciliere (idPrezentare={found_id})",
context={"submission_id": submission_id, "id_prezentare": found_id})
return True return True
return False return False
@@ -191,24 +222,48 @@ def reconcile_and_mark(conn, rar: RarClient, token: str, submission_id: int, con
def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: dict) -> str: def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: dict) -> str:
"""Trimite o prezentare claimed. Intoarce starea finala (pentru teste/loguri).""" """Trimite o prezentare claimed. Intoarce starea finala (pentru teste/loguri)."""
sid = claimed["id"] sid = claimed["id"]
account_id = claimed.get("account_id")
content = claimed["content"] content = claimed["content"]
payload = build_rar_payload(content) payload = build_rar_payload(content)
try: try:
data = rar.post_prezentare(token, payload) data = rar.post_prezentare(token, payload)
mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id")) mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id"))
print(f"[worker] submission {sid} -> sent (idPrezentare={data.get('id')})", flush=True) _wlog(conn, "submission_sent", f"submission {sid} -> sent (idPrezentare={data.get('id')})",
account_id=account_id, context={"submission_id": sid, "id_prezentare": data.get("id")})
return "sent" return "sent"
except RarError as exc: except RarError as exc:
if exc.status_code == 400: if exc.status_code == 400:
detail = json.dumps(exc.field_errors, ensure_ascii=False) if exc.field_errors else str(exc) if exc.field_errors:
enriched = [
errors.eroare("RAR_VALIDARE", field=fe.get("field"), cauza=fe.get("message"))
for fe in exc.field_errors
]
else:
enriched = [errors.eroare("RAR_VALIDARE", cauza=str(exc))]
detail = json.dumps(enriched, ensure_ascii=False)
mark(conn, sid, "needs_data", rar_status_code=400, rar_error=detail) mark(conn, sid, "needs_data", rar_status_code=400, rar_error=detail)
print(f"[worker] submission {sid} -> needs_data: {detail}", flush=True) _wlog(conn, "submission_needs_data", f"submission {sid} -> needs_data (RAR 400)",
nivel="WARNING", account_id=account_id, cod="RAR_VALIDARE",
context={"submission_id": sid})
return "needs_data" return "needs_data"
if exc.status_code == 500 and exc.rar_message:
# RAR a raspuns DEFINITIV cu o eroare de procesare (ex. ORA-12899). NU e o
# pierdere de raspuns ambigua -> NU reconcilia (recordul, daca exista la RAR,
# e PARTIAL/rupt si nu trebuie marcat fals 'sent') si NU reincerca (acelasi
# input va esua iar). Marcam 'error' cu mesajul real RAR.
detail = json.dumps(errors.eroare("RAR_EROARE_SERVER", cauza=exc.rar_message), ensure_ascii=False)
mark(conn, sid, "error", rar_status_code=500, rar_error=detail)
_wlog(conn, "submission_error", f"submission {sid} -> error (RAR 500): {exc.rar_message}",
nivel="ERROR", account_id=account_id, cod="RAR_EROARE_SERVER",
context={"submission_id": sid, "http": 500})
return "error"
if _is_transient(exc): if _is_transient(exc):
return _handle_transient(conn, settings, rar, token, sid, content, str(exc)) return _handle_transient(conn, settings, rar, token, sid, content, str(exc))
# 4xx nerecuperabil (nu 400/401/408/429) -> error. # 4xx nerecuperabil (nu 400/401/408/429) -> error.
mark(conn, sid, "error", rar_status_code=exc.status_code, rar_error=str(exc)) mark(conn, sid, "error", rar_status_code=exc.status_code, rar_error=str(exc))
print(f"[worker] submission {sid} -> error: {exc}", flush=True) _wlog(conn, "submission_error", f"submission {sid} -> error: {exc}",
nivel="ERROR", account_id=account_id,
context={"submission_id": sid, "http": exc.status_code})
return "error" return "error"
except (httpx.TimeoutException, httpx.TransportError) as exc: except (httpx.TimeoutException, httpx.TransportError) as exc:
return _handle_transient(conn, settings, rar, token, sid, content, f"retea: {exc}") return _handle_transient(conn, settings, rar, token, sid, content, f"retea: {exc}")
@@ -232,18 +287,23 @@ def recover_orphans(conn, settings: Settings, rar: RarClient, token: str, accoun
`account_id` filtreaza la orfanii unui cont (login-ul e per-cont); None = toti `account_id` filtreaza la orfanii unui cont (login-ul e per-cont); None = toti
(compat teste / single-account). (compat teste / single-account).
""" """
cutoff = _iso(_now() - timedelta(seconds=settings.worker_sending_lease_s)) # Cutoff calculat SQLite-side, in ACELASI format ca sending_since (scris cu
# datetime('now') in claim_one -> 'YYYY-MM-DD HH:MM:SS', cu spatiu). Daca am
# compara cu _iso() (format ISO cu 'T'), spatiul (0x20) < 'T' (0x54) ar face
# orice rand 'sending' sa para mereu <= cutoff -> lease-ul de 120s ignorat,
# iar fiecare rand proaspat revendicat ar fi tratat instant ca orfan.
lease = f"-{int(settings.worker_sending_lease_s)} seconds"
if account_id is not None: if account_id is not None:
orphans = conn.execute( orphans = conn.execute(
"SELECT id, payload_json FROM submissions WHERE status='sending' " "SELECT id, payload_json FROM submissions WHERE status='sending' "
"AND (sending_since IS NULL OR sending_since <= ?) AND account_id=?", "AND (sending_since IS NULL OR sending_since <= datetime('now', ?)) AND account_id=?",
(cutoff, account_id), (lease, account_id),
).fetchall() ).fetchall()
else: else:
orphans = conn.execute( orphans = conn.execute(
"SELECT id, payload_json FROM submissions WHERE status='sending' " "SELECT id, payload_json FROM submissions WHERE status='sending' "
"AND (sending_since IS NULL OR sending_since <= ?)", "AND (sending_since IS NULL OR sending_since <= datetime('now', ?))",
(cutoff,), (lease,),
).fetchall() ).fetchall()
recovered = 0 recovered = 0
for row in orphans: for row in orphans:
@@ -298,13 +358,25 @@ class AccountSessions:
rar = RarClient(self.settings) rar = RarClient(self.settings)
try: try:
token = rar.login(creds["email"], creds["password"]) token = rar.login(creds["email"], creds["password"])
except RarAuthError as exc:
rar.close()
# Login esuat (401) — FARA email/parola (doar codul HTTP + contul).
log_event("rar_login", nivel="WARNING", account_id=account_id,
cod="RAR_CREDS_INVALIDE",
mesaj=f"login RAR esuat (cont {account_id}): {exc.status_code or 401}",
context={"rezultat": "esuat", "http": exc.status_code or 401},
conn=conn, sursa="worker")
raise
except Exception: except Exception:
rar.close() rar.close()
raise raise
self._sessions[account_id] = (rar, token) self._sessions[account_id] = (rar, token)
write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})") write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})")
# Login reusit (fara email/parola in clar — context curat).
log_event("rar_login", account_id=account_id, mesaj=f"login RAR ok (cont {account_id})",
context={"rezultat": "ok", "http": 200}, conn=conn, sursa="worker")
# Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge. # Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge.
# GATE PURJARE (T1/Voce#5): sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc. # GATE PURJARE: sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc.
# Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart). # Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart).
# Canal API pur: purjarea e identica cu Treapta 1 (neatinsa). # Canal API pur: purjarea e identica cu Treapta 1 (neatinsa).
conn.execute( conn.execute(
@@ -343,7 +415,7 @@ def _creds_for(claimed: dict, settings: Settings) -> dict | None:
def _creds_from_account(conn, account_id: int) -> dict | None: def _creds_from_account(conn, account_id: int) -> dict | None:
"""Fallback T1/D4: crede RAR durabile per-cont din accounts.rar_creds_enc. """Fallback: crede RAR durabile per-cont din accounts.rar_creds_enc.
Canal web nu are re-pusher. Cand submission n-are creds (sterse dupa primul login Canal web nu are re-pusher. Cand submission n-are creds (sterse dupa primul login
sau upload web fara creds), worker-ul re-citeste din cont si poate re-login oricand. sau upload web fara creds), worker-ul re-citeste din cont si poate re-login oricand.
@@ -356,30 +428,94 @@ 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)
settings = get_settings() settings = get_settings()
set_source("worker") # evenimentele worker-ului au sursa=worker (fisier app-worker.log)
init_db() init_db()
conn = get_connection() conn = get_connection()
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True) print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True)
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:
write_heartbeat(conn, detail=f"poll (queue={_queue_depth(conn)})") write_heartbeat(conn, detail=f"poll (queue={_queue_depth(conn)})")
# T16: purjare periodica (odata pe ora) — NU mai frecvent. # Purjare periodica (odata pe ora) — NU mai frecvent.
now_ts = time.time() now_ts = time.time()
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S: if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
stats = purge_expired(conn) stats = purge_expired(conn)
if stats["submissions_purged"] or stats["batches_purged"]: if stats["submissions_purged"] or stats["batches_purged"] or stats["events_purged"]:
print( print(
f"[worker] purjare: {stats['submissions_purged']} submissions, " f"[worker] purjare: {stats['submissions_purged']} submissions, "
f"{stats['batches_purged']} batches sterse", f"{stats['batches_purged']} batches, {stats['events_purged']} evenimente sterse",
flush=True, flush=True,
) )
_last_purge_time = now_ts _last_purge_time = now_ts
@@ -393,21 +529,34 @@ 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
sid = claimed["id"] sid = claimed["id"]
account_id = claimed["account_id"] account_id = claimed["account_id"]
# T1/D4: incearca creds din submission (canal API efemer), cu fallback la # Randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima trimitere
# a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea RAR
# cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea,
# ignorand corectia. Re-login imediat cu creds-urile noi.
if claimed.get("creds_enc"):
sessions.invalidate(account_id)
# Incearca creds din submission (canal API efemer), cu fallback la
# accounts.rar_creds_enc (canal web durabil). Canal web n-are re-pusher. # accounts.rar_creds_enc (canal web durabil). Canal web n-are re-pusher.
creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id) creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id)
try: try:
token = sessions.get_token(conn, account_id, creds) token = sessions.get_token(conn, account_id, creds)
except RarAuthError as exc: except RarAuthError as exc:
# Creds gresite (login 401): NU se face retry (plan, failure registry). # Creds gresite (login 401): NU se face retry.
mark(conn, sid, "error", rar_status_code=401, rar_error="credentiale RAR invalide") mark(conn, sid, "error", rar_status_code=401,
print(f"[worker] submission {sid} (cont {account_id}) -> error: {exc}", flush=True) rar_error=json.dumps(errors.eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False))
# rar_login esuat e deja logat in get_token; aici doar tranzitia submission-ului.
_wlog(conn, "submission_error", f"submission {sid} (cont {account_id}) -> error: creds RAR invalide",
nivel="ERROR", account_id=account_id, cod="RAR_CREDS_INVALIDE",
context={"submission_id": sid, "http": 401})
continue continue
if token is None: if token is None:
@@ -419,6 +568,14 @@ def run() -> int:
rar = sessions.rar(account_id) rar = sessions.rar(account_id)
# Recupereaza orfanii contului inainte de trimitere (acelasi token). # Recupereaza orfanii contului inainte de trimitere (acelasi token).
recover_orphans(conn, settings, rar, token, account_id=account_id) recover_orphans(conn, settings, rar, token, account_id=account_id)
# Guard: recover_orphans putea atinge chiar randul tocmai revendicat
# (reconciliat 'sent' sau requeue 'queued'). Daca nu mai e 'sending',
# NU mai face POST — altfel s-ar crea un duplicat la RAR.
still_sending = conn.execute(
"SELECT 1 FROM submissions WHERE id=? AND status='sending'", (sid,)
).fetchone()
if still_sending is None:
continue
try: try:
process_one(conn, settings, rar, token, claimed) process_one(conn, settings, rar, token, claimed)
except RarAuthError as exc: except RarAuthError as exc:

View File

@@ -9,15 +9,19 @@ services:
api: api:
build: . build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8010 command: uvicorn app.main:app --host 0.0.0.0 --port 8010
ports:
- "8010:8010"
volumes: volumes:
- autopass-data:/data - autopass-data:/data
environment: environment:
AUTOPASS_DB_PATH: /data/autopass.db AUTOPASS_DB_PATH: /data/autopass.db
AUTOPASS_RAR_ENV: test # Override din environment (Dokploy) pentru staging; default = prod.
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-prod}
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
TZ: ${TZ:-Europe/Bucharest}
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)} AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false} AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false}
# Embeddings (sugestie mapare, Stratul 2): prima cerere /mapari lazy-load-eaza
# modelul ~230MB. Doar API-ul il incarca (worker-ul nu). Default off.
AUTOPASS_EMBEDDINGS_ENABLED: ${AUTOPASS_EMBEDDINGS_ENABLED:-false}
restart: always restart: always
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8010/healthz').status==200 else 1)"] test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8010/healthz').status==200 else 1)"]
@@ -32,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: "false" # in Dokploy ca worker-ul sa NU trimita declaratii reale la RAR (Legea 142/2023).
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:-true}
restart: always restart: always
depends_on: depends_on:
- api - api

File diff suppressed because one or more lines are too long

View File

@@ -175,6 +175,136 @@ Aplicate deja pe ambele medii (test + producție):
Acestea devin reguli Pydantic exacte în `app/api`. Validează la gateway înainte de enqueue Acestea devin reguli Pydantic exacte în `app/api`. Validează la gateway înainte de enqueue
(stare `needs_data`) ca nu primești 4xx de la RAR. (stare `needs_data`) ca nu primești 4xx de la RAR.
## Envelope de eroare imbogatit (PRD 5.4)
### Forma unui obiect de eroare
Incepand cu PRD 5.4, fiecare obiect de eroare returnat de gateway contine **6 chei**:
| Cheie | Tip | Rol | Back-compat |
|---|---|---|---|
| `field` | string \| null | Campul care a generat eroarea (null daca eroarea e globala) | DA existent anterior |
| `message` | string | Mesajul scurt (identic cu `cauza` cand e disponibila, altfel `problema`) | DA existent anterior |
| `cod` | string | Identificator stabil de tip eroare (ex. `VIN_FORMAT`). Camp nou. | NU adaugat 5.4 |
| `problema` | string | Ce s-a intamplat descriere scurta, inteligibila pentru utilizator | NU adaugat 5.4 |
| `cauza` | string | De ce a aparut eroarea concret; pentru erorile RAR 400, mesajul exact de la RAR (passthrough) | NU adaugat 5.4 |
| `fix` | string | Ce trebuie facut pentru remediere | NU adaugat 5.4 |
**Exemplu JSON concret** (eroare VIN invalid, returnat de `POST /v1/prezentari/valideaza`):
```json
{
"field": "vin",
"message": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
"cod": "VIN_FORMAT",
"problema": "VIN invalid",
"cauza": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
"fix": "Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere majuscule, fara spatii si fara literele O, I, Q."
}
```
### Nota de back-compat
Cheile `field` si `message` sunt **pastrate neschimbate** pe toate raspunsurile. Cheile `cod`, `problema`, `cauza`, `fix` sunt **aditive** (camp nou in plus). Clientii care citesc doar `field`/`message` (sau `error`/`message` la import) continua sa functioneze fara modificare.
### Unde apare envelope-ul imbogatit
**1. `POST /v1/prezentari/valideaza` (dry-run)**
Campul `erori` (array) si campul `nemapate` (array) din raspuns contin obiecte cu toate cele 6 chei.
**2. `submissions.rar_error` (stocat in DB, vizibil prin `GET /v1/prezentari/{id}` si in dashboard)**
Campul `rar_error` e superset al formei de mai sus si variaza cu starea submission-ului:
- `needs_data` array de obiecte `{field, message, cod, problema, cauza, fix}`:
```json
[
{
"field": "dataPrestatie",
"message": "Data prestatiei nu poate fi anterioara datei de 01.12.2024.",
"cod": "RAR_VALIDARE",
"problema": "RAR a respins prezentarea",
"cauza": "Data prestatiei nu poate fi anterioara datei de 01.12.2024.",
"fix": "Corecteaza campul semnalat de RAR (vezi cauza) si reincearca; detaliile exacte sunt in mesajul tehnic RAR."
}
]
```
- `needs_mapping` (cod nemapat): obiect cu cheile `unmapped` (array), `cod`, `problema`, `cauza`, `fix`:
```json
{
"unmapped": ["SCHIMB_ULEI_COMPLET"],
"cod": "COD_NEMAPAT",
"problema": "Lipseste codul RAR al operatiei",
"cauza": "Operatia SCHIMB_ULEI_COMPLET nu are un cod RAR mapat.",
"fix": "Alege codul RAR pentru aceasta operatie in tab-ul Mapari (ai sugestii automate)."
}
```
- `needs_mapping` cu `auto_send` oprit: obiect cu `auto_send`, `cod: "AUTO_SEND_OPRIT"`, `problema`, `cauza`, `fix`.
- Eroare RAR 400: array imbogatit cu `cod: "RAR_VALIDARE"` pe fiecare element.
- Eroare RAR 401 (creds invalide): obiect cu `cod: "RAR_CREDS_INVALIDE"`, `problema`, `cauza`, `fix`.
**3. Erori de import (`POST /v1/import`, preview, commit)**
Campul `detail` din raspunsurile de eroare este superset: contine cheile vechi `error`/`message` plus `cod`, `problema`, `cauza`, `fix`.
**Exceptii din scope 5.4**: erorile de login/signup si CSRF raman mesaje plate (fara envelope imbogatit).
### Tabel cod → problema / fix (toate codurile din `app/errors.CATALOG`)
#### Validare date prestatie
| Cod | Problema | Fix |
|---|---|---|
| `VIN_FORMAT` | VIN invalid | Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere majuscule, fara spatii si fara literele O, I, Q. |
| `NR_INMATRICULARE_FORMAT` | Numar de inmatriculare invalid | Foloseste doar litere si cifre majuscule, maxim 10 caractere, fara spatii sau cratima (ex. B123ABC). |
| `DATA_FORMAT` | Data prestatiei in format gresit | Scrie data ca AAAA-LL-ZZ (ex. 2026-06-22). |
| `DATA_PREA_VECHE` | Data prestatiei prea veche | RAR accepta prestatii doar incepand cu 01.12.2024; verifica data prestatiei. |
| `DATA_VIITOR` | Data prestatiei in viitor | Data prestatiei nu poate fi dupa ziua de azi; corecteaza data. |
| `ODOMETRU_FINAL_FORMAT` | Odometru final invalid | Scrie kilometrajul final ca numar intreg, fara zecimale sau text (ex. 145000). |
| `ODOMETRU_INITIAL_LIPSA` | Lipseste odometrul initial | Prestatiile R-ODO / I-ODO cer kilometrajul initial; completeaza-l. |
| `ODOMETRU_INITIAL_FORMAT` | Odometru initial invalid | Scrie kilometrajul initial ca numar intreg, fara zecimale sau text. |
| `ODOMETRU_INITIAL_ORDINE` | Odometru initial mai mare decat finalul | Kilometrajul initial trebuie sa fie mai mic sau egal cu cel final; verifica cele doua valori. |
| `PRESTATII_GOALE` | Nicio prestatie | Adauga cel putin o prestatie cu cod RAR valid. |
| `B64_INVALID` | Imaginea nu este base64 valid | Trimite imaginea codata base64 corect, sau omite campul daca nu ai imagine. |
#### Mapare operatie
| Cod | Problema | Fix |
|---|---|---|
| `COD_NEMAPAT` | Lipseste codul RAR al operatiei | Alege codul RAR pentru aceasta operatie in tab-ul Mapari (ai sugestii automate). |
| `AUTO_SEND_OPRIT` | Necesita confirmare manuala | Codul e mapat cu trimitere automata oprita; verifica randul si pune-l manual in coada. |
#### Erori RAR (raspuns live de la RAR)
| Cod | Problema | Fix |
|---|---|---|
| `RAR_VALIDARE` | RAR a respins prezentarea | Corecteaza campul semnalat de RAR (vezi cauza) si reincearca; detaliile exacte sunt in mesajul tehnic RAR. |
| `RAR_EROARE_SERVER` | RAR a esuat la inregistrarea prezentarii | RAR a raspuns cu o eroare de server (vezi cauza). Trimiterea NU se reincearca automat si NU a fost confirmata — verifica datele (in special codul prestatiei) si re-trimite dupa corectare. |
| `RAR_CREDS_INVALIDE` | Credentiale RAR invalide | Verifica email-ul si parola contului RAR in tab-ul Cont; trimiterea nu se reincearca automat la credentiale gresite. |
> **Clasificarea esecurilor RAR la `postPrezentare` (worker).** Un **400** -> `needs_data`
> (validare continut). Un **500 cu corp de eroare** (`{statusCode,message}`, ex. `ORA-12899`)
> e un esec DEFINITIV: RAR a raspuns „am esuat", deci NU e o pierdere de raspuns ambigua
> -> worker-ul marcheaza `error` (`RAR_EROARE_SERVER`), **fara reconciliere si fara retry**
> (altfel ar marca fals `sent` pe un record PARTIAL pe care RAR, ne-tranzactional, il lasa
> la esec). Doar erorile **ambigue** — timeout / TransportError / 502/503/504 / 429 / 408 —
> declanseaza reconcilierea anti-duplicat + retry cu backoff.
#### Import fisier
| Cod | Problema | Fix |
|---|---|---|
| `IMPORT_FISIER_PREA_MARE` | Fisier prea mare | Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand. |
| `IMPORT_ANTET_NECLAR` | Antet de coloane neclar | Asigura-te ca primul rand contine numele coloanelor (ex. VIN, Numar, Data). |
| `IMPORT_ENCODING` | Codare de caractere nesuportata | Salveaza fisierul ca CSV UTF-8 (sau xlsx) si reincarca. |
| `IMPORT_FISIER_NERECUNOSCUT` | Fisier nerecunoscut | Incarca un fisier .xlsx sau .csv valid. |
| `IMPORT_MULTIPLE_SHEETS` | Mai multe foi in fisier | Pastreaza datele intr-o singura foaie sau alege foaia de import. |
| `IMPORT_FARA_MAPARE_COLOANE` | Coloanele nu sunt mapate | Mapeaza intai coloanele fisierului la campurile cerute, apoi continua. |
| `IMPORT_CONFIRMARE_GRESITA` | Numar confirmat gresit | Numarul confirmat difera de randurile gata de trimis; verifica preview-ul si reconfirma. |
| `IMPORT_OVERRIDE_ILIZIBIL` | Editarea anterioara nu se poate citi | Editarea salvata este ilizibila (probabil cheia s-a schimbat); reediteaza randul. |
| `COLOANE_FORMAT_JSON` | Format de coloane (JSON) invalid | Verifica sintaxa JSON a maparii de coloane (ghilimele duble, acolade inchise corect). |
## Nomenclator prestații (18 coduri, verificat live 2026-06-15) ## Nomenclator prestații (18 coduri, verificat live 2026-06-15)
| cod | nume | | cod | nume |
@@ -272,7 +402,7 @@ Aceasta e suprafata **gateway-ului**, nu RAR. Un item din `prestatii` la
| Camp item | Note | | Camp item | Note |
|---|---| |---|---|
| `cod_prestatie` | cod RAR direct (ex. `OE-1`). Trece neatins -> validare T3 -> coada. | | `cod_prestatie` | cod RAR direct (ex. `OE-1`). **Validat fata de nomenclator** -> validare T3 -> coada. Cod NECUNOSCUT in nomenclator e tratat ca operatie de mapat (vezi mai jos). |
| `cod_op_service` | cod intern ROAAUTO. Gateway-ul il traduce in cod RAR prin `operations_mapping`. | | `cod_op_service` | cod intern ROAAUTO. Gateway-ul il traduce in cod RAR prin `operations_mapping`. |
| `denumire` | denumirea operatiei ROAAUTO; folosita pentru fuzzy lookup in editor. | | `denumire` | denumirea operatiei ROAAUTO; folosita pentru fuzzy lookup in editor. |
@@ -283,11 +413,49 @@ web. La salvarea maparii, submission-urile blocate pe acel cod se re-rezolva aut
rezolvat se scrie inapoi in `payload_json`, deci payload builder + worker raman rezolvat se scrie inapoi in `payload_json`, deci payload builder + worker raman
code-driven. code-driven.
**Validare `cod_prestatie` la ingestie (2026-06-23).** RAR accepta NUMAI coduri din
nomenclator: coloana `COD_PRESTATIE` are max 5 caractere si un cod necunoscut
intoarce **HTTP 500** (`ORA-12899`) — confirmat live. Periculos: RAR NU e tranzactional
si lasa un **record partial** (`FINALIZATA`, terminal) chiar cand apelul esueaza, iar
reconcilierea worker-ului il poate marca fals `sent`. De aceea gateway-ul NU mai trimite
un `cod_prestatie` care nu e in nomenclator: il promoveaza la `cod_op_service` (cu
`denumire`=cod, pentru fuzzy) si il trateaza ca operatie de mapat.
**Optiunea `on_unmapped_error`** (camp boolean top-level optional pe `POST /v1/prezentari`
si `/v1/prezentari/valideaza`) controleaza ce se intampla la cod necunoscut/nemapat:
- `false` (default) — submission `needs_mapping`, apare in editor (non-distructiv);
- `true` — respinge fara enqueue: `SubmissionResult` cu `status="error"`,
`submission_id=null`, `erori=[COD_NEMAPAT...]`.
Cand campul lipseste se aplica default-ul contului (`accounts.on_unmapped_error_default`,
implicit `false`/`0`). Override per-cerere > default cont > `false`.
Endpointuri noi: Endpointuri noi:
- `GET /v1/mapari/pending` — operatii nemapate distincte + sugestii fuzzy (`{cod_prestatie, nume_prestatie, score}`). - `GET /v1/mapari/pending` — operatii nemapate distincte + sugestii fuzzy (`{cod_prestatie, nume_prestatie, score}`).
- `POST /v1/mapari` `{account_id?, cod_op_service, cod_prestatie, auto_send}` — upsert mapare + re-rezolvare. Respinge `cod_prestatie` inexistent in nomenclator (422). - `POST /v1/mapari` `{account_id?, cod_op_service, cod_prestatie, auto_send}` — upsert mapare + re-rezolvare. Respinge `cod_prestatie` inexistent in nomenclator (422).
- Web: `GET /_fragments/mapari` (editor HTMX), `POST /mapari` (form, salveaza + re-randeaza). - Web: `GET /_fragments/mapari` (editor HTMX), `POST /mapari` (form, salveaza + re-randeaza).
### Lifecycle trimiteri blocate (PRD 5.6)
`POST /v1/prezentari` — camp **aditiv** in fiecare `SubmissionResult`: `reactivated: bool`.
La resubmit cu aceeasi cheie de continut peste un rand `error` (ex. parola RAR corectata),
randul se RE-ACTIVEAZA (re-clasificat + creds actualizate) si raspunsul poarta
`reactivated: true` + starea noua. `deduped` pastreaza semantica actuala (clientii vechi
care testeaza `deduped` nu se sparg). Pentru `sent`/`queued`/`sending`/`needs_*` ->
`deduped: true` (neschimbat).
- `DELETE /v1/prezentari/{id}` — sterge o trimitere blocata a contului cheii API.
**200 + body JSON** `{ok, submission_id, status_anterior}` (NU 204 — clienti VFP string-parse).
Scope evaluat INAINTEA starii: cross-account / inexistent -> **404** (acelasi mesaj, B3);
own-account `sent`/`sending` -> **409** (conflict de stare).
- `POST /v1/prezentari/{id}/repune` — re-pune in coada (`error -> queued`, re-ruleaza classify).
**200 + body JSON** `{ok, submission_id, status_anterior, status_nou}`. Acelasi oracol scope/stare.
- `GET /v1/prezentari/{id}` expune ACUM si `rar_error` (T9) — recovery observabil prin API
(de ce a esuat); contine doar coduri/mesaje de validare RAR, niciodata creds.
Web (dashboard, scoped pe sesiune + CSRF): `POST /trimitere/{id}/sterge`,
`POST /trimitere/{id}/repune`, `POST /trimiteri/sterge-bulk` (selectie multipla, doar blocate).
Fuzzy: `rapidfuzz.token_sort_ratio` pe denumire normalizata (fara diacritice, upper). Fuzzy: `rapidfuzz.token_sort_ratio` pe denumire normalizata (fara diacritice, upper).
Nomenclatorul se ia **live** din RAR (worker upsert la fiecare login); seed fallback Nomenclatorul se ia **live** din RAR (worker upsert la fiecare login); seed fallback
de 18 coduri la boot (`app/nomenclator_seed.py`) ca editorul sa mearga offline. de 18 coduri la boot (`app/nomenclator_seed.py`) ca editorul sa mearga offline.
@@ -326,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>

233
docs/design.md Normal file
View File

@@ -0,0 +1,233 @@
# design.md — Sistemul de design Gateway RAR AUTOPASS
Sursa de adevar pentru deciziile vizuale ale aplicatiei web. **Orice plan de design
(`/plan-design-review`, `/design-consultation`, `/design-review`) si orice modificare
de UI trebuie sa porneasca de aici.** Unde un mockup sau o propunere difera de acest
document, documentul are dreptate (sau se actualizeaza explicit, intr-un commit separat).
Limba UI: romana, fara diacritice in cod/atribute tehnice, cu diacritice acceptate in
textul vizibil (fontul are `latin-ext`). Fara emoji.
CSS-ul traieste inline in `app/web/templates/base.html` (un singur `<style>`). Nu exista
build step. Tokenii de mai jos sunt variabile CSS reale definite acolo.
---
## 1. Principii
1. **Compact, nu inghesuit.** Densitate mare de informatie utila, dar cu ritm si spatiu.
Pe ecrane mici aratam ESENTIALUL, nu tot ce incape pe desktop. Restul intra in detaliu
(modal) sau in linii secundare mici.
2. **Compactarea e si pentru desktop.** Cand o componenta e mai lizibila compacta (ex.
wizard-ul de import), forma compacta se aplica pe toate latimile, nu doar pe mobil.
3. **Mobile-first ca verificare, nu ca scuza.** Orice ecran trebuie sa fie complet
utilizabil la 360px latime, fara scroll orizontal de pagina si fara text rupt vertical.
4. **Starea prin text + culoare, niciodata doar culoare** (accesibilitate; pill-uri cu
eticheta umana, glife ✓/✗ cu text).
5. **O singura zona de actiune dominanta pe ecran.** Un singur buton primar vizibil per
context (ex. „Trimite la RAR"). Restul sunt secundare/ghost.
6. **Tinte de atins generoase pe touch, sobre pe desktop.** Vezi scara de control.
---
## 2. Tokeni
### 2.1 Culoare (variabile CSS, 4 teme)
Paleta e definita pe `:root` (dark, default) si suprascrisa pe `[data-theme="light|petrol"]`.
`auto` se rezolva la light/dark dupa `prefers-color-scheme`. **Nu folosi culori hardcodate;
foloseste mereu variabilele.** Pentru tente, `color-mix(in srgb, var(--x) N%, transparent|var(--card))`.
| Token | Rol | dark | light | petrol |
|-------|-----|------|-------|--------|
| `--bg` | fundal pagina | `#0f1218` | `#f5f7fa` | `#0e1416` |
| `--card` | suprafata card/meniu | `#181c24` | `#ffffff` | `#161e20` |
| `--ink` | text principal | `#e6e9ef` | `#1a1d24` | `#e6e9ef` |
| `--muted` | text secundar | `#8b93a7` | `#5c6473` | `#8b93a7` |
| `--line` | borduri/separatoare | `#262b36` | `#e2e5ea` | `#232c2e` |
| `--accent` | actiune primara / link | `#2E74D6` | `#1F66C9` | `#0E7C7B` |
| `--ok` | succes / trimis | `#2FBF8F` | `#15803d` | `#2FBF8F` |
| `--warn` | atentie / de verificat | `#E0A93B` | `#b45309` | `#E0A93B` |
| `--err` | eroare / distructiv | `#E05D5D` | `#dc2626` | `#E05D5D` |
Accentul light (`#1F66C9`) e ales pentru contrast AA pe alb (5.51:1). Orice text colorat
pe `--card` trebuie sa ramana >= 4.5:1 in toate cele 3 palete.
### 2.2 Tipografie
Font: **IBM Plex Sans** (UI), **IBM Plex Mono** (VIN, coduri, ID-uri). Self-hosted, `latin-ext`
pentru diacritice, `font-display:swap`. Greutati disponibile: 400, 500, 700.
Scara (px / weight) — folosita consecvent, nu inventa marimi noi:
| Rol | size | weight | note |
|-----|------|--------|------|
| Titlu pagina (header) | 20 (desktop) / 17 (mobil) | 700 | letter-spacing -.01em |
| Titlu sectiune / card | 15 | 600 | `h2.sec` |
| Subtitlu / `h3` | 14 | 600 | |
| Corp / controale | 14 | 400/500 | inputuri, butoane |
| Eticheta camp, link card | 13 | 400/500 | `.cardlink`, label form |
| Secundar / meta | 12 | 400 | text muted, sub-linii |
| Micro (coduri, badge) | 11 | 500/700 | mono pentru coduri |
Numerele tabulare: `font-variant-numeric: tabular-nums` pe tabele (aliniere coloane).
Coduri/VIN/ID: `font-family: "IBM Plex Mono"`.
### 2.3 Spatiere
Scara 4px: **4, 6, 8, 10, 12, 14, 16, 20, 24**. Padding card desktop `16px 20px`, mobil `16px`.
Gap intre carduri `1416px`. Gap intre controale pe o linie `812px`.
### 2.4 Radius
| Valoare | Uz |
|---------|-----|
| `6px` | controale: butoane, input, select |
| `78px` | carduri-rand, meniuri, butoane icon |
| `10px` | carduri de sectiune |
| `12px` | modal (desktop) |
| `99px` | pill-uri, badge-uri, bara de progres |
### 2.5 Elevatie
Plat implicit (border `1px solid var(--line)`). Umbra DOAR pentru elemente plutitoare:
meniuri/kebab `0 8px 24px rgba(0,0,0,.18)`, modal `0 16px 48px rgba(0,0,0,.35)`.
---
## 3. Breakpoints
Un singur prag conceptual mobil la **768px**; un prag de densitate la **1024px**.
| Interval | Numit | Regula |
|----------|-------|--------|
| `>= 1024px` | desktop | layout complet; aplica si compactarile globale (wizard) |
| `7681024px` | tableta | **card-uri** pentru tabelele actionabile (Trimiteri, Preview, Mapari), UN card pe rand (nu 2/rand); tabelele dense read-only raman cu scroll contained |
| `< 768px` | mobil | un card pe rand, o coloana, tinte touch 44px |
CSS custom properties NU functioneaza in `@media`; pragul se scrie literal
(`@media (max-width:767px)`, `@media (max-width:1024px)`). Reutilizeaza aceste praguri,
nu introduce altele noi.
---
## 4. Scara de control (tinte de atins)
| Context | min-height | Uz |
|---------|-----------|-----|
| Touch (`< 768px`) | **44px** | orice buton/link/select interactiv |
| Desktop standard | **36px** | butoane, icon-btn, cardlink, intrari meniu |
| Compact (desktop) | **32px** | kebab summary, butoane pager, pill-cat |
Pe desktop nu fortam 44px peste tot (devine greoi); pe mobil da. Latimea butoanelor:
**auto, nu full-width**, cu exceptia butonului primar de actiune dintr-o bara dedicata
(ex. „Trimite la RAR" in bara sticky, „Salveaza si continua").
---
## 5. Componente
### 5.1 Butoane — sistem unificat
Patru variante. Toate: `font:inherit` (IBM Plex Sans), `font-weight:500`, `border-radius:6px`,
`padding:8px 14px` (desktop), tinta conform scarii de control. Tranzitie `filter/background .15s`.
| Varianta | Clasa | Fundal | Text | Bordura | Uz |
|----------|-------|--------|------|---------|-----|
| Primar | `.btn` (default `<button>`) | `--accent` | `#fff` | `--accent` | actiunea dominanta |
| Secundar | `.btn-secondary` | transparent | `--ink` | `--line` | actiuni neutre (Editeaza, Filtreaza) |
| Ghost | `.btn-ghost` | transparent | `--accent` | transparent | actiuni tertiare/linkuri-actiune |
| Distructiv | `.btn-danger` | transparent | `--err` | `--err` | Sterge; hover → fundal `--err`, text `#fff` |
**Iconite in butoane:** label text + iconita optionala la stanga (16px, `fill:currentColor`,
`aria-hidden`). **Butoanele icon-only sunt interzise pentru actiuni cu text echivalent**
(ex. Salveaza/Sterge in tabele) — au cauzat „bloc colorat cu iconita invizibila" pe mobil.
Cand spatiul e strans, foloseste un grup compact `[ Salveaza ] [ Sterge ]` cu text scurt,
nu doua blocuri full-width unul sub altul. Icon-only ramane permis DOAR pentru: comutator
tema, hamburger cont, kebab, inchidere modal — toate cu `aria-label`.
Stari: `:hover``filter:brightness(1.08)` (primar) sau `background:var(--line)` (secundar/ghost);
`:focus-visible``outline:2px solid var(--accent); outline-offset:2px`; `:disabled`
`opacity:.45; cursor:default`. Stare „dirty" (modificari nesalvate) pe butonul de salvare:
fundal `--accent`.
### 5.2 Card
`background:var(--card); border:1px solid var(--line); border-radius:10px`. Carduri de
sectiune cu titlu `h2.sec` (15/600). Carduri-rand (lista pe mobil/tableta) cu radius 810px,
stivuite vertical, gap intern 78px.
### 5.3 Tabel → card-uri (responsive)
Tabelele **actionabile** (Trimiteri, Preview import, Mapari) devin card-uri sub 1024px.
Regula de card (vezi §3): NU folosi pattern-ul „eticheta cu `min-width` fix + valoare in
flex" — sparge valorile pe verticala. In schimb:
- **Stivuieste**: eticheta mica deasupra valorii (`display:block`), SAU
- **Card semantic**: linie titlu (identificator + stare), linii secundare mici. Preferat
pentru liste lungi (Trimiteri, Preview).
Listele actionabile (Trimiteri, Preview, Mapari) raman **O COLOANA (un card pe rand)** pe
TOT intervalul sub 1024px — nu se foloseste grila 2/rand. Decizie confirmata cu userul
(gate 2026-06-27): simplitate si consecventa primeaza fata de densitate pe tableta.
Tabelele **dense read-only** (Jurnal, Nomenclator, Admin) raman tabel cu scroll orizontal
**contained in card** (`.tablewrap { overflow-x:auto }`), nu se cardifica.
### 5.4 Stepper / wizard import — COMPACT pe toate latimile
Patru pasi: Incarca fisier · Potriveste coloanele · Verifica · Confirma trimiterea.
- **Desktop**: o bara slim orizontala — pastila numar (sau ✓) + titlu scurt pe O linie,
pasul activ evidentiat. **Fara paragraf de ajutor inalt** in bara (ajutorul, daca e
nevoie, e text mic sub bara, o singura linie). Inaltime tinta ~44px, nu blocuri inalte.
- **Tableta/mobil**: colapsat la o singura linie — `Pasul N din 4 · <Titlu>` + bara de
progres (`height:5px; border-radius:99px`, umplere `--accent` la `N/4`). Ajutorul pasului
activ sub bara, text 12px muted.
Niciodata 4 coloane egale cu text — se taie/se rupe pe ecrane inguste.
### 5.5 Pill-uri si badge-uri
Stare: `.pill` 12px, radius 99px, cu clasa de culoare (`.s-ok/.s-warn/...`). Filtre de
stare: `.pill-cat` (contur inactiv, umplere activa pe culoarea categoriei). Badge contor:
cerc 18px, `--err`, text alb 11/700.
### 5.6 Formulare
Label 1213px muted deasupra controlului. Input/select: `--bg`, bordura `--line`, radius 6px,
padding `7px 10px`. Pe mobil controalele de formular din sectiunile de continut sunt
full-width (tinta 44px). Pe desktop pastreaza latimi rezonabile (`select` max ~340px).
### 5.7 Modal
Desktop: dialog centrat `max-width:680px`, radius 12px, backdrop `rgba(0,0,0,.55)`,
scroll intern. Mobil (`< 768px`): full-screen (fara colturi/umbra), buton inchidere 44px,
focus-trap + scroll-lock + `inert` pe `<main>`.
---
## 6. Accesibilitate (obligatoriu)
- Contrast text >= 4.5:1 (normal), >= 3:1 (>=18px bold) in toate cele 3 palete.
- Stare comunicata prin text, nu doar culoare.
- `:focus-visible` vizibil pe tot ce e interactiv (outline `--accent`).
- Tinte touch >= 44px pe mobil.
- Icon-only obligatoriu cu `aria-label`; SVG decorativ `aria-hidden="true"`.
- Modale: `role="dialog"`, `aria-modal`, focus-trap, focus return pe trigger.
- `prefers-reduced-motion`: scurteaza/elimina tranzitiile non-esentiale.
---
## 7. Pentru planurile de design (cum se foloseste acest fisier)
Inainte de orice propunere vizuala:
1. Citeste acest fisier integral. Foloseste DOAR tokenii de aici (culoare, type, radius, spatiu).
2. Verifica fiecare ecran la 360 / 768 / 1024 / 1280px.
3. Aplica compactarile globale (wizard, butoane) si pe desktop, nu doar pe mobil.
4. Respecta „un singur primar per context" si scara de control.
5. Daca o propunere cere un token nou (culoare/marime/radius), justifica si adauga-l AICI
in acelasi PR — nu introduce valori ad-hoc in template.
Stadiul de implementare a regulilor responsive se urmareste in PRD-ul activ
(`docs/prd/prd-5.13-responsive-compact.md`) si in `docs/ROADMAP.md`.

112
docs/landing-page-prompt.md Normal file
View File

@@ -0,0 +1,112 @@
# Prompt landing page — claude.ai/design
Referinta pentru generarea unui mockup de landing page comerciala (homepage public) a
produsului. De copiat ca atare in claude.ai/design. Tokenii de culoare/tipografie/control sunt
preluati din `design.md` (sursa de adevar a sistemului de design) — orice modificare la paleta
sau scara de control se face intai acolo, apoi se reflecta aici.
---
```
Creează un mockup pentru o LANDING PAGE comercială (homepage public) a unui produs SaaS B2B românesc.
== PRODUS ==
Nume: Gateway RAR AUTOPASS (de la ROMFAST).
Ce face: un serviciu web prin care atelierele service-auto din România își declară automat
prestațiile la sistemul RAR AUTOPASS — o obligație legală (Legea 142/2023, OM 210/2024).
Înlocuiește raportarea manuală. Publicul țintă: administratori și recepționeri de service auto,
oameni ne-tehnici, ocupați. Mesajul cheie: "Conformitate RAR fără bătaie de cap — încarci un
fișier sau conectezi softul de service, noi trimitem la RAR în siguranță."
Tonul: serios, de încredere, instituțional dar modern. NU startup glumeț, NU emoji.
Limba: română (cu diacritice în text vizibil). Fără emoji nicăieri.
== OBIECTIV PAGINĂ ==
Convinge un service auto că produsul rezolvă obligația legală RAR simplu și sigur, și să se
înregistreze. Acțiune dominantă: "Creează cont" / "Autentificare".
Cârlig comercial principal: GRATUIT pentru service-urile mici, până la 100 de prezentări pe lună.
== SISTEM DE CULORI (folosește exact aceste valori — temă întunecată ca implicit) ==
Fundal pagină: #0f1218
Suprafață card: #181c24
Text principal: #e6e9ef
Text secundar: #8b93a7
Borduri/linii: #262b36
Accent (CTA/link): #2E74D6 (azur ROMFAST)
Succes: #2FBF8F
Atenție: #E0A93B
Eroare: #E05D5D
Oferă și o variantă pe temă LIGHT: fundal #f5f7fa, card #ffffff, text #1a1d24,
text secundar #5c6473, linii #e2e5ea, accent #1F66C9.
== TIPOGRAFIE ==
Font UI: IBM Plex Sans (weights 400/500/700).
Font mono (pentru coduri, VIN, ID-uri tehnice dacă apar): IBM Plex Mono.
Titlu hero mare și greu (700, letter-spacing ușor negativ). Corp 1516px, secundar 1213px.
== STIL CONTROALE ==
Butoane: radius 6px, padding ~10px 18px, înălțime ~44px.
- Primar: fundal #2E74D6, text alb, hover ușor mai luminos.
- Secundar: transparent, text deschis, bordură #262b36.
Carduri: fundal #181c24, bordură 1px #262b36, radius 10px, plat (umbră doar la elemente plutitoare).
Pill/badge: radius 99px. Iconițe simple liniare, monocrome, NU ilustrații colorate gen 3D.
Aspect plat, sobru, mult spațiu de respirație. Densitate compactă dar aerisită.
== STRUCTURĂ PAGINĂ (de sus în jos) ==
1. HEADER fix: stânga logo "ROMFAST" (text wordmark, accent azur pe "FAST"); dreapta două
butoane: "Autentificare" (secundar) + "Creează cont" (primar). Comutator temă opțional.
2. HERO: titlu puternic (ex. "Declară prestațiile la RAR AUTOPASS, automat"), subtitlu de o frază
despre conformitate legală fără efort. Sub titlu, un badge/pill verde vizibil:
"Gratuit până la 100 de prezentări pe lună". Apoi un buton primar mare "Creează cont gratuit"
+ un buton secundar "Vezi cum funcționează". Sub butoane, o linie mică de încredere
(ex. "Conform Legii 142/2023 · datele tale criptate · fără card bancar"). În dreapta hero:
un mockup vizual al dashboardului aplicației (un card cu o listă de "Trimiteri" cu pill-uri de
stare colorate: Trimis=verde, În coadă=albastru, Eroare=roșu).
3. BANDĂ DE ÎNCREDERE: "Construit de ROMFAST" + mențiune că înlocuiește integrarea/raportarea manuală.
4. SECȚIUNE "Cum funcționează" — 3 pași cu iconițe liniare:
(1) Încarci fișierul (xlsx/csv) sau conectezi softul de service prin API,
(2) Verifici și mapezi coloanele o singură dată (le ținem minte pentru data viitoare),
(3) Trimitem automat la RAR, tu urmărești starea live.
5. SECȚIUNE BENEFICII — de ce merită această interfață (6 carduri scurte, fiecare cu iconiță liniară):
- "Zero raportare manuală" — încarci un fișier, gata; nu mai introduci prezentări una câte una în portalul RAR.
- "Mapare reținută" — potrivești coloanele o singură dată per format; fișierele următoare se completează singure.
- "Anti-duplicat" — verificare automată ca aceeași prezentare să nu ajungă de două ori la RAR (RAR nu permite anulare).
- "Validare înainte de trimitere" — erorile (VIN, cod prestație, kilometraj) sunt prinse și explicate înainte să meargă la RAR.
- "Date criptate (GDPR)" — datele cu caracter personal sunt criptate și șterse automat după perioada legală.
- "Stare live" — vezi în timp real ce s-a trimis, ce e în coadă și ce trebuie corectat.
6. SECȚIUNE INTEGRARE API (pentru service-uri cu soft propriu / ROAAUTO):
Card mai mare, pe două coloane. Stânga: titlu "Ai deja un soft de service? Conectează-l direct."
text scurt despre faptul că nu mai e nevoie de export manual — softul tău trimite prezentările
automat printr-un singur apel API, cu cheie API per cont. Dreapta: un mic bloc de cod cu font
mono (IBM Plex Mono) care arată un exemplu de request, ex.:
POST /v1/prezentari
Authorization: rfak_••••••••
{ "vin": "...", "cod_prestatie": "...", "odometru": ... }
Sub el un link/buton secundar "Vezi documentația API".
7. SECȚIUNE PREȚ — simplă, 2 planuri unul lângă altul:
- "Gratuit": "0 lei/lună · până la 100 de prezentări/lună · import web + API · toate funcțiile de bază".
Marcat ca recomandat pentru service-uri mici. Buton primar "Începe gratuit".
- "Volum mare": "Pentru service-uri cu peste 100 de prezentări/lună · contactează-ne".
Buton secundar "Contact".
Accent pe ideea că pentru un service mic nu costă nimic, fără card bancar la înscriere.
8. CTA FINAL: card mare centrat "Începe să declari la RAR în câteva minute" + buton primar "Creează cont gratuit".
9. FOOTER: linkuri (Termeni, Confidențialitate/GDPR, Documentație API, Contact), © ROMFAST, mențiune legală.
== RESPONSIVE ==
Arată DOUĂ artboard-uri: desktop (1280px) și mobil (375px). Pe mobil: o coloană, butoanele CTA
full-width, header colapsat, cardurile de preț stivuite vertical. Ținte de atins minim 44px pe mobil.
== ACCESIBILITATE ==
Contrast text minim 4.5:1. Starea comunicată prin text + culoare (nu doar culoare) — pill-urile de
stare au etichetă text, nu doar pastilă colorată.
Livrează mockup-ul în tema întunecată ca varianta principală, plus un preview al heroului pe tema light.
```

View File

@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PRD 5.15 — mockup-uri piese lipsa (D6 strip / E4 picker / odo reveal)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;}
/* grafit (default dark) — aceleasi tokenuri ca landing.html */
:root{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--warn:#E0A93B;}
body{margin:0;padding:32px;background:#0b0e13;font-family:'IBM Plex Sans',system-ui,sans-serif;-webkit-font-smoothing:antialiased;color:var(--text);}
h1{font:700 22px 'IBM Plex Sans';margin:0 0 4px;}
.pgsub{font:400 13px 'IBM Plex Sans';color:var(--sub);margin:0 0 28px;}
.seclabel{font:500 13px 'IBM Plex Sans';color:var(--sub);letter-spacing:.04em;text-transform:uppercase;margin:34px 0 14px;border-top:1px solid var(--line);padding-top:18px;}
.frames{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;}
.frlabel{font:500 12px 'IBM Plex Sans';color:var(--sub);margin-bottom:10px;}
/* componente slim */
.counter{flex:1;background:var(--card2);border:1px solid var(--line);border-radius:8px;padding:10px 12px;}
.cnum{font:700 22px 'IBM Plex Sans';line-height:1;}
.clabel{font:400 11px 'IBM Plex Sans';color:var(--sub);margin-top:5px;}
.csub{font:400 10px 'IBM Plex Mono';color:var(--sub);margin-top:3px;}
.row{display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2);}
.vin{font:500 13px 'IBM Plex Mono';color:var(--text);}
.meta{font:400 11px 'IBM Plex Sans';color:var(--sub);margin-top:3px;}
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;font:500 12px 'IBM Plex Sans';}
.dot{width:6px;height:6px;border-radius:99px;}
.lab{font:400 11px 'IBM Plex Sans';color:var(--sub);margin-bottom:4px;}
.fld{height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);font:400 12px 'IBM Plex Mono';color:var(--text);}
.fldsans{font-family:'IBM Plex Sans';}
.chip{display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent) 18%,transparent);color:var(--accent);font:600 11px 'IBM Plex Mono';}
.chipx{opacity:.7;cursor:pointer;}
.chipbox{min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:var(--card2);}
.addcode{display:inline-flex;align-items:center;height:22px;padding:0 7px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;color:var(--accent);font:500 10px 'IBM Plex Sans';cursor:pointer;}
.oprow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);margin-bottom:8px;}
.opname{font:500 12px 'IBM Plex Sans';color:var(--text);}
.picker{height:26px;display:inline-flex;align-items:center;gap:6px;padding:0 8px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);font:500 11px 'IBM Plex Sans';cursor:pointer;}
.saverule{font:400 10px 'IBM Plex Sans';color:var(--okt);margin-top:3px;display:inline-flex;align-items:center;gap:4px;}
.btn{margin-top:6px;height:34px;padding:0 16px;border-radius:6px;background:var(--accent);border:none;color:#fff;font:600 12px 'IBM Plex Sans';cursor:pointer;align-self:flex-start;}
.form{display:flex;flex-direction:column;gap:11px;padding:18px;}
</style>
</head>
<body>
<h1>PRD 5.15 — mockup-uri pentru piesele fara design</h1>
<p class="pgsub">Tema grafit (dark), tokenuri identice cu landing.html. Trei piese pe care mockup-urile existente nu le acopera: stripul de sanatate D6, pickerul prestatii E4 (op&harr;cod), si reveal-ul odometru initial.</p>
<!-- ===================== D6 STRIP SANATATE ===================== -->
<div class="seclabel">1 · D6 — strip sanatate mereu-vizibil (deasupra contoarelor)</div>
<div class="frames">
<div>
<div class="frlabel">Stare BLOCAT (rosu) — declaratiile NU pleaca</div>
<div style="width:600px;background:var(--bg);border:1px solid var(--line);border-radius:12px;overflow:hidden;">
<div style="padding:14px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-radius:8px;background:color-mix(in srgb,var(--errt) 16%,var(--card));border:1px solid color-mix(in srgb,var(--errt) 40%,transparent);margin-bottom:14px;">
<div style="display:flex;align-items:center;gap:9px;">
<span aria-hidden="true" style="font:700 15px 'IBM Plex Sans';color:var(--errt);">&#10007;</span>
<span style="font:700 13px 'IBM Plex Sans';color:var(--text);">Blocat: worker oprit — declaratiile NU pleaca</span>
</div>
<span style="font:400 11px 'IBM Plex Mono';color:var(--sub);white-space:nowrap;">Ultima autentificare RAR: azi 08:14</span>
</div>
<div style="margin-bottom:14px;">
<div style="font:700 17px 'IBM Plex Sans';color:var(--text);">Trimiteri RAR AUTOPASS</div>
<div style="font:400 12px 'IBM Plex Sans';color:var(--sub);margin-top:2px;">Service Auto Valcea · 28 iun 2026</div>
</div>
<div style="display:flex;gap:12px;margin-bottom:14px;">
<div class="counter"><div class="cnum" style="color:var(--text);">847</div><div class="clabel">Trimise (total)</div><div class="csub">luna 124 · azi 9</div></div>
<div class="counter"><div class="cnum" style="color:var(--accent);">12</div><div class="clabel">In coada</div></div>
<div class="counter"><div class="cnum" style="color:var(--errt);">2</div><div class="clabel">De corectat</div></div>
</div>
<div>
<div class="row"><div><div class="vin">WBA8E9...K7F2</div><div class="meta">Inspectie tehnica · 09:42</div></div><span class="pill" style="background:color-mix(in srgb,var(--okt) 13%,transparent);color:var(--okt);"><span class="dot" style="background:var(--okt);"></span>Trimis</span></div>
<div class="row"><div><div class="vin">WVWZZZ...3M1</div><div class="meta">Revizie periodica · 09:38</div></div><span class="pill" style="background:color-mix(in srgb,var(--accent) 14%,transparent);color:var(--infot);"><span class="dot" style="background:var(--accent);"></span>In coada</span></div>
<div class="row" style="border-bottom:none;"><div><div class="vin">VF1RFB...A88</div><div class="meta">Sistem franare · 09:31</div></div><span class="pill" style="background:color-mix(in srgb,var(--errt) 14%,transparent);color:var(--errt);"><span class="dot" style="background:var(--errt);"></span>De corectat</span></div>
</div>
</div>
</div>
</div>
<div>
<div class="frlabel">Stare OK (verde) — declaratiile curg</div>
<div style="width:600px;background:var(--bg);border:1px solid var(--line);border-radius:12px;overflow:hidden;">
<div style="padding:14px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-radius:8px;background:color-mix(in srgb,var(--okt) 13%,transparent);border:1px solid color-mix(in srgb,var(--okt) 30%,transparent);margin-bottom:14px;">
<div style="display:flex;align-items:center;gap:9px;">
<span aria-hidden="true" style="font:700 15px 'IBM Plex Sans';color:var(--okt);">&#10003;</span>
<span style="font:600 13px 'IBM Plex Sans';color:var(--text);">Declaratiile curg normal</span>
</div>
<span style="font:400 11px 'IBM Plex Mono';color:var(--sub);white-space:nowrap;">Ultima autentificare RAR: azi 09:12</span>
</div>
<div style="margin-bottom:14px;">
<div style="font:700 17px 'IBM Plex Sans';color:var(--text);">Trimiteri RAR AUTOPASS</div>
<div style="font:400 12px 'IBM Plex Sans';color:var(--sub);margin-top:2px;">Service Auto Valcea · 28 iun 2026</div>
</div>
<div style="display:flex;gap:12px;margin-bottom:14px;">
<div class="counter"><div class="cnum" style="color:var(--text);">847</div><div class="clabel">Trimise (total)</div><div class="csub">luna 124 · azi 9</div></div>
<div class="counter"><div class="cnum" style="color:var(--accent);">3</div><div class="clabel">In coada</div></div>
<div class="counter"><div class="cnum" style="color:var(--sub);">0</div><div class="clabel">De corectat</div></div>
</div>
<div>
<div class="row"><div><div class="vin">WBA8E9...K7F2</div><div class="meta">Inspectie tehnica · 09:42</div></div><span class="pill" style="background:color-mix(in srgb,var(--okt) 13%,transparent);color:var(--okt);"><span class="dot" style="background:var(--okt);"></span>Trimis</span></div>
<div class="row" style="border-bottom:none;"><div><div class="vin">ZAR937...C04</div><div class="meta">Schimb ulei · 09:24</div></div><span class="pill" style="background:color-mix(in srgb,var(--okt) 13%,transparent);color:var(--okt);"><span class="dot" style="background:var(--okt);"></span>Trimis</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- ===================== E4 PICKER PRESTATII ===================== -->
<div class="seclabel">2 · E4 — formular editare slim: VIN unic + Observatii + picker prestatii PE operatie</div>
<div class="frames">
<div>
<div class="frlabel">Editare trimitere (needs_data)</div>
<div style="width:640px;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;">
<div class="form">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:2px;">
<span style="font:500 12px 'IBM Plex Mono';color:var(--sub);">corecteaza · needs_data</span>
<span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,var(--errt) 14%,transparent);color:var(--errt);font:600 11px 'IBM Plex Sans';">Date incomplete</span>
</div>
<div><div class="lab">VIN (serie sasiu)</div><div class="fld">U1234567890123456</div></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:11px;">
<div><div class="lab">Data prestatiei</div><div class="fld">2026-06-22</div></div>
<div><div class="lab">Numar inmatriculare</div><div class="fld">CT88NOE</div></div>
</div>
<div><div class="lab">Observatii (operatiile efectuate)</div><div class="fld fldsans" style="height:auto;min-height:48px;align-items:flex-start;padding:8px 10px;">Revizie; schimbare placute frana</div></div>
<div>
<div class="lab">Prestatii — cod RAR pe fiecare operatie</div>
<div class="oprow">
<span class="opname">REVIZIE PERIODICA</span>
<span style="display:flex;align-items:center;gap:8px;">
<span class="chip"><span class="chipx">&times;</span>REV2</span>
<span class="picker">+ alt cod</span>
</span>
</div>
<div class="saverule">&#10003; salveaza regula REVIZIE PERIODICA &rarr; REV2 (auto-rezolva data viitoare)</div>
<div class="oprow" style="border-color:color-mix(in srgb,var(--warn) 45%,var(--line));margin-top:10px;">
<span class="opname">SCHIMBARE PLACUTE FRANA <span style="color:var(--warn);font:500 10px 'IBM Plex Sans';">· lipsa cod</span></span>
<span class="picker" style="border-style:solid;border-color:var(--warn);color:var(--warn);">alege cod RAR &#9662;</span>
</div>
<div style="font:400 10px 'IBM Plex Sans';color:var(--sub);margin-top:8px;">Fara operatie (corectie pura): chip-uri de coduri libere &middot; dedupare per-pereche (op,cod), nu doar dupa cod.</div>
</div>
<button class="btn">Salveaza si retrimite</button>
</div>
</div>
</div>
</div>
<!-- ===================== ODO REVEAL ===================== -->
<div class="seclabel">3 · Reveal odometru initial — apare doar la coduri R-ODO / I-ODO (server-driven, E6)</div>
<div class="frames">
<div>
<div class="frlabel">Inainte · niciun R-ODO → odometru initial ascuns</div>
<div style="width:480px;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;">
<div class="form">
<div><div class="lab">Prestatii</div>
<div class="chipbox"><span class="chip"><span class="chipx">&times;</span>REV2</span><span class="addcode">+ cod</span></div>
</div>
<div><div class="lab">Odometru final</div><div class="fld">39000</div></div>
<div style="font:400 10px 'IBM Plex Sans';color:var(--sub);font-style:italic;">Odometru initial se cere doar pentru coduri R-ODO / I-ODO.</div>
<button class="btn">Salveaza</button>
</div>
</div>
</div>
<div>
<div class="frlabel">Dupa · adaugi R-ODO → campul apare</div>
<div style="width:480px;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;">
<div class="form">
<div><div class="lab">Prestatii</div>
<div class="chipbox">
<span class="chip"><span class="chipx">&times;</span>REV2</span>
<span class="chip" style="background:color-mix(in srgb,var(--warn) 22%,transparent);color:var(--warn);"><span class="chipx">&times;</span>R-ODO</span>
<span class="addcode">+ cod</span>
</div>
</div>
<div><div class="lab">Odometru final</div><div class="fld">39000</div></div>
<div style="border-left:2px solid var(--warn);padding-left:10px;margin-left:-12px;">
<div class="lab" style="color:var(--warn);">Odometru initial · necesar pentru R-ODO</div>
<div class="fld" style="border-color:color-mix(in srgb,var(--warn) 50%,var(--line));">12500</div>
</div>
<button class="btn">Salveaza</button>
</div>
</div>
</div>
</div>
</body>
</html>

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,876 @@
" ";"DENOP";"NR"
"51";"DIAGNOZA";"809"
"160";"EFECTUAT REVIZIE PERIODICA MICA";"279"
"159";"EFECTUAT REVIZIE PERIODICA";"214"
"152";"EFECTUARE REVIZIE PERIODICA-MARE";"48"
"57";"DIAGNOZA SISTEM AD-BLUE SI SCR";"36"
"156";"EFECTUAT REVIZIE MICA";"34"
"153";"EFECTUARE REVIZIE PERIODICA-MICA";"33"
"625";"INLOCUIT SONDA ADBLUE";"29"
"801";"REVIZIE ULEI + FILTRE";"28"
"791";"REVIZIE MICA PERIODICA";"28"
"792";"REVIZIE MICA PROGRAMATA";"27"
"805";"SCHIMB ULEI + FILTRE";"24"
"155";"EFECTUAT REVIZIE MARE";"24"
"598";"INLOCUIT SENZOR NOX 1";"23"
"769";"REPARAT SCARA SPATE STG.";"23"
"768";"REPARAT SCARA SPATE DRT.";"23"
"149";"DTC CONTROL";"22"
"164";"E-PDI";"20"
"681";"P.D.I.";"20"
"599";"INLOCUIT SENZOR NOX 2";"18"
"527";"INLOCUIT POMPA ADBLUE";"18"
"789";"REVIZIE MARE PROGRAMATA";"17"
"524";"INLOCUIT PLACUTE FRANA FATA";"17"
"49";"DEZANSAMBLAT ARC AXA SPATE";"15"
"168";"FSA 202322 - ECU UPDATE";"15"
"545";"INLOCUIT PTO";"13"
"525";"INLOCUIT PLACUTE FRANA SPATE";"13"
"171";"FSA 202520-F3V17 BCM UPDATE";"13"
"788";"REVIZIE MARE";"11"
"827";"VERIFICARE FILTRU ULEI MOTOR";"11"
"439";"INLOCUIT KIT AMBREIAJ";"11"
"453";"INLOCUIT LAMPA GABARIT CABINA DRT.";"10"
"794";"REVIZIE PERIODICA ISUZU";"10"
"253";"INLOCUIT BECURI LAMPI SPATE";"10"
"705";"REGENERARE FORTATA";"10"
"103";"D/R CV DE PE AUTO";"9"
"173";"FSA202511A-F3V17 UPDATE BCM";"9"
"704";"REGENERARE";"9"
"661";"INLOCUIT VALVA RAMPA COMBUSTIBIL";"9"
"817";"UPDATE BCM";"9"
"435";"INLOCUIT INJECTOR ADBLUE";"9"
"798";"REVIZIE PROGRAMATA FILTRE + ULEI";"8"
"835";"VERIFICARE PIERDERI ULEI MOTOR";"8"
"31";"DEMONTAT ARC SPATE";"8"
"151";"EFECTUARE REVIZIE PERIODICA";"8"
"454";"INLOCUIT LAMPA GABARIT CABINA STG.";"8"
"819";"UPDATE ECM";"7"
"515";"INLOCUIT PLACA RELEE MARE";"7"
"409";"INLOCUIT FOAIE DE ARC SPATE";"7"
"410";"INLOCUIT FOAIE 1 ARC SPATE";"7"
"70";"D/R ARC SPATE";"7"
"440";"INLOCUIT KIT AMBREIAJ + VOLANTA";"7"
"203";"INLOCUIT ALTERNATOR";"7"
"441";"INLOCUIT KIT AMBREIAJ, VOLANTA + RULMENT VOLANTA";"7"
"799";"REVIZIE PROGRAMATA TRAKKER";"6"
"743";"REPARAT CABLAJ LAMPI SPATE";"6"
"597";"INLOCUIT SENZOR NOX UPSTREAM DOC 1";"6"
"218";"INLOCUIT ARC FATA FORD";"6"
"55";"DIAGNOZA RESETARE INTERVAL";"6"
"618";"INLOCUIT SIMERING ARBORE SPATE";"6"
"832";"VERIFICARE MECANICA GENERALA";"5"
"273";"INLOCUIT BUJII MOTOR";"5"
"72";"D/R BARA FATA";"5"
"75";"D/R BARA PROTECTIE FATA";"5"
"543";"INLOCUIT PROIECTOR LUCRU DRT.";"5"
"752";"REPARAT CV LA BANC";"5"
"455";"INLOCUIT LAMPA LATERALA GABARIT";"5"
"186";"INDREPTAT SUPORTI + MONTAT ARIPA DRT. PUNTE MOTRICA";"5"
"170";"FSA 202511A - F3V17 BCMUPDATE";"5"
"464";"INLOCUIT LAMPA SPATE STG";"5"
"800";"REVIZIE PROGRAMATA ULEI + FILTRE";"5"
"634";"INLOCUIT SUPAPA RETUR POMPA ADBLUE";"5"
"330";"INLOCUIT CONDUCTA A/C";"5"
"178";"GOLIT REZERVOR COMBUSTIBIL";"4"
"862";"VERIFICAT SI INCARCAT INSTALATIE CU A/C";"4"
"100";"D/R CUTIE DE VITEZE";"4"
"234";"INLOCUIT BASCULA DR FATA";"4"
"169";"FSA 202413";"4"
"588";"INLOCUIT SENZOR ABS SPATE DR";"4"
"611";"INLOCUIT SENZORI PLACUTE FATA";"4"
"462";"INLOCUIT LAMPA SPATE DR";"4"
"839";"VERIFICARE SI INCARCARE INSTALATIE A/C";"4"
"375";"INLOCUIT DISCURI FRINA SPATE";"4"
"607";"INLOCUIT SENZOR TEMPERATURA CATALIZATOR";"4"
"829";"VERIFICARE INSTALATIE A/C + INCARCARE CU FREON";"4"
"828";"VERIFICARE GENERALA";"4"
"552";"INLOCUIT RADIATOR RACIRE MOTOR";"4"
"25";"CURATAT REZERVOR ADBLUE";"4"
"402";"INLOCUIT FILTRU EPURATOR";"4"
"857";"VERIFICAT INSTALATIE AC + INCARCAT FREON";"4"
"654";"INLOCUIT TURBOSUFLANTA";"4"
"683";"PIAGGIO SCHIMB ULEI + FILTRE";"4"
"821";"UPDATE TAQ 766187";"4"
"78";"D/R BORD AUTO";"4"
"530";"INLOCUIT POMPA APA";"4"
"813";"TELESERVICII PENTRU LIMITARE";"4"
"617";"INLOCUIT SIMERING ARBORE FATA";"4"
"824";"VERIF SI INCARCAT AC";"4"
"71";"D/R ARIPA FATA STG";"4"
"595";"INLOCUIT SENZOR NOX DUPA DPF (ULTIMUL)";"3"
"495";"INLOCUIT OGLINDA PIETON";"3"
"139";"D/R ROTI AXA 2FATA STG/DR";"3"
"521";"INLOCUIT PLACUTE FRANA";"3"
"756";"REPARAT INSTALATIE ELECTRICA LAMPI SPATE";"3"
"501";"INLOCUIT ORNAMENT ARIPA FATA";"3"
"468";"INLOCUIT LEVIER COMANDA + CAPETE DE BARA TRANSV";"3"
"549";"INLOCUIT RADIATOR AEROTERMA";"3"
"467";"INLOCUIT LAMPI SPATE";"3"
"596";"INLOCUIT SENZOR NOX INAINTE DE DPF";"3"
"179";"GRESAT";"3"
"489";"INLOCUIT OCHELAR PROIECTOR DRT.";"3"
"478";"INLOCUIT MANETA SCHIMBATOR VITEZE";"3"
"484";"INLOCUIT MONITOR + CAMERA";"3"
"673";"MATERIALE ELECTRICE";"3"
"290";"INLOCUIT CAMERA SPATE";"3"
"575";"INLOCUIT RULMENTI INTERMEDIARI CARDAN";"3"
"573";"INLOCUIT RULMENTI AXA SPATE";"3"
"698";"REFACUT CABLAJ ELECTRIC USA STG";"3"
"675";"MONTAT ARC AXA SPATE";"3"
"581";"INLOCUIT SABOTI FRANA MANA";"3"
"331";"INLOCUIT CONDUCTA AC";"3"
"343";"INLOCUIT CONTACT PEDALA FRANA";"3"
"670";"MANOPERA";"3"
"666";"INLOCUIT VOLANTA MOTOR";"3"
"662";"INLOCUIT VAS EXPANSIUNE";"3"
"741";"REPARAT CABLAJ ELECTRIC LA SENZORUL DE PLACUTE STG FATA";"3"
"610";"INLOCUIT SENZORI NOX 1 SI 2";"3"
"394";"INLOCUIT FILTRU ADBLUE";"3"
"184";"INCARCAT INST A/C + VERIFICAT";"3"
"187";"INDREPTAT SUPORTI + MONTAT ARIPA STG. PUNTE MOTRICA";"3"
"373";"INLOCUIT DISCURI FRANA STG + DR SPATE";"3"
"715";"REMEDIAT CABLAJ PANOU COMANDA SUPRASTRUCTURA";"3"
"710";"REGLAT FARURUI";"3"
"249";"INLOCUIT BECURI";"3"
"222";"INLOCUIT ARIPA + ORNAMENT DRT. PUNTE FATA";"3"
"233";"INLOCUIT BARA FATA STG";"3"
"235";"INLOCUIT BASCULA INFERIOARA FATA STG";"3"
"132";"D/R REZERVOR COMBUSTIBIL";"3"
"131";"D/R REZERVOR ADBLUE";"3"
"779";"REPARATII ELECTRICE";"3"
"822";"UPDATE TAS 766161";"3"
"101";"D/R CUTIE VITEZA SI INLOCUIT BOLT SELECTOR";"3"
"4";"AERISIT INSTALATIE RACIRE";"3"
"122";"D/R MOTOR SI ACCESORII MOTOR";"3"
"87";"D/R CARDAN";"3"
"806";"SERVICE SCHIMB ULEI";"3"
"107";"D/R FATA DE USA STANGA";"3"
"16";"CURATAT INSTALATIE ALIMENTARE COMBUSTIBIL";"3"
"796";"REVIZIE PERIODICA OTOKAR";"3"
"288";"INLOCUIT CABLURI TIMONERIE";"2"
"560";"INLOCUIT ROLA AC+CUREA";"2"
"264";"INLOCUIT BUCSI BARA TORSIUINE FATA STG";"2"
"443";"INLOCUIT KIT CUREA DISTRIBUTIE";"2"
"699";"REFACUT CABLAJ LAMPA SPATE STG";"2"
"671";"MANOPERA ADITIONALA";"2"
"424";"INLOCUIT GARNITURI DPF";"2"
"433";"INLOCUIT IMPULSOR CV";"2"
"83";"D/R CAPAC OGLINDA INF STG";"2"
"569";"INLOCUIT RULMENT ROATA FATA STG";"2"
"267";"INLOCUIT BUCSI CABINA FATA";"2"
"562";"INLOCUIT ROLE + CUREA";"2"
"434";"INLOCUIT INJECTOARE";"2"
"81";"D/R CADRU FATA AUTO";"2"
"456";"INLOCUIT LAMPA NR DR";"2"
"457";"INLOCUIT LAMPA NR STG";"2"
"714";"REMEDIAT CABLAJ ELECTRIC LAMPI SPATE";"2"
"859";"VERIFICAT INSTALATIE COMPACTARE";"2"
"713";"REMEDIAT CABLAJ ELECTRIC";"2"
"863";"VERIFICAT SI REPARAT INSTALATIE GIDRAULICA SUPRASTRUCTURA";"2"
"723";"REMEDIAT PIERDERI AER PE INSTALATIA DE FRANARE";"2"
"861";"VERIFICAT INSTALATIE ELECTRICA SISTEM EVACUARE";"2"
"803";"REVIZIE 40 000KM";"2"
"550";"INLOCUIT RADIATOR APA";"2"
"858";"VERIFICAT INSTALATIE ALIMENTARE CU AD-BLUE";"2"
"447";"INLOCUIT KIT SINCROANE REDUCTOR CV";"2"
"448";"INLOCUIT KIT SINCROANE VIT. 1- 2";"2"
"558";"INLOCUIT REZERVOR COMBUSTIBIL";"2"
"255";"INLOCUIT BOLTURI ETRIER SPATE";"2"
"444";"INLOCUIT KIT DISTRIBUTIE + POMPA DE APA";"2"
"856";"VERIFICAT INSTALATIE A/C";"2"
"808";"SERVICII VOPSITORIE";"2"
"554";"INLOCUIT REGULATOR PRESIUNE AER";"2"
"90";"D/R CHIULASA + GARNITURA";"2"
"450";"INLOCUIT LAMPA CABINA STG";"2"
"291";"INLOCUIT CAMERA VIDEO";"2"
"345";"INLOCUIT CRABOTI SELECTOR CV";"2"
"344";"INLOCUIT CONTACTOR PEDALA FRANA";"2"
"346";"INLOCUIT CRUCE CARDAN";"2"
"627";"INLOCUIT SONDA LITROMETRICA";"2"
"605";"INLOCUIT SENZOR PTO";"2"
"338";"INLOCUIT CONDUCTA RACIRE Y";"2"
"600";"INLOCUIT SENZOR PLACUTE";"2"
"601";"INLOCUIT SENZOR PM";"2"
"342";"INLOCUIT CONDUCTE RACIRE";"2"
"602";"INLOCUIT SENZOR PRESIUNE";"2"
"358";"INLOCUIT CUZINETI ARBORE COTIT";"2"
"372";"INLOCUIT DISCURI FRANA FATA";"2"
"360";"INLOCUIT CUZINETI BIELE";"2"
"368";"INLOCUIT DISCURI + ROTI AXA SPATE";"2"
"369";"INLOCUIT DISCURI + SET PLACUTE FRANA SPATE";"2"
"44";"DEMONTAT/MONTAT CV";"2"
"58";"DIAGNOZA SISTEM SCR";"2"
"623";"INLOCUIT SIMERING SI INEL RT/SP/DR";"2"
"374";"INLOCUIT DISCURI FRINA FATA";"2"
"376";"INLOCUIT DISCURI SI PLACUTE FRANA FATA";"2"
"633";"INLOCUIT SUPAPA MODUL ADBLUE";"2"
"303";"INLOCUIT CAPETE TIRANTI";"2"
"664";"INLOCUIT VASCOCUPLAJ VENTILATOR";"2"
"658";"INLOCUIT ULEI MOTOR + FILTRU";"2"
"651";"INLOCUIT TERMOSTATE SISTEM DE RACIRE";"2"
"407";"INLOCUIT FILTRU ULEI HIDRAULIC";"2"
"294";"INLOCUIT CAP BARA DR";"2"
"77";"D/R BARA TORSIUNE STG";"2"
"295";"INLOCUIT CAP BARA STG";"2"
"665";"INLOCUIT VENTILATOR AEROTERMA";"2"
"76";"D/R BARA TORSIUNE DR";"2"
"594";"INLOCUIT SENZOR NIVEL LICHID RACIRE OTOKAR";"2"
"325";"INLOCUIT COMPRESOR AC";"2"
"390";"INLOCUIT FAR FAZA SCURTA STG.";"2"
"332";"INLOCUIT CONDUCTA CU SENZOR INCALZIRE ADBLUE";"2"
"39";"DEMONTAT SCAUN SOFER SI INLOCUIT PERNA SEZUT";"2"
"399";"INLOCUIT FILTRU DE PARTICULE";"2"
"403";"INLOCUIT FILTRU HIDRAULIC";"2"
"397";"INLOCUIT FILTRU COMBUSTIBIL";"2"
"591";"INLOCUIT SENZOR FILTRU COMBUSTIBIL + FILTRU";"2"
"396";"INLOCUIT FILTRU AER";"2"
"158";"EFECTUAT REVIZIE M2";"2"
"194";"INLOCUIRE GARNITURA CULBUTORI";"2"
"748";"REPARAT CABLAJ SUPRASTRUCTURA";"2"
"127";"D/R PRAG USA DR";"2"
"165";"FACTURA TRACTARE ANB0093/07.04.2026";"2"
"502";"INLOCUIT ORNAMENT ARIPA STG. PUNTE FATA";"2"
"534";"INLOCUIT POMPA DE ULEI";"2"
"157";"EFECTUAT REVIZIE MOTOR AUXILIAR";"2"
"129";"D/R RADIATOARE + CONDENSOARE";"2"
"507";"INLOCUIT PINION VIT 1";"2"
"104";"D/R DIFERENTIAL AXA SPATE";"2"
"536";"INLOCUIT POMPA ULEI";"2"
"868";"VF. + INLOCUIT MONITOR";"2"
"746";"REPARAT CABLAJ SENZORI PLACUTE FRANA";"2"
"745";"REPARAT CABLAJ MOTOR";"2"
"167";"FSA 202415B1";"2"
"172";"FSA202420-INLOCUIT FLANSA + BOLT CARDAN";"2"
"8";"COMPLETARE FREON + VERIFICARE INST A/C";"2"
"786";"REVIZIE EO-MPE00000";"2"
"870";"VF. SI INCARCAT SISTEM AC CU FREON";"2"
"176";"GOLIT INSTALATIE RACIRE MOT.";"2"
"174";"GOLIT INSTALATIE A/C";"2"
"121";"D/R MOTOR DE PE AUTO";"2"
"757";"REPARAT INSTALATIE ELECTRICA SUPRASTRUCTURA";"2"
"784";"REVIZIE ULEI + FILTRE";"2"
"797";"REVIZIE PERIODICA ULEI SI FILTRE";"2"
"869";"VF. + REMEDIAT I.E. ILUMINAT EXTERIOR";"2"
"490";"INLOCUIT OCHELAR PROIECTOR STG.";"2"
"492";"INLOCUIT OGLINDA BORDURA";"2"
"499";"INLOCUIT OGLINDA STG ELECTRICA";"2"
"531";"INLOCUIT POMPA APA MOTOR";"2"
"224";"INLOCUIT ARIPA FATA STG";"2"
"219";"INLOCUIT ARC SPATE";"2"
"475";"INLOCUIT MACARA STG";"2"
"512";"INLOCUIT PIVOTI INF + SUP (AMBELE PARTI)";"2"
"466";"INLOCUIT LAMPI LATERALE GABARIT";"2"
"514";"INLOCUIT PLACA RELEE";"2"
"14";"CURATAT INSTALATIA ADMISIE";"2"
"138";"D/R ROTI AXA FATA STG/DR";"2"
"469";"INLOCUIT LEVIER DE COMANDA";"2"
"98";"D/R CONDUCTE COMBUSTIBIL PARTIAL";"2"
"771";"REPARAT TOBA FINALA";"2"
"519";"INLOCUIT PLACUTE FR FATA";"2"
"133";"D/R RIGIDIZARE TREAPTA STG";"2"
"520";"INLOCUIT PLACUTE FR FATA + SPATE";"2"
"729";"REMEDIAT PIERDERI ULEI CILINDRU COMPACTARE STG.";"2"
"776";"REPARATIE GRUP DIFERENTIAL";"2"
"510";"INLOCUIT PIVOTI AXA FATA";"2"
"511";"INLOCUIT PIVOTI FATA STG + DR";"2"
"99";"D/R CUTIE DE VITEZA AUTO";"2"
"217";"INLOCUIT ARBORE MOTOR";"2"
"206";"INLOCUIT AMORTIZOARE AXA FATA";"2"
"609";"INLOCUIT SENZOR ULEI MOTOR";"1"
"517";"INLOCUIT PLACUTE AXA FATA";"1"
"586";"INLOCUIT SENZOR ABS DR SPATE";"1"
"587";"INLOCUIT SENZOR ABS FATA DR";"1"
"834";"VERIFICARE PIERDERI ULEI";"1"
"526";"INLOCUIT PLACUTE FRINA";"1"
"846";"VERIFICARI ELECTRICE SI UDT";"1"
"836";"VERIFICARE PRESIUNE INSTALATIE ADBLUE";"1"
"590";"INLOCUIT SENZOR AXA CAME";"1"
"845";"VERIFICARE ULEI GRUP DIFERENTIAL";"1"
"589";"INLOCUIT SENZOR AMONIAC";"1"
"518";"INLOCUIT PLACUTE AXA SPATE";"1"
"522";"INLOCUIT PLACUTE FRANA + AERISIT";"1"
"523";"INLOCUIT PLACUTE FRANA AXA FATA";"1"
"841";"VERIFICARE SISTEM ALIMENTARE";"1"
"840";"VERIFICARE SISTEM A/C";"1"
"604";"INLOCUIT SENZOR PRESIUNE ULEI";"1"
"874";"VOPSIT PRIZA AER STG";"1"
"606";"INLOCUIT SENZOR STANGA FATA";"1"
"838";"VERIFICARE SERVODIRECTIE";"1"
"603";"INLOCUIT SENZOR PRESIUNE DIFERENTIALA";"1"
"593";"INLOCUIT SENZOR LIFT PUBELE";"1"
"872";"VOPSIT BARA FATA STG";"1"
"592";"INLOCUIT SENZOR LICHID DE RACIRE";"1"
"844";"VERIFICARE ULEI CV";"1"
"608";"INLOCUIT SENZOR ULEI";"1"
"837";"VERIFICARE SENZORI UZURA SI ABS";"1"
"842";"VERIFICARE SISTEM ELECTRIC AD-BLUE";"1"
"873";"VOPSIT CAPOTA MOTOR";"1"
"875";"VOPSITORIE + MATERIALE VOPSITORIE";"1"
"843";"VERIFICARE SISTEM FRANARE";"1"
"585";"INLOCUIT SENZOR + CABLAJ NOX";"1"
"539";"INLOCUIT PRESOSTAT";"1"
"538";"INLOCUIT POMPITA STERGATOR";"1"
"537";"INLOCUIT POMPITA SPALATOR PARBRIZ";"1"
"867";"VERIFICAT UZURA CILINDRI SI BLOC MOTOR";"1"
"557";"INLOCUIT RELEU BUJII";"1"
"540";"INLOCUIT PREZOANE ROATA DR";"1"
"559";"INLOCUIT REZISTENTA AEROTERMA";"1"
"561";"INLOCUIT ROLA GHIDARE + CUREA TRANSMISIE";"1"
"852";"VERIFICAT INCARCARE ALTERNATOR";"1"
"563";"INLOCUIT RULMENT AMBREIAJ";"1"
"855";"VERIFICAT INSTALATI ELECTRICA A/C";"1"
"854";"VERIFICAT INJECTOARE";"1"
"853";"VERIFICAT/ INCARCAT INSTALATIE A/C";"1"
"541";"INLOCUIT PROIECTOARE";"1"
"860";"VERIFICAT INSTALATIE ELECTRICA SI INLOCUIT BUTON AVARIE";"1"
"553";"INLOCUIT RAMA PROIECTOR + BEC";"1"
"547";"INLOCUIT RACORD RACIRE EGR";"1"
"548";"INLOCUIT RADIATOR A/C";"1"
"864";"VERIFICAT SISTEM ALIMENTARE";"1"
"551";"INLOCUIT RADIATOR RACIRE";"1"
"546";"INLOCUIT RACORD FLEXIBIL EVACUARE";"1"
"555";"INLOCUIT REGULATOR PRESIUNE COMBUSTIBIL";"1"
"556";"INLOCUIT RELEE - 2 BUC";"1"
"542";"INLOCUIT PROIECTOR CEATA STG.";"1"
"865";"VERIFICAT SISTEM EVACUARE";"1"
"544";"INLOCUIT PROTECTII FOAIE DE ARC TRANSVERSALA";"1"
"866";"VERIFICAT SISTEM FRANARE";"1"
"564";"INLOCUIT RULMENT AXA SPATE";"1"
"580";"INLOCUIT SABOTI AXA SPATE";"1"
"529";"INLOCUIT POMPA AMOR";"1"
"849";"VERIFICAT BUJII + APRINDERE";"1"
"577";"INLOCUIT RULMENTI ROTI SPATE";"1"
"578";"INLOCUIT RULMRNT PRESIUNE AMBREIAJ";"1"
"579";"INLOCUIT RULMRNT PRIZA CV";"1"
"582";"INLOCUIT SABOTI SPATE";"1"
"847";"VERIFICAT + AERISIT SIST FRANARE";"1"
"871";"VF. SI REMEDIAT CABLAJ ELECTROMOTOR";"1"
"584";"INLOCUIT SEMNALIZARE DR PE OGLINDA";"1"
"528";"INLOCUIT POMPA AMBREIAJ";"1"
"583";"INLOCUIT SELECTOR VITEZE";"1"
"848";"VERIFICAT ADMISIE AER";"1"
"576";"INLOCUIT RULMENTI ROTI FATA AMBELE PARTI";"1"
"568";"INLOCUIT RULMENT ROATA FATA DR";"1"
"535";"INLOCUIT POMPA INALTA PRESIUNE";"1"
"570";"INLOCUIT RULMENT ROATA SPATE";"1"
"565";"INLOCUIT RULMENT BUTUC DR. SPATE";"1"
"566";"INLOCUIT RULMENT DIFERENTIAL";"1"
"567";"INLOCUIT RULMENT GRUP DIF.";"1"
"533";"INLOCUIT POMPA DE AMORSARE INST DE COMBUSTIBIL";"1"
"574";"INLOCUIT RULMENTI FATA (AMBELE PARTI)";"1"
"850";"VERIFICAT CABLAJ ELECTRIC";"1"
"532";"INLOCUIT POMPA DE AMBREIAJ";"1"
"571";"INLOCUIT RULMENT VOLANTA";"1"
"572";"INLOCUIT RULMENTI AXA FATA";"1"
"851";"VERIFICAT CONCENTRATIE + COMPLETAT ANTIGEL";"1"
"726";"REMEDIAT PIERDERI DE AER PE SISTEMUL DE FRANARE";"1"
"725";"REMEDIAT PIERDERI APA LA INSTALATIA DE SPALAT";"1"
"728";"REMEDIAT PIERDERI ULEI CILINDRU ACTIONARE LIFT STG.";"1"
"727";"REMEDIAT PIERDERI DE ULEI MOTOR";"1"
"724";"REMEDIAT PIERDERI ANTIGEL";"1"
"720";"REMEDIAT INSTALATIE ELECTRICA";"1"
"719";"REMEDIAT FCT. PROIECTOARE SPATE";"1"
"722";"REMEDIAT LUMINI MI";"1"
"721";"REMEDIAT INTRERUPERI CABLAJ MOTOR";"1"
"736";"REPARAT ARIPA SPATE STG";"1"
"735";"REPARAT ARC DR FATA";"1"
"738";"REPARAT BARA STG. FATA";"1"
"737";"REPARAT BARA DRT. FATA";"1"
"734";"REMEDIERE PRINDERE COMP AC";"1"
"731";"REMEDIAT PRINDERE COLIER TURBOSUFLANTA";"1"
"730";"REMEDIAT PIULITA GRUP DIFERENTIAL";"1"
"733";"REMEDIAT PRINDERI CONDUCTA A/C";"1"
"732";"REMEDIAT PRINDERI ACTIONARE LIFT SUPERIOARE STG. + DRT.";"1"
"718";"REMEDIAT DEFECTIUNI PORNIRE";"1"
"810";"SPALAT MOTOR CU SOLUTIE";"1"
"703";"REFACUT SUPORT TABLOU SIGURANTE";"1"
"706";"REGLARE FARURI";"1"
"809";"SPALAT INSTALATIE ALIMENTARE AD-BLUE";"1"
"702";"REFACUT INSTALATIE ELECTRICA SUPRASTRUCTURA";"1"
"700";"REFACUT CABLAJ SENZOR PLACUTE SPATE";"1"
"697";"REFACTURARE CURATARE DPF";"1"
"811";"TELESERVICII";"1"
"701";"REFACUT CABLAJ TABLOU CMD. HYDRO-MAK";"1"
"716";"REMEDIAT CABLAJ SENZOR MARSARIER";"1"
"807";"SERVICII TERTI FACTURA 30/14.02.2025 REPARATIE INJECTOARE";"1"
"804";"REVOPSIT USA STG FATA";"1"
"717";"REMEDIAT CONDUCTA EVACURE";"1"
"712";"REGLAT VOLAN";"1"
"708";"REGLAT CULBUTORI";"1"
"707";"REGLAT CABLURI FRANA";"1"
"711";"REGLAT FRANA MANA";"1"
"709";"REGLAT FARURI";"1"
"739";"REPARAT CABLAJ LAMPA SPATE";"1"
"782";"RESOFTARE";"1"
"765";"REPARAT PARABICICLISTI DR";"1"
"766";"REPARAT PRINDERE GIROFAR FATA";"1"
"781";"RESETAT INTERVAL REVIZIE";"1"
"764";"REPARAT PANOU COMANDA COMPACTOR";"1"
"762";"REPARAT MUFE ELECTRICE BOBINE";"1"
"785";"REVIZIE DIFERENTIAL";"1"
"763";"REPARAT ORNAMENT TOBA";"1"
"783";"REV 20000";"1"
"774";"REPARATIE BOLT SUPRASTRUCTURA COMPACTARE";"1"
"773";"REPARAT USA STG FATA";"1"
"777";"REPARATIE LONJERON STG";"1"
"775";"REPARATIE CABLAJ ELECTRIC";"1"
"772";"REPARAT TRAVERSA SPATE SASIU";"1"
"780";"REPROGRAMARE BODY COMPUTER";"1"
"767";"REPARAT SCARA DREAPTA";"1"
"770";"REPARAT SISTEM EVACUARE";"1"
"778";"REPARATIE LUMINI LAMPI SPATE";"1"
"761";"REPARAT MUFA ELECTRICA PE CABLAJ NOX";"1"
"750";"REPARAT CONDUCTA SISTEM PNEUMATIC SPATE";"1"
"749";"REPARAT CABLAJ TABLOU CMD.";"1"
"753";"REPARAT INST. ELECTRICA SENZOR PEDALA FR";"1"
"751";"REPARAT CUTIE VITEZE";"1"
"747";"REPARAT CABLAJ SI INLOCUIT LAMPA SPATE";"1"
"802";"REVIZIE ULEI +FILTRE";"1"
"740";"REPARAT CABLAJ COMUTATOR FTANA MANA";"1"
"744";"REPARAT CABLAJ LUMINI SPATE";"1"
"742";"REPARAT CABLAJ ELECTRIC SISTEM EVACUARE";"1"
"790";"REVIZIE MICA - PROGRAMATA";"1"
"759";"REPARAT LIFT SUPRASTRUCTURA";"1"
"760";"REPARAT MANETA COMENZI HIDRAULICE";"1"
"787";"REVIZIE FILTRE + ULEI";"1"
"758";"REPARAT INSTALATIE HIDRAULICA SUPRASTRUCTURA";"1"
"755";"REPARAT INSTALATIE ELECTRICA";"1"
"754";"REPARAT INSTALATIE ELECRICA ILUMINAT";"1"
"793";"REVIZIE MOTOR AUXILIAR";"1"
"795";"REVIZIE PERIODICA MARE";"1"
"696";"REFACTURARE ALEZAJ BLOC MOTOR";"1"
"638";"INLOCUIT SUPORT FAR STG";"1"
"637";"INLOCUIT SUPORT CAPOTA INFERIOR STG";"1"
"640";"INLOCUIT SUPORTI BARA SPATE STG + DR";"1"
"639";"INLOCUIT SUPORT INF. DR CAPOTA";"1"
"823";"UPDATE VBR 768840";"1"
"635";"INLOCUIT SUPAPA SISTEM ALIMENTARE RAMPA";"1"
"826";"VERIFICARE ELECTRICA";"1"
"825";"VERIFICARE AUTO/CONSTATARE";"1"
"636";"INLOCUIT SUPAPA SUPRASTRUCTURA";"1"
"647";"INLOCUIT TAMPOANE CABINA FATA";"1"
"646";"INLOCUIT TAMPOANE ARCURI SPATE";"1"
"649";"INLOCUIT TAMPOANE SUPERIOARE RIGIDIZARE FATA";"1"
"648";"INLOCUIT TAMPOANE INFERIOARE PUNTE FATA";"1"
"645";"INLOCUIT TAMBURI SPATE AXA 3";"1"
"642";"INLOCUIT SURUBURI FUZETA SPATE";"1"
"641";"INLOCUIT SURUB AMORTIZOR FATA STG/DR";"1"
"644";"INLOCUIT TAMBURI FRANA AXA 2 FATA";"1"
"643";"INLOCUIT SURUBURI VOLANTA";"1"
"632";"INLOCUIT SUPAPA GV";"1"
"831";"VERIFICARE INSTALATIE ELECTRA SISTEM AD-BLUE";"1"
"833";"VERIFICARE PIERDERI COMBUSTIBIL";"1"
"620";"INLOCUIT SIMERING BUTUC SPATE";"1"
"619";"INLOCUIT SIMERING AX CAME";"1"
"616";"INLOCUIT SIMERERING GRUP DIFERENTIAL";"1"
"613";"INLOCUIT SENZOT PTO";"1"
"612";"INLOCUIT SENZORI TEMPERATURA EVACUARE";"1"
"615";"INLOCUIT SIGURANTA 7.5 AH";"1"
"614";"INLOCUIT SERPENTINA ADBLUE";"1"
"629";"INLOCUIT STUT";"1"
"830";"VERIFICARE INSTALATIE ALIMENTARE CU AD-BLUE";"1"
"631";"INLOCUIT SUPAPA EGR";"1"
"630";"INLOCUIT SUPAPA CUTIE DE VITEZA";"1"
"628";"INLOCUIT SONDA REZERVOR";"1"
"622";"INLOCUIT SIMERING PALIER SPATE";"1"
"621";"INLOCUIT SIMERING FATA MOTOR";"1"
"626";"INLOCUIT SONDA LAMBDA";"1"
"624";"INLOCUIT SINE SCAUN SOFER";"1"
"650";"INLOCUIT TERMOFLOT";"1"
"685";"PREGATIRE ELEMENTE DIN PLASTIC";"1"
"684";"PIESE MARUNTE";"1"
"686";"PREGATIRE ELEMENTE METALICE";"1"
"812";"TELESERVICII IVECO INLOCUIRE INSTRUMENTE BORD";"1"
"814";"TELESERVICII- SOFT ALM";"1"
"679";"MONTAT PARABICICLISTI STG";"1"
"678";"MONTAT CONDUCTA ALIMENTARE PENTRU INCALZIRE AUXILIARA";"1"
"682";"PIAGGIO INLOCUIT DISCURI SI PLACUTE FRANA FATA";"1"
"680";"MONTAT PROIECTOARE FATA";"1"
"693";"RAMPA INJECTOARE DEMONTATA";"1"
"692";"PURJAT FILTRU MOTORINA";"1"
"695";"RECTIFICAT FILETE COMPRESOR A/C";"1"
"694";"RECTIFICAT FILET AXA DR SPATE";"1"
"691";"PROGRAMARE CHEIE";"1"
"688";"PRINDERE CONDUCTA EVACUARE PE SASIU";"1"
"687";"PREGATIRE PENTRU VOPSIRE";"1"
"690";"PROGRAMARE BODY COMPUTER";"1"
"689";"PRINDERE CONDUCTE A/C";"1"
"677";"MONTAT BUTUC ROATA SPATE DR";"1"
"660";"INLOCUIT USA STG FATA";"1"
"659";"INLOCUIT UNITATE CONTROL BUJII";"1"
"818";"UPDATE BCM 76618 ATAQ";"1"
"820";"UPDATE SOFT FSA 202418";"1"
"657";"INLOCUIT ULEI CV";"1"
"653";"INLOCUIT TURBINA MOTOR AUXILIAR";"1"
"652";"INLOCUIT TREAPTA CABINA ARIPA DR";"1"
"656";"INLOCUIT ULEI CUTIE VITEZE";"1"
"655";"INLOCUIT ULEI + FILTRU ULEI";"1"
"674";"MONTAT APARATOARE NOROI SPATE";"1"
"672";"MASURAT UZURA ARBORE SI INLOCUIT CUZINETI PALIER (6 +1 AXIAL)";"1"
"676";"MONTAT ARC AXA SPATE DR";"1"
"815";"TEST ACUMULATORI";"1"
"816";"TEST FRANARE";"1"
"667";"INLOCUIT 2 BIELE MOTOR";"1"
"663";"INLOCUIT VAS EXPANSIUNE CU SENZOR";"1"
"669";"LIMITARE VITEZA LA 110KM/H";"1"
"668";"LIMITARE AUTO 110 KM/H";"1"
"183";"INCARCAT INSTALATIE A/C CU FREON";"1"
"182";"INCARCARE INSTALATIE A/C";"1"
"185";"INDREPTAT CAPAC SPATE PARTEA DRT.";"1"
"189";"INL. INCHIZ CAPOTA DR";"1"
"188";"INL DUBLURA INTERIOARA LONGERON FATA STG";"1"
"181";"IINLOCUIT TERMOSTAT";"1"
"166";"FACTURA VOPSITORIE F1897";"1"
"163";"EFECTUAT TESTE + REGENERARE";"1"
"175";"GOLIT INSTALATIE RACIRE";"1"
"180";"GRESAT PUNCTE";"1"
"177";"GOLIT REZERVOR AD-BLUE";"1"
"198";"INLOCUIRE VENTILATOR AEROTERMA";"1"
"197";"INLOCUIRE RIGIDIZARE STALP EXT. INT. DR";"1"
"199";"INLOCUIT + REGLAT CABLURI TIMONERIE";"1"
"201";"INLOCUIT ACUMULATORI";"1"
"200";"INLOCUIT ACUMULATOR";"1"
"196";"INLOCUIRE PERNA AER PUNTE MOTRICA DRT. SPRE FATA";"1"
"191";"INLOCOUIT FAR FAZA LUNGA STG.";"1"
"190";"INL. INCHIZ CAPOTA STG";"1"
"192";"INLOCUIRE BARA FATA STG";"1"
"195";"INLOCUIRE PERNA AER PUNTE MOTRICA";"1"
"193";"INLOCUIRE BARA PROTECTIE FATA";"1"
"136";"D/R ROATA SPATE DR AXA 4";"1"
"135";"D/R ROATA DR SPATE";"1"
"137";"D/R ROTI ( AXA 1/2/3) SI VERIFICAT ELEMENTE DE FRANARE";"1"
"141";"D/R SCAUN SOFER";"1"
"140";"D/R SCARA USA STG";"1"
"134";"D/R ROATA";"1"
"125";"D/R POMPA DE APA";"1"
"124";"D/R PARASOC STG BARA FATA";"1"
"126";"D/R PRAG STG";"1"
"130";"D/R RADIATOR APA SI VERIFICAT ETANSEITATE";"1"
"128";"D/R PROIECTOR FATA DR";"1"
"150";"ECHIPAT ANEXE PE MOTOR";"1"
"148";"D/R TURBOSUFLANTA";"1"
"154";"EFECTUAT REGLAJ DIRECTIE";"1"
"162";"EFECTUAT TEST FRANARE";"1"
"161";"EFECTUAT REVIZIE PERIODICA-MICA";"1"
"147";"D/R TURBINA";"1"
"143";"D/R SI INLOCUIRE SEMNALIZATOR FAT DR";"1"
"142";"D/R SEMNALIZARE LATERALA DR";"1"
"144";"D/R SUPORT STG BARA FATA";"1"
"146";"D/R TREAPTA ARIPA DR CABINA";"1"
"145";"D/R SUSPENSIE FATA";"1"
"202";"INLOCUIT ALM";"1"
"242";"INLOCUIT BEC FAR ST + DR";"1"
"241";"INLOCUIT BEC FAR DR";"1"
"243";"INLOCUIT BEC FAZA SCURTA";"1"
"245";"INLOCUIT BEC POZITIE";"1"
"244";"INLOCUIT BEC LAMPA NR";"1"
"240";"INLOCUIT BCM";"1"
"236";"INLOCUIT BASCULA STG";"1"
"232";"INLOCUIT BARA FATA DR";"1"
"237";"INLOCUIT BASCULA STG FATA";"1"
"239";"INLOCUIT BASCULE FATA";"1"
"238";"INLOCUIT BASCULA SUPERIOARA FATA STG";"1"
"256";"INLOCUIT BORNA BATERIE MINUS";"1"
"254";"INLOCUIT BECURI PE LAMPILE GABARIT";"1"
"257";"INLOCUIT BRATE SUSPENSIE FATA";"1"
"259";"INLOCUIT BUCSI ARCURI FATA";"1"
"258";"INLOCUIT BROASCA USA FATA STG";"1"
"252";"INLOCUIT BECURI LAMPI GABARIT";"1"
"247";"INLOCUIT BEC STOP";"1"
"246";"INLOCUIT BEC SEMNALIZARE SI POZITIE";"1"
"248";"INLOCUIT BEC STOP FRANA";"1"
"251";"INLOCUIT BECURI FARURI FATA";"1"
"250";"INLOCUIT BECURI FARURI";"1"
"212";"INLOCUIT AMORTIZOR CABINA FATA STG/DR";"1"
"211";"INLOCUIT AMORTIZOR AXA FATA (AMBELE PARTI)";"1"
"213";"INLOCUIT AMORTIZOR DR FATA";"1"
"215";"INLOCUIT ANSAMBLU BASCULA FATA STG";"1"
"214";"INLOCUIT ANSAMBLU BASCULA FATA DR";"1"
"210";"INLOCUIT AMORTIZOARE SPATE";"1"
"205";"INLOCUIT AMORTIOARE FATA";"1"
"204";"INLOCUIT AMBREIAJ";"1"
"207";"INLOCUIT AMORTIZOARE CABINA FATA";"1"
"209";"INLOCUIT AMORTIZOARE FATA S + D";"1"
"208";"INLOCUIT AMORTIZOARE FATA";"1"
"228";"INLOCUIT BALAMALE USA STG FATA";"1"
"227";"INLOCUIT BALAMALE USA DR";"1"
"229";"INLOCUIT BARA DRT. FATA";"1"
"231";"INLOCUIT BARA FATA + SUPORTI BARA";"1"
"230";"INLOCUIT BARA FATA";"1"
"226";"INLOCUIT ARIPA ROATA FATA STG";"1"
"220";"INLOCUIT ARC SPATE STG";"1"
"216";"INLOCUIT ANSAMBLU USCATOR AER";"1"
"221";"INLOCUIT ARC SPATE(O PARTE)";"1"
"225";"INLOCUIT ARIPA ROATA FATA DR";"1"
"223";"INLOCUIT ARIPA + ORNAMENT STG. PUNTE FATA";"1"
"123";"D/R ORNAMENT STG GRILA";"1"
"36";"DEMONTAT FURCI CUPLARE CV";"1"
"35";"DEMONTAT COMPRESOR A/C";"1"
"37";"DEMONTAT REZERVOR COMBUSTIBIL";"1"
"40";"DEMONTAT SEGMENTI CILINDRU EXPULZIE";"1"
"38";"DEMONTAT ROTI AXA SPATE";"1"
"34";"DEMONTAT BORD SI INLOCUIT CEASURI";"1"
"29";"DEMONTAT ANEXE DE PE MOTOR";"1"
"28";"DEMNTAT BUTUC AXA SPATE DR";"1"
"30";"DEMONTAT ARC AXA SPATE STG";"1"
"33";"DEMONTAT BORD DREAPTA";"1"
"32";"DEMONTAT BORD AUTO";"1"
"50";"DEZANSAMBLAT ARC AXA SPATE STG";"1"
"48";"DESFACUT SI REPARAT CILINDRI COMPACTARE";"1"
"52";"DIAGNOZA NOX";"1"
"54";"DIAGNOZA PTO";"1"
"53";"DIAGNOZA: MARTOR CHECK ENGINE";"1"
"47";"DESCARCAT-INCARCAT INST A/C";"1"
"42";"DEMONTAT/MONTAT CAPAC BAIE ULEI";"1"
"41";"DEMONTAT SISTEM EVACUARE";"1"
"43";"DEMONTAT/MONTAT CILINDRU EXPULZIE";"1"
"46";"DEPRESAT RULMENTI";"1"
"45";"DEMONTAT/MONTAT CV PE AUTO";"1"
"10";"COMPLETAT CU FREON";"1"
"9";"COMPLETARE LICHID FRANA";"1"
"11";"COMPLETAT INSTALATIE FREON";"1"
"13";"CURATAT FILTRU DE PARTICULE";"1"
"12";"CURATAT BIELA SI INLOCUIT CUZINETI (6 BIELE)";"1"
"7";"APARATOARE ARIPA";"1"
"2";"AERISIT INSTALATIE FRANARE";"1"
"1";"AERISIT FRANE";"1"
"3";"AERISIT INSTALATIE HIDRAULICA";"1"
"6";"ANSAMBLAT BORD AUTO";"1"
"5";"AERISIT SISTEM HIDRAULIC AMBREIAJ";"1"
"23";"CURATAT PINI";"1"
"22";"CURATAT MUFE SI CABLAJE";"1"
"24";"CURATAT PISTOANE SI INLOCUIT SEGMENTI ( 6 PISTOANE)";"1"
"27";"DECONECTAT CONDUCTE COMBUSTIBIL";"1"
"26";"CURATAT SUPAPA DEBIT GAZE";"1"
"21";"CURATAT INTRECOOLER";"1"
"17";"CURATAT INSTALATIE DE AER";"1"
"15";"CURATAT INSTALATIE ADBLUE";"1"
"18";"CURATAT INSTALATIE DRENARE A/C";"1"
"20";"CURATAT INSTALATIE RACIRE";"1"
"19";"CURATAT INSTALATIE ELECTRICA PORNIRE";"1"
"56";"DIAGNOZA SI UPDATE AUTO";"1"
"105";"D/R DISC FRANA SI BUTUC ROATA DR SPATE";"1"
"102";"D/R CUTIE VITEZE";"1"
"106";"D/R EPURATOR GAZE SI INLOCUIT RACORD";"1"
"109";"D/R FILTRU DE PARTICULE SI INLOCUIT GARNITURI + COLIERE";"1"
"108";"D/R FATA DE USA STG";"1"
"97";"D/R CONDUCTA PRESIUNE POMPA SERVO";"1"
"93";"D/R CILINDRU DE EXPULZIE";"1"
"92";"D/R CILINDRI HIDRAULICI SPATE COMPACTOR";"1"
"94";"D/R CILINDRU EXPULZIE";"1"
"96";"D/R COMPONENTE CATALIZATOR";"1"
"95";"D/R COLTAR FATA DR";"1"
"117";"D/R LAMPA SEMNALIZARE DR";"1"
"116";"D/R KIT AMBREIAJ + VOLANTA";"1"
"118";"D/R MANER CAPOTA MOTOR";"1"
"120";"D/R MOTOR";"1"
"119";"D/R MODUL ALIMENTARE AD-BLUE";"1"
"115";"D/R JANTA FATA STG";"1"
"111";"D/R GEAM INF USA DR";"1"
"110";"D/R FILTRU PARTICULE";"1"
"112";"D/R GRILA RADIATOR";"1"
"114";"D/R JANTA FATA DR";"1"
"113";"D/R INJECTOARE";"1"
"66";"D/R + REPARAT ELECTROMOTOR";"1"
"65";"D/R RADIATOARE";"1"
"67";"D/R ANVELOPA FATA DR";"1"
"69";"D/R APARATOARE";"1"
"68";"D/R ANVELOPA FATA STG";"1"
"64";"D/M SI REPARAT CILINDRU LIFT";"1"
"60";"D/M FILTRU PARTICULE";"1"
"59";"D/M CHIULASA MOTOR SI INLOCUIT GARNITURA CHIULASA";"1"
"61";"D/M GRUP DIFERENTIAL";"1"
"63";"D/M SI REPARAT CILINDRU GHEARA";"1"
"62";"D/M SI REPARAT CILINDRU EXPULZIE";"1"
"86";"D/R CAPITONAJ USA DR SP\";"1"
"85";"D/R CAPITONAJ FATA DR CABINA";"1"
"88";"D/R CATALIZATOR X 2";"1"
"91";"D/R CHIULOASA MOTOR";"1"
"89";"D/R CAUTATOR CV";"1"
"84";"D/R CAPAC SUPERIOR OGLINDA EXT DR";"1"
"74";"D/R BARA FATA DR SI INLOCUIT";"1"
"73";"D/R BARA FATA DR";"1"
"79";"D/R BUTUC ROATA SPATE";"1"
"82";"D/R CADRU MOTOR FATA";"1"
"80";"D/R BUTUCI ROATA SPATE";"1"
"260";"INLOCUIT BUCSI ARCURI SPATE + BRIDE";"1"
"418";"INLOCUIT FURTUN RACIRE COMPRESOR";"1"
"417";"INLOCUIT FURTUN INTERCOOLER";"1"
"419";"INLOCUIT FURTUNE INSTALATIE HIDRAULICA";"1"
"421";"INLOCUIT FUZETA FATA DR";"1"
"420";"INLOCUIT FURTUNE RACIRE";"1"
"416";"INLOCUIT FURTUN INFERIOR RADIATOR APA";"1"
"412";"INLOCUIT FOAIE 2 ARC SPATE STG";"1"
"411";"INLOCUIT FOAIE 2 ARC SPATE DR";"1"
"413";"INLOCUIT FOAIE 3 ARC SPATE";"1"
"415";"INLOCUIT FURTUN COMPRESOR AER";"1"
"414";"INLOCUIT FULIE ALTERNATOR";"1"
"430";"INLOCUIT GEAM OGLINDA MICA DR";"1"
"429";"INLOCUIT GEAM OGLINDA MIC STG";"1"
"431";"INLOCUIT GIROFAR FATA";"1"
"436";"INLOCUIT INTINZATOR ALTERNATOR";"1"
"432";"INLOCUIT GRILA SUPERIOARA";"1"
"428";"INLOCUIT GARNITURI RACITOR DE ULEI MOTOR";"1"
"423";"INLOCUIT GARNITURA CHIULOASA";"1"
"422";"INLOCUIT GARNITURA CAPAC CUVA";"1"
"425";"INLOCUIT GARNITURI EGR";"1"
"427";"INLOCUIT GARNITURI FILTRU";"1"
"426";"INLOCUIT GARNITURI ETANSARE DPF";"1"
"386";"INLOCUIT ETRIER STG";"1"
"385";"INLOCUIT ETRIER FRANA DR SPATE";"1"
"387";"INLOCUIT FAR DR";"1"
"389";"INLOCUIT FAR DR COMPLET";"1"
"388";"INLOCUIT FAR DR + LAMPA POZITIE";"1"
"384";"INLOCUIT ELECTROVALVA";"1"
"380";"INLOCUIT DOZATOR ADBLUE";"1"
"379";"INLOCUIT DISTRIBUITOR LIFT";"1"
"381";"INLOCUIT ECU GEARBOX";"1"
"383";"INLOCUIT ECU ULEI MOTOR";"1"
"382";"INLOCUIT ECU PTO";"1"
"404";"INLOCUIT FILTRU POLEN";"1"
"401";"INLOCUIT FILTRU DPF";"1"
"405";"INLOCUIT FILTRU SCRUF";"1"
"408";"INLOCUIT FOAIE DE ARC FATA";"1"
"406";"INLOCUIT FILTRU ULEI";"1"
"400";"INLOCUIT FILTRU DE ULEI";"1"
"392";"INLOCUIT FERODOURI AXA 2 FATA STG/DR";"1"
"391";"INLOCUIT FAR STG";"1"
"393";"INLOCUIT FILTRE MOTORINA";"1"
"398";"INLOCUIT FILTRU DE AER MOTOR";"1"
"395";"INLOCUIT FILTRU ADBLUE+CAPAC FILTRU ADBLUE";"1"
"437";"INLOCUIT KIT ACCESORII";"1"
"491";"INLOCUIT OGLINDA STG";"1"
"488";"INLOCUIT NUCA SCHIMBATOR";"1"
"493";"INLOCUIT OGLINDA DR";"1"
"496";"INLOCUIT OGLINDA SI BUSON REZERVOR";"1"
"494";"INLOCUIT OGLINDA MICA STG.";"1"
"487";"INLOCUIT MOTOR ELECTRIC ACTIONARE LIFT";"1"
"482";"INLOCUIT MODUL DOZARE UPSTREAM CATALIZATOR";"1"
"481";"INLOCUIT MODUL - POMPA ADBLUE";"1"
"483";"INLOCUIT MODULATOR ABS/EBS";"1"
"486";"INLOCUIT MONITOR+CAMERA VIDE";"1"
"485";"INLOCUIT MONITOR LED";"1"
"508";"INLOCUIT PINION VIT2 + SINCROANE 1/2";"1"
"506";"INLOCUIT PINION CV VIT. 6";"1"
"509";"INLOCUIT PIVOTI";"1"
"516";"INLOCUIT PLACUTE FR";"1"
"513";"INLOCUIT PLACA DE COMANDA LIFT";"1"
"505";"INLOCUIT PINION CV VIT 2";"1"
"498";"INLOCUIT OGLINDA STG";"1"
"497";"INLOCUIT OGLINDA ST";"1"
"500";"INLOCUIT ORNAMENT ARIPA DRT. PUNTE FATA";"1"
"504";"INLOCUIT PERNA AER";"1"
"503";"INLOCUIT ORNAMENT PROIECTOR";"1"
"458";"INLOCUIT LAMPA NUMAR";"1"
"452";"INLOCUIT LAMPA DR PE CABINA";"1"
"459";"INLOCUIT LAMPA SEMNAL PE OGLINDA DREAPA";"1"
"461";"INLOCUIT LAMPA SEMNALIZARE STG PE OGLINDA";"1"
"460";"INLOCUIT LAMPA SEMNAL STANGA IN BARA";"1"
"451";"INLOCUIT LAMPA CORN STG SPATE";"1"
"442";"INLOCUIT KIT CUREA ACCESORII";"1"
"438";"INLOCUIT KIT AMBREAJ";"1"
"445";"INLOCUIT KIT GARNITURI CILINDRU EXPULZIE";"1"
"449";"INLOCUIT LAMPA CABINA DR";"1"
"446";"INLOCUIT KIT HIDRAULIC";"1"
"476";"INLOCUIT MANER USA DR FATA";"1"
"474";"INLOCUIT MACARA GEAM USA FATA STG";"1"
"477";"INLOCUIT MANER USA FATA STG";"1"
"480";"INLOCUIT MANSETE LA CILINDRU DE EXPULZIE";"1"
"479";"INLOCUIT MANETA SEMNALIZARE";"1"
"473";"INLOCUIT MACARA GEAM STG";"1"
"465";"INLOCUIT LAMPI";"1"
"463";"INLOCUIT LAMPA SPATE DRT.";"1"
"470";"INLOCUIT LICHID RACIRE";"1"
"472";"INLOCUIT MACARA GEAM DR FATA";"1"
"471";"INLOCUIT MACARA CU MOTORAS USA STG";"1"
"378";"INLOCUIT DISTRIBUITOR HIDRAULIC PRINCIPAL";"1"
"298";"INLOCUIT CAPETE BARA AXA VIRATOARE S +D";"1"
"297";"INLOCUIT CAPAC VIZITARE FAR STG SUPERIOR";"1"
"299";"INLOCUIT CAPETE BARA ST + DR";"1"
"301";"INLOCUIT CAPETE DE BARA LA CASETA DE DIRECTIE";"1"
"300";"INLOCUIT CAPETE BARA TRANSVERSALA S + D AXA 1";"1"
"296";"INLOCUIT CAPAC VIZITARE FAR STG INFERIOR";"1"
"287";"INLOCUIT CABLURI FRANA PARCARE";"1"
"286";"INLOCUIT CABLURI FRANA MANA";"1"
"289";"INLOCUIT CAMERA SI MONITOR";"1"
"293";"INLOCUIT CAP BARA DIRECTIE ST + DR";"1"
"292";"INLOCUIT CAP BARA (AMBELE PARTI)";"1"
"310";"INLOCUIT CAUTATOR CV";"1"
"309";"INLOCUIT CATALIZATOR";"1"
"311";"INLOCUIT CHIULASA COMPRESOR";"1"
"313";"INLOCUIT CILINDRU LATERAL";"1"
"312";"INLOCUIT CILINDRU AMBREIAJ";"1"
"308";"INLOCUIT CASETA DE DIRECTIE";"1"
"304";"INLOCUIT CAPOTA MOTOR";"1"
"302";"INLOCUIT CAPETE DE BARA S + D";"1"
"305";"INLOCUIT CARCASA OGLINDA DR";"1"
"307";"INLOCUIT CARDAN";"1"
"306";"INLOCUIT CARCASA OGLINDA STG.";"1"
"270";"INLOCUIT BUJIE INCANDESCENTA (4)";"1"
"269";"INLOCUIT BUCSI ETRIERI FATA";"1"
"271";"INLOCUIT BUJIE MOTOR 1 BUCATA";"1"
"274";"INLOCUIT BUJII 2 BUCATI";"1"
"272";"INLOCUIT BUJII";"1"
"268";"INLOCUIT BUCSI CABINA SPATE";"1"
"262";"INLOCUIT BUCSI BARA STABILIZATIOARE FATA";"1"
"261";"INLOCUIT BUCSI BARA STAB SPATE";"1"
"263";"INLOCUIT BUCSI BARA STABILIZATOARE FATA";"1"
"266";"INLOCUIT BUCSI BASCULA FATA STG + DR";"1"
"265";"INLOCUIT BUCSI BARA TORSIUNE FATA DR";"1"
"282";"INLOCUIT CABLU AMBREIAJ";"1"
"281";"INLOCUIT CABLU ACCELERATIE";"1"
"283";"INLOCUIT CABLU TIMONERIE";"1"
"285";"INLOCUIT CABLURI FRANA DE MANA";"1"
"284";"INLOCUIT CABLURI FRANA DE MANA";"1"
"280";"INLOCUIT CABLAJ POMPA ADBLUE";"1"
"276";"INLOCUIT BUTUC ROATA";"1"
"275";"INLOCUIT BUTUC FATA DREAPTA";"1"
"277";"INLOCUIT BUTUC ROATA FATA + RULMENT DR";"1"
"279";"INLOCUIT CABLAJ NOXE";"1"
"278";"INLOCUIT BUTUCI FATA CU RULMENTI";"1"
"314";"INLOCUIT CILINDRU RECEPTOR";"1"
"354";"INLOCUIT CUREA ALTERNATOR";"1"
"353";"INLOCUIT CUREA ALTERBATOR";"1"
"355";"INLOCUIT CUREA +ROLA TRANSMISIE";"1"
"357";"INLOCUIT CUREA TRANSMISIE";"1"
"356";"INLOCUIT CUREA SI ROLE";"1"
"352";"INLOCUIT CUREA A/C";"1"
"348";"INLOCUIT CRUCI CARDANICE";"1"
"347";"INLOCUIT CRUCE CRADAN";"1"
"349";"INLOCUIT CULISANTE ETRIER SPATE STG/DR";"1"
"351";"INLOCUIT CUREA + ROLA DISTRIBUTIE";"1"
"350";"INLOCUIT CUREA + ROLA AC";"1"
"367";"INLOCUIT DISCURI + PLACUTE FATA";"1"
"366";"INLOCUIT DISC SI PLACUTE AXA FATA";"1"
"370";"INLOCUIT DISCURI FATA";"1"
"377";"INLOCUIT DISTRIBUITOR COMPACTARE";"1"
"371";"INLOCUIT DISCURI FATA + PLACUTE";"1"
"365";"INLOCUIT DISC FRINA + PLACUTE SPATE";"1"
"361";"INLOCUIT DISC AMBREIAJ";"1"
"359";"INLOCUIT CUZINETI BIELA + 1 BIELA";"1"
"362";"INLOCUIT DISC FR SP (AMBELE)";"1"
"364";"INLOCUIT DISC FRANA(AMBELE)";"1"
"363";"INLOCUIT DISC FRANA FATA STG/DR";"1"
"322";"INLOCUIT COMPRESOR + CUREA";"1"
"321";"INLOCUIT COMANDA LIFT";"1"
"323";"INLOCUIT COMPRESOR A.C.";"1"
"326";"INLOCUIT COMPRESOR AER";"1"
"324";"INLOCUIT COMPRESOR A/C";"1"
"320";"INLOCUIT COLIERE TURBO";"1"
"316";"INLOCUIT CIRCUIT HIDRAULIC AMBREIAJ";"1"
"315";"INLOCUIT CILINDRU RECEPTOR AMBREIAJ";"1"
"317";"INLOCUIT CLAPETA ACCELERATIE";"1"
"319";"INLOCUIT COLIERE SI GARNITURI EVACUARE";"1"
"318";"INLOCUIT COLIERE FURTUN TURBINA";"1"
"337";"INLOCUIT CONDUCTA RACIRE EGR";"1"
"336";"INLOCUIT CONDUCTA RACIRE COMPRESOR/CHIULOASA";"1"
"339";"INLOCUIT CONDUCTA SISTEM INCALZIRE AD-BLUE";"1"
"341";"INLOCUIT CONDUCTE ALIMENTARE COMBUSTIBIL";"1"
"340";"INLOCUIT CONDUCTA ULEI TURBINA";"1"
"335";"INLOCUIT CONDUCTA POMPA RABATARE";"1"
"328";"INLOCUIT COMUTATOT STOP FRANA";"1"
"327";"INLOCUIT COMUTATOR MI";"1"
"329";"INLOCUIT CONDUCT RACIRE VAS - RADIATOR";"1"
"334";"INLOCUIT CONDUCTA INJECTOR";"1"
"333";"INLOCUIT CONDUCTA EGR";"1"
1 DENOP NR
2 51 DIAGNOZA 809
3 160 EFECTUAT REVIZIE PERIODICA MICA 279
4 159 EFECTUAT REVIZIE PERIODICA 214
5 152 EFECTUARE REVIZIE PERIODICA-MARE 48
6 57 DIAGNOZA SISTEM AD-BLUE SI SCR 36
7 156 EFECTUAT REVIZIE MICA 34
8 153 EFECTUARE REVIZIE PERIODICA-MICA 33
9 625 INLOCUIT SONDA ADBLUE 29
10 801 REVIZIE ULEI + FILTRE 28
11 791 REVIZIE MICA PERIODICA 28
12 792 REVIZIE MICA PROGRAMATA 27
13 805 SCHIMB ULEI + FILTRE 24
14 155 EFECTUAT REVIZIE MARE 24
15 598 INLOCUIT SENZOR NOX 1 23
16 769 REPARAT SCARA SPATE STG. 23
17 768 REPARAT SCARA SPATE DRT. 23
18 149 DTC CONTROL 22
19 164 E-PDI 20
20 681 P.D.I. 20
21 599 INLOCUIT SENZOR NOX 2 18
22 527 INLOCUIT POMPA ADBLUE 18
23 789 REVIZIE MARE PROGRAMATA 17
24 524 INLOCUIT PLACUTE FRANA FATA 17
25 49 DEZANSAMBLAT ARC AXA SPATE 15
26 168 FSA 202322 - ECU UPDATE 15
27 545 INLOCUIT PTO 13
28 525 INLOCUIT PLACUTE FRANA SPATE 13
29 171 FSA 202520-F3V17 BCM UPDATE 13
30 788 REVIZIE MARE 11
31 827 VERIFICARE FILTRU ULEI MOTOR 11
32 439 INLOCUIT KIT AMBREIAJ 11
33 453 INLOCUIT LAMPA GABARIT CABINA DRT. 10
34 794 REVIZIE PERIODICA ISUZU 10
35 253 INLOCUIT BECURI LAMPI SPATE 10
36 705 REGENERARE FORTATA 10
37 103 D/R CV DE PE AUTO 9
38 173 FSA202511A-F3V17 UPDATE BCM 9
39 704 REGENERARE 9
40 661 INLOCUIT VALVA RAMPA COMBUSTIBIL 9
41 817 UPDATE BCM 9
42 435 INLOCUIT INJECTOR ADBLUE 9
43 798 REVIZIE PROGRAMATA FILTRE + ULEI 8
44 835 VERIFICARE PIERDERI ULEI MOTOR 8
45 31 DEMONTAT ARC SPATE 8
46 151 EFECTUARE REVIZIE PERIODICA 8
47 454 INLOCUIT LAMPA GABARIT CABINA STG. 8
48 819 UPDATE ECM 7
49 515 INLOCUIT PLACA RELEE MARE 7
50 409 INLOCUIT FOAIE DE ARC SPATE 7
51 410 INLOCUIT FOAIE 1 ARC SPATE 7
52 70 D/R ARC SPATE 7
53 440 INLOCUIT KIT AMBREIAJ + VOLANTA 7
54 203 INLOCUIT ALTERNATOR 7
55 441 INLOCUIT KIT AMBREIAJ, VOLANTA + RULMENT VOLANTA 7
56 799 REVIZIE PROGRAMATA TRAKKER 6
57 743 REPARAT CABLAJ LAMPI SPATE 6
58 597 INLOCUIT SENZOR NOX UPSTREAM DOC 1 6
59 218 INLOCUIT ARC FATA FORD 6
60 55 DIAGNOZA RESETARE INTERVAL 6
61 618 INLOCUIT SIMERING ARBORE SPATE 6
62 832 VERIFICARE MECANICA GENERALA 5
63 273 INLOCUIT BUJII MOTOR 5
64 72 D/R BARA FATA 5
65 75 D/R BARA PROTECTIE FATA 5
66 543 INLOCUIT PROIECTOR LUCRU DRT. 5
67 752 REPARAT CV LA BANC 5
68 455 INLOCUIT LAMPA LATERALA GABARIT 5
69 186 INDREPTAT SUPORTI + MONTAT ARIPA DRT. PUNTE MOTRICA 5
70 170 FSA 202511A - F3V17 BCMUPDATE 5
71 464 INLOCUIT LAMPA SPATE STG 5
72 800 REVIZIE PROGRAMATA ULEI + FILTRE 5
73 634 INLOCUIT SUPAPA RETUR POMPA ADBLUE 5
74 330 INLOCUIT CONDUCTA A/C 5
75 178 GOLIT REZERVOR COMBUSTIBIL 4
76 862 VERIFICAT SI INCARCAT INSTALATIE CU A/C 4
77 100 D/R CUTIE DE VITEZE 4
78 234 INLOCUIT BASCULA DR FATA 4
79 169 FSA 202413 4
80 588 INLOCUIT SENZOR ABS SPATE DR 4
81 611 INLOCUIT SENZORI PLACUTE FATA 4
82 462 INLOCUIT LAMPA SPATE DR 4
83 839 VERIFICARE SI INCARCARE INSTALATIE A/C 4
84 375 INLOCUIT DISCURI FRINA SPATE 4
85 607 INLOCUIT SENZOR TEMPERATURA CATALIZATOR 4
86 829 VERIFICARE INSTALATIE A/C + INCARCARE CU FREON 4
87 828 VERIFICARE GENERALA 4
88 552 INLOCUIT RADIATOR RACIRE MOTOR 4
89 25 CURATAT REZERVOR ADBLUE 4
90 402 INLOCUIT FILTRU EPURATOR 4
91 857 VERIFICAT INSTALATIE AC + INCARCAT FREON 4
92 654 INLOCUIT TURBOSUFLANTA 4
93 683 PIAGGIO SCHIMB ULEI + FILTRE 4
94 821 UPDATE TAQ 766187 4
95 78 D/R BORD AUTO 4
96 530 INLOCUIT POMPA APA 4
97 813 TELESERVICII PENTRU LIMITARE 4
98 617 INLOCUIT SIMERING ARBORE FATA 4
99 824 VERIF SI INCARCAT AC 4
100 71 D/R ARIPA FATA STG 4
101 595 INLOCUIT SENZOR NOX DUPA DPF (ULTIMUL) 3
102 495 INLOCUIT OGLINDA PIETON 3
103 139 D/R ROTI AXA 2FATA STG/DR 3
104 521 INLOCUIT PLACUTE FRANA 3
105 756 REPARAT INSTALATIE ELECTRICA LAMPI SPATE 3
106 501 INLOCUIT ORNAMENT ARIPA FATA 3
107 468 INLOCUIT LEVIER COMANDA + CAPETE DE BARA TRANSV 3
108 549 INLOCUIT RADIATOR AEROTERMA 3
109 467 INLOCUIT LAMPI SPATE 3
110 596 INLOCUIT SENZOR NOX INAINTE DE DPF 3
111 179 GRESAT 3
112 489 INLOCUIT OCHELAR PROIECTOR DRT. 3
113 478 INLOCUIT MANETA SCHIMBATOR VITEZE 3
114 484 INLOCUIT MONITOR + CAMERA 3
115 673 MATERIALE ELECTRICE 3
116 290 INLOCUIT CAMERA SPATE 3
117 575 INLOCUIT RULMENTI INTERMEDIARI CARDAN 3
118 573 INLOCUIT RULMENTI AXA SPATE 3
119 698 REFACUT CABLAJ ELECTRIC USA STG 3
120 675 MONTAT ARC AXA SPATE 3
121 581 INLOCUIT SABOTI FRANA MANA 3
122 331 INLOCUIT CONDUCTA AC 3
123 343 INLOCUIT CONTACT PEDALA FRANA 3
124 670 MANOPERA 3
125 666 INLOCUIT VOLANTA MOTOR 3
126 662 INLOCUIT VAS EXPANSIUNE 3
127 741 REPARAT CABLAJ ELECTRIC LA SENZORUL DE PLACUTE STG FATA 3
128 610 INLOCUIT SENZORI NOX 1 SI 2 3
129 394 INLOCUIT FILTRU ADBLUE 3
130 184 INCARCAT INST A/C + VERIFICAT 3
131 187 INDREPTAT SUPORTI + MONTAT ARIPA STG. PUNTE MOTRICA 3
132 373 INLOCUIT DISCURI FRANA STG + DR SPATE 3
133 715 REMEDIAT CABLAJ PANOU COMANDA SUPRASTRUCTURA 3
134 710 REGLAT FARURUI 3
135 249 INLOCUIT BECURI 3
136 222 INLOCUIT ARIPA + ORNAMENT DRT. PUNTE FATA 3
137 233 INLOCUIT BARA FATA STG 3
138 235 INLOCUIT BASCULA INFERIOARA FATA STG 3
139 132 D/R REZERVOR COMBUSTIBIL 3
140 131 D/R REZERVOR ADBLUE 3
141 779 REPARATII ELECTRICE 3
142 822 UPDATE TAS 766161 3
143 101 D/R CUTIE VITEZA SI INLOCUIT BOLT SELECTOR 3
144 4 AERISIT INSTALATIE RACIRE 3
145 122 D/R MOTOR SI ACCESORII MOTOR 3
146 87 D/R CARDAN 3
147 806 SERVICE SCHIMB ULEI 3
148 107 D/R FATA DE USA STANGA 3
149 16 CURATAT INSTALATIE ALIMENTARE COMBUSTIBIL 3
150 796 REVIZIE PERIODICA OTOKAR 3
151 288 INLOCUIT CABLURI TIMONERIE 2
152 560 INLOCUIT ROLA AC+CUREA 2
153 264 INLOCUIT BUCSI BARA TORSIUINE FATA STG 2
154 443 INLOCUIT KIT CUREA DISTRIBUTIE 2
155 699 REFACUT CABLAJ LAMPA SPATE STG 2
156 671 MANOPERA ADITIONALA 2
157 424 INLOCUIT GARNITURI DPF 2
158 433 INLOCUIT IMPULSOR CV 2
159 83 D/R CAPAC OGLINDA INF STG 2
160 569 INLOCUIT RULMENT ROATA FATA STG 2
161 267 INLOCUIT BUCSI CABINA FATA 2
162 562 INLOCUIT ROLE + CUREA 2
163 434 INLOCUIT INJECTOARE 2
164 81 D/R CADRU FATA AUTO 2
165 456 INLOCUIT LAMPA NR DR 2
166 457 INLOCUIT LAMPA NR STG 2
167 714 REMEDIAT CABLAJ ELECTRIC LAMPI SPATE 2
168 859 VERIFICAT INSTALATIE COMPACTARE 2
169 713 REMEDIAT CABLAJ ELECTRIC 2
170 863 VERIFICAT SI REPARAT INSTALATIE GIDRAULICA SUPRASTRUCTURA 2
171 723 REMEDIAT PIERDERI AER PE INSTALATIA DE FRANARE 2
172 861 VERIFICAT INSTALATIE ELECTRICA SISTEM EVACUARE 2
173 803 REVIZIE 40 000KM 2
174 550 INLOCUIT RADIATOR APA 2
175 858 VERIFICAT INSTALATIE ALIMENTARE CU AD-BLUE 2
176 447 INLOCUIT KIT SINCROANE REDUCTOR CV 2
177 448 INLOCUIT KIT SINCROANE VIT. 1- 2 2
178 558 INLOCUIT REZERVOR COMBUSTIBIL 2
179 255 INLOCUIT BOLTURI ETRIER SPATE 2
180 444 INLOCUIT KIT DISTRIBUTIE + POMPA DE APA 2
181 856 VERIFICAT INSTALATIE A/C 2
182 808 SERVICII VOPSITORIE 2
183 554 INLOCUIT REGULATOR PRESIUNE AER 2
184 90 D/R CHIULASA + GARNITURA 2
185 450 INLOCUIT LAMPA CABINA STG 2
186 291 INLOCUIT CAMERA VIDEO 2
187 345 INLOCUIT CRABOTI SELECTOR CV 2
188 344 INLOCUIT CONTACTOR PEDALA FRANA 2
189 346 INLOCUIT CRUCE CARDAN 2
190 627 INLOCUIT SONDA LITROMETRICA 2
191 605 INLOCUIT SENZOR PTO 2
192 338 INLOCUIT CONDUCTA RACIRE Y 2
193 600 INLOCUIT SENZOR PLACUTE 2
194 601 INLOCUIT SENZOR PM 2
195 342 INLOCUIT CONDUCTE RACIRE 2
196 602 INLOCUIT SENZOR PRESIUNE 2
197 358 INLOCUIT CUZINETI ARBORE COTIT 2
198 372 INLOCUIT DISCURI FRANA FATA 2
199 360 INLOCUIT CUZINETI BIELE 2
200 368 INLOCUIT DISCURI + ROTI AXA SPATE 2
201 369 INLOCUIT DISCURI + SET PLACUTE FRANA SPATE 2
202 44 DEMONTAT/MONTAT CV 2
203 58 DIAGNOZA SISTEM SCR 2
204 623 INLOCUIT SIMERING SI INEL RT/SP/DR 2
205 374 INLOCUIT DISCURI FRINA FATA 2
206 376 INLOCUIT DISCURI SI PLACUTE FRANA FATA 2
207 633 INLOCUIT SUPAPA MODUL ADBLUE 2
208 303 INLOCUIT CAPETE TIRANTI 2
209 664 INLOCUIT VASCOCUPLAJ VENTILATOR 2
210 658 INLOCUIT ULEI MOTOR + FILTRU 2
211 651 INLOCUIT TERMOSTATE SISTEM DE RACIRE 2
212 407 INLOCUIT FILTRU ULEI HIDRAULIC 2
213 294 INLOCUIT CAP BARA DR 2
214 77 D/R BARA TORSIUNE STG 2
215 295 INLOCUIT CAP BARA STG 2
216 665 INLOCUIT VENTILATOR AEROTERMA 2
217 76 D/R BARA TORSIUNE DR 2
218 594 INLOCUIT SENZOR NIVEL LICHID RACIRE OTOKAR 2
219 325 INLOCUIT COMPRESOR AC 2
220 390 INLOCUIT FAR FAZA SCURTA STG. 2
221 332 INLOCUIT CONDUCTA CU SENZOR INCALZIRE ADBLUE 2
222 39 DEMONTAT SCAUN SOFER SI INLOCUIT PERNA SEZUT 2
223 399 INLOCUIT FILTRU DE PARTICULE 2
224 403 INLOCUIT FILTRU HIDRAULIC 2
225 397 INLOCUIT FILTRU COMBUSTIBIL 2
226 591 INLOCUIT SENZOR FILTRU COMBUSTIBIL + FILTRU 2
227 396 INLOCUIT FILTRU AER 2
228 158 EFECTUAT REVIZIE M2 2
229 194 INLOCUIRE GARNITURA CULBUTORI 2
230 748 REPARAT CABLAJ SUPRASTRUCTURA 2
231 127 D/R PRAG USA DR 2
232 165 FACTURA TRACTARE ANB0093/07.04.2026 2
233 502 INLOCUIT ORNAMENT ARIPA STG. PUNTE FATA 2
234 534 INLOCUIT POMPA DE ULEI 2
235 157 EFECTUAT REVIZIE MOTOR AUXILIAR 2
236 129 D/R RADIATOARE + CONDENSOARE 2
237 507 INLOCUIT PINION VIT 1 2
238 104 D/R DIFERENTIAL AXA SPATE 2
239 536 INLOCUIT POMPA ULEI 2
240 868 VF. + INLOCUIT MONITOR 2
241 746 REPARAT CABLAJ SENZORI PLACUTE FRANA 2
242 745 REPARAT CABLAJ MOTOR 2
243 167 FSA 202415B1 2
244 172 FSA202420-INLOCUIT FLANSA + BOLT CARDAN 2
245 8 COMPLETARE FREON + VERIFICARE INST A/C 2
246 786 REVIZIE EO-MPE00000 2
247 870 VF. SI INCARCAT SISTEM AC CU FREON 2
248 176 GOLIT INSTALATIE RACIRE MOT. 2
249 174 GOLIT INSTALATIE A/C 2
250 121 D/R MOTOR DE PE AUTO 2
251 757 REPARAT INSTALATIE ELECTRICA SUPRASTRUCTURA 2
252 784 REVIZIE ULEI + FILTRE 2
253 797 REVIZIE PERIODICA ULEI SI FILTRE 2
254 869 VF. + REMEDIAT I.E. ILUMINAT EXTERIOR 2
255 490 INLOCUIT OCHELAR PROIECTOR STG. 2
256 492 INLOCUIT OGLINDA BORDURA 2
257 499 INLOCUIT OGLINDA STG ELECTRICA 2
258 531 INLOCUIT POMPA APA MOTOR 2
259 224 INLOCUIT ARIPA FATA STG 2
260 219 INLOCUIT ARC SPATE 2
261 475 INLOCUIT MACARA STG 2
262 512 INLOCUIT PIVOTI INF + SUP (AMBELE PARTI) 2
263 466 INLOCUIT LAMPI LATERALE GABARIT 2
264 514 INLOCUIT PLACA RELEE 2
265 14 CURATAT INSTALATIA ADMISIE 2
266 138 D/R ROTI AXA FATA STG/DR 2
267 469 INLOCUIT LEVIER DE COMANDA 2
268 98 D/R CONDUCTE COMBUSTIBIL PARTIAL 2
269 771 REPARAT TOBA FINALA 2
270 519 INLOCUIT PLACUTE FR FATA 2
271 133 D/R RIGIDIZARE TREAPTA STG 2
272 520 INLOCUIT PLACUTE FR FATA + SPATE 2
273 729 REMEDIAT PIERDERI ULEI CILINDRU COMPACTARE STG. 2
274 776 REPARATIE GRUP DIFERENTIAL 2
275 510 INLOCUIT PIVOTI AXA FATA 2
276 511 INLOCUIT PIVOTI FATA STG + DR 2
277 99 D/R CUTIE DE VITEZA AUTO 2
278 217 INLOCUIT ARBORE MOTOR 2
279 206 INLOCUIT AMORTIZOARE AXA FATA 2
280 609 INLOCUIT SENZOR ULEI MOTOR 1
281 517 INLOCUIT PLACUTE AXA FATA 1
282 586 INLOCUIT SENZOR ABS DR SPATE 1
283 587 INLOCUIT SENZOR ABS FATA DR 1
284 834 VERIFICARE PIERDERI ULEI 1
285 526 INLOCUIT PLACUTE FRINA 1
286 846 VERIFICARI ELECTRICE SI UDT 1
287 836 VERIFICARE PRESIUNE INSTALATIE ADBLUE 1
288 590 INLOCUIT SENZOR AXA CAME 1
289 845 VERIFICARE ULEI GRUP DIFERENTIAL 1
290 589 INLOCUIT SENZOR AMONIAC 1
291 518 INLOCUIT PLACUTE AXA SPATE 1
292 522 INLOCUIT PLACUTE FRANA + AERISIT 1
293 523 INLOCUIT PLACUTE FRANA AXA FATA 1
294 841 VERIFICARE SISTEM ALIMENTARE 1
295 840 VERIFICARE SISTEM A/C 1
296 604 INLOCUIT SENZOR PRESIUNE ULEI 1
297 874 VOPSIT PRIZA AER STG 1
298 606 INLOCUIT SENZOR STANGA FATA 1
299 838 VERIFICARE SERVODIRECTIE 1
300 603 INLOCUIT SENZOR PRESIUNE DIFERENTIALA 1
301 593 INLOCUIT SENZOR LIFT PUBELE 1
302 872 VOPSIT BARA FATA STG 1
303 592 INLOCUIT SENZOR LICHID DE RACIRE 1
304 844 VERIFICARE ULEI CV 1
305 608 INLOCUIT SENZOR ULEI 1
306 837 VERIFICARE SENZORI UZURA SI ABS 1
307 842 VERIFICARE SISTEM ELECTRIC AD-BLUE 1
308 873 VOPSIT CAPOTA MOTOR 1
309 875 VOPSITORIE + MATERIALE VOPSITORIE 1
310 843 VERIFICARE SISTEM FRANARE 1
311 585 INLOCUIT SENZOR + CABLAJ NOX 1
312 539 INLOCUIT PRESOSTAT 1
313 538 INLOCUIT POMPITA STERGATOR 1
314 537 INLOCUIT POMPITA SPALATOR PARBRIZ 1
315 867 VERIFICAT UZURA CILINDRI SI BLOC MOTOR 1
316 557 INLOCUIT RELEU BUJII 1
317 540 INLOCUIT PREZOANE ROATA DR 1
318 559 INLOCUIT REZISTENTA AEROTERMA 1
319 561 INLOCUIT ROLA GHIDARE + CUREA TRANSMISIE 1
320 852 VERIFICAT INCARCARE ALTERNATOR 1
321 563 INLOCUIT RULMENT AMBREIAJ 1
322 855 VERIFICAT INSTALATI ELECTRICA A/C 1
323 854 VERIFICAT INJECTOARE 1
324 853 VERIFICAT/ INCARCAT INSTALATIE A/C 1
325 541 INLOCUIT PROIECTOARE 1
326 860 VERIFICAT INSTALATIE ELECTRICA SI INLOCUIT BUTON AVARIE 1
327 553 INLOCUIT RAMA PROIECTOR + BEC 1
328 547 INLOCUIT RACORD RACIRE EGR 1
329 548 INLOCUIT RADIATOR A/C 1
330 864 VERIFICAT SISTEM ALIMENTARE 1
331 551 INLOCUIT RADIATOR RACIRE 1
332 546 INLOCUIT RACORD FLEXIBIL EVACUARE 1
333 555 INLOCUIT REGULATOR PRESIUNE COMBUSTIBIL 1
334 556 INLOCUIT RELEE - 2 BUC 1
335 542 INLOCUIT PROIECTOR CEATA STG. 1
336 865 VERIFICAT SISTEM EVACUARE 1
337 544 INLOCUIT PROTECTII FOAIE DE ARC TRANSVERSALA 1
338 866 VERIFICAT SISTEM FRANARE 1
339 564 INLOCUIT RULMENT AXA SPATE 1
340 580 INLOCUIT SABOTI AXA SPATE 1
341 529 INLOCUIT POMPA AMOR 1
342 849 VERIFICAT BUJII + APRINDERE 1
343 577 INLOCUIT RULMENTI ROTI SPATE 1
344 578 INLOCUIT RULMRNT PRESIUNE AMBREIAJ 1
345 579 INLOCUIT RULMRNT PRIZA CV 1
346 582 INLOCUIT SABOTI SPATE 1
347 847 VERIFICAT + AERISIT SIST FRANARE 1
348 871 VF. SI REMEDIAT CABLAJ ELECTROMOTOR 1
349 584 INLOCUIT SEMNALIZARE DR PE OGLINDA 1
350 528 INLOCUIT POMPA AMBREIAJ 1
351 583 INLOCUIT SELECTOR VITEZE 1
352 848 VERIFICAT ADMISIE AER 1
353 576 INLOCUIT RULMENTI ROTI FATA AMBELE PARTI 1
354 568 INLOCUIT RULMENT ROATA FATA DR 1
355 535 INLOCUIT POMPA INALTA PRESIUNE 1
356 570 INLOCUIT RULMENT ROATA SPATE 1
357 565 INLOCUIT RULMENT BUTUC DR. SPATE 1
358 566 INLOCUIT RULMENT DIFERENTIAL 1
359 567 INLOCUIT RULMENT GRUP DIF. 1
360 533 INLOCUIT POMPA DE AMORSARE INST DE COMBUSTIBIL 1
361 574 INLOCUIT RULMENTI FATA (AMBELE PARTI) 1
362 850 VERIFICAT CABLAJ ELECTRIC 1
363 532 INLOCUIT POMPA DE AMBREIAJ 1
364 571 INLOCUIT RULMENT VOLANTA 1
365 572 INLOCUIT RULMENTI AXA FATA 1
366 851 VERIFICAT CONCENTRATIE + COMPLETAT ANTIGEL 1
367 726 REMEDIAT PIERDERI DE AER PE SISTEMUL DE FRANARE 1
368 725 REMEDIAT PIERDERI APA LA INSTALATIA DE SPALAT 1
369 728 REMEDIAT PIERDERI ULEI CILINDRU ACTIONARE LIFT STG. 1
370 727 REMEDIAT PIERDERI DE ULEI MOTOR 1
371 724 REMEDIAT PIERDERI ANTIGEL 1
372 720 REMEDIAT INSTALATIE ELECTRICA 1
373 719 REMEDIAT FCT. PROIECTOARE SPATE 1
374 722 REMEDIAT LUMINI MI 1
375 721 REMEDIAT INTRERUPERI CABLAJ MOTOR 1
376 736 REPARAT ARIPA SPATE STG 1
377 735 REPARAT ARC DR FATA 1
378 738 REPARAT BARA STG. FATA 1
379 737 REPARAT BARA DRT. FATA 1
380 734 REMEDIERE PRINDERE COMP AC 1
381 731 REMEDIAT PRINDERE COLIER TURBOSUFLANTA 1
382 730 REMEDIAT PIULITA GRUP DIFERENTIAL 1
383 733 REMEDIAT PRINDERI CONDUCTA A/C 1
384 732 REMEDIAT PRINDERI ACTIONARE LIFT SUPERIOARE STG. + DRT. 1
385 718 REMEDIAT DEFECTIUNI PORNIRE 1
386 810 SPALAT MOTOR CU SOLUTIE 1
387 703 REFACUT SUPORT TABLOU SIGURANTE 1
388 706 REGLARE FARURI 1
389 809 SPALAT INSTALATIE ALIMENTARE AD-BLUE 1
390 702 REFACUT INSTALATIE ELECTRICA SUPRASTRUCTURA 1
391 700 REFACUT CABLAJ SENZOR PLACUTE SPATE 1
392 697 REFACTURARE CURATARE DPF 1
393 811 TELESERVICII 1
394 701 REFACUT CABLAJ TABLOU CMD. HYDRO-MAK 1
395 716 REMEDIAT CABLAJ SENZOR MARSARIER 1
396 807 SERVICII TERTI FACTURA 30/14.02.2025 REPARATIE INJECTOARE 1
397 804 REVOPSIT USA STG FATA 1
398 717 REMEDIAT CONDUCTA EVACURE 1
399 712 REGLAT VOLAN 1
400 708 REGLAT CULBUTORI 1
401 707 REGLAT CABLURI FRANA 1
402 711 REGLAT FRANA MANA 1
403 709 REGLAT FARURI 1
404 739 REPARAT CABLAJ LAMPA SPATE 1
405 782 RESOFTARE 1
406 765 REPARAT PARABICICLISTI DR 1
407 766 REPARAT PRINDERE GIROFAR FATA 1
408 781 RESETAT INTERVAL REVIZIE 1
409 764 REPARAT PANOU COMANDA COMPACTOR 1
410 762 REPARAT MUFE ELECTRICE BOBINE 1
411 785 REVIZIE DIFERENTIAL 1
412 763 REPARAT ORNAMENT TOBA 1
413 783 REV 20000 1
414 774 REPARATIE BOLT SUPRASTRUCTURA COMPACTARE 1
415 773 REPARAT USA STG FATA 1
416 777 REPARATIE LONJERON STG 1
417 775 REPARATIE CABLAJ ELECTRIC 1
418 772 REPARAT TRAVERSA SPATE SASIU 1
419 780 REPROGRAMARE BODY COMPUTER 1
420 767 REPARAT SCARA DREAPTA 1
421 770 REPARAT SISTEM EVACUARE 1
422 778 REPARATIE LUMINI LAMPI SPATE 1
423 761 REPARAT MUFA ELECTRICA PE CABLAJ NOX 1
424 750 REPARAT CONDUCTA SISTEM PNEUMATIC SPATE 1
425 749 REPARAT CABLAJ TABLOU CMD. 1
426 753 REPARAT INST. ELECTRICA SENZOR PEDALA FR 1
427 751 REPARAT CUTIE VITEZE 1
428 747 REPARAT CABLAJ SI INLOCUIT LAMPA SPATE 1
429 802 REVIZIE ULEI +FILTRE 1
430 740 REPARAT CABLAJ COMUTATOR FTANA MANA 1
431 744 REPARAT CABLAJ LUMINI SPATE 1
432 742 REPARAT CABLAJ ELECTRIC SISTEM EVACUARE 1
433 790 REVIZIE MICA - PROGRAMATA 1
434 759 REPARAT LIFT SUPRASTRUCTURA 1
435 760 REPARAT MANETA COMENZI HIDRAULICE 1
436 787 REVIZIE FILTRE + ULEI 1
437 758 REPARAT INSTALATIE HIDRAULICA SUPRASTRUCTURA 1
438 755 REPARAT INSTALATIE ELECTRICA 1
439 754 REPARAT INSTALATIE ELECRICA ILUMINAT 1
440 793 REVIZIE MOTOR AUXILIAR 1
441 795 REVIZIE PERIODICA MARE 1
442 696 REFACTURARE ALEZAJ BLOC MOTOR 1
443 638 INLOCUIT SUPORT FAR STG 1
444 637 INLOCUIT SUPORT CAPOTA INFERIOR STG 1
445 640 INLOCUIT SUPORTI BARA SPATE STG + DR 1
446 639 INLOCUIT SUPORT INF. DR CAPOTA 1
447 823 UPDATE VBR 768840 1
448 635 INLOCUIT SUPAPA SISTEM ALIMENTARE RAMPA 1
449 826 VERIFICARE ELECTRICA 1
450 825 VERIFICARE AUTO/CONSTATARE 1
451 636 INLOCUIT SUPAPA SUPRASTRUCTURA 1
452 647 INLOCUIT TAMPOANE CABINA FATA 1
453 646 INLOCUIT TAMPOANE ARCURI SPATE 1
454 649 INLOCUIT TAMPOANE SUPERIOARE RIGIDIZARE FATA 1
455 648 INLOCUIT TAMPOANE INFERIOARE PUNTE FATA 1
456 645 INLOCUIT TAMBURI SPATE AXA 3 1
457 642 INLOCUIT SURUBURI FUZETA SPATE 1
458 641 INLOCUIT SURUB AMORTIZOR FATA STG/DR 1
459 644 INLOCUIT TAMBURI FRANA AXA 2 FATA 1
460 643 INLOCUIT SURUBURI VOLANTA 1
461 632 INLOCUIT SUPAPA GV 1
462 831 VERIFICARE INSTALATIE ELECTRA SISTEM AD-BLUE 1
463 833 VERIFICARE PIERDERI COMBUSTIBIL 1
464 620 INLOCUIT SIMERING BUTUC SPATE 1
465 619 INLOCUIT SIMERING AX CAME 1
466 616 INLOCUIT SIMERERING GRUP DIFERENTIAL 1
467 613 INLOCUIT SENZOT PTO 1
468 612 INLOCUIT SENZORI TEMPERATURA EVACUARE 1
469 615 INLOCUIT SIGURANTA 7.5 AH 1
470 614 INLOCUIT SERPENTINA ADBLUE 1
471 629 INLOCUIT STUT 1
472 830 VERIFICARE INSTALATIE ALIMENTARE CU AD-BLUE 1
473 631 INLOCUIT SUPAPA EGR 1
474 630 INLOCUIT SUPAPA CUTIE DE VITEZA 1
475 628 INLOCUIT SONDA REZERVOR 1
476 622 INLOCUIT SIMERING PALIER SPATE 1
477 621 INLOCUIT SIMERING FATA MOTOR 1
478 626 INLOCUIT SONDA LAMBDA 1
479 624 INLOCUIT SINE SCAUN SOFER 1
480 650 INLOCUIT TERMOFLOT 1
481 685 PREGATIRE ELEMENTE DIN PLASTIC 1
482 684 PIESE MARUNTE 1
483 686 PREGATIRE ELEMENTE METALICE 1
484 812 TELESERVICII IVECO INLOCUIRE INSTRUMENTE BORD 1
485 814 TELESERVICII- SOFT ALM 1
486 679 MONTAT PARABICICLISTI STG 1
487 678 MONTAT CONDUCTA ALIMENTARE PENTRU INCALZIRE AUXILIARA 1
488 682 PIAGGIO INLOCUIT DISCURI SI PLACUTE FRANA FATA 1
489 680 MONTAT PROIECTOARE FATA 1
490 693 RAMPA INJECTOARE DEMONTATA 1
491 692 PURJAT FILTRU MOTORINA 1
492 695 RECTIFICAT FILETE COMPRESOR A/C 1
493 694 RECTIFICAT FILET AXA DR SPATE 1
494 691 PROGRAMARE CHEIE 1
495 688 PRINDERE CONDUCTA EVACUARE PE SASIU 1
496 687 PREGATIRE PENTRU VOPSIRE 1
497 690 PROGRAMARE BODY COMPUTER 1
498 689 PRINDERE CONDUCTE A/C 1
499 677 MONTAT BUTUC ROATA SPATE DR 1
500 660 INLOCUIT USA STG FATA 1
501 659 INLOCUIT UNITATE CONTROL BUJII 1
502 818 UPDATE BCM 76618 ATAQ 1
503 820 UPDATE SOFT FSA 202418 1
504 657 INLOCUIT ULEI CV 1
505 653 INLOCUIT TURBINA MOTOR AUXILIAR 1
506 652 INLOCUIT TREAPTA CABINA ARIPA DR 1
507 656 INLOCUIT ULEI CUTIE VITEZE 1
508 655 INLOCUIT ULEI + FILTRU ULEI 1
509 674 MONTAT APARATOARE NOROI SPATE 1
510 672 MASURAT UZURA ARBORE SI INLOCUIT CUZINETI PALIER (6 +1 AXIAL) 1
511 676 MONTAT ARC AXA SPATE DR 1
512 815 TEST ACUMULATORI 1
513 816 TEST FRANARE 1
514 667 INLOCUIT 2 BIELE MOTOR 1
515 663 INLOCUIT VAS EXPANSIUNE CU SENZOR 1
516 669 LIMITARE VITEZA LA 110KM/H 1
517 668 LIMITARE AUTO 110 KM/H 1
518 183 INCARCAT INSTALATIE A/C CU FREON 1
519 182 INCARCARE INSTALATIE A/C 1
520 185 INDREPTAT CAPAC SPATE PARTEA DRT. 1
521 189 INL. INCHIZ CAPOTA DR 1
522 188 INL DUBLURA INTERIOARA LONGERON FATA STG 1
523 181 IINLOCUIT TERMOSTAT 1
524 166 FACTURA VOPSITORIE F1897 1
525 163 EFECTUAT TESTE + REGENERARE 1
526 175 GOLIT INSTALATIE RACIRE 1
527 180 GRESAT PUNCTE 1
528 177 GOLIT REZERVOR AD-BLUE 1
529 198 INLOCUIRE VENTILATOR AEROTERMA 1
530 197 INLOCUIRE RIGIDIZARE STALP EXT. INT. DR 1
531 199 INLOCUIT + REGLAT CABLURI TIMONERIE 1
532 201 INLOCUIT ACUMULATORI 1
533 200 INLOCUIT ACUMULATOR 1
534 196 INLOCUIRE PERNA AER PUNTE MOTRICA DRT. SPRE FATA 1
535 191 INLOCOUIT FAR FAZA LUNGA STG. 1
536 190 INL. INCHIZ CAPOTA STG 1
537 192 INLOCUIRE BARA FATA STG 1
538 195 INLOCUIRE PERNA AER PUNTE MOTRICA 1
539 193 INLOCUIRE BARA PROTECTIE FATA 1
540 136 D/R ROATA SPATE DR AXA 4 1
541 135 D/R ROATA DR SPATE 1
542 137 D/R ROTI ( AXA 1/2/3) SI VERIFICAT ELEMENTE DE FRANARE 1
543 141 D/R SCAUN SOFER 1
544 140 D/R SCARA USA STG 1
545 134 D/R ROATA 1
546 125 D/R POMPA DE APA 1
547 124 D/R PARASOC STG BARA FATA 1
548 126 D/R PRAG STG 1
549 130 D/R RADIATOR APA SI VERIFICAT ETANSEITATE 1
550 128 D/R PROIECTOR FATA DR 1
551 150 ECHIPAT ANEXE PE MOTOR 1
552 148 D/R TURBOSUFLANTA 1
553 154 EFECTUAT REGLAJ DIRECTIE 1
554 162 EFECTUAT TEST FRANARE 1
555 161 EFECTUAT REVIZIE PERIODICA-MICA 1
556 147 D/R TURBINA 1
557 143 D/R SI INLOCUIRE SEMNALIZATOR FAT DR 1
558 142 D/R SEMNALIZARE LATERALA DR 1
559 144 D/R SUPORT STG BARA FATA 1
560 146 D/R TREAPTA ARIPA DR CABINA 1
561 145 D/R SUSPENSIE FATA 1
562 202 INLOCUIT ALM 1
563 242 INLOCUIT BEC FAR ST + DR 1
564 241 INLOCUIT BEC FAR DR 1
565 243 INLOCUIT BEC FAZA SCURTA 1
566 245 INLOCUIT BEC POZITIE 1
567 244 INLOCUIT BEC LAMPA NR 1
568 240 INLOCUIT BCM 1
569 236 INLOCUIT BASCULA STG 1
570 232 INLOCUIT BARA FATA DR 1
571 237 INLOCUIT BASCULA STG FATA 1
572 239 INLOCUIT BASCULE FATA 1
573 238 INLOCUIT BASCULA SUPERIOARA FATA STG 1
574 256 INLOCUIT BORNA BATERIE MINUS 1
575 254 INLOCUIT BECURI PE LAMPILE GABARIT 1
576 257 INLOCUIT BRATE SUSPENSIE FATA 1
577 259 INLOCUIT BUCSI ARCURI FATA 1
578 258 INLOCUIT BROASCA USA FATA STG 1
579 252 INLOCUIT BECURI LAMPI GABARIT 1
580 247 INLOCUIT BEC STOP 1
581 246 INLOCUIT BEC SEMNALIZARE SI POZITIE 1
582 248 INLOCUIT BEC STOP FRANA 1
583 251 INLOCUIT BECURI FARURI FATA 1
584 250 INLOCUIT BECURI FARURI 1
585 212 INLOCUIT AMORTIZOR CABINA FATA STG/DR 1
586 211 INLOCUIT AMORTIZOR AXA FATA (AMBELE PARTI) 1
587 213 INLOCUIT AMORTIZOR DR FATA 1
588 215 INLOCUIT ANSAMBLU BASCULA FATA STG 1
589 214 INLOCUIT ANSAMBLU BASCULA FATA DR 1
590 210 INLOCUIT AMORTIZOARE SPATE 1
591 205 INLOCUIT AMORTIOARE FATA 1
592 204 INLOCUIT AMBREIAJ 1
593 207 INLOCUIT AMORTIZOARE CABINA FATA 1
594 209 INLOCUIT AMORTIZOARE FATA S + D 1
595 208 INLOCUIT AMORTIZOARE FATA 1
596 228 INLOCUIT BALAMALE USA STG FATA 1
597 227 INLOCUIT BALAMALE USA DR 1
598 229 INLOCUIT BARA DRT. FATA 1
599 231 INLOCUIT BARA FATA + SUPORTI BARA 1
600 230 INLOCUIT BARA FATA 1
601 226 INLOCUIT ARIPA ROATA FATA STG 1
602 220 INLOCUIT ARC SPATE STG 1
603 216 INLOCUIT ANSAMBLU USCATOR AER 1
604 221 INLOCUIT ARC SPATE(O PARTE) 1
605 225 INLOCUIT ARIPA ROATA FATA DR 1
606 223 INLOCUIT ARIPA + ORNAMENT STG. PUNTE FATA 1
607 123 D/R ORNAMENT STG GRILA 1
608 36 DEMONTAT FURCI CUPLARE CV 1
609 35 DEMONTAT COMPRESOR A/C 1
610 37 DEMONTAT REZERVOR COMBUSTIBIL 1
611 40 DEMONTAT SEGMENTI CILINDRU EXPULZIE 1
612 38 DEMONTAT ROTI AXA SPATE 1
613 34 DEMONTAT BORD SI INLOCUIT CEASURI 1
614 29 DEMONTAT ANEXE DE PE MOTOR 1
615 28 DEMNTAT BUTUC AXA SPATE DR 1
616 30 DEMONTAT ARC AXA SPATE STG 1
617 33 DEMONTAT BORD DREAPTA 1
618 32 DEMONTAT BORD AUTO 1
619 50 DEZANSAMBLAT ARC AXA SPATE STG 1
620 48 DESFACUT SI REPARAT CILINDRI COMPACTARE 1
621 52 DIAGNOZA NOX 1
622 54 DIAGNOZA PTO 1
623 53 DIAGNOZA: MARTOR CHECK ENGINE 1
624 47 DESCARCAT-INCARCAT INST A/C 1
625 42 DEMONTAT/MONTAT CAPAC BAIE ULEI 1
626 41 DEMONTAT SISTEM EVACUARE 1
627 43 DEMONTAT/MONTAT CILINDRU EXPULZIE 1
628 46 DEPRESAT RULMENTI 1
629 45 DEMONTAT/MONTAT CV PE AUTO 1
630 10 COMPLETAT CU FREON 1
631 9 COMPLETARE LICHID FRANA 1
632 11 COMPLETAT INSTALATIE FREON 1
633 13 CURATAT FILTRU DE PARTICULE 1
634 12 CURATAT BIELA SI INLOCUIT CUZINETI (6 BIELE) 1
635 7 APARATOARE ARIPA 1
636 2 AERISIT INSTALATIE FRANARE 1
637 1 AERISIT FRANE 1
638 3 AERISIT INSTALATIE HIDRAULICA 1
639 6 ANSAMBLAT BORD AUTO 1
640 5 AERISIT SISTEM HIDRAULIC AMBREIAJ 1
641 23 CURATAT PINI 1
642 22 CURATAT MUFE SI CABLAJE 1
643 24 CURATAT PISTOANE SI INLOCUIT SEGMENTI ( 6 PISTOANE) 1
644 27 DECONECTAT CONDUCTE COMBUSTIBIL 1
645 26 CURATAT SUPAPA DEBIT GAZE 1
646 21 CURATAT INTRECOOLER 1
647 17 CURATAT INSTALATIE DE AER 1
648 15 CURATAT INSTALATIE ADBLUE 1
649 18 CURATAT INSTALATIE DRENARE A/C 1
650 20 CURATAT INSTALATIE RACIRE 1
651 19 CURATAT INSTALATIE ELECTRICA PORNIRE 1
652 56 DIAGNOZA SI UPDATE AUTO 1
653 105 D/R DISC FRANA SI BUTUC ROATA DR SPATE 1
654 102 D/R CUTIE VITEZE 1
655 106 D/R EPURATOR GAZE SI INLOCUIT RACORD 1
656 109 D/R FILTRU DE PARTICULE SI INLOCUIT GARNITURI + COLIERE 1
657 108 D/R FATA DE USA STG 1
658 97 D/R CONDUCTA PRESIUNE POMPA SERVO 1
659 93 D/R CILINDRU DE EXPULZIE 1
660 92 D/R CILINDRI HIDRAULICI SPATE COMPACTOR 1
661 94 D/R CILINDRU EXPULZIE 1
662 96 D/R COMPONENTE CATALIZATOR 1
663 95 D/R COLTAR FATA DR 1
664 117 D/R LAMPA SEMNALIZARE DR 1
665 116 D/R KIT AMBREIAJ + VOLANTA 1
666 118 D/R MANER CAPOTA MOTOR 1
667 120 D/R MOTOR 1
668 119 D/R MODUL ALIMENTARE AD-BLUE 1
669 115 D/R JANTA FATA STG 1
670 111 D/R GEAM INF USA DR 1
671 110 D/R FILTRU PARTICULE 1
672 112 D/R GRILA RADIATOR 1
673 114 D/R JANTA FATA DR 1
674 113 D/R INJECTOARE 1
675 66 D/R + REPARAT ELECTROMOTOR 1
676 65 D/R RADIATOARE 1
677 67 D/R ANVELOPA FATA DR 1
678 69 D/R APARATOARE 1
679 68 D/R ANVELOPA FATA STG 1
680 64 D/M SI REPARAT CILINDRU LIFT 1
681 60 D/M FILTRU PARTICULE 1
682 59 D/M CHIULASA MOTOR SI INLOCUIT GARNITURA CHIULASA 1
683 61 D/M GRUP DIFERENTIAL 1
684 63 D/M SI REPARAT CILINDRU GHEARA 1
685 62 D/M SI REPARAT CILINDRU EXPULZIE 1
686 86 D/R CAPITONAJ USA DR SP\ 1
687 85 D/R CAPITONAJ FATA DR CABINA 1
688 88 D/R CATALIZATOR X 2 1
689 91 D/R CHIULOASA MOTOR 1
690 89 D/R CAUTATOR CV 1
691 84 D/R CAPAC SUPERIOR OGLINDA EXT DR 1
692 74 D/R BARA FATA DR SI INLOCUIT 1
693 73 D/R BARA FATA DR 1
694 79 D/R BUTUC ROATA SPATE 1
695 82 D/R CADRU MOTOR FATA 1
696 80 D/R BUTUCI ROATA SPATE 1
697 260 INLOCUIT BUCSI ARCURI SPATE + BRIDE 1
698 418 INLOCUIT FURTUN RACIRE COMPRESOR 1
699 417 INLOCUIT FURTUN INTERCOOLER 1
700 419 INLOCUIT FURTUNE INSTALATIE HIDRAULICA 1
701 421 INLOCUIT FUZETA FATA DR 1
702 420 INLOCUIT FURTUNE RACIRE 1
703 416 INLOCUIT FURTUN INFERIOR RADIATOR APA 1
704 412 INLOCUIT FOAIE 2 ARC SPATE STG 1
705 411 INLOCUIT FOAIE 2 ARC SPATE DR 1
706 413 INLOCUIT FOAIE 3 ARC SPATE 1
707 415 INLOCUIT FURTUN COMPRESOR AER 1
708 414 INLOCUIT FULIE ALTERNATOR 1
709 430 INLOCUIT GEAM OGLINDA MICA DR 1
710 429 INLOCUIT GEAM OGLINDA MIC STG 1
711 431 INLOCUIT GIROFAR FATA 1
712 436 INLOCUIT INTINZATOR ALTERNATOR 1
713 432 INLOCUIT GRILA SUPERIOARA 1
714 428 INLOCUIT GARNITURI RACITOR DE ULEI MOTOR 1
715 423 INLOCUIT GARNITURA CHIULOASA 1
716 422 INLOCUIT GARNITURA CAPAC CUVA 1
717 425 INLOCUIT GARNITURI EGR 1
718 427 INLOCUIT GARNITURI FILTRU 1
719 426 INLOCUIT GARNITURI ETANSARE DPF 1
720 386 INLOCUIT ETRIER STG 1
721 385 INLOCUIT ETRIER FRANA DR SPATE 1
722 387 INLOCUIT FAR DR 1
723 389 INLOCUIT FAR DR COMPLET 1
724 388 INLOCUIT FAR DR + LAMPA POZITIE 1
725 384 INLOCUIT ELECTROVALVA 1
726 380 INLOCUIT DOZATOR ADBLUE 1
727 379 INLOCUIT DISTRIBUITOR LIFT 1
728 381 INLOCUIT ECU GEARBOX 1
729 383 INLOCUIT ECU ULEI MOTOR 1
730 382 INLOCUIT ECU PTO 1
731 404 INLOCUIT FILTRU POLEN 1
732 401 INLOCUIT FILTRU DPF 1
733 405 INLOCUIT FILTRU SCRUF 1
734 408 INLOCUIT FOAIE DE ARC FATA 1
735 406 INLOCUIT FILTRU ULEI 1
736 400 INLOCUIT FILTRU DE ULEI 1
737 392 INLOCUIT FERODOURI AXA 2 FATA STG/DR 1
738 391 INLOCUIT FAR STG 1
739 393 INLOCUIT FILTRE MOTORINA 1
740 398 INLOCUIT FILTRU DE AER MOTOR 1
741 395 INLOCUIT FILTRU ADBLUE+CAPAC FILTRU ADBLUE 1
742 437 INLOCUIT KIT ACCESORII 1
743 491 INLOCUIT OGLINDA STG 1
744 488 INLOCUIT NUCA SCHIMBATOR 1
745 493 INLOCUIT OGLINDA DR 1
746 496 INLOCUIT OGLINDA SI BUSON REZERVOR 1
747 494 INLOCUIT OGLINDA MICA STG. 1
748 487 INLOCUIT MOTOR ELECTRIC ACTIONARE LIFT 1
749 482 INLOCUIT MODUL DOZARE UPSTREAM CATALIZATOR 1
750 481 INLOCUIT MODUL - POMPA ADBLUE 1
751 483 INLOCUIT MODULATOR ABS/EBS 1
752 486 INLOCUIT MONITOR+CAMERA VIDE 1
753 485 INLOCUIT MONITOR LED 1
754 508 INLOCUIT PINION VIT2 + SINCROANE 1/2 1
755 506 INLOCUIT PINION CV VIT. 6 1
756 509 INLOCUIT PIVOTI 1
757 516 INLOCUIT PLACUTE FR 1
758 513 INLOCUIT PLACA DE COMANDA LIFT 1
759 505 INLOCUIT PINION CV VIT 2 1
760 498 INLOCUIT OGLINDA STG 1
761 497 INLOCUIT OGLINDA ST 1
762 500 INLOCUIT ORNAMENT ARIPA DRT. PUNTE FATA 1
763 504 INLOCUIT PERNA AER 1
764 503 INLOCUIT ORNAMENT PROIECTOR 1
765 458 INLOCUIT LAMPA NUMAR 1
766 452 INLOCUIT LAMPA DR PE CABINA 1
767 459 INLOCUIT LAMPA SEMNAL PE OGLINDA DREAPA 1
768 461 INLOCUIT LAMPA SEMNALIZARE STG PE OGLINDA 1
769 460 INLOCUIT LAMPA SEMNAL STANGA IN BARA 1
770 451 INLOCUIT LAMPA CORN STG SPATE 1
771 442 INLOCUIT KIT CUREA ACCESORII 1
772 438 INLOCUIT KIT AMBREAJ 1
773 445 INLOCUIT KIT GARNITURI CILINDRU EXPULZIE 1
774 449 INLOCUIT LAMPA CABINA DR 1
775 446 INLOCUIT KIT HIDRAULIC 1
776 476 INLOCUIT MANER USA DR FATA 1
777 474 INLOCUIT MACARA GEAM USA FATA STG 1
778 477 INLOCUIT MANER USA FATA STG 1
779 480 INLOCUIT MANSETE LA CILINDRU DE EXPULZIE 1
780 479 INLOCUIT MANETA SEMNALIZARE 1
781 473 INLOCUIT MACARA GEAM STG 1
782 465 INLOCUIT LAMPI 1
783 463 INLOCUIT LAMPA SPATE DRT. 1
784 470 INLOCUIT LICHID RACIRE 1
785 472 INLOCUIT MACARA GEAM DR FATA 1
786 471 INLOCUIT MACARA CU MOTORAS USA STG 1
787 378 INLOCUIT DISTRIBUITOR HIDRAULIC PRINCIPAL 1
788 298 INLOCUIT CAPETE BARA AXA VIRATOARE S +D 1
789 297 INLOCUIT CAPAC VIZITARE FAR STG SUPERIOR 1
790 299 INLOCUIT CAPETE BARA ST + DR 1
791 301 INLOCUIT CAPETE DE BARA LA CASETA DE DIRECTIE 1
792 300 INLOCUIT CAPETE BARA TRANSVERSALA S + D AXA 1 1
793 296 INLOCUIT CAPAC VIZITARE FAR STG INFERIOR 1
794 287 INLOCUIT CABLURI FRANA PARCARE 1
795 286 INLOCUIT CABLURI FRANA MANA 1
796 289 INLOCUIT CAMERA SI MONITOR 1
797 293 INLOCUIT CAP BARA DIRECTIE ST + DR 1
798 292 INLOCUIT CAP BARA (AMBELE PARTI) 1
799 310 INLOCUIT CAUTATOR CV 1
800 309 INLOCUIT CATALIZATOR 1
801 311 INLOCUIT CHIULASA COMPRESOR 1
802 313 INLOCUIT CILINDRU LATERAL 1
803 312 INLOCUIT CILINDRU AMBREIAJ 1
804 308 INLOCUIT CASETA DE DIRECTIE 1
805 304 INLOCUIT CAPOTA MOTOR 1
806 302 INLOCUIT CAPETE DE BARA S + D 1
807 305 INLOCUIT CARCASA OGLINDA DR 1
808 307 INLOCUIT CARDAN 1
809 306 INLOCUIT CARCASA OGLINDA STG. 1
810 270 INLOCUIT BUJIE INCANDESCENTA (4) 1
811 269 INLOCUIT BUCSI ETRIERI FATA 1
812 271 INLOCUIT BUJIE MOTOR 1 BUCATA 1
813 274 INLOCUIT BUJII 2 BUCATI 1
814 272 INLOCUIT BUJII 1
815 268 INLOCUIT BUCSI CABINA SPATE 1
816 262 INLOCUIT BUCSI BARA STABILIZATIOARE FATA 1
817 261 INLOCUIT BUCSI BARA STAB SPATE 1
818 263 INLOCUIT BUCSI BARA STABILIZATOARE FATA 1
819 266 INLOCUIT BUCSI BASCULA FATA STG + DR 1
820 265 INLOCUIT BUCSI BARA TORSIUNE FATA DR 1
821 282 INLOCUIT CABLU AMBREIAJ 1
822 281 INLOCUIT CABLU ACCELERATIE 1
823 283 INLOCUIT CABLU TIMONERIE 1
824 285 INLOCUIT CABLURI FRANA DE MANA 1
825 284 INLOCUIT CABLURI FRANA DE MANA 1
826 280 INLOCUIT CABLAJ POMPA ADBLUE 1
827 276 INLOCUIT BUTUC ROATA 1
828 275 INLOCUIT BUTUC FATA DREAPTA 1
829 277 INLOCUIT BUTUC ROATA FATA + RULMENT DR 1
830 279 INLOCUIT CABLAJ NOXE 1
831 278 INLOCUIT BUTUCI FATA CU RULMENTI 1
832 314 INLOCUIT CILINDRU RECEPTOR 1
833 354 INLOCUIT CUREA ALTERNATOR 1
834 353 INLOCUIT CUREA ALTERBATOR 1
835 355 INLOCUIT CUREA +ROLA TRANSMISIE 1
836 357 INLOCUIT CUREA TRANSMISIE 1
837 356 INLOCUIT CUREA SI ROLE 1
838 352 INLOCUIT CUREA A/C 1
839 348 INLOCUIT CRUCI CARDANICE 1
840 347 INLOCUIT CRUCE CRADAN 1
841 349 INLOCUIT CULISANTE ETRIER SPATE STG/DR 1
842 351 INLOCUIT CUREA + ROLA DISTRIBUTIE 1
843 350 INLOCUIT CUREA + ROLA AC 1
844 367 INLOCUIT DISCURI + PLACUTE FATA 1
845 366 INLOCUIT DISC SI PLACUTE AXA FATA 1
846 370 INLOCUIT DISCURI FATA 1
847 377 INLOCUIT DISTRIBUITOR COMPACTARE 1
848 371 INLOCUIT DISCURI FATA + PLACUTE 1
849 365 INLOCUIT DISC FRINA + PLACUTE SPATE 1
850 361 INLOCUIT DISC AMBREIAJ 1
851 359 INLOCUIT CUZINETI BIELA + 1 BIELA 1
852 362 INLOCUIT DISC FR SP (AMBELE) 1
853 364 INLOCUIT DISC FRANA(AMBELE) 1
854 363 INLOCUIT DISC FRANA FATA STG/DR 1
855 322 INLOCUIT COMPRESOR + CUREA 1
856 321 INLOCUIT COMANDA LIFT 1
857 323 INLOCUIT COMPRESOR A.C. 1
858 326 INLOCUIT COMPRESOR AER 1
859 324 INLOCUIT COMPRESOR A/C 1
860 320 INLOCUIT COLIERE TURBO 1
861 316 INLOCUIT CIRCUIT HIDRAULIC AMBREIAJ 1
862 315 INLOCUIT CILINDRU RECEPTOR AMBREIAJ 1
863 317 INLOCUIT CLAPETA ACCELERATIE 1
864 319 INLOCUIT COLIERE SI GARNITURI EVACUARE 1
865 318 INLOCUIT COLIERE FURTUN TURBINA 1
866 337 INLOCUIT CONDUCTA RACIRE EGR 1
867 336 INLOCUIT CONDUCTA RACIRE COMPRESOR/CHIULOASA 1
868 339 INLOCUIT CONDUCTA SISTEM INCALZIRE AD-BLUE 1
869 341 INLOCUIT CONDUCTE ALIMENTARE COMBUSTIBIL 1
870 340 INLOCUIT CONDUCTA ULEI TURBINA 1
871 335 INLOCUIT CONDUCTA POMPA RABATARE 1
872 328 INLOCUIT COMUTATOT STOP FRANA 1
873 327 INLOCUIT COMUTATOR MI 1
874 329 INLOCUIT CONDUCT RACIRE VAS - RADIATOR 1
875 334 INLOCUIT CONDUCTA INJECTOR 1
876 333 INLOCUIT CONDUCTA EGR 1

View File

@@ -1,7 +1,7 @@
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260619-093652.md --> <!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260619-093652.md -->
# PRD 3.6 — Editare celule in preview + Acasa unificata (Trimiteri inline, upload slim, Mapari tabelar) # PRD 3.6 — Editare celule in preview + Acasa unificata (Trimiteri inline, upload slim, Mapari tabelar)
**Stare**: aprobat (post-autoplan 2026-06-19raport de review la finalul fisierului; stories revizuite cu fix-urile aplicate) **Stare**: inchis (2026-06-22CLOSE: `/code-review` high a prins 1 bug real, reparat; dashboard ROADMAP → DONE. Toate US-001..007 implementate, 523 teste pass, VERIFY E2E browser + LIVE RAR test PASS; vezi `## Raport VERIFY` si `## Raport CLOSE`)
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`. > Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead). > Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
@@ -269,8 +269,28 @@ Val 3 (Cosmetic mapari + toggle sigur): [US-005] → [US-006] [US
## Raport VERIFY ## Raport VERIFY
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. > Verificare 2026-06-19 (lead-driven: suita automata + E2E browser Playwright + trimitere LIVE pe RAR test).
> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E pe RAR test). Lipseste pana la VERIFY.
### Rezultat global: **PASS**
**Teste automate**: `python3 -m pytest -q` → **523 passed, 0 failed** (baseline pre-3.6: 483; +33 stories echipe, +7 US-007).
**Per story (E2E browser pe `/`, mediu test, auth dev):**
- **US-003 PASS** — tab "Trimiteri" absent din tab-bar (Acasa·Mapari·Cont·Nomenclator); sectiunea "Trimiterile tale" randata sub upload cu heading + badge + filtre + tabel; `GET /?tab=coada` si `GET /_fragments/coada` servesc continut Acasa (200, contin "Trimiterile tale", fara fragment `_coada.html` orfan); cat preview-ul de import e activ, sectiunea Trimiteri e ascunsa (un singur sticky bar — D-1.2); wayfinding "Mapari"/"Coduri RAR" pastrat.
- **US-004 PASS** — upload comprimat la bara slim ("Importa:" + buton + "sau trage aici" + microcopy discret); drag-drop + select foaie pastrate; multi-sheet ok.
- **US-001 PASS** — `import_rows.override_json` (nullable, Fernet, `_migrate` defensiv) aplicat ULTIMUL in `_resolve_row_for_preview` (l.194) si `commit_import` (l.1070); ruta scoped JOIN→404, guard committed→409, empty=clear; verificat LIVE ca override COMPLETEAZA o coloana ABSENTA din fisier (fixture `import_lipsa_coloana.csv` fara coloana data → editarea adauga data → `ok`).
- **US-002 PASS** — buton "Editeaza" pe rand (aria-label cu nr.+VIN); swap pe `<tr>` + OOB pe rezumat/contoare (NU pe `#import-section`); form propriu (Enter nu declanseaza confirm); mutual-exclusion (Editeaza celorlalte randuri + "Trimite la RAR" dezactivate cat un rand e in editare); `hx-indicator` "se salveaza…"; reuse grila responsiva `_trimitere_detaliu.html`.
- **US-005/006 PASS** — "De rezolvat", "Operatii salvate" si "Formate de coloane" ca tabele `.tablewrap`; H4 confirmat (checkbox auto_send reflecta valoarea STOCATA: OP-AUTO bifat, OP-MANUAL nebifat); POST-uri neschimbate.
- **US-007 PASS** — comutator etichetat pe COADA ("La fisierele viitoare cu aceasta operatie:" + "Pune automat in coada" / caption "Tine pentru verificare … nimic nu pleaca la RAR pana confirmi"); fara "Manual"/"trimite"/"auto-send"; scoped pe operatie; `name="auto_send"` pastrat (semantica de prezenta → bool corect cu ambele parsere, zero backend); prezent in preview + ambele locuri din Mapari.
**E2E LIVE pe RAR test (trimitere reala):** import `import_lipsa_coloana.csv` (fara coloana data) → preview `needs_data` → Editeaza → completare data `2026-06-08` (override pe coloana absenta) → `ok` → commit "Trimite la RAR" → worker (`send_enabled=true`, creds `<test>`) login RAR test + nomenclator refresh (18 coduri) → `postPrezentare` → submission `sent`, `id_prezentare=68696`. Confirmat independent: `python3 -m tools.rar_finalizate` listeaza `68696 WVWZZZ1KZAW000456 2026-06-08 98765`. Dovedeste lantul complet override→commit→worker→RAR pe mediul real de test.
**Bug-uri prinse la VERIFY (JS, invizibile la TestClient) si reparate (htmx 1.9.12):**
1. `htmx.config.useTemplateFragments=true` (`base.html`) — raspunsul de editare (`<tr>` + OOB `#preview-rezumat`/`#preview-ok-count`) era parsat in context de tabel (`<table><tbody>`), foster-parenta div-urile OOB → `htmx:swapError` + contoare pierdute.
2. Re-activarea `#confirm-btn` dupa salvare deferita pe `setTimeout(0)` (`_preview_rand.html`) — altfel `updateN` rula cat inca exista `tr[data-editing="1"]` tranzitoriu → butonul "Trimite la RAR" ramanea blocat dupa o editare reusita.
3. `updateN` actualizeaza si numarul ok din `#n-hint` (`_preview_import.html`) — textul "(N ok)" ramanea stale dupa editare.
**Non-Goals respectate:** worker / masina stari / idempotenta-logica / mapping-rezolvare NEATINSE; singura atingere de schema = 1 coloana nullable `override_json` cu migrare defensiva (relaxare decisa la poarta autoplan, Approach B); `submissions` neatins de ruta de editare.
--- ---
@@ -541,3 +561,34 @@ Gate de design: D-3.1 + D-3.3 (swap distructiv + Enter-trimite) = problema de co
| 12 | Design | First-run pastreaza hero "Primul fisier?"; slim accentuat; pastreaza wayfinding Mapari/Coduri | Auto | P1 | discoverability first-run | | 12 | Design | First-run pastreaza hero "Primul fisier?"; slim accentuat; pastreaza wayfinding Mapari/Coduri | Auto | P1 | discoverability first-run |
| UC-A | CEO/Design | US-007 reformulare labels (user a ales explicit "Automat/Manual") | USER CHALLENGE | — | toti reviewerii: risc send-safety. User decide. | | UC-A | CEO/Design | US-007 reformulare labels (user a ales explicit "Automat/Manual") | USER CHALLENGE | — | toti reviewerii: risc send-safety. User decide. |
| DG-1 | CEO | Split: ship US-003(+004) intai, US-001/002 dupa reuse, US-005/006 batch, US-007 redesign | Taste | P6 | cele 4 sunt independente (graf valuri) | | DG-1 | CEO | Split: ship US-003(+004) intai, US-001/002 dupa reuse, US-005/006 batch, US-007 redesign | Taste | P6 | cele 4 sunt independente (graf valuri) |
---
## Raport CLOSE (2026-06-22)
CLOSE conform ROADMAP §5.8 pe diff-ul livrabilei (`ead6324..178bc87`, doar `app/`).
**`/code-review` high** (8 unghiuri finder + verificare, recall-biased):
- **REPARAT (1 bug real, corectitudine).** Decriptarea `override_json` era in afara `try/except`-ului
care protejeaza `raw_json` in ambele cai de preview:
- `app/api/v1/import_router.py` `preview_import` (linia 699)
- `app/web/routes.py` `_web_compute_preview` (linia 1061)
La rotatie cheie Fernet (risc acceptat R4) sau token corupt, `raw_json` degrada gratios la `{}`
dar `override_json` arunca exceptie -> 500 pe TOT batch-ul in loc de preview cu override gol.
Fix: `override_json` ambalat in `try/except` identic cu `raw_json` (fallback `None` -> `{}`).
523 teste pass dupa fix.
- **NOTAT, nereparat (cleanup viitor — disciplina "backend trimitere neatins").** Duplicare
`_override_of` (decriptare override) + blocul "canonicalize dupa override" in 3-4 locuri
(preview/commit pe canalul API vs. web). Refactor intr-un resolver partajat = candidat de cleanup,
in afara scopului unui pas de CLOSE. Verificat ca cele 4 cai sunt logic identice (preview si commit
produc aceeasi cheie de idempotenta), deci nu e bug azi.
- Restul candidatelor (form-binding HTML, fallback tab `coada`->`acasa`, falsy-zero pe override)
REFUTED la verificare: comportament intentionat (US-003/004) sau deja corect in cod.
**Convenții (CLAUDE.md):** fara incalcari (RO peste tot, fara emoji, invariante idempotenta/scoping/
422-no-echo pastrate).
Toate PASS -> writeback dashboard (DONE, 2026-06-22) + PRD `**Stare**: inchis`.

Some files were not shown because too many files have changed in this diff Show More