Compare commits
120 Commits
97798a3cbc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5ce0e2e2b | ||
|
|
deb6afff3e | ||
|
|
b4818349be | ||
|
|
ff9d0f41d1 | ||
|
|
7371c3703d | ||
|
|
851f76ca16 | ||
|
|
a29896a790 | ||
|
|
3f513f6c12 | ||
|
|
8f39dfbc1e | ||
|
|
e1243f603e | ||
|
|
80d90f317d | ||
|
|
12021eb269 | ||
|
|
308fee6c27 | ||
|
|
756f77730f | ||
|
|
c05fa00007 | ||
|
|
ce90dac833 | ||
|
|
c9f9a1ca0e | ||
|
|
9eccb9f6fa | ||
|
|
8dd0e1678c | ||
|
|
3fc53534e2 | ||
|
|
9e42e7ed6f | ||
|
|
19f89ecd70 | ||
|
|
9031f81908 | ||
|
|
4caf055c53 | ||
|
|
822185e138 | ||
|
|
41aa385644 | ||
|
|
865c208821 | ||
|
|
670019361c | ||
|
|
8d4ff3400e | ||
|
|
bafaf05e83 | ||
|
|
b26dbb79e1 | ||
|
|
283299ff20 | ||
|
|
412102b9b1 | ||
|
|
a4531acd69 | ||
|
|
d487afad73 | ||
|
|
c31a1e254c | ||
|
|
4a2afc68bf | ||
|
|
f05fe5b221 | ||
|
|
074b6e7c8a | ||
|
|
5a964a1a8d | ||
|
|
3bc0825e0b | ||
|
|
74ac16f456 | ||
|
|
35e97faae5 | ||
|
|
d3433015ad | ||
|
|
141949dc95 | ||
|
|
45f6fbb726 | ||
|
|
878e319ac5 | ||
|
|
fd4a05436d | ||
|
|
6d10f92452 | ||
|
|
0ba728cab5 | ||
|
|
32408ed3b5 | ||
|
|
51dc504f1d | ||
|
|
c80c79462c | ||
|
|
ac57b9250a | ||
|
|
6bad6bc01e | ||
|
|
c842e3352a | ||
|
|
f48346de5c | ||
|
|
0b288b90d7 | ||
|
|
90603609a1 | ||
|
|
5dc963a02c | ||
|
|
0517ae59fb | ||
|
|
e3f295f912 | ||
|
|
36ec50d667 | ||
|
|
1fbd894329 | ||
|
|
14e1c463f0 | ||
|
|
b48501d8e4 | ||
|
|
ae7960294f | ||
|
|
f0786051f5 | ||
|
|
be36c2c53b | ||
|
|
35f35d03cc | ||
|
|
178bc87006 | ||
|
|
6f6b163867 | ||
|
|
ead63245da | ||
|
|
c8a19e2f06 | ||
|
|
d7ba1195d4 | ||
|
|
d10e9db998 | ||
|
|
4a1d28749a | ||
|
|
ccd26115f8 | ||
|
|
a40b20b3b4 | ||
|
|
4e2b6102a4 | ||
|
|
f149b24f96 | ||
|
|
958b182e8e | ||
|
|
b92055eb01 | ||
|
|
504b490d3b | ||
|
|
748ab8b289 | ||
|
|
1c5b0cbc18 | ||
|
|
6515de415b | ||
|
|
5dc3a477de | ||
|
|
fbf82622b6 | ||
|
|
c38807d88c | ||
|
|
9106983dd5 | ||
|
|
0b3e2464e1 | ||
|
|
5a8787bbc4 | ||
|
|
854db66abc | ||
|
|
55adfa214f | ||
|
|
70f717d874 | ||
|
|
2c8367109c | ||
|
|
ef52dc2823 | ||
|
|
8cdfc976e4 | ||
|
|
61a7b4ea1c | ||
|
|
12f0ca3a81 | ||
|
|
4ea21a034e | ||
|
|
80897ccbb1 | ||
|
|
4295a0aa31 | ||
|
|
fa65e1da2e | ||
|
|
b12be3d26c | ||
|
|
bba5b31540 | ||
|
|
facb1ca8b4 | ||
|
|
39c0e16248 | ||
|
|
8748c21379 | ||
|
|
db64972c1d | ||
|
|
8d3bc6bea5 | ||
|
|
6ab22ea0fb | ||
|
|
6fb92466cb | ||
|
|
fbb2695336 | ||
|
|
c17c1aa4f4 | ||
|
|
a6df3b636f | ||
|
|
77088daf29 | ||
|
|
36d1b916d5 | ||
|
|
2117ab5c1e |
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
||||
# Gateway RAR AUTOPASS — variabile de mediu (copiaza in .env; .env NU se comite).
|
||||
# Compose citeste .env automat. Prefix AUTOPASS_ pentru toate.
|
||||
|
||||
# --- CRITIC: cheie criptare creds RAR (Fernet) ---
|
||||
# PARTAJATA intre api si worker (API cripteaza, worker decripteaza). Genereaza:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
AUTOPASS_CREDS_KEY=
|
||||
|
||||
# --- Auth API-key ---
|
||||
# true = orice /v1/* cere cheie valida (prod). false = dev (fara cheie -> cont id=1).
|
||||
AUTOPASS_REQUIRE_API_KEY=false
|
||||
|
||||
# --- Worker ---
|
||||
# Send catre RAR. false = nu trimite (default, sigur pentru probe). true = end-to-end.
|
||||
AUTOPASS_WORKER_SEND_ENABLED=false
|
||||
# Dev: foloseste creds <test> din settings.xml cand submission-ul nu are creds criptate.
|
||||
AUTOPASS_WORKER_USE_TEST_CREDS=false
|
||||
|
||||
# --- RAR ---
|
||||
# test | prod
|
||||
AUTOPASS_RAR_ENV=test
|
||||
|
||||
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
||||
# false = dezactivat (default; /mapari instant, sugestii din GOLD/SILVER + fuzzy).
|
||||
# true = sugestii semantice. Prima cerere /mapari lazy-load-eaza modelul fastembed/ONNX
|
||||
# (~230MB pe disc) sincron -> hang la prima cerere. Doar API-ul il incarca.
|
||||
AUTOPASS_EMBEDDINGS_ENABLED=false
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -13,6 +13,10 @@ settings.xml
|
||||
*.key
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# --- start.sh: PID-uri si loguri proces local ---
|
||||
.run/
|
||||
|
||||
# --- VFP: programe compilate (se regenerează din .prg) ---
|
||||
*.fxp
|
||||
@@ -70,3 +74,18 @@ venv/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.gstack/
|
||||
|
||||
# --- Claude Code: config + memorie agenti (stare locala, nu artefact de proiect) ---
|
||||
.claude/
|
||||
|
||||
# --- Playwright MCP: artefacte sesiune browser (snapshot-uri, stare locala) ---
|
||||
.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
|
||||
|
||||
84
CLAUDE.md
Normal file
84
CLAUDE.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Ce este
|
||||
|
||||
Gateway web (Python / FastAPI) care preia prezentari de service-auto si le declara la **RAR AUTOPASS** (Legea 142/2023, OM 210/2024). Inlocuieste integrarea Visual FoxPro `RarAutoPass` (ROAAUTO), arhivata in `legacy-vfp/` (doar referinta).
|
||||
|
||||
Limba proiectului este **romana**: cod, comentarii, commit-uri, documentatie. Fara emoji in raspunsuri sau fisiere noi (preferinta proiect).
|
||||
|
||||
## Surse de adevar (citeste-le inainte sa modifici comportament)
|
||||
|
||||
- `docs/api-rar-contract.md` — contractul RAR. **Unde un plan/cod difera de contract, contractul are dreptate.**
|
||||
- `docs/ROADMAP.md` — singura sursa de progres + procesul de lucru. O sesiune noua porneste de aici. Doar sectiunea "Stadiu Implementare" se modifica pe parcurs; detaliile stau in PRD-uri (`docs/prd/`).
|
||||
|
||||
## Comenzi
|
||||
|
||||
```bash
|
||||
pip3 install -r requirements.txt # Python 3.12+
|
||||
|
||||
# Rulare locala (dev): API + worker sunt PROCESE SEPARATE
|
||||
uvicorn app.main:app --reload --port 8010 # API: dashboard /, Swagger /docs, /healthz, /metrics
|
||||
python3 -m app.worker # worker (necesar doar pentru a procesa coada)
|
||||
|
||||
# Wrapper-ul start.sh ambaleaza mediu (test/prod) + rol (api/worker/both/finalizate)
|
||||
./start.sh test both --send # API + worker, trimite efectiv la RAR test (loguri in .run/)
|
||||
./start.sh test finalizate # listeaza prezentarile inregistrate la RAR (verificare independenta)
|
||||
./start.sh status # stare procese + /healthz
|
||||
./start.sh stop # opreste procesele pornite cu "both"
|
||||
./start-test.sh / ./start-prod.sh # fixeaza mediul, forwardeaza rolul
|
||||
|
||||
# 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 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 -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)
|
||||
python3 -m tools.apikey create --account 2 # cheie afisata O SINGURA DATA (rfak_...)
|
||||
python3 -m tools.apikey list|rotate|revoke
|
||||
|
||||
# Docker (deploy): api + worker + autoheal, acelasi image + volum SQLite
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Arhitectura
|
||||
|
||||
**Doua procese peste acelasi SQLite (WAL) persistent**, care comunica EXCLUSIV prin tabela `submissions`:
|
||||
|
||||
- **API** (`app.main:app`) — API v1 (`app/api/v1/router.py` + `import_router.py`), dashboard web HTMX (`app/web/routes.py` + `templates/`), `/healthz`, `/metrics`. Worker-ul **nu** ruleaza ca task aici: un worker mort nu trebuie sa lase containerul "sanatos".
|
||||
- **Worker** (`app/worker/__main__.py`) — bucla: heartbeat → recupereaza orfane → claim atomic (`BEGIN IMMEDIATE`) → login RAR (per cont) → `postPrezentare` → update. Retry/backoff exponential, reconciliere anti-duplicat, lease pe randuri `sending` orfane, re-login la JWT expirat (TTL 30h). Send DEZACTIVAT implicit (`AUTOPASS_WORKER_SEND_ENABLED=false`) — sigur pentru probe.
|
||||
|
||||
**Doua canale de intrare** convergent in coada `submissions`:
|
||||
- Canal API (Treapta 1): `POST /v1/prezentari` pentru ROAAUTO / soft propriu.
|
||||
- Import web (Treapta 2): upload xlsx/csv → mapare coloane → preview → commit (`app/import_parse.py`, `import_router.py`, dashboard).
|
||||
|
||||
Flux: validare (`validation.py`) → mapare operatie→cod (`mapping.py`) → enqueue cu PII criptat → worker trimite → dashboard monitorizeaza live.
|
||||
|
||||
### Invariante critice (usor de stricat)
|
||||
|
||||
- **`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.
|
||||
- **`FINALIZATA` e terminal la RAR** — fara anulare/corectie prin API. De aceea reconcilierea anti-duplicat: pe eroare **ambigua** (timeout / TransportError / 502/503/504 / 429 / 408) sau rand `sending` orfan, worker-ul cauta in finalizate (match pe vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara a re-trimite (`reconcile.py`). **EXCEPTIE: un RAR 500 cu mesaj** (`RarError.rar_message`, ex. `ORA-12899`) e un esec DEFINITIV (RAR a raspuns „am esuat", nu o pierdere de raspuns) → worker-ul NU reconciliaza si NU reincearca, marcheaza `error` cu mesajul RAR (`RAR_EROARE_SERVER`). Altfel ar marca fals `sent` pe un record PARTIAL pe care RAR (ne-tranzactional) il lasa la esec.
|
||||
- **Creds RAR per cont**: durabile in `accounts.rar_creds_enc` (canal web, fallback re-login) SAU efemere in `submissions.rar_creds_enc` (canal API, sterse dupa primul login reusit). Worker incearca submission-ul intai, apoi fallback la cont. Purjarea sterge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_enc`.
|
||||
- **Auth API-key** (`auth.py`): identifica CONTUL ROAAUTO, separat de credentialele RAR. Stocam doar SHA-256 al cheii. Enforcement prin `AUTOPASS_REQUIRE_API_KEY`: `false` (dev) → fara cheie merge pe cont id=1, cheie invalida → 401; `true` (prod) → cheie obligatorie pe `/v1/*` protejat. POST-urile + rutele de import sunt account-scoped; GET-urile de listare sunt si ele account-scoped (5.15/US-011: fragmentele `_fragments/submissions|trimitere|mapari|status|jurnal|nomenclator|trimiteri-versiune` sub `require_login` + scope, 404-before-leak pe id strain; `GET /v1/prezentari(/{id})`/`/v1/mapari`/`/v1/audit/export` filtrate pe cont). `GET /v1/nomenclator` ramane public intentionat (coduri RAR publice, fara PII).
|
||||
- **Mapare coloane retinuta per `(account_id, signature_coloane)`** (`column_mappings`): la urmatorul fisier cu aceleasi coloane, pentru acelasi cont, maparea se reaplica automat. Un cont poate avea mai multe formate memorate simultan.
|
||||
- **Mapare operatie→cod**: prestatie poate veni cu `cod_prestatie` (cod RAR direct) sau `cod_op_service` (cod intern) + `denumire`. Nerezolvat → submission `needs_mapping` (nu se trimite), apare in editorul web cu sugestie fuzzy; la salvarea maparii se re-rezolva automat submission-urile blocate.
|
||||
- **`cod_prestatie` e VALIDAT fata de nomenclator la ingestie** (`resolve_prestatii(..., valid_codes)`): un cod direct NECUNOSCUT in nomenclator NU se mai trimite raw — e promovat la `cod_op_service` (denumire=cod) si tratat ca operatie de mapat. Motiv (confirmat live 2026-06-23): RAR accepta NUMAI coduri din nomenclator (coloana `COD_PRESTATIE` max 5 car.); un cod necunoscut da **HTTP 500** (`ORA-12899`), iar RAR **NU e tranzactional** → lasa un record PARTIAL `FINALIZATA` (terminal) chiar pe esec, pe care reconcilierea worker-ului l-ar marca fals `sent`. Comportamentul la cod necunoscut/nemapat: `on_unmapped_error` (camp boolean top-level pe `POST /v1/prezentari` + `/valideaza`) = `false` (intra in editor, `needs_mapping`) sau `true` (respinge fara enqueue → `submission_id=null` + `erori`). Default = `accounts.on_unmapped_error_default` (implicit `false`/`0`); precedenta cerere > cont > `false`.
|
||||
- **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`).
|
||||
- **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
|
||||
|
||||
`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
|
||||
|
||||
Vezi `/workspace/CLAUDE.md` (workspace-level): cand esti lansat cu `claude -p`, creeaza fisiere noi DOAR in `/workspace/.claude-work/<task>/`, nu in `/workspace`. Modificarile la fisiere existente se fac in locatia originala.
|
||||
341
DESIGN.md
Normal file
341
DESIGN.md
Normal 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">×</button>
|
||||
REV2
|
||||
</span>
|
||||
<span class="chip chip-warn"> <!-- varianta warn pentru R-ODO/I-ODO -->
|
||||
<button class="chip-del" aria-label="Sterge codul R-ODO" type="button">×</button>
|
||||
R-ODO
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Clase aditionale:
|
||||
- `.chip-warn` — fundal `--warn` 22% (pentru coduri R-ODO/I-ODO care cer odometruInitial)
|
||||
|
||||
### `.add-code` + `.op-row` (picker E4)
|
||||
|
||||
Buton dashed pentru adaugare cod (`.add-code`) si randul operatie<->cod (`.op-row`, `.op-row-name`,
|
||||
`.op-row-warn`). Folosite de picker-ul E4 din US-007 (formular editare).
|
||||
|
||||
```html
|
||||
<div class="op-row">
|
||||
<span class="op-row-name">REVIZIE PERIODICA</span>
|
||||
<span class="chip">REV2 <button class="chip-del" ...>×</button></span>
|
||||
<button class="add-code" type="button">+ alt cod</button>
|
||||
</div>
|
||||
<div class="op-row op-row-warn"> <!-- bordura warn: lipsa cod -->
|
||||
<span class="op-row-name">SCHIMBARE PLACUTE FRANA</span>
|
||||
<button class="add-code" type="button">alege cod RAR</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Ce NU schimbam
|
||||
|
||||
- Mecanismul light/dark existent (anti-FOUC, persistenta `localStorage`, comutator) — il pastram,
|
||||
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`.
|
||||
@@ -3,10 +3,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
# Fus orar RO: SQLite 'localtime' (bucketare contoare azi/luna, E7) depinde de TZ.
|
||||
# tzdata ofera baza de fusuri; TZ alege Europe/Bucharest (DST-aware, UTC+2/+3).
|
||||
TZ=Europe/Bucharest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# tzdata = necesar pentru ca 'localtime' din SQLite sa rezolve Europe/Bucharest.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
178
README.md
Normal file
178
README.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Gateway RAR AUTOPASS
|
||||
|
||||
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 (ROAAUTO).
|
||||
|
||||
Doua procese peste acelasi SQLite, care comunica prin tabela `submissions`:
|
||||
|
||||
- **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.
|
||||
|
||||
Trimiterea catre RAR e **dezactivata implicit** (`AUTOPASS_WORKER_SEND_ENABLED=false`) — sigur pentru probe.
|
||||
|
||||
Sursa de adevar pentru contractul RAR: [`docs/api-rar-contract.md`](docs/api-rar-contract.md).
|
||||
Progres + proces: [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
||||
|
||||
## Pornire rapida
|
||||
|
||||
```bash
|
||||
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)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Dev rapid fara login: porneste cu `AUTOPASS_WEB_AUTH_REQUIRED=false` (dashboard pe contul implicit id=1).
|
||||
|
||||
### Cu `start.sh` (ambaleaza mediu + rol)
|
||||
|
||||
```bash
|
||||
./start.sh test both --send # API + worker, trimite la RAR test (loguri in .run/)
|
||||
./start.sh test finalizate # listeaza prezentarile inregistrate la RAR (verificare independenta)
|
||||
./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
|
||||
```
|
||||
|
||||
## Pagini web
|
||||
|
||||
| URL | Ce vezi |
|
||||
|-----|---------|
|
||||
| `/` | Dashboard: coada, prezentari blocate, stare worker, import fisier, mapari, nomenclator |
|
||||
| `/signup` · `/login` | Inregistrare cont (emite cheia API o data) · autentificare |
|
||||
| `/admin` | Panou admin: conturi pe stari, activare/blocare/arhivare (doar admini) |
|
||||
| `/integrare` | Exemple cod (Python/C#/Node/VFP), export Postman/OpenAPI, testeaza conexiunea |
|
||||
| `/docs` | Swagger UI — API v1 interactiv |
|
||||
| `/healthz` · `/metrics` | sanatate JSON · metrici text |
|
||||
|
||||
## Import fisier (xlsx / csv)
|
||||
|
||||
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.
|
||||
|
||||
Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, `Odometru final`,
|
||||
`Odometru initial`, `Operatie`, `Observatii`. Fiecare cont poate avea mai multe formate memorate.
|
||||
|
||||
## API v1 (curl)
|
||||
|
||||
Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`.
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8010/healthz | python3 -m json.tool # sanatate
|
||||
curl -s http://localhost:8010/v1/nomenclator # coduri RAR (cache local)
|
||||
curl -s http://localhost:8010/v1/prezentari # coada
|
||||
|
||||
# Trimite o prezentare. `rar_credentials` e OPTIONAL: daca lipseste, worker-ul
|
||||
# foloseste creds-urile RAR salvate pe cont (POST /v1/conturi/rar-creds). Trimite-le
|
||||
# explicit doar cand vrei sa le suprascrii pe acea cerere.
|
||||
curl -s -X POST http://localhost:8010/v1/prezentari \
|
||||
-H 'X-API-Key: rfak_...' -H 'Content-Type: application/json' \
|
||||
-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'
|
||||
```
|
||||
|
||||
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
|
||||
# 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
|
||||
```
|
||||
|
||||
**Creds RAR per cont** (ca worker-ul sa trimita fara parola in fiecare cerere) — criptate Fernet at-rest:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8010/v1/conturi/rar-creds \
|
||||
-H 'X-API-Key: rfak_...' -H 'Content-Type: application/json' \
|
||||
-d '{"email": "service@exemplu.ro", "password": "parola-rar"}'
|
||||
```
|
||||
|
||||
> GET-urile de listare (`/v1/prezentari`, `/v1/nomenclator`, `/v1/audit/export`) sunt momentan
|
||||
> **globale si neprotejate** — filtrarea pe cont ramane de adaugat.
|
||||
|
||||
## Proba reala la RAR (mediu test)
|
||||
|
||||
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
|
||||
python3 -m pytest -q # toata suita
|
||||
python3 -m pytest tests/test_x.py -q # un fisier
|
||||
```
|
||||
|
||||
## Docker / deploy
|
||||
|
||||
```bash
|
||||
cp .env.example .env # CRITIC: completeaza AUTOPASS_CREDS_KEY (partajata api+worker)
|
||||
docker compose up --build # api (:8010) + worker + autoheal, acelasi image + volum SQLite
|
||||
```
|
||||
|
||||
## Structura
|
||||
|
||||
```
|
||||
app/
|
||||
main.py # FastAPI: API v1 + dashboard + auth + admin
|
||||
api/v1/ # router.py (prezentari, valideaza, nomenclator, mapari, conturi),
|
||||
# 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
|
||||
rar_client.py # client HTTP RAR (login/JWT, postPrezentare, nomenclator)
|
||||
auth.py users.py accounts.py # chei API, parole scrypt + admin, lifecycle conturi
|
||||
validation.py mapping.py errors.py crypto.py # validare, mapare cod, erori 3-niveluri, Fernet
|
||||
schema.sql # schema SQLite
|
||||
tools/ # CLI admin: account, apikey, backup, rar_finalizate, import_dbf
|
||||
docs/ # contract RAR + ROADMAP + prd/
|
||||
tests/ legacy-vfp/ # suita pytest · arhiva ROAAUTO (referinta)
|
||||
```
|
||||
78
TODOS.md
Normal file
78
TODOS.md
Normal 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.)
|
||||
257
app/accounts.py
Normal file
257
app/accounts.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Lifecycle conturi ROAAUTO (admin, fara suprafata HTTP).
|
||||
|
||||
Functii pure de creare/listare/(de)activare cont, partajate intre CLI
|
||||
(`tools/account.py`, Etapa 3.1) si fluxul web de self-onboarding (Etapa 3.3,
|
||||
care reuseaza `create_account` + `active`). Identitatea de login (email/parola)
|
||||
NU traieste aici — apartine 3.3.
|
||||
|
||||
NOTA lifecycle `active`: coloana `accounts.active` este un flag de lifecycle
|
||||
consumat de 3.3 (gate „cont in asteptare", `active=0`). Pana la gate-ul worker din
|
||||
3.3, `active=0` NU opreste trimiterile (worker-ul nu citeste contul, doar
|
||||
`api_keys.active`). `deactivate` marcheaza intentia administrativa; nu blocheaza
|
||||
inca fluxul de trimitere. (Addendum A2.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
def _norm_cui(cui: str | None) -> str | None:
|
||||
"""trim + upper; sir gol -> ValueError daca e string gol, None daca e None."""
|
||||
if cui is None:
|
||||
return None
|
||||
cui = cui.strip().upper()
|
||||
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(
|
||||
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:
|
||||
"""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); sir gol -> ValueError.
|
||||
`email` se normalizeaza (trim+lower); sir gol -> ValueError.
|
||||
Un CUI deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial
|
||||
`ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta.
|
||||
|
||||
`requested_plan`: planul CERUT la signup (separat de `tier`). NU acorda drepturi — `tier`
|
||||
ramane mereu 'free' la creare; planul cerut e doar o intentie pentru integrarea platilor.
|
||||
Valoare invalida (nu e in VALID_TIERS) -> ignorata (stocata NULL), nu arunca.
|
||||
`consent_at`: marca temporala consimtamant Termeni+GDPR (proba); None = fara flux consimtamant.
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("name gol (un cont are nevoie de nume)")
|
||||
cui = _norm_cui(cui)
|
||||
email = _norm_email(email)
|
||||
# Planul cerut: pastram doar valori valide; orice altceva -> NULL (defensiv).
|
||||
req_plan = requested_plan if requested_plan in VALID_TIERS else None
|
||||
try:
|
||||
# Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z.
|
||||
trial_until = (
|
||||
(datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
||||
cur = conn.execute(
|
||||
"INSERT INTO accounts (name, cui, email, active, status, tier, trial_until, "
|
||||
"requested_plan, consent_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(name, cui, email, 1 if active else 0, "active" if active else "pending",
|
||||
"free", trial_until, req_plan, consent_at),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
||||
owner = existing["id"] if existing else "?"
|
||||
raise ValueError(
|
||||
f"CUI {cui} e deja folosit de contul {owner} "
|
||||
f"(foloseste 'activate --account {owner}' sau alt CUI)"
|
||||
)
|
||||
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:
|
||||
"""Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca).
|
||||
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()
|
||||
if not row:
|
||||
raise ValueError(f"cont inexistent: {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]:
|
||||
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
|
||||
(stergere soft -> invizibile in panou)."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, cui, email, active, status, tier, trial_until, "
|
||||
"requested_plan, consent_at, created_at FROM accounts "
|
||||
"WHERE status != 'deleted' ORDER BY id"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
1397
app/api/v1/import_router.py
Normal file
1397
app/api/v1/import_router.py
Normal file
File diff suppressed because it is too large
Load Diff
180
app/api/v1/integrare_router.py
Normal file
180
app/api/v1/integrare_router.py
Normal 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")
|
||||
@@ -1,49 +1,266 @@
|
||||
"""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).
|
||||
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
|
||||
- GET /v1/nomenclator: cache local.
|
||||
- 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
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...auth import require_api_access, resolve_account_id
|
||||
from ...crypto import encrypt_creds
|
||||
from ...db import get_connection
|
||||
from ...idempotency import idempotency_key
|
||||
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
|
||||
from ...errors import eroare as err_eroare
|
||||
from ...idempotency import build_key, canonicalize_row
|
||||
from ...mapping import (
|
||||
_emite_text_rule_hits,
|
||||
account_or_default,
|
||||
account_scope_clause,
|
||||
classify_prezentare,
|
||||
load_mapping_meta,
|
||||
load_nomenclator_codes,
|
||||
load_text_rules,
|
||||
pending_unmapped,
|
||||
reresolve_account,
|
||||
save_mapping,
|
||||
)
|
||||
from ...models import (
|
||||
PrezentareRequest,
|
||||
PrezentariResponse,
|
||||
SubmissionResult,
|
||||
ValidarePrezentariRequest,
|
||||
ValidareResponse,
|
||||
ValidareResult,
|
||||
)
|
||||
from ...observ import log_event
|
||||
from ...payload_view import prezentare_din_payload
|
||||
from ...submissions_admin import (
|
||||
SubmissionNotFound,
|
||||
SubmissionStateConflict,
|
||||
delete_submission,
|
||||
requeue_submission,
|
||||
)
|
||||
|
||||
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)
|
||||
def create_prezentari(req: PrezentareRequest) -> PrezentariResponse:
|
||||
def create_prezentari(
|
||||
req: PrezentareRequest,
|
||||
account_id: int = Depends(require_api_access),
|
||||
) -> PrezentariResponse:
|
||||
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
|
||||
|
||||
TODO(T3): validare Pydantic completa inainte de enqueue (VIN/data/nrInm),
|
||||
ruteaza needs_data/needs_mapping.
|
||||
TODO(auth): rezolva account_id din API key (acum None).
|
||||
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi
|
||||
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
|
||||
Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU
|
||||
resping cererea, ci enqueue-aza cu status `needs_data` + motiv. JSON malformat ->
|
||||
422 din Pydantic (validare de shape).
|
||||
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.
|
||||
Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
|
||||
cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`).
|
||||
"""
|
||||
account_id = None # TODO(auth): din API key
|
||||
acct = account_or_default(account_id)
|
||||
# Creds RAR efemere: criptate si lipite de fiecare submission nou pana la
|
||||
# primul login reusit pentru cont (worker le sterge atunci). Zero-storage at
|
||||
# rest — niciodata in clar in DB/loguri. Optional: cand lipsesc,
|
||||
# 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()
|
||||
results: list[SubmissionResult] = []
|
||||
try:
|
||||
# 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 = {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:
|
||||
content = prez.model_dump()
|
||||
key = idempotency_key(account_id, content)
|
||||
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
||||
# build_key aplica account_or_default(account_id) inainte de hash:
|
||||
# None si 1 colapseaza la aceeasi cheie (canal API + canal import).
|
||||
canon = canonicalize_row(content)
|
||||
key = build_key(account_id, canon)
|
||||
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
|
||||
content.update({
|
||||
"vin": canon["vin"],
|
||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
existing = conn.execute(
|
||||
"SELECT id, status, id_prezentare FROM submissions WHERE idempotency_key=?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
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(
|
||||
SubmissionResult(
|
||||
submission_id=existing["id"],
|
||||
@@ -53,48 +270,213 @@ def create_prezentari(req: PrezentareRequest) -> PrezentariResponse:
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
||||
# (canonicalize + mapare op->cod + validare; auto_send gate eliminat dupa US-001).
|
||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||
if cl["blocked_error"]:
|
||||
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
||||
results.append(_rezultat_respins(None, cl))
|
||||
continue
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, 'queued', ?)",
|
||||
(key, account_id, json.dumps(content, ensure_ascii=False)),
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(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="queued"))
|
||||
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:
|
||||
conn.close()
|
||||
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")
|
||||
def list_prezentari(status: str | None = None, limit: int = 100) -> dict:
|
||||
def list_prezentari(
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
|
||||
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
|
||||
cols = (
|
||||
"id, status, id_prezentare, rar_status_code, retry_count, "
|
||||
"created_at, updated_at, payload_json"
|
||||
)
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
"FROM submissions WHERE status=? ORDER BY id DESC LIMIT ?",
|
||||
(status, limit),
|
||||
f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? "
|
||||
f"ORDER BY id DESC LIMIT ?",
|
||||
scope_params + [status, limit],
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
"FROM submissions ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
|
||||
scope_params + [limit],
|
||||
).fetchall()
|
||||
return {"submissions": [dict(r) for r in rows]}
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
# Campuri afisabile derivate din payload (acelasi helper ca dashboardul web);
|
||||
# payload_json brut nu se intoarce in raspuns.
|
||||
d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None))
|
||||
out.append(d)
|
||||
return {"submissions": out}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita.
|
||||
# Exclude: rar_creds_enc, payload_json, idempotency_key, sending_since.
|
||||
_PREZENTARE_FIELDS = frozenset({
|
||||
"id", "status", "id_prezentare", "rar_status_code", "retry_count",
|
||||
"next_attempt_at", "created_at", "updated_at", "account_id",
|
||||
"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",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/prezentari/{submission_id}")
|
||||
def get_prezentare(submission_id: int) -> dict:
|
||||
def get_prezentare(
|
||||
submission_id: int,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM submissions WHERE id=?", (submission_id,)).fetchone()
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
row = conn.execute(
|
||||
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
|
||||
[submission_id] + scope_params,
|
||||
).fetchone()
|
||||
if not row:
|
||||
# Acelasi mesaj indiferent daca randul exista dar apartine altui cont
|
||||
# sau nu exista deloc — nu confirmam existenta.
|
||||
raise HTTPException(status_code=404, detail="submission inexistent")
|
||||
out = dict(row)
|
||||
out.pop("payload_json", None) # nu expunem payload-ul brut (PII) in listare
|
||||
return out
|
||||
row_dict = dict(row)
|
||||
return {k: v for k, v in row_dict.items() if k in _PREZENTARE_FIELDS}
|
||||
finally:
|
||||
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()
|
||||
|
||||
@@ -111,17 +493,222 @@ def get_nomenclator() -> dict:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/mapari")
|
||||
def get_mapari(account_id: int | None = None) -> dict:
|
||||
AUDIT_COLUMNS = [
|
||||
"submission_id",
|
||||
"status",
|
||||
"id_prezentare",
|
||||
"account_id",
|
||||
"vin",
|
||||
"nr_inmatriculare",
|
||||
"data_prestatie",
|
||||
"odometru_final",
|
||||
"prestatii",
|
||||
"rar_status_code",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"purge_after",
|
||||
]
|
||||
|
||||
|
||||
def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, account_id: int):
|
||||
"""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 IS NULL apartin contului 1. b64_image NU intra in CSV.
|
||||
"""
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
sql = (
|
||||
"SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, "
|
||||
"created_at, updated_at, purge_after FROM submissions"
|
||||
)
|
||||
where = [scope_sql]
|
||||
params: list = list(scope_params)
|
||||
if status != "all":
|
||||
where.append("status=?")
|
||||
params.append(status)
|
||||
if date_from:
|
||||
where.append("date(updated_at) >= date(?)")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
where.append("date(updated_at) <= date(?)")
|
||||
params.append(date_to)
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sql += " ORDER BY id"
|
||||
|
||||
for r in conn.execute(sql, params).fetchall():
|
||||
try:
|
||||
p = json.loads(r["payload_json"]) if r["payload_json"] else {}
|
||||
except (ValueError, TypeError):
|
||||
p = {}
|
||||
codes = ",".join(
|
||||
(it.get("cod_prestatie") or it.get("cod_op_service") or "")
|
||||
for it in (p.get("prestatii") or [])
|
||||
if isinstance(it, dict)
|
||||
)
|
||||
yield {
|
||||
"submission_id": r["id"],
|
||||
"status": r["status"],
|
||||
"id_prezentare": r["id_prezentare"] or "",
|
||||
# NULL→cont 1: coloana reflecta invariantul de scope, nu "" ambiguu.
|
||||
"account_id": account_or_default(r["account_id"]),
|
||||
"vin": p.get("vin") or "",
|
||||
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
|
||||
"data_prestatie": p.get("data_prestatie") or "",
|
||||
"odometru_final": p.get("odometru_final") or "",
|
||||
"prestatii": codes,
|
||||
"rar_status_code": r["rar_status_code"] or "",
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
"purge_after": r["purge_after"] or "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/audit/export")
|
||||
def audit_export(
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
status: str = "sent",
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> StreamingResponse:
|
||||
"""CSV audit scoped pe contul cheii API. Filtre optionale `date_from`/`date_to` (YYYY-MM-DD)
|
||||
|
||||
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
|
||||
`purge_after`. b64_image nu se exporta.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
if account_id is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service",
|
||||
(account_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM operations_mapping ORDER BY account_id, cod_op_service").fetchall()
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS)
|
||||
writer.writeheader()
|
||||
for row in _audit_rows(conn, date_from, date_to, status, account_id):
|
||||
writer.writerow(row)
|
||||
data = buf.getvalue()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
fname = f"audit_{status}_{date_from or 'inceput'}_{date_to or 'azi'}.csv"
|
||||
return StreamingResponse(
|
||||
iter([data]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mapari")
|
||||
def get_mapari(
|
||||
key_account: int = Depends(resolve_account_id),
|
||||
account_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Maparile operatie->cod ale contului curent.
|
||||
|
||||
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul
|
||||
efectiv vine MEREU din cheia API. Daca e prezent si difera -> 400.
|
||||
"""
|
||||
if account_id is not None and account_id != key_account:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="account_id din query nu corespunde contului cheii API",
|
||||
)
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service",
|
||||
(key_account,),
|
||||
).fetchall()
|
||||
return {"mapari": [dict(r) for r in rows]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/mapari/pending")
|
||||
def get_mapari_pending(
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy.
|
||||
|
||||
Filtrate pe contul cheii API. Fiecare intrare: {account_id, cod_op_service,
|
||||
denumire, blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
return {"pending": pending_unmapped(conn, account_id=account_id)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class MapareIn(BaseModel):
|
||||
cod_op_service: str = Field(..., min_length=1)
|
||||
cod_prestatie: str = Field(..., min_length=1)
|
||||
auto_send: bool = True
|
||||
|
||||
|
||||
@router.post("/mapari")
|
||||
def create_mapare(
|
||||
req: MapareIn,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Salveaza/actualizeaza o mapare op->cod si re-rezolva submission-urile blocate.
|
||||
|
||||
Contul vine din cheia API (NU din body) — un cont nu poate edita maparile
|
||||
altuia. Verifica intai ca `cod_prestatie` exista in nomenclator (nu lasam
|
||||
mapari catre coduri inexistente). Apoi upsert + re-rezolvare `needs_mapping`.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cod = req.cod_prestatie.strip().upper()
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
||||
).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(status_code=422, detail=f"cod_prestatie '{cod}' nu exista in nomenclator")
|
||||
save_mapping(conn, account_id, req.cod_op_service, cod, req.auto_send)
|
||||
stats = reresolve_account(conn, account_id)
|
||||
return {"saved": {"cod_op_service": req.cod_op_service.strip(), "cod_prestatie": cod}, "reresolve": stats}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class RarCredsIn(BaseModel):
|
||||
"""Creds RAR durabile per-cont. Stocate criptate (Fernet) in accounts.rar_creds_enc."""
|
||||
|
||||
email: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=1, repr=False)
|
||||
|
||||
|
||||
@router.post("/conturi/rar-creds")
|
||||
def set_rar_creds(
|
||||
req: RarCredsIn,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Seteaza creds RAR durabile per-cont.
|
||||
|
||||
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).
|
||||
Contul vine din cheia API.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
enc = encrypt_creds({"email": req.email, "password": req.password})
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
||||
(enc, acct),
|
||||
)
|
||||
return {"ok": True, "account_id": acct}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/conturi/rar-creds")
|
||||
def delete_rar_creds(
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Sterge creds RAR durabile per-cont (revenire la modelul efemer Treapta 1)."""
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("UPDATE accounts SET rar_creds_enc=NULL WHERE id=?", (acct,))
|
||||
return {"ok": True, "account_id": acct}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
221
app/auth.py
Normal file
221
app/auth.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Autentificare API-key per cont (CORE securitate).
|
||||
|
||||
Cheile API sunt separate de credentialele RAR: identifica CONTUL ROAAUTO care
|
||||
foloseste gateway-ul, nu utilizatorul RAR. Stocam doar SHA-256 al cheii (tabela
|
||||
`api_keys`), niciodata cheia in clar — emisa o singura data la creare.
|
||||
|
||||
Enforcement controlat de `AUTOPASS_require_api_key`:
|
||||
- True (prod): orice /v1/* protejat cere cheie valida -> 401 fara/cu cheie gresita.
|
||||
- False (dev/test): fara cheie -> cont implicit id=1 (back-compat); cheie prezenta
|
||||
dar invalida -> tot 401 (o cheie gresita nu trebuie sa treaca tacit).
|
||||
|
||||
Lifecycle (emitere/rotire/revocare) se face din CLI `python -m tools.apikey`
|
||||
(adminul ruleaza pe masina) — nicio suprafata HTTP de admin de securizat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
|
||||
from .config import get_settings
|
||||
from .db import get_connection
|
||||
from .mapping import DEFAULT_ACCOUNT_ID
|
||||
|
||||
KEY_PREFIX = "rfak_" # romfast autopass key
|
||||
|
||||
|
||||
def generate_key() -> str:
|
||||
"""Cheie noua in clar (emisa o singura data). `rfak_` + 43 caractere urlsafe."""
|
||||
return KEY_PREFIX + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_key(plaintext: str) -> str:
|
||||
"""SHA-256 hex al cheii. Comparam mereu pe hash, niciodata pe clar."""
|
||||
return hashlib.sha256(plaintext.strip().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def create_api_key(conn: sqlite3.Connection, account_id: int) -> str:
|
||||
"""Emite o cheie pentru cont, stocheaza hash-ul, intoarce cheia in clar.
|
||||
|
||||
Verifica intai ca exista contul (FK ON DELETE CASCADE nu raporteaza un cont
|
||||
inexistent la INSERT decat ca eroare obscura). Intoarce cheia in clar — NU se
|
||||
mai poate recupera ulterior.
|
||||
"""
|
||||
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not acct:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
plaintext = generate_key()
|
||||
conn.execute(
|
||||
"INSERT INTO api_keys (account_id, key_hash, active) VALUES (?, ?, 1)",
|
||||
(account_id, hash_key(plaintext)),
|
||||
)
|
||||
return plaintext
|
||||
|
||||
|
||||
def revoke_api_key(conn: sqlite3.Connection, key_id: int) -> bool:
|
||||
"""Revoca o cheie (active=0 + revoked_at). Intoarce True daca a fost activa."""
|
||||
cur = conn.execute(
|
||||
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE id=? AND active=1",
|
||||
(key_id,),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def rotate_api_key(conn: sqlite3.Connection, account_id: int) -> str:
|
||||
"""Revoca toate cheile active ale contului si emite una noua. Intoarce cheia noua."""
|
||||
conn.execute(
|
||||
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE account_id=? AND active=1",
|
||||
(account_id,),
|
||||
)
|
||||
return create_api_key(conn, account_id)
|
||||
|
||||
|
||||
def list_keys(conn: sqlite3.Connection, account_id: int | None = None) -> list[dict]:
|
||||
"""Metadate chei (FARA hash). Pentru CLI list."""
|
||||
if account_id is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT id, account_id, active, created_at, revoked_at FROM api_keys "
|
||||
"WHERE account_id=? ORDER BY id",
|
||||
(account_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, account_id, active, created_at, revoked_at FROM api_keys ORDER BY id"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def account_for_key(conn: sqlite3.Connection, plaintext: str) -> int | None:
|
||||
"""account_id pentru o cheie activa, sau None daca lipseste/revocata."""
|
||||
if not plaintext:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT account_id FROM api_keys WHERE key_hash=? AND active=1",
|
||||
(hash_key(plaintext),),
|
||||
).fetchone()
|
||||
return int(row["account_id"]) if row else None
|
||||
|
||||
|
||||
def _extract_key(x_api_key: str | None, authorization: str | None) -> str | None:
|
||||
"""Cheia din `X-API-Key` sau `Authorization: Bearer <key>` (prima are prioritate)."""
|
||||
if x_api_key and x_api_key.strip():
|
||||
return x_api_key.strip()
|
||||
if authorization and authorization.strip():
|
||||
parts = authorization.strip().split(None, 1)
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
return parts[1].strip()
|
||||
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(
|
||||
request: Request,
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> int:
|
||||
"""Dependency FastAPI: contul cererii din cheia API.
|
||||
|
||||
- cheie valida -> account_id al cheii
|
||||
- cheie invalida (prezenta) -> 401 (mereu, indiferent de flag)
|
||||
- fara cheie + flag off -> cont implicit (id=1), back-compat
|
||||
- fara cheie + flag on -> 401
|
||||
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie.
|
||||
"""
|
||||
settings = get_settings()
|
||||
plaintext = _extract_key(x_api_key, authorization)
|
||||
|
||||
if plaintext is None:
|
||||
if settings.require_api_key:
|
||||
_log_auth_esuat(request, None, "cheie API lipsa (prod)")
|
||||
raise HTTPException(status_code=401, detail="cheie API lipsa")
|
||||
return DEFAULT_ACCOUNT_ID
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
account_id = account_for_key(conn, plaintext)
|
||||
finally:
|
||||
conn.close()
|
||||
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")
|
||||
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
|
||||
107
app/config.py
107
app/config.py
@@ -1,8 +1,8 @@
|
||||
"""Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite.
|
||||
|
||||
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO
|
||||
(vezi plan.md sect. 5). Helper-ul `load_test_credentials` citeste blocul
|
||||
<test> din settings.xml DOAR pentru dev local / probe pe mediul de test.
|
||||
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO.
|
||||
Helper-ul `load_test_credentials` citeste blocul <test> din settings.xml DOAR
|
||||
pentru dev local / probe pe mediul de test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,25 +22,116 @@ class Settings(BaseSettings):
|
||||
# --- Bază de date ---
|
||||
db_path: Path = ROOT / "data" / "autopass.db"
|
||||
|
||||
# --- 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 ->
|
||||
# cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA
|
||||
# dar invalida da 401 indiferent de flag.
|
||||
require_api_key: bool = False
|
||||
|
||||
# Cheie Fernet pentru criptarea creds RAR efemere in submissions (zero-storage
|
||||
# at rest). Nesetata -> cheie efemera la runtime (creds nu supravietuiesc
|
||||
# restartului). In productie seteaz-o persistent. Genereaza:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
creds_key: str | None = None
|
||||
|
||||
# --- Sesiuni web ---
|
||||
# Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
|
||||
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
|
||||
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
session_secret: str | None = None
|
||||
# True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login;
|
||||
# CSRF enforce. Pentru dev rapid pe contul implicit id=1,
|
||||
# seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false.
|
||||
web_auth_required: bool = True
|
||||
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag.
|
||||
# False (dev): cookie fara Secure, functioneaza pe HTTP.
|
||||
session_https_only: bool = False
|
||||
|
||||
# --- Contact suport (US-001, PRD 5.12) ---
|
||||
# Email/canal de contact afisat in mesaje catre utilizatori (ex. CUI duplicat la signup).
|
||||
# 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_port: int = 587
|
||||
smtp_user: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_from: str | None = None
|
||||
|
||||
# --- Rate-limit signup + login ---
|
||||
# Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua).
|
||||
signup_rate_max: int = 5
|
||||
signup_rate_window_s: int = 3600
|
||||
# Max incercari POST /login per IP (brute-force parole). Fereastra impartita cu signup.
|
||||
login_rate_max: int = 10
|
||||
|
||||
# --- RAR ---
|
||||
rar_env: str = "test" # "test" | "prod"
|
||||
rar_base_url_test: str = "https://apps.rarom.ro/test-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
|
||||
# docs/api-rar-contract.md). Toate apelurile httpx il trimit.
|
||||
# WAF-ul RAR da 403 fara User-Agent de browser. Toate apelurile httpx il trimit.
|
||||
http_user_agent: str = "Mozilla/5.0"
|
||||
http_timeout_s: float = 30.0
|
||||
|
||||
# --- Worker ---
|
||||
worker_poll_interval_s: float = 5.0
|
||||
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
|
||||
# pentru proba end-to-end. Reconcilierea/retry-ul complet = T2.
|
||||
# Send DEZACTIVAT implicit (nu trimite la RAR). Activeaza-l explicit pentru
|
||||
# proba end-to-end.
|
||||
worker_send_enabled: bool = False
|
||||
# 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
|
||||
# Keepalive RAR: cand coada e goala, worker-ul face un login de proba la fiecare
|
||||
# atata timp ca sa pastreze last_rar_login_ok proaspat (sub pragul de 30h al
|
||||
# dashboard-ului) — altfel banner-ul "RAR inaccesibil" apare fals doar din lipsa
|
||||
# de trafic. 0 = dezactivat. Implicit o data pe zi (24h < 30h, margine de 6h).
|
||||
worker_rar_keepalive_interval_s: int = 86400
|
||||
worker_sending_lease_s: int = 120 # rand 'sending' mai vechi de atat = orfan (worker mort mid-POST)
|
||||
worker_retry_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
|
||||
worker_retry_max_s: int = 300
|
||||
worker_max_retries: int = 8 # peste atat -> error + banner
|
||||
|
||||
# --- Planuri de cont (PRD 5.17) ---
|
||||
# Enforcement DUR al limitelor de plan (volum + acces API). True (implicit) = activ.
|
||||
# False = kill-switch de operare: sare toate gate-urile de plan (util pentru debugging
|
||||
# sau rollback rapid fara revert de cod). Enforcement DUR e activ implicit de la deploy
|
||||
# (decizie user 2026-06-28, decizia #22 autoplan): nu exista conturi legacy, produs in TESTE.
|
||||
enforce_plans: bool = True
|
||||
|
||||
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
||||
# ACTIVAT implicit: editorul de mapari ofera sugestii semantice (model fastembed/ONNX).
|
||||
# Cost: prima folosire lazy-load-eaza modelul (~230MB pe disc) sincron in thread-ul de
|
||||
# cerere -> prima cerere /mapari poate dura 30-120s pana modelul intra in memorie; cererile
|
||||
# urmatoare sunt instant. SUGGESTION-ONLY: nu intra in resolve_prestatii (nu auto-trimite).
|
||||
# Pune-l pe False (start.sh/Docker/.env: AUTOPASS_EMBEDDINGS_ENABLED=false) cand vrei
|
||||
# /mapari instant la prima cerere sau suita de teste rapida (cade pe GOLD/SILVER+fuzzy).
|
||||
embeddings_enabled: bool = True
|
||||
|
||||
# --- Seed corpus operatii etichetate (SILVER, PRD 5.18 US-004) ---
|
||||
# ACTIVAT implicit: la init_db, populeaza mapping_suggestions din artefactul comis
|
||||
# `app/data/operatii-etichetate.json` (INSERT OR IGNORE). Asa SILVER nu mai e gol in
|
||||
# productie -> sugestii exact-match + corpus k-NN reale. SUGGESTION-ONLY.
|
||||
# Pune-l pe False (AUTOPASS_SEED_OPERATII_ENABLED=false) cand vrei SILVER gol —
|
||||
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
|
||||
seed_operatii_enabled: bool = True
|
||||
|
||||
@property
|
||||
def rar_base_url(self) -> str:
|
||||
|
||||
79
app/crypto.py
Normal file
79
app/crypto.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Criptare simetrica pentru credentialele RAR efemere (zero-storage at rest).
|
||||
|
||||
Plan sect. 5: parola RAR vine per-cerere, se stocheaza CRIPTATA in submission
|
||||
pana la primul login reusit pentru cont, apoi se sterge. JWT (30h) acopera
|
||||
restul trimiterilor. Cheia traieste doar in `AUTOPASS_creds_key` (env), niciodata
|
||||
in cod sau in DB.
|
||||
|
||||
Daca `AUTOPASS_creds_key` nu e setat, generam o cheie EFEMERA la prima folosire:
|
||||
creds criptate NU supravietuiesc unui restart (acceptabil — modelul e efemer,
|
||||
ROAAUTO re-trimite). Pentru productie seteaza o cheie persistenta (vezi README/deploy).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _fernet() -> Fernet:
|
||||
key = get_settings().creds_key
|
||||
if key:
|
||||
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||
generated = Fernet.generate_key()
|
||||
print(
|
||||
"[crypto] AUTOPASS_creds_key nesetat — cheie efemera generata; creds "
|
||||
"criptate NU supravietuiesc restartului worker-ului/API-ului",
|
||||
flush=True,
|
||||
)
|
||||
return Fernet(generated)
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Reseteaza cheia memorata (pentru teste care schimba env-ul)."""
|
||||
_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:
|
||||
"""Cripteaza un dict de creds -> token Fernet (str). Compact, fara spatii."""
|
||||
blob = json.dumps(creds, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
return _fernet().encrypt(blob).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_creds(token: str | None) -> dict | None:
|
||||
"""Decripteaza un token Fernet -> dict, sau None daca lipseste/cheie gresita/corupt."""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
plain = _fernet().decrypt(token.encode("ascii"))
|
||||
data = json.loads(plain.decode("utf-8"))
|
||||
return data if isinstance(data, dict) else None
|
||||
except (InvalidToken, ValueError, TypeError):
|
||||
return None
|
||||
137450
app/data/operatii-etichetate.json
Normal file
137450
app/data/operatii-etichetate.json
Normal file
File diff suppressed because it is too large
Load Diff
307
app/db.py
307
app/db.py
@@ -27,14 +27,253 @@ def get_connection() -> sqlite3.Connection:
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Creeaza schema daca lipseste. Idempotent — sigur la fiecare boot."""
|
||||
"""Creeaza schema daca lipseste + migrari aditive. Idempotent — sigur la fiecare boot."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.executescript(_SCHEMA.read_text(encoding="utf-8"))
|
||||
_migrate(conn)
|
||||
# Seed fallback nomenclator (doar daca e gol) ca editorul de mapari + fuzzy
|
||||
# sa mearga inainte ca worker-ul sa fi luat lista live din RAR.
|
||||
from .mapping import seed_nomenclator_if_empty
|
||||
|
||||
seed_nomenclator_if_empty(conn)
|
||||
# Seed corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
|
||||
# Gated: OFF in teste (conftest), ON in productie. INSERT OR IGNORE -> idempotent.
|
||||
# DOAR daca mapping_suggestions e gol: seedul are ~17k randuri; re-rularea lui pe
|
||||
# FIECARE boot (API + worker concurent) tinea write-lock-ul indelung -> al doilea
|
||||
# proces primea "database is locked" la pornire. Guard "_if_empty" (ca nomenclatorul)
|
||||
# -> boot rapid cand e deja seeded. Re-seed dupa actualizarea fisierului = manual
|
||||
# (goleste tabela), consistent cu semantica v1 ignore-not-upsert a seederului.
|
||||
if get_settings().seed_operatii_enabled:
|
||||
already = conn.execute(
|
||||
"SELECT 1 FROM mapping_suggestions LIMIT 1"
|
||||
).fetchone()
|
||||
if not already:
|
||||
from .operatii_seed import seed_operatii_etichetate
|
||||
|
||||
seed_operatii_etichetate(conn)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Migrari aditive pentru DB create inainte de o coloana noua (CREATE IF NOT EXISTS nu altereaza)."""
|
||||
# Coloane submissions
|
||||
sub_cols = {r["name"] for r in conn.execute("PRAGMA table_info(submissions)").fetchall()}
|
||||
if "next_attempt_at" not in sub_cols:
|
||||
conn.execute("ALTER TABLE submissions ADD COLUMN next_attempt_at TEXT")
|
||||
if "rar_creds_enc" not in sub_cols:
|
||||
conn.execute("ALTER TABLE submissions ADD COLUMN rar_creds_enc TEXT")
|
||||
if "purge_after" not in sub_cols:
|
||||
conn.execute("ALTER TABLE submissions ADD COLUMN purge_after TEXT")
|
||||
if "batch_id" not in sub_cols:
|
||||
conn.execute("ALTER TABLE submissions ADD COLUMN batch_id INTEGER")
|
||||
if "row_index" not in sub_cols:
|
||||
conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER")
|
||||
if "rar_env" not in sub_cols:
|
||||
# PRD 5.20 US-001. Mediul RAR tinta pe submission. Pe DB existent NU lasam
|
||||
# randurile pe DEFAULT 'test': un rand prod pre-migrare etichetat 'test' ar fi
|
||||
# reconciliat de worker (US-006) contra endpoint TEST -> no-match -> re-send prod
|
||||
# = DUPLICAT REAL IREVERSIBIL. Backfill din AUTOPASS_RAR_ENV global (ancora de
|
||||
# migrare) + recompute idempotency_key env-aware. Ruleaza O SINGURA DATA (in
|
||||
# blocul de adaugare a coloanei); pe DB fresh coloana vine din schema.sql (fara rows).
|
||||
conn.execute(
|
||||
"ALTER TABLE submissions ADD COLUMN rar_env TEXT NOT NULL DEFAULT 'test' "
|
||||
"CHECK (rar_env IN ('test', 'prod'))"
|
||||
)
|
||||
_backfill_submissions_rar_env(conn)
|
||||
|
||||
# Coloane accounts
|
||||
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||
if "rar_creds_enc" not in acc_cols:
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
|
||||
acc_cols.add("rar_creds_enc")
|
||||
# Medii RAR per cont (PRD 5.20 US-001): activare + slot creds + default, per mediu.
|
||||
_migrate_accounts_medii(conn, acc_cols)
|
||||
if "active" not in acc_cols:
|
||||
# Conturi existente raman active (default 1).
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
||||
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.
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
||||
)
|
||||
|
||||
# Coloane users (DB cu users creata inaintea acestor coloane)
|
||||
user_tbl = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
).fetchone()
|
||||
if user_tbl:
|
||||
user_cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
if "is_admin" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
||||
if "email_verified" not in user_cols:
|
||||
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)
|
||||
existing_idx = {r["name"] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'"
|
||||
).fetchall()}
|
||||
if "idx_submissions_batch" not in existing_idx:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_submissions_batch ON submissions(batch_id) "
|
||||
"WHERE batch_id IS NOT NULL"
|
||||
)
|
||||
if "idx_submissions_account_status" not in existing_idx:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_submissions_account_status "
|
||||
"ON submissions(account_id, status)"
|
||||
)
|
||||
|
||||
|
||||
def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
|
||||
"""PRD 5.20 US-001: coloane medii RAR per cont + backfill din ancora globala.
|
||||
|
||||
Adauga (idempotent): rar_test_enabled/rar_prod_enabled (bife activare),
|
||||
rar_creds_test_enc/rar_creds_prod_enc (sloturi creds), rar_env_default.
|
||||
|
||||
Backfill (O SINGURA DATA, cand coloanele tocmai au fost adaugate pe DB existent):
|
||||
creds-ul legacy `rar_creds_enc` apartine mediului `AUTOPASS_RAR_ENV` global de la
|
||||
momentul migrarii (ancora) — il copiem in slotul acelui mediu, activam DOAR acel
|
||||
mediu (celalalt dezactivat) si fixam default-ul pe el. Conturile fara creds raman
|
||||
pe default-urile coloanei (prod on / test off). Migrarea NU presupune env-ul; se
|
||||
bazeaza pe ancora globala, exact cum opera contul inainte de 5.20.
|
||||
"""
|
||||
newly_added = "rar_env_default" not in acc_cols
|
||||
if "rar_test_enabled" not in acc_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN rar_test_enabled INTEGER NOT NULL DEFAULT 0 "
|
||||
"CHECK (rar_test_enabled IN (0, 1))"
|
||||
)
|
||||
if "rar_prod_enabled" not in acc_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN rar_prod_enabled INTEGER NOT NULL DEFAULT 1 "
|
||||
"CHECK (rar_prod_enabled IN (0, 1))"
|
||||
)
|
||||
if "rar_creds_test_enc" not in acc_cols:
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_test_enc TEXT")
|
||||
if "rar_creds_prod_enc" not in acc_cols:
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_prod_enc TEXT")
|
||||
if "rar_env_default" not in acc_cols:
|
||||
# ALTER nu poate adauga CHECK pe coloana noua in SQLite -> validarea ('test'/'prod')
|
||||
# se face in cod (rar_env.py / rutele de cont). DEFAULT 'prod' (cont client nou).
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_env_default TEXT NOT NULL DEFAULT 'prod'")
|
||||
|
||||
if not newly_added:
|
||||
return # coloanele existau deja -> backfill-ul a rulat la o pornire anterioara
|
||||
|
||||
# Are coloana legacy rar_creds_enc randuri de migrat? (Pe DB foarte nou, e absenta.)
|
||||
if "rar_creds_enc" not in acc_cols:
|
||||
return
|
||||
env = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test"
|
||||
other = "prod" if env == "test" else "test"
|
||||
slot = f"rar_creds_{env}_enc"
|
||||
conn.execute(
|
||||
f"UPDATE accounts SET {slot} = rar_creds_enc, "
|
||||
f"rar_{env}_enabled = 1, rar_{other}_enabled = 0, rar_env_default = ? "
|
||||
f"WHERE rar_creds_enc IS NOT NULL AND TRIM(rar_creds_enc) <> '' AND {slot} IS NULL",
|
||||
(env,),
|
||||
)
|
||||
|
||||
|
||||
def _backfill_submissions_rar_env(conn: sqlite3.Connection) -> None:
|
||||
"""PRD 5.20 US-001 (AUTO-FIX G + E4/3): backfill rar_env + recompute idempotency_key.
|
||||
|
||||
Ruleaza O SINGURA DATA, imediat dupa ce coloana `submissions.rar_env` a fost adaugata
|
||||
pe un DB existent. Toate randurile pre-migrare au fost trimise (sau urmeaza) catre
|
||||
mediul `AUTOPASS_RAR_ENV` global — le etichetam cu acel env (NU DEFAULT 'test'), altfel
|
||||
reconcilierea worker-ului ar lovi endpoint-ul gresit -> duplicat ireversibil.
|
||||
|
||||
Recompute `idempotency_key` la forma env-aware (`build_key(account_id, canon, rar_env)`):
|
||||
altfel un re-POST al unui rand legacy (cheie env-less) ar rata randul existent ->
|
||||
duplicat. Recompute-ul e consistent (acelasi env pe toate randurile pre-migrare) deci
|
||||
nu poate crea coliziuni intre randuri care erau deja distincte.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from .idempotency import build_key, canonicalize_row
|
||||
|
||||
env = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test"
|
||||
conn.execute("UPDATE submissions SET rar_env = ?", (env,))
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, account_id, idempotency_key, payload_json FROM submissions"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
try:
|
||||
content = _json.loads(r["payload_json"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
canon = canonicalize_row(content)
|
||||
# Pastreaza prestatiile rezolvate (cod_prestatie/cod_op_service) pentru _op_identity.
|
||||
canon["prestatii"] = content.get("prestatii") or []
|
||||
new_key = build_key(r["account_id"], canon, env)
|
||||
if new_key == r["idempotency_key"]:
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET idempotency_key = ? WHERE id = ?",
|
||||
(new_key, r["id"]),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# Coliziune improbabila pe UNIQUE(idempotency_key): lasa cheia veche (no-op),
|
||||
# randul ramane gasibil prin dual-lookup legacy.
|
||||
continue
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
@@ -60,3 +299,69 @@ def read_heartbeat(conn: sqlite3.Connection) -> sqlite3.Row | None:
|
||||
def queue_depth(conn: sqlite3.Connection) -> int:
|
||||
row = conn.execute("SELECT COUNT(*) AS n FROM submissions WHERE status='queued'").fetchone()
|
||||
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()
|
||||
|
||||
59
app/email.py
Normal file
59
app/email.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Helper notificare email admin la signup.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
import textwrap
|
||||
from email.message import EmailMessage
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
|
||||
"""Notifica adminii despre un cont nou in asteptare (best-effort).
|
||||
|
||||
Daca smtp_host e None SAU admin_emails e gol -> log si return (degradat).
|
||||
Daca SMTP ridica exceptie -> log eroare si return (NU se propaga).
|
||||
Timeout mic (5s) pe conexiunea SMTP.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.smtp_host or not admin_emails:
|
||||
print(
|
||||
f"SIGNUP-NOTIFY degradat (fara SMTP) cont={account_id} "
|
||||
f"email={email} admins={len(admin_emails)}",
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
expeditor = settings.smtp_from or settings.smtp_user or "autopass@localhost"
|
||||
msg["From"] = expeditor
|
||||
msg["To"] = ", ".join(admin_emails)
|
||||
msg["Subject"] = f"AutoPass: cont nou {account_id} in asteptare"
|
||||
msg.set_content(textwrap.dedent(f"""\
|
||||
Cont nou inregistrat si in asteptare de activare.
|
||||
|
||||
ID cont: {account_id}
|
||||
Email: {email}
|
||||
|
||||
Actioneaza din panoul admin /admin sau din CLI:
|
||||
python3 -m tools.account activate --account {account_id}
|
||||
"""))
|
||||
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=5) as smtp:
|
||||
if settings.smtp_user and settings.smtp_password:
|
||||
smtp.starttls()
|
||||
smtp.login(settings.smtp_user, settings.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"SIGNUP-NOTIFY esuat cont={account_id}: {type(exc).__name__}",
|
||||
flush=True,
|
||||
)
|
||||
249
app/embeddings.py
Normal file
249
app/embeddings.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""Modul embedding in-proces pentru sugestie cod RAR -- L14-S4.
|
||||
|
||||
Design (PRD 5.14, Decision #16/#16b):
|
||||
- Model multilingv via fastembed/ONNX (~230MB pe disc, quantizat, fara torch)
|
||||
- Lazy load la prima folosire, NU la import si NU pe /healthz
|
||||
- Worker NU incarca modelul (API-only)
|
||||
- Degradare gratioasa: daca modelul nu se incarca -> is_available()=False,
|
||||
suggest_nearest() -> [] fara exceptie, ingestia NU e blocata
|
||||
- Embeddings = DOAR sugestie (nu intra in lantul de enqueue/resolve_prestatii)
|
||||
- NU apelat din resolve_prestatii/load_mapping (wiring vine in L14-S6 DUPA 5.15)
|
||||
|
||||
API public (nivel modul):
|
||||
index_corpus(items) -> None
|
||||
suggest_nearest(text, top_k) -> [{cod, is_nul, similaritate}]
|
||||
is_available() -> bool
|
||||
|
||||
Clase (pentru teste / injectare backend):
|
||||
EmbeddingEngine(backend) -- motor testabil cu backend injectabil
|
||||
FastEmbedBackend() -- backend real fastembed/ONNX
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Modelul ales: paraphrase-multilingual-MiniLM-L12-v2
|
||||
# ~230MB pe disc (ONNX quantizat), 384 dim, multilingv (ro/en/etc.), suportat de
|
||||
# fastembed, fara torch. (Estimarea initiala din PRD de ~50MB a fost gresita.)
|
||||
FASTEMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Protocol backend (mockabil in teste) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@runtime_checkable
|
||||
class EmbeddingBackend(Protocol):
|
||||
"""Interfata minimala pentru un backend de embedding."""
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Intoarce o lista de vectori (cate unul per text)."""
|
||||
...
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Backend real: fastembed/ONNX #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class FastEmbedBackend:
|
||||
"""Backend fastembed/ONNX. Lazy-load la constructie.
|
||||
|
||||
Arunca ImportError daca fastembed nu e instalat, sau orice exceptie
|
||||
de la TextEmbedding (download esuat, ONNX incompatibil etc.).
|
||||
Apelantul (_load_engine) prinde aceste exceptii.
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str = FASTEMBED_MODEL):
|
||||
from fastembed import TextEmbedding # import tardiv -- nu blocheaza la import modul
|
||||
self._model = TextEmbedding(model_name=model_name)
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
# fastembed.embed() intoarce un generator de numpy arrays
|
||||
return [vec.tolist() for vec in self._model.embed(texts)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Motor de embedding (testabil, backend injectabil) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Similaritate cosine intre doi vectori. Returneaza 0.0 pe vectori nuli."""
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(x * x for x in b))
|
||||
if na == 0.0 or nb == 0.0:
|
||||
return 0.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
class EmbeddingEngine:
|
||||
"""Motor de embedding cu corpus indexat si cautare NN cosine.
|
||||
|
||||
Parametri:
|
||||
backend: instanta EmbeddingBackend (real sau mock).
|
||||
None => degradare gratioasa (is_available=False).
|
||||
"""
|
||||
|
||||
def __init__(self, backend: EmbeddingBackend | None = None):
|
||||
self._backend = backend
|
||||
self._corpus_vecs: list[list[float]] = []
|
||||
self._corpus_items: list[dict] = []
|
||||
self._corpus_sig: str | None = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""True daca backend-ul e disponibil si gata de folosire."""
|
||||
return self._backend is not None
|
||||
|
||||
def has_corpus(self) -> bool:
|
||||
"""True daca un corpus a fost indexat (suggest_nearest poate produce ceva)."""
|
||||
return bool(self._corpus_items)
|
||||
|
||||
def corpus_signature(self) -> str | None:
|
||||
"""Semnatura corpusului indexat (None daca gol). Apelantul re-indexeaza
|
||||
doar cand semnatura nomenclatorului s-a schimbat (evita re-embed inutil)."""
|
||||
return self._corpus_sig
|
||||
|
||||
def index_corpus(self, items: list[dict], signature: str | None = None) -> None:
|
||||
"""Vectorizeaza corpus [{denumire, cod}] si il pastreaza in memorie.
|
||||
|
||||
Ignora silentios daca backend-ul lipseste, corpus-ul e gol sau apare
|
||||
orice exceptie la vectorizare (degradare gratioasa).
|
||||
"""
|
||||
self._corpus_vecs = []
|
||||
self._corpus_items = []
|
||||
self._corpus_sig = None
|
||||
|
||||
if not items or not self.is_available():
|
||||
return
|
||||
|
||||
try:
|
||||
texts = [str(item["denumire"]) for item in items]
|
||||
vecs = self._backend.embed(texts)
|
||||
self._corpus_vecs = vecs
|
||||
self._corpus_items = list(items)
|
||||
self._corpus_sig = signature
|
||||
except Exception as exc:
|
||||
log.warning("embeddings: index_corpus esuat: %s", exc)
|
||||
# corpus ramane gol -- suggest_nearest va returna []
|
||||
|
||||
def suggest_nearest(
|
||||
self,
|
||||
denumire: str,
|
||||
top_k: int = 3,
|
||||
) -> list[dict]:
|
||||
"""Returneaza top_k vecini cosine [{cod, is_nul, similaritate}].
|
||||
|
||||
`is_nul` (PRD 5.18 US-005): cand corpusul include exemple NUL (non-operatii),
|
||||
un vecin NUL = semnal de SUPRESIE, nu cod. Default False pe corpusuri vechi
|
||||
fara `is_nul` in itemi. Returneaza [] daca backend-ul lipseste, corpus-ul e gol
|
||||
sau apare orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
|
||||
"""
|
||||
if not self.is_available() or not self._corpus_items:
|
||||
return []
|
||||
|
||||
try:
|
||||
query_vecs = self._backend.embed([str(denumire)])
|
||||
query_vec = query_vecs[0]
|
||||
scored = [
|
||||
{
|
||||
"cod": item["cod"],
|
||||
"is_nul": bool(item.get("is_nul", False)),
|
||||
"similaritate": _cosine_similarity(query_vec, vec),
|
||||
}
|
||||
for item, vec in zip(self._corpus_items, self._corpus_vecs)
|
||||
]
|
||||
scored.sort(key=lambda r: r["similaritate"], reverse=True)
|
||||
return scored[:top_k]
|
||||
except Exception as exc:
|
||||
log.warning("embeddings: suggest_nearest esuat: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Singleton global cu lazy load (API-only, NU worker) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_engine: EmbeddingEngine | None = None
|
||||
|
||||
|
||||
def _load_engine() -> EmbeddingEngine:
|
||||
"""Lazy load: construieste engine-ul la prima folosire.
|
||||
|
||||
Captureaza ORICE exceptie la incarcare (import, download, ONNX init)
|
||||
si returneaza un engine degradat (backend=None) -- ingestia continua
|
||||
pe exact+fuzzy, embedding = sugestie dezactivata.
|
||||
"""
|
||||
try:
|
||||
backend = FastEmbedBackend()
|
||||
log.info("embeddings: backend fastembed incarcat (%s)", FASTEMBED_MODEL)
|
||||
return EmbeddingEngine(backend=backend)
|
||||
except ImportError:
|
||||
log.warning(
|
||||
"embeddings: fastembed nu e instalat -- sugestii NN dezactivate"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"embeddings: incarcare backend esuata (%s) -- sugestii NN dezactivate",
|
||||
exc,
|
||||
)
|
||||
return EmbeddingEngine(backend=None)
|
||||
|
||||
|
||||
def _get_engine() -> EmbeddingEngine:
|
||||
"""Returneaza engine-ul global (lazy-init)."""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = _load_engine()
|
||||
return _engine
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# API public la nivel de modul (wiring L14-S6) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True daca modelul e incarcat si gata de folosire."""
|
||||
return _get_engine().is_available()
|
||||
|
||||
|
||||
def has_corpus() -> bool:
|
||||
"""True daca un corpus a fost indexat in motorul global.
|
||||
|
||||
NU forteaza incarcarea modelului: daca engine-ul nu a fost initializat inca
|
||||
(`_engine is None`), corpus-ul e gol prin definitie -> False, fara cost.
|
||||
Apelantii (ex. enrich_suggestions) folosesc asta ca poarta ieftina inainte de
|
||||
a atinge calea scumpa (is_available/suggest_nearest, care lazy-load ~230MB).
|
||||
"""
|
||||
if _engine is None:
|
||||
return False
|
||||
return _engine.has_corpus()
|
||||
|
||||
|
||||
def corpus_signature() -> str | None:
|
||||
"""Semnatura corpusului global indexat (None daca engine ne-initializat/gol).
|
||||
|
||||
NU forteaza incarcarea modelului: `_engine is None` -> None fara cost.
|
||||
"""
|
||||
if _engine is None:
|
||||
return None
|
||||
return _engine.corpus_signature()
|
||||
|
||||
|
||||
def index_corpus(items: list[dict], signature: str | None = None) -> None:
|
||||
"""Vectorizeaza corpus [{denumire, cod}] in motorul global.
|
||||
|
||||
Silentios pe eroare (degradare gratioasa).
|
||||
"""
|
||||
_get_engine().index_corpus(items, signature=signature)
|
||||
|
||||
|
||||
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]:
|
||||
"""Returneaza top_k sugestii [{cod, is_nul, similaritate}] sau [] la eroare.
|
||||
|
||||
Sigur de apelat indiferent de starea backend-ului.
|
||||
"""
|
||||
return _get_engine().suggest_nearest(denumire, top_k=top_k)
|
||||
235
app/errors.py
Normal file
235
app/errors.py
Normal 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,
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
"""Cheie de idempotenta = hash de continut canonic.
|
||||
|
||||
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.
|
||||
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra.
|
||||
Hash stabil peste o reprezentare canonica a prezentarii.
|
||||
|
||||
canonicalize_row + build_key sunt helpere publice partajate intre canalul API si
|
||||
canalul import:
|
||||
- canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE
|
||||
de validare si INAINTE de 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.
|
||||
|
||||
Randuri vechi cu cheie-None nu sunt gasite de build_key nou: dual-lookup la
|
||||
already_sent (cheia noua, apoi build_key_legacy) sau recompute-keys o singura data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,10 +22,100 @@ import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
"""SHA-256 peste (account_id + campurile semnificative ale prezentarii).
|
||||
def _op_identity(p: Any) -> str:
|
||||
"""Cod RAR (normalizat) daca exista, altfel codul intern ROAAUTO."""
|
||||
get = p.get if isinstance(p, dict) else (lambda k, d=None: getattr(p, k, d))
|
||||
cod = (get("cod_prestatie", "") or "").strip().upper()
|
||||
if cod:
|
||||
return cod
|
||||
return (get("cod_op_service", "") or "").strip()
|
||||
|
||||
|
||||
def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalizare canonica a unui rand brut. Apelata INAINTE de validare si de build_key.
|
||||
|
||||
- VIN, nr_inmatriculare: strip + upper.
|
||||
- odometru_final: strip ".0" (Excel coercion numeric 123456.0 -> "123456").
|
||||
Necesar ca validation._parse_int (isdigit()) sa nu respinga float-string.
|
||||
- data_prestatie: strip (normalizarea la YYYY-MM-DD se face in parser).
|
||||
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
|
||||
"""
|
||||
vin = (raw.get("vin") or "").strip().upper()
|
||||
nr = (raw.get("nr_inmatriculare") or "").strip().upper()
|
||||
|
||||
# Odometru: strip ".0" Excel float coercion
|
||||
odo_raw = raw.get("odometru_final")
|
||||
if odo_raw is not None:
|
||||
odo_s = str(odo_raw).strip()
|
||||
# "123456.0" -> "123456"; "123456.50" nu (nu e coercion Excel pur)
|
||||
if "." in odo_s:
|
||||
before, after = odo_s.split(".", 1)
|
||||
if after == "0" and before.lstrip("-").isdigit():
|
||||
odo_s = before
|
||||
else:
|
||||
odo_s = ""
|
||||
|
||||
# Data (pastrata ca string; parsarea la YYYY-MM-DD e in parser)
|
||||
data = str(raw.get("data_prestatie") or "").strip()
|
||||
|
||||
# Prestatii (copie superficiala; rezolvarea e upstream)
|
||||
prestatii = list(raw.get("prestatii") or [])
|
||||
|
||||
return {
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": nr,
|
||||
"data_prestatie": data,
|
||||
"odometru_final": odo_s,
|
||||
"prestatii": prestatii,
|
||||
}
|
||||
|
||||
|
||||
def build_key(account_id: int | None, canon: dict[str, Any], rar_env: str = "test") -> str:
|
||||
"""SHA-256 partajat canal-API + canal-import, env-aware (PRD 5.20 US-003).
|
||||
|
||||
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
||||
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
||||
|
||||
`rar_env` ('test'|'prod') intra in cheie: aceeasi prezentare la test si apoi la
|
||||
prod sunt DOUA trimiteri reale distincte (sisteme RAR separate), nu un duplicat.
|
||||
Default 'test' = back-compat cu apelantii care nu paseaza inca env-ul; toate
|
||||
rutele de ingestie paseaza env-ul rezolvat explicit.
|
||||
"""
|
||||
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
|
||||
from .mapping import account_or_default
|
||||
acct = account_or_default(account_id)
|
||||
canonic = {
|
||||
"account_id": acct,
|
||||
"rar_env": rar_env,
|
||||
"vin": canon.get("vin", ""),
|
||||
"nr_inmatriculare": canon.get("nr_inmatriculare", ""),
|
||||
"data_prestatie": canon.get("data_prestatie"),
|
||||
"odometru_final": canon.get("odometru_final", ""),
|
||||
"prestatii": sorted(_op_identity(p) for p in (canon.get("prestatii") or [])),
|
||||
}
|
||||
blob = json.dumps(canonic, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def idempotency_key(account_id: int | None, prezentare: dict[str, Any], rar_env: str = "test") -> str:
|
||||
"""SHA-256 peste (account_id + rar_env + campurile semnificative ale prezentarii).
|
||||
|
||||
Wrapper backward-compat peste canonicalize_row + build_key.
|
||||
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
||||
|
||||
NOTA: account_id=None si account_id=1 produc ACEEASI cheie (via
|
||||
account_or_default in build_key). Randuri vechi cu cheie-None nu sunt
|
||||
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
|
||||
"""
|
||||
canon = canonicalize_row(prezentare)
|
||||
return build_key(account_id, canon, rar_env)
|
||||
|
||||
|
||||
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
"""Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize).
|
||||
|
||||
Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi.
|
||||
Nu folosi pentru randuri noi.
|
||||
"""
|
||||
canonic = {
|
||||
"account_id": account_id,
|
||||
@@ -22,10 +123,7 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
"nr_inmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(),
|
||||
"data_prestatie": prezentare.get("data_prestatie"),
|
||||
"odometru_final": str(prezentare.get("odometru_final") or "").strip(),
|
||||
"prestatii": sorted(
|
||||
str(p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", ""))
|
||||
for p in (prezentare.get("prestatii") or [])
|
||||
),
|
||||
"prestatii": sorted(_op_identity(p) for p in (prezentare.get("prestatii") or [])),
|
||||
}
|
||||
blob = json.dumps(canonic, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
748
app/import_parse.py
Normal file
748
app/import_parse.py
Normal file
@@ -0,0 +1,748 @@
|
||||
"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2).
|
||||
|
||||
Arhitectura 2-treceri:
|
||||
Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet.
|
||||
Trecerea 2 — normal-mode: header + merged cells + body.
|
||||
Aceasta separare e necesara deoarece openpyxl read_only=True nu vede celule imbinate.
|
||||
|
||||
Modulul este PUR in sensul ca nu face I/O DB, nu trimite nimic la RAR si nu are
|
||||
efecte laterale — intoarce structuri Python testabile direct.
|
||||
|
||||
Stari per-rand (resolved_status):
|
||||
ok — date complete, gata de trimis dupa mapare + validare
|
||||
needs_review — coercion suspectat (VIN numeric, odometru float) sau data ambigua
|
||||
needs_data — camp obligatoriu lipsa (dupa coercion)
|
||||
(needs_mapping, already_sent, duplicate_in_file — calculate in preview, nu aici)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import date, datetime
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Constante #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
MAX_ROWS = 5_000
|
||||
MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
# Prag rata None pe o coloana obligatorie -> mesaj formule necalculate
|
||||
FORMULA_NONE_RATE = 0.6
|
||||
|
||||
# Coloane cheie pentru detectia footer-ului (trim structural)
|
||||
KEY_COLS = {"vin", "data_prestatie"}
|
||||
|
||||
# Delimitatori incercati la sniff CSV (ordinea conteaza: ; primul, export RO)
|
||||
CSV_DELIMITERS = [";", ",", "\t"]
|
||||
|
||||
# Encodinguri incercate in ordine (BOM-aware + RO)
|
||||
CSV_ENCODINGS = ["utf-8-sig", "utf-8", "cp1250", "latin2"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Exceptii custom #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class FileTooLarge(Exception):
|
||||
"""Fisier depaseste limita de randuri sau dimensiune."""
|
||||
def __init__(self, *, rows: int | None = None, bytes_: int | None = None):
|
||||
self.rows = rows
|
||||
self.bytes_ = bytes_
|
||||
parts = []
|
||||
if rows is not None:
|
||||
parts.append(f"{rows} randuri (max {MAX_ROWS})")
|
||||
if bytes_ is not None:
|
||||
parts.append(f"{bytes_ // 1024} KB (max {MAX_BYTES // 1024} KB)")
|
||||
super().__init__(f"Fisier prea mare: {', '.join(parts)}")
|
||||
|
||||
|
||||
class HeaderError(Exception):
|
||||
"""Header lipsa, duplicat sau un singur camp detectat."""
|
||||
def __init__(self, message: str, found: list[str] | None = None):
|
||||
self.found = found or []
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MultipleSheets(Exception):
|
||||
"""Workbook cu mai mult de un sheet non-gol — utilizatorul trebuie sa aleaga."""
|
||||
def __init__(self, sheet_names: list[str]):
|
||||
self.sheet_names = sheet_names
|
||||
super().__init__(f"Mai multe sheet-uri non-goale: {sheet_names}. Alege sheet-ul de importat.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Structura interna de rezultat #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class ParsedFile(NamedTuple):
|
||||
"""Rezultatul parsarii unui fisier."""
|
||||
columns: list[str] # Numele coloanelor detectate (din header)
|
||||
rows: list[dict[str, Any]] # Fiecare rand: {coloana: valoare_bruta}
|
||||
coercion_flags: dict[int, list[str]] # {row_index: [motive needs_review]}
|
||||
formula_columns: list[str] # Coloane cu rata None ridicata
|
||||
date_col_format: dict[str, str] # {coloana: "DD.MM.YYYY" | "YYYY-MM-DD" | "native" | "ambiguous"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# XLSX — trecerea 1: dim-check (read_only) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _xlsx_dimcheck(data: bytes) -> list[str]:
|
||||
"""Trecerea 1 read_only: verifica dimensiunile si intoarce lista de sheet-uri non-goale.
|
||||
|
||||
Ridica FileTooLarge daca depaseste limita.
|
||||
Ridica MultipleSheets daca sunt >1 sheet-uri non-goale.
|
||||
Intoarce lista (cu un singur element daca totul e ok).
|
||||
"""
|
||||
import openpyxl
|
||||
|
||||
if len(data) > MAX_BYTES:
|
||||
raise FileTooLarge(bytes_=len(data))
|
||||
|
||||
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||
try:
|
||||
non_empty: list[str] = []
|
||||
for name in wb.sheetnames:
|
||||
ws = wb[name]
|
||||
# In read_only, max_row poate fi None daca sheet-ul e gol
|
||||
max_row = ws.max_row or 0
|
||||
if max_row > 0:
|
||||
non_empty.append(name)
|
||||
if max_row > MAX_ROWS:
|
||||
raise FileTooLarge(rows=max_row)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
if len(non_empty) > 1:
|
||||
raise MultipleSheets(non_empty)
|
||||
|
||||
return non_empty # 0 sau 1 element
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# XLSX — trecerea 2: header + merged + body (normal-mode) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _unmerge_header(ws) -> dict[int, str]:
|
||||
"""Rezolva celulele imbinate din primul rand non-gol.
|
||||
|
||||
Intoarce {col_index_1based: valoare_str}.
|
||||
Merge range-urile din header propaga valoarea topleft la toate coloanele din grup.
|
||||
"""
|
||||
# Gaseste primul rand non-gol
|
||||
header_row = None
|
||||
for row in ws.iter_rows(max_row=20):
|
||||
vals = [c.value for c in row if c.value is not None]
|
||||
if vals:
|
||||
header_row = row[0].row
|
||||
break
|
||||
if header_row is None:
|
||||
return {}
|
||||
|
||||
# Mapa col_index -> valoare din celule normale
|
||||
col_vals: dict[int, str] = {}
|
||||
for cell in ws[header_row]:
|
||||
if cell.value is not None:
|
||||
col_vals[cell.column] = str(cell.value).strip()
|
||||
|
||||
# Propaga valoarea topleft pentru merge range-uri din randul header
|
||||
for merged_range in ws.merged_cells.ranges:
|
||||
if merged_range.min_row <= header_row <= merged_range.max_row:
|
||||
# Valoarea e in celula topleft
|
||||
topleft = ws.cell(row=merged_range.min_row, column=merged_range.min_col)
|
||||
val = str(topleft.value or "").strip()
|
||||
for col in range(merged_range.min_col, merged_range.max_col + 1):
|
||||
col_vals[col] = val
|
||||
|
||||
return col_vals
|
||||
|
||||
|
||||
def _deduplicate_columns(names: list[str]) -> list[str]:
|
||||
"""Adauga sufix _2/_3 la coloane cu acelasi nume (din merged cells)."""
|
||||
seen: dict[str, int] = {}
|
||||
result = []
|
||||
for n in names:
|
||||
if n not in seen:
|
||||
seen[n] = 1
|
||||
result.append(n)
|
||||
else:
|
||||
seen[n] += 1
|
||||
result.append(f"{n}_{seen[n]}")
|
||||
return result
|
||||
|
||||
|
||||
def _xlsx_parse_sheet(ws, sheet_name: str) -> ParsedFile:
|
||||
"""Parseaza un sheet in normal-mode (trecerea 2).
|
||||
|
||||
Presupune ca dim-check a trecut deja (FileTooLarge nu se verifica din nou).
|
||||
"""
|
||||
# Header cu merged cells
|
||||
col_map = _unmerge_header(ws)
|
||||
if not col_map:
|
||||
raise HeaderError(f"Sheet '{sheet_name}': niciun header detectat.", found=[])
|
||||
|
||||
# Ordoneaza coloanele dupa index
|
||||
sorted_cols = sorted(col_map.items()) # [(col_idx, name), ...]
|
||||
col_indices = [idx for idx, _ in sorted_cols]
|
||||
col_names = [name for _, name in sorted_cols]
|
||||
|
||||
# Dezambiguizeaza duplicate (provin din merge care se propaga la mai multe coloane)
|
||||
col_names = _deduplicate_columns(col_names)
|
||||
|
||||
if len(col_names) < 2:
|
||||
raise HeaderError(f"Doar {len(col_names)} coloana detectata — verifica fisierul.", found=col_names)
|
||||
|
||||
# Gaseste randul header ca sa sarim peste el
|
||||
header_row_num = ws.cell(row=1, column=col_indices[0]).row
|
||||
# Re-detect: prima celula din col_map
|
||||
# Obtinem randul headerului din prima celula valida
|
||||
for row in ws.iter_rows(max_row=20):
|
||||
for c in row:
|
||||
if c.column in col_map and c.value is not None:
|
||||
header_row_num = c.row
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
# Citeste randurile de date
|
||||
raw_rows: list[dict[str, Any]] = []
|
||||
# Colecteaza valorile pe coloane pentru detectia datei si a formulelor
|
||||
col_values: dict[str, list[Any]] = {name: [] for name in col_names}
|
||||
|
||||
for row in ws.iter_rows(min_row=header_row_num + 1):
|
||||
row_dict: dict[str, Any] = {}
|
||||
for col_idx, col_name in zip(col_indices, col_names):
|
||||
# Cauta celula cu col_idx in rand (unele randuri pot fi mai scurte)
|
||||
found_cell = None
|
||||
for c in row:
|
||||
if c.column == col_idx:
|
||||
found_cell = c
|
||||
break
|
||||
val = found_cell.value if found_cell is not None else None
|
||||
row_dict[col_name] = val
|
||||
col_values[col_name].append(val)
|
||||
raw_rows.append(row_dict)
|
||||
|
||||
# Trim footer: elimina randuri trailing unde coloanele cheie sunt goale
|
||||
raw_rows = _trim_footer(raw_rows, col_names)
|
||||
|
||||
# Detectie coloane cu formule (rata None ridicata)
|
||||
formula_columns = _detect_formula_columns(col_values, len(raw_rows))
|
||||
|
||||
# Detectie format data la nivel de coloana
|
||||
date_col_format = _detect_date_formats(col_values, col_names)
|
||||
|
||||
# Coercion + flags needs_review
|
||||
coercion_flags: dict[int, list[str]] = {}
|
||||
processed_rows: list[dict[str, Any]] = []
|
||||
for i, row_dict in enumerate(raw_rows):
|
||||
processed, flags = _coerce_row(row_dict, col_names)
|
||||
processed_rows.append(processed)
|
||||
if flags:
|
||||
coercion_flags[i] = flags
|
||||
|
||||
return ParsedFile(
|
||||
columns=col_names,
|
||||
rows=processed_rows,
|
||||
coercion_flags=coercion_flags,
|
||||
formula_columns=formula_columns,
|
||||
date_col_format=date_col_format,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Trim footer structural #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _is_key_empty(row_dict: dict[str, Any], col_names: list[str]) -> bool:
|
||||
"""Randul e structural gol daca coloanele cheie (VIN + data) sunt ambele None/gol."""
|
||||
# Detecta coloanele cheie prin nume normalized
|
||||
from .mapping import normalize_for_match
|
||||
norm_names = {normalize_for_match(n): n for n in col_names}
|
||||
|
||||
vin_col = None
|
||||
date_col_key = None
|
||||
for norm, orig in norm_names.items():
|
||||
if "VIN" in norm or "SERIE" in norm or "SASIU" in norm:
|
||||
vin_col = orig
|
||||
if "DATA" in norm or "DATE" in norm or "PRESTATIE" in norm:
|
||||
date_col_key = orig
|
||||
|
||||
def _empty(v: Any) -> bool:
|
||||
return v is None or str(v).strip() == ""
|
||||
|
||||
vin_empty = _empty(row_dict.get(vin_col)) if vin_col else True
|
||||
date_empty = _empty(row_dict.get(date_col_key)) if date_col_key else True
|
||||
return vin_empty and date_empty
|
||||
|
||||
|
||||
def _trim_footer(rows: list[dict[str, Any]], col_names: list[str]) -> list[dict[str, Any]]:
|
||||
"""Elimina randuri trailing unde VIN + data sunt goale (footer TOTAL/Intocmit de:)."""
|
||||
i = len(rows) - 1
|
||||
while i >= 0 and _is_key_empty(rows[i], col_names):
|
||||
i -= 1
|
||||
return rows[: i + 1]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Detectie coloane formule #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> list[str]:
|
||||
"""Coloane unde rata de None depaseste pragul -> probabil formule necalculate."""
|
||||
if n_rows == 0:
|
||||
return []
|
||||
result = []
|
||||
for col_name, vals in col_values.items():
|
||||
none_count = sum(1 for v in vals if v is None)
|
||||
rate = none_count / n_rows
|
||||
if rate >= FORMULA_NONE_RATE:
|
||||
result.append(col_name)
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dezambiguizare data la nivel de coloana #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str]) -> dict[str, str]:
|
||||
"""Detecteaza formatul datei pentru fiecare coloana de tip data.
|
||||
|
||||
Rezultate posibile per coloana:
|
||||
"native" — toate valorile non-None sunt datetime nativ openpyxl (neambigue)
|
||||
"DD.MM.YYYY" — coloana e DD-first (cel putin un rand are token[1] > 12)
|
||||
"YYYY-MM-DD" — format ISO
|
||||
"ambiguous" — string, toti zi <= 12 (si DD si MM ar fi valide)
|
||||
"mixed" — amestec datetime nativ + string
|
||||
(Nu e inclusa daca coloana nu pare a fi de tip data)
|
||||
"""
|
||||
from .mapping import normalize_for_match
|
||||
|
||||
result: dict[str, str] = {}
|
||||
for col_name in col_names:
|
||||
norm = normalize_for_match(col_name)
|
||||
# Filtra coloanele de data dupa nume
|
||||
if not any(kw in norm for kw in ("DATA", "DATE", "PRESTATIE", "ZI", "AN")):
|
||||
continue
|
||||
|
||||
vals = [v for v in col_values.get(col_name, []) if v is not None]
|
||||
if not vals:
|
||||
continue
|
||||
|
||||
native_count = sum(1 for v in vals if isinstance(v, (datetime, date)))
|
||||
str_vals = [str(v).strip() for v in vals if not isinstance(v, (datetime, date))]
|
||||
|
||||
if native_count == len(vals):
|
||||
result[col_name] = "native"
|
||||
continue
|
||||
|
||||
if native_count > 0 and str_vals:
|
||||
result[col_name] = "mixed"
|
||||
continue
|
||||
|
||||
# Toate string — detectie format la nivel de coloana
|
||||
fmt = _infer_date_format_from_column(str_vals)
|
||||
result[col_name] = fmt
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _infer_date_format_from_column(str_vals: list[str]) -> str:
|
||||
"""Detecteaza formatul datei dintr-o lista de valori string.
|
||||
|
||||
Daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first.
|
||||
Daca toti zi <= 12 -> ambiguu.
|
||||
"""
|
||||
dd_first_evidence = False
|
||||
iso_evidence = False
|
||||
parseable = 0
|
||||
|
||||
for s in str_vals:
|
||||
if not s:
|
||||
continue
|
||||
|
||||
# Incearca ISO (YYYY-MM-DD sau YYYY/MM/DD)
|
||||
if _looks_iso(s):
|
||||
iso_evidence = True
|
||||
parseable += 1
|
||||
continue
|
||||
|
||||
# Incearca DD.MM.YYYY sau DD/MM/YYYY sau DD-MM-YYYY
|
||||
parts = _split_date(s)
|
||||
if parts and len(parts) == 3:
|
||||
try:
|
||||
day_candidate = int(parts[0])
|
||||
month_candidate = int(parts[1])
|
||||
if day_candidate > 12:
|
||||
dd_first_evidence = True
|
||||
# Daca month_candidate > 12 -> cu siguranta DD.MM (luna e la pozitia 1)
|
||||
if month_candidate > 12:
|
||||
dd_first_evidence = True
|
||||
parseable += 1
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not parseable:
|
||||
return "ambiguous"
|
||||
|
||||
if iso_evidence and not dd_first_evidence:
|
||||
return "YYYY-MM-DD"
|
||||
|
||||
if dd_first_evidence:
|
||||
return "DD.MM.YYYY"
|
||||
|
||||
# Toti zi <= 12: nu putem distinge DD.MM de MM.DD
|
||||
return "ambiguous"
|
||||
|
||||
|
||||
def _looks_iso(s: str) -> bool:
|
||||
"""Verifica rapid daca string-ul arata ca YYYY-MM-DD."""
|
||||
parts = s.replace("/", "-").split("-")
|
||||
if len(parts) == 3:
|
||||
try:
|
||||
y = int(parts[0])
|
||||
return y > 1900
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _split_date(s: str) -> list[str] | None:
|
||||
"""Imparte un string data dupa separatorul comun (., /, -)."""
|
||||
for sep in (".", "/", "-"):
|
||||
parts = s.split(sep)
|
||||
if len(parts) == 3:
|
||||
return parts
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Coercion per rand #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _coerce_row(row_dict: dict[str, Any], col_names: list[str]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Coerce valorile unui rand si colecteaza flags needs_review.
|
||||
|
||||
Reguli:
|
||||
- VIN citit ca int/float (openpyxl: "0123..." -> 123.0) -> str + flag needs_review
|
||||
- Odometru float cu .0 -> tunde ".0" (via canonicalize_row logic)
|
||||
- Datetime nativ -> convertit la YYYY-MM-DD string
|
||||
- Valori goale/None raman None
|
||||
"""
|
||||
from .mapping import normalize_for_match
|
||||
|
||||
flags: list[str] = []
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
norm_names = {normalize_for_match(n): n for n in col_names}
|
||||
|
||||
# Identifica coloanele semantice
|
||||
vin_col = _find_col(norm_names, ("VIN", "SERIE SASIU", "SASIU", "SERIE"))
|
||||
odo_col = _find_col(norm_names, ("ODOMETRU", "KM", "KILOMETRI", "ODO"))
|
||||
|
||||
for col_name, val in row_dict.items():
|
||||
if val is None:
|
||||
out[col_name] = None
|
||||
continue
|
||||
|
||||
# Datetime nativ -> string YYYY-MM-DD
|
||||
if isinstance(val, datetime):
|
||||
out[col_name] = val.date().isoformat()
|
||||
continue
|
||||
if isinstance(val, date):
|
||||
out[col_name] = val.isoformat()
|
||||
continue
|
||||
|
||||
# VIN: daca e numeric (float sau int) -> coercion suspectat
|
||||
if col_name == vin_col:
|
||||
if isinstance(val, (int, float)):
|
||||
flags.append(f"VIN numeric ({val}) — verificati seria sasiului")
|
||||
out[col_name] = str(int(val)) if val == int(val) else str(val)
|
||||
else:
|
||||
out[col_name] = str(val).strip().upper()
|
||||
continue
|
||||
|
||||
# Odometru: float cu .0 -> int string
|
||||
if col_name == odo_col:
|
||||
if isinstance(val, float):
|
||||
s = str(val)
|
||||
if s.endswith(".0"):
|
||||
out[col_name] = s[:-2] # "123456.0" -> "123456"
|
||||
else:
|
||||
# Float non-integer -> pastreaza si lasa validarea sa decida
|
||||
flags.append(f"Odometru float nestandard ({val})")
|
||||
out[col_name] = str(val)
|
||||
elif isinstance(val, int):
|
||||
out[col_name] = str(val)
|
||||
else:
|
||||
out[col_name] = str(val).strip()
|
||||
continue
|
||||
|
||||
# Default: string
|
||||
out[col_name] = str(val).strip() if isinstance(val, str) else val
|
||||
|
||||
return out, flags
|
||||
|
||||
|
||||
def _find_col(norm_names: dict[str, str], keywords: tuple[str, ...]) -> str | None:
|
||||
"""Gaseste o coloana dupa cuvinte cheie in numele normalizat."""
|
||||
for kw in keywords:
|
||||
for norm, orig in norm_names.items():
|
||||
if kw in norm:
|
||||
return orig
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parsare data per rand (folosita de preview resolve) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def parse_date_value(
|
||||
val: Any,
|
||||
col_format: str,
|
||||
) -> tuple[str | None, bool]:
|
||||
"""Parseaza o valoare de data si intoarce (iso_string, is_ambiguous).
|
||||
|
||||
- val e deja string (coercion a convertit datetime nativ).
|
||||
- col_format: "native", "DD.MM.YYYY", "YYYY-MM-DD", "ambiguous", "mixed".
|
||||
- Intoarce (None, False) daca valoarea e goala.
|
||||
- Intoarce (iso, True) daca data e ambigua (needs_review).
|
||||
"""
|
||||
if val is None or str(val).strip() == "":
|
||||
return None, False
|
||||
|
||||
# Daca coercion a convertit deja la ISO (din datetime nativ)
|
||||
s = str(val).strip()
|
||||
try:
|
||||
d = date.fromisoformat(s)
|
||||
return d.isoformat(), False
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if col_format in ("native", "YYYY-MM-DD"):
|
||||
# Incearca ISO
|
||||
parts = s.replace("/", "-").split("-")
|
||||
if len(parts) == 3:
|
||||
try:
|
||||
d = date(int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
return d.isoformat(), False
|
||||
except ValueError:
|
||||
pass
|
||||
return None, False
|
||||
|
||||
if col_format == "DD.MM.YYYY":
|
||||
parts = _split_date(s)
|
||||
if parts and len(parts) == 3:
|
||||
try:
|
||||
d = date(int(parts[2]), int(parts[1]), int(parts[0]))
|
||||
return d.isoformat(), False
|
||||
except ValueError:
|
||||
pass
|
||||
return None, False
|
||||
|
||||
if col_format == "ambiguous":
|
||||
# Incearca DD.MM.YYYY
|
||||
parts = _split_date(s)
|
||||
if parts and len(parts) == 3:
|
||||
try:
|
||||
d = date(int(parts[2]), int(parts[1]), int(parts[0]))
|
||||
return d.isoformat(), True # ambiguu -> needs_review
|
||||
except ValueError:
|
||||
pass
|
||||
return None, True
|
||||
|
||||
# mixed sau necunoscut: incearca ambele
|
||||
parts = _split_date(s)
|
||||
if parts and len(parts) == 3:
|
||||
try:
|
||||
# Incearca DD.MM.YYYY
|
||||
d = date(int(parts[2]), int(parts[1]), int(parts[0]))
|
||||
return d.isoformat(), True # ambiguu
|
||||
except ValueError:
|
||||
pass
|
||||
return None, False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CSV #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _decode_csv(data: bytes) -> str:
|
||||
"""Decodifica bytes CSV cu fallback encoding RO."""
|
||||
for enc in CSV_ENCODINGS:
|
||||
try:
|
||||
return data.decode(enc)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
raise UnicodeDecodeError("csv", data, 0, len(data), "Encoding nesuportat (incercat utf-8, cp1250, latin2)")
|
||||
|
||||
|
||||
def _sniff_delimiter(sample: str) -> str:
|
||||
"""Detecteaza delimiter-ul CSV. Export Excel RO foloseste ';'."""
|
||||
# Incearca Sniffer standard
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(sample, delimiters=";,\t")
|
||||
return dialect.delimiter
|
||||
except csv.Error:
|
||||
pass
|
||||
|
||||
# Proba explicita: alege delimiter-ul care da cel mai mare numar consistent de coloane
|
||||
best_delim = ","
|
||||
best_cols = 0
|
||||
for delim in CSV_DELIMITERS:
|
||||
lines = sample.splitlines()[:10]
|
||||
counts = []
|
||||
for line in lines:
|
||||
if line.strip():
|
||||
counts.append(len(line.split(delim)))
|
||||
if counts:
|
||||
# Cel mai frecvent count
|
||||
from collections import Counter
|
||||
common = Counter(counts).most_common(1)[0][0]
|
||||
if common > best_cols:
|
||||
best_cols = common
|
||||
best_delim = delim
|
||||
|
||||
return best_delim
|
||||
|
||||
|
||||
def parse_csv(data: bytes) -> ParsedFile:
|
||||
"""Parseaza un fisier CSV. Detecteaza delimiter + encoding RO."""
|
||||
if len(data) > MAX_BYTES:
|
||||
raise FileTooLarge(bytes_=len(data))
|
||||
|
||||
text = _decode_csv(data)
|
||||
sample = text[:8192]
|
||||
delimiter = _sniff_delimiter(sample)
|
||||
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
|
||||
|
||||
# Citeste toate randurile (limitat la MAX_ROWS)
|
||||
raw_rows: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(reader):
|
||||
if i >= MAX_ROWS:
|
||||
raise FileTooLarge(rows=i + 1)
|
||||
raw_rows.append(dict(row))
|
||||
|
||||
if not raw_rows:
|
||||
raise HeaderError("CSV gol sau fara randuri de date.", found=[])
|
||||
|
||||
col_names = list(raw_rows[0].keys())
|
||||
if not col_names or len(col_names) < 2:
|
||||
raise HeaderError(
|
||||
f"Doar {len(col_names)} coloana detectata cu delimiter '{delimiter}' — verifica separatorul.",
|
||||
found=col_names,
|
||||
)
|
||||
|
||||
# Curata cheile None (DictReader poate produce None pt coloane extra)
|
||||
col_names = [c for c in col_names if c is not None and str(c).strip()]
|
||||
|
||||
# Strip whitespace din valori
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for row in raw_rows:
|
||||
cleaned.append({k: (v.strip() if isinstance(v, str) else v) for k, v in row.items() if k in col_names})
|
||||
|
||||
# Trim footer
|
||||
cleaned = _trim_footer(cleaned, col_names)
|
||||
|
||||
# Colecteaza valori per coloana pentru detectii
|
||||
col_values: dict[str, list[Any]] = {c: [] for c in col_names}
|
||||
for row in cleaned:
|
||||
for c in col_names:
|
||||
col_values[c].append(row.get(c))
|
||||
|
||||
formula_columns: list[str] = [] # CSV nu are formule
|
||||
date_col_format = _detect_date_formats(col_values, col_names)
|
||||
|
||||
coercion_flags: dict[int, list[str]] = {}
|
||||
processed: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(cleaned):
|
||||
p, flags = _coerce_row(row, col_names)
|
||||
processed.append(p)
|
||||
if flags:
|
||||
coercion_flags[i] = flags
|
||||
|
||||
return ParsedFile(
|
||||
columns=col_names,
|
||||
rows=processed,
|
||||
coercion_flags=coercion_flags,
|
||||
formula_columns=formula_columns,
|
||||
date_col_format=date_col_format,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# XLSX — entry point #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def parse_xlsx(data: bytes, *, sheet_name: str | None = None) -> ParsedFile:
|
||||
"""Parseaza un fisier XLSX.
|
||||
|
||||
Arhitectura 2-treceri:
|
||||
1. read_only=True: dim-check + detectie multi-sheet
|
||||
2. normal-mode: header + merged cells + body
|
||||
|
||||
Parametru sheet_name: daca workbook-ul are mai multe sheet-uri, utilizatorul
|
||||
trebuie sa aleaga; trimite-l inapoi la acest apel. Daca None si >1 sheet ->
|
||||
ridica MultipleSheets.
|
||||
"""
|
||||
import openpyxl
|
||||
|
||||
# Trecerea 1: dim-check
|
||||
try:
|
||||
non_empty = _xlsx_dimcheck(data)
|
||||
except MultipleSheets as ms:
|
||||
if sheet_name is not None:
|
||||
# Utilizatorul a ales deja un sheet — continuam cu cel ales
|
||||
non_empty = ms.sheet_names
|
||||
else:
|
||||
raise
|
||||
|
||||
if not non_empty:
|
||||
raise HeaderError("Workbook fara sheet-uri cu date.", found=[])
|
||||
|
||||
# Alegere sheet
|
||||
if sheet_name is not None:
|
||||
target = sheet_name
|
||||
elif len(non_empty) == 1:
|
||||
target = non_empty[0]
|
||||
else:
|
||||
raise MultipleSheets(non_empty)
|
||||
|
||||
# Trecerea 2: normal-mode
|
||||
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=False, data_only=True)
|
||||
try:
|
||||
if target not in wb.sheetnames:
|
||||
raise HeaderError(f"Sheet '{target}' nu exista in workbook.", found=wb.sheetnames)
|
||||
ws = wb[target]
|
||||
return _xlsx_parse_sheet(ws, target)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Entry point universal #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def parse_file(
|
||||
data: bytes,
|
||||
filename: str,
|
||||
*,
|
||||
sheet_name: str | None = None,
|
||||
) -> ParsedFile:
|
||||
"""Entry point unic: detecteaza tipul dupa extensie si parseaza.
|
||||
|
||||
Ridica: FileTooLarge, HeaderError, MultipleSheets, UnicodeDecodeError,
|
||||
openpyxl.utils.exceptions.InvalidFileException (fisier corupt).
|
||||
"""
|
||||
name_lower = filename.lower()
|
||||
if name_lower.endswith(".csv"):
|
||||
return parse_csv(data)
|
||||
elif name_lower.endswith((".xlsx", ".xls")):
|
||||
return parse_xlsx(data, sheet_name=sheet_name)
|
||||
else:
|
||||
raise HeaderError(f"Tip fisier nesuportat: '{filename}'. Acceptat: xlsx, xls, csv.")
|
||||
120
app/main.py
120
app/main.py
@@ -1,45 +1,147 @@
|
||||
"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics.
|
||||
|
||||
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").
|
||||
|
||||
Pornire dev: uvicorn app.main:app --reload
|
||||
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici:
|
||||
un worker mort nu trebuie sa lase containerul "sanatos".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
import traceback
|
||||
|
||||
from . import __version__
|
||||
from . import errors
|
||||
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 .config import get_settings
|
||||
from .crypto import validate_creds_key
|
||||
from .db import get_connection, init_db, queue_depth, read_heartbeat
|
||||
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.auth_routes import router as auth_router
|
||||
from .web.admin_routes import router as admin_router
|
||||
from .web.csrf import CsrfError
|
||||
from .web.session import AdminRequired, LoginRequired
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
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()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan)
|
||||
|
||||
settings = get_settings()
|
||||
_session_secret = settings.session_secret or secrets.token_hex(32)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=_session_secret,
|
||||
session_cookie="autopass_session",
|
||||
https_only=settings.session_https_only,
|
||||
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)
|
||||
async def login_required_handler(request: Request, exc: LoginRequired) -> RedirectResponse:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
@app.exception_handler(AdminRequired)
|
||||
async def admin_required_handler(request: Request, exc: AdminRequired) -> JSONResponse:
|
||||
return JSONResponse(status_code=403, content={"detail": "acces interzis (necesita admin)"})
|
||||
|
||||
|
||||
@app.exception_handler(CsrfError)
|
||||
async def csrf_error_handler(request: Request, exc: CsrfError) -> JSONResponse:
|
||||
return JSONResponse(status_code=403, content={"detail": "CSRF invalid"})
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""422 fara echo de credentiale.
|
||||
|
||||
Pydantic include implicit `input` (+ uneori `ctx`) in fiecare eroare — pe
|
||||
/v1/prezentari asta ar reflecta inapoi `rar_credentials.password`. Pastram
|
||||
type/loc/msg (clientul stie ce camp e gresit) si DROP-am input/ctx. Defense
|
||||
in depth pe TOATE rutele, nu doar prezentari.
|
||||
"""
|
||||
cleaned = [{"type": e.get("type"), "loc": e.get("loc"), "msg": e.get("msg")} for e in exc.errors()]
|
||||
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
|
||||
# offline (LXC/VPS, Cloudflare Tunnel). Fara asta, dashboard-ul ramane static
|
||||
# (zero polling banner/coada) cand unpkg e blocat/inaccesibil. Aceeasi decizie
|
||||
# offline-first ca fontul UI (fara dependinta CDN).
|
||||
_STATIC_DIR = Path(__file__).resolve().parent / "web" / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
|
||||
app.include_router(api_v1_router)
|
||||
app.include_router(import_v1_router)
|
||||
app.include_router(integrare_v1_router)
|
||||
app.include_router(web_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict:
|
||||
"""Sanatate: worker viu + ultimul login RAR reusit + adancime coada.
|
||||
|
||||
Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort
|
||||
-> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii;
|
||||
orchestratorul decide pe campul `worker_alive`.
|
||||
Intoarce 200 mereu cu detalii; orchestratorul decide restartul pe campul
|
||||
`worker_alive`.
|
||||
"""
|
||||
settings = get_settings()
|
||||
conn = get_connection()
|
||||
|
||||
900
app/mapping.py
Normal file
900
app/mapping.py
Normal file
@@ -0,0 +1,900 @@
|
||||
"""Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor.
|
||||
|
||||
Contract (varianta hibrida): un item de prestatie poate veni
|
||||
fie cu `cod_prestatie` (cod RAR direct), fie cu `cod_op_service`
|
||||
(cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern
|
||||
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
|
||||
cu ajutorul unei sugestii fuzzy pe nomenclatorul RAR. La salvarea maparii,
|
||||
submission-urile blocate pe acel cod se re-rezolva automat.
|
||||
|
||||
Functiile de la inceput (normalize/suggest/resolve) sunt PURE (fara DB/HTTP),
|
||||
unit-testabile direct. Cele cu `conn` sunt helpere de persistenta.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from rapidfuzz import fuzz, process
|
||||
|
||||
from . import errors as err_mod
|
||||
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
||||
from .validation import validate_prezentare
|
||||
|
||||
# Cont implicit cat timp auth API-key (CORE) nu e implementat: ingestiile vin cu
|
||||
# account_id NULL si le atribuim contului seed-at in schema (id=1).
|
||||
DEFAULT_ACCOUNT_ID = 1
|
||||
|
||||
# Sub acest scor (0..100) nu preselectam nicio sugestie — userul alege manual.
|
||||
SUGGEST_MIN_SCORE = 60
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pur: normalizare + fuzzy + rezolvare #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def normalize_for_match(value: object) -> str:
|
||||
"""Upper + fara diacritice + spatii colapsate, pentru potrivire robusta.
|
||||
|
||||
'Reparație motor' -> 'REPARATIE MOTOR'. Diacriticele romanesti (ă/â/î/ș/ț)
|
||||
si artefactele de encoding nu trebuie sa strice scorul fuzzy.
|
||||
"""
|
||||
s = str(value or "")
|
||||
s = unicodedata.normalize("NFKD", s)
|
||||
s = "".join(ch for ch in s if not unicodedata.combining(ch))
|
||||
return " ".join(s.upper().split())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pre-filtru determinist non-operatii (NUL) — US-001 PRD 5.18 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
#
|
||||
# Masuratoarea k-NN (memorie test-precizie-knn-embeddings) arata recall NUL doar
|
||||
# 64%: gunoiul evident (ITP, plata, discount, nr. inmatriculare, tractare) scapa
|
||||
# semantic ca OE-1. Un pre-filtru text/regex il marcheaza NUL INAINTE de k-NN.
|
||||
#
|
||||
# Garantie: ZERO fals-pozitiv pe operatii reale. Regulile au fost calibrate pe
|
||||
# `docs/operatii-service/*.csv` (toate aparitiile distincte). Triggerele NEambigue
|
||||
# (ITP, ACHITAT/PLATA, DISCOUNT/REDUCERE, TAXA) sunt neconditionate (0 FP masurat).
|
||||
# Triggerele AMBIGUE (TRACTARE, NR INMATRICULARE + pattern placuta) apar si in
|
||||
# operatii reale ("D/R CARLIG TRACTARE", "D/R ELECTROMOTOR CT 44 MKY") -> sunt
|
||||
# ECRANATE de un context de piesa/operatie (`_NUL_CTX_PIESA`).
|
||||
|
||||
# Trigger-uri neambigue (substring/regex pe text normalizat).
|
||||
_NUL_ITP = re.compile(r"(?:\bITP\b|\d\s*X\s*ITP|X\s*ITP\b|\bITP[.,])")
|
||||
_NUL_PLATA = re.compile(r"\b(ACHITAT|ACHITARE|PLATA|PLATIT|PLATIRE)\b")
|
||||
_NUL_DISCOUNT = re.compile(r"\b(DISCOUNT|REDUCERE)\b")
|
||||
_NUL_TAXA = re.compile(r"\bTAXA\b")
|
||||
|
||||
# Trigger-uri ambigue — valide ca NUL DOAR in absenta unui context de piesa.
|
||||
_NUL_TRACTARE = re.compile(r"\b(TRACTARE|TRACTARI)\b")
|
||||
_NUL_NR_PLACUTA = re.compile(
|
||||
r"(\bNR\s+INMATRICULARE\b|\bNUMAR\s+INMATRICULARE\b|\b[A-Z]{1,2}\s?\d{2,3}\s?[A-Z]{3}\b)"
|
||||
)
|
||||
# Daca apare oricare cuvant de aici, TRACTARE/placuta e nume de piesa sau operatie
|
||||
# reala (carlig/capac de tractare, suport placuta, placuta lipita la o reparatie).
|
||||
_NUL_CTX_PIESA = re.compile(
|
||||
r"\b(D/R|D-R|CARLIG|CAPAC|BARA|PROTECTIE|MONTAT|MONTAJ|DEMONTAT|INLOCUIT|"
|
||||
r"INLOCUIRE|REPARAT|REPARATIE|VOPSIT|SCHIMBAT|SUPORT)\b"
|
||||
)
|
||||
|
||||
|
||||
def prefiltru_nul(denumire: object) -> bool:
|
||||
"""True daca operatia e gunoi evident (non-operatie de service) -> NUL determinist.
|
||||
|
||||
Ruleaza INAINTE de k-NN/embeddings in `enrich_suggestions` (US-006). Pur, fara DB.
|
||||
Zero fals-pozitiv pe operatii reale (vezi comentariul de mai sus + tests).
|
||||
"""
|
||||
text = normalize_for_match(denumire)
|
||||
if not text:
|
||||
return False
|
||||
# Neambigue: 0 FP masurat, fara ecranare.
|
||||
if _NUL_ITP.search(text) or _NUL_PLATA.search(text) or _NUL_DISCOUNT.search(text) or _NUL_TAXA.search(text):
|
||||
return True
|
||||
# Ambigue: doar daca NU e context de piesa.
|
||||
if _NUL_CTX_PIESA.search(text):
|
||||
return False
|
||||
if _NUL_TRACTARE.search(text) or _NUL_NR_PLACUTA.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def suggest_codes(
|
||||
denumire: object,
|
||||
nomenclator: list[dict],
|
||||
*,
|
||||
limit: int = 5,
|
||||
) -> list[dict]:
|
||||
"""Clasament fuzzy al codurilor RAR pentru o denumire de operatie ROAAUTO.
|
||||
|
||||
`nomenclator` = randuri {cod_prestatie, nume_prestatie}. Intoarce
|
||||
[{cod_prestatie, nume_prestatie, score}] sortat descrescator dupa scor.
|
||||
Daca denumirea e goala, intoarce nomenclatorul in ordinea data, scor 0.
|
||||
"""
|
||||
query = normalize_for_match(denumire)
|
||||
rows = [r for r in nomenclator if (r.get("cod_prestatie") or "")]
|
||||
if not query:
|
||||
return [{**r, "score": 0.0} for r in rows[:limit]]
|
||||
|
||||
choices = {r["cod_prestatie"]: normalize_for_match(r.get("nume_prestatie")) for r in rows}
|
||||
by_cod = {r["cod_prestatie"]: r for r in rows}
|
||||
# token_sort_ratio (nu token_set_ratio): recompenseaza acoperirea cat mai multor
|
||||
# cuvinte din denumire, in loc sa dea 100 la orice subset (ex. "REPARATIE" si
|
||||
# "REPARATIE ODOMETRU" ar fi egale la set_ratio).
|
||||
ranked = process.extract(
|
||||
query,
|
||||
choices,
|
||||
scorer=fuzz.token_sort_ratio,
|
||||
limit=limit,
|
||||
)
|
||||
# process.extract -> [(value, score, key)]; key = cod_prestatie.
|
||||
return [
|
||||
{
|
||||
"cod_prestatie": cod,
|
||||
"nume_prestatie": by_cod[cod].get("nume_prestatie"),
|
||||
"score": float(score),
|
||||
}
|
||||
for _val, score, cod in ranked
|
||||
]
|
||||
|
||||
|
||||
# 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(
|
||||
prestatii: list[dict] | None,
|
||||
mapping: dict[str, str],
|
||||
valid_codes: set[str] | None = None,
|
||||
text_rules: list[dict] | None = None,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
|
||||
|
||||
Reguli (hibrid):
|
||||
- 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, 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
|
||||
si campurile originale (cod_op_service/denumire) ca re-rezolvarea sa aiba
|
||||
contextul; payload-ul RAR citeste doar cod_prestatie. `nemapate` =
|
||||
[{cod_op_service, denumire}] pentru editor.
|
||||
"""
|
||||
resolved: list[dict] = []
|
||||
unmapped: list[dict] = []
|
||||
for item in prestatii or []:
|
||||
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()
|
||||
op = (it.get("cod_op_service") or "").strip()
|
||||
cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes)
|
||||
if cod_valid:
|
||||
it["cod_prestatie"] = cod
|
||||
else:
|
||||
# cod lipsa SAU necunoscut in nomenclator -> ruta de mapare.
|
||||
if cod and not op:
|
||||
# Promovam codul direct necunoscut la cod_op_service ca sa-l poti mapa
|
||||
# in editor (cu denumire = codul, pentru sugestia fuzzy) si sa se retina.
|
||||
op = cod
|
||||
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)
|
||||
return resolved, unmapped
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Persistenta (conn) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def account_or_default(account_id: int | None) -> int:
|
||||
return account_id if account_id is not None else DEFAULT_ACCOUNT_ID
|
||||
|
||||
|
||||
def account_scope_clause(account_id: int) -> tuple[str, list]:
|
||||
"""Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable.
|
||||
|
||||
Aplica regula: NULL apartine contului 1 (legacy).
|
||||
Foloseste DOAR pe submissions (account_id NULLABLE).
|
||||
NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu.
|
||||
"""
|
||||
return (
|
||||
"(account_id = ? OR (account_id IS NULL AND ? = 1))",
|
||||
[account_id, account_id],
|
||||
)
|
||||
|
||||
|
||||
def seed_nomenclator_if_empty(conn) -> int:
|
||||
"""Seed fallback (18 coduri din contract) DOAR daca nomenclator_rar e gol.
|
||||
|
||||
Worker-ul suprascrie live; aici doar garantam ca editorul fuzzy merge offline.
|
||||
Intoarce nr. de randuri inserate.
|
||||
"""
|
||||
n = conn.execute("SELECT COUNT(*) AS n FROM nomenclator_rar").fetchone()["n"]
|
||||
if n:
|
||||
return 0
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||
FALLBACK_NOMENCLATOR,
|
||||
)
|
||||
return len(FALLBACK_NOMENCLATOR)
|
||||
|
||||
|
||||
def upsert_nomenclator(conn, items: list[dict]) -> int:
|
||||
"""Upsert nomenclator live din RAR. `items` = forma API (codPrestatie/numePrestatie).
|
||||
|
||||
Tolerant la chei: codPrestatie/cod_prestatie/cod, numePrestatie/nume_prestatie/nume.
|
||||
Intoarce nr. de coduri upsert-ate.
|
||||
"""
|
||||
rows: list[tuple[str, str]] = []
|
||||
for it in items or []:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
cod = it.get("codPrestatie") or it.get("cod_prestatie") or it.get("cod")
|
||||
nume = it.get("numePrestatie") or it.get("nume_prestatie") or it.get("nume")
|
||||
if cod:
|
||||
rows.append((str(cod).strip().upper(), str(nume or "").strip()))
|
||||
if not rows:
|
||||
return 0
|
||||
conn.executemany(
|
||||
"INSERT INTO nomenclator_rar (cod_prestatie, nume_prestatie, updated_at) "
|
||||
"VALUES (?, ?, datetime('now')) "
|
||||
"ON CONFLICT(cod_prestatie) DO UPDATE SET nume_prestatie=excluded.nume_prestatie, "
|
||||
"updated_at=datetime('now')",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def load_nomenclator(conn) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT cod_prestatie, nume_prestatie FROM nomenclator_rar ORDER BY cod_prestatie"
|
||||
).fetchall()
|
||||
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]:
|
||||
"""{cod_op_service -> cod_prestatie} pentru un cont."""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT cod_op_service, cod_prestatie FROM operations_mapping WHERE account_id=?",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
return {r["cod_op_service"]: r["cod_prestatie"] for r in rows}
|
||||
|
||||
|
||||
def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
|
||||
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
|
||||
|
||||
Varianta extinsa care include si flagul auto_send per operatie.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT cod_op_service, cod_prestatie, auto_send FROM operations_mapping WHERE account_id=?",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
return {
|
||||
r["cod_op_service"]: {"cod_prestatie": r["cod_prestatie"], "auto_send": bool(r["auto_send"])}
|
||||
for r in rows
|
||||
}
|
||||
|
||||
|
||||
def classify_prezentare(
|
||||
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.
|
||||
|
||||
Apelat de AMBELE rute (POST /v1/prezentari si POST /v1/prezentari/valideaza) pentru
|
||||
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).
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
def pending_unmapped(conn, account_id=None) -> list[dict]:
|
||||
"""Operatii distincte nemapate, agregate din submission-urile `needs_mapping`.
|
||||
|
||||
account_id=None (default): global — intentionat pentru web/routes.py (back-compat).
|
||||
Apelantii noi din API TREBUIE sa paseze account_id explicit; None global e
|
||||
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
|
||||
apartine contului 1). Filtrarea in SQL, nu post-hoc in Python.
|
||||
"""
|
||||
nomenclator = load_nomenclator(conn)
|
||||
if account_id is not None:
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
rows = conn.execute(
|
||||
f"SELECT id, account_id, payload_json FROM submissions "
|
||||
f"WHERE status='needs_mapping' AND {scope_sql}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'"
|
||||
).fetchall()
|
||||
|
||||
agg: dict[tuple[int, str], dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
acct = r["account_id"] if r["account_id"] is not None else DEFAULT_ACCOUNT_ID
|
||||
try:
|
||||
content = json.loads(r["payload_json"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
for item in content.get("prestatii") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if (item.get("cod_prestatie") or ""):
|
||||
continue
|
||||
op = (item.get("cod_op_service") or "").strip()
|
||||
if not op:
|
||||
continue
|
||||
key = (acct, op)
|
||||
entry = agg.setdefault(
|
||||
key,
|
||||
{"account_id": acct, "cod_op_service": op, "denumire": item.get("denumire"), "blocked": 0, "_ids": set()},
|
||||
)
|
||||
if not entry["denumire"] and item.get("denumire"):
|
||||
entry["denumire"] = item.get("denumire")
|
||||
entry["_ids"].add(r["id"])
|
||||
|
||||
# Indexeaza corpusul embeddings o data inainte de bucla (no-op cand flagul e off).
|
||||
ensure_embeddings_corpus(conn, nomenclator)
|
||||
|
||||
out: list[dict] = []
|
||||
for entry in agg.values():
|
||||
entry["blocked"] = len(entry.pop("_ids"))
|
||||
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
|
||||
# L14-S6: imbogatire sugestii cu GOLD partajat > SILVER > embeddings (Eng-F2).
|
||||
# SUGGESTION-ONLY: nu intra in resolve_prestatii/load_mapping (#13).
|
||||
enriched = enrich_suggestions(conn, entry["denumire"])
|
||||
entry["sugestie_principala"] = enriched["sugestie_principala"]
|
||||
entry["surse_sugestie"] = enriched["surse"]
|
||||
out.append(entry)
|
||||
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
|
||||
return out
|
||||
|
||||
|
||||
def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestatie: str, auto_send: bool) -> None:
|
||||
"""Upsert o mapare op->cod (UNIQUE pe account_id+cod_op_service)."""
|
||||
acct = account_or_default(account_id)
|
||||
op = (cod_op_service or "").strip()
|
||||
cod = (cod_prestatie or "").strip().upper()
|
||||
if not op or not cod:
|
||||
raise ValueError("cod_op_service si cod_prestatie sunt obligatorii")
|
||||
conn.execute(
|
||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
|
||||
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send",
|
||||
(acct, op, cod, 1 if auto_send else 0),
|
||||
)
|
||||
|
||||
|
||||
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]:
|
||||
"""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 ->
|
||||
ruleaza validarea de continut si trece pe `queued` (sau `needs_data` cu
|
||||
motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu
|
||||
motivul actualizat. Intoarce {requeued, still_blocked, needs_data, review_manual}.
|
||||
|
||||
auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane 'needs_mapping'
|
||||
cu motiv "review manual"); previne FINALIZATA eronat permanent.
|
||||
|
||||
batch_id != None -> scope la seria comitata (NU cross-batch).
|
||||
batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus).
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
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
|
||||
# Incarca regulile text O DATA, inainte de bucla pe randuri.
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
|
||||
if batch_id is not None:
|
||||
# Scope la batch-ul specificat (import commit explicit).
|
||||
# NU atinge randuri din alte batches sau din feed API.
|
||||
rows = conn.execute(
|
||||
"SELECT id, payload_json FROM submissions "
|
||||
"WHERE status='needs_mapping' AND account_id=? AND batch_id=?",
|
||||
(acct, batch_id),
|
||||
).fetchall()
|
||||
else:
|
||||
# POST /v1/mapari (save manual): re-rezolva EXCLUSIV canalul API (batch_id IS NULL).
|
||||
# Salvarea unei mapari NU re-queues randuri din batches de import (cross-batch /
|
||||
# cross-feed). Batches de import sunt re-rezolvate doar la commit explicit.
|
||||
rows = conn.execute(
|
||||
"SELECT id, payload_json FROM submissions "
|
||||
"WHERE status='needs_mapping' AND account_id=? AND batch_id IS NULL",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
|
||||
stats = {"requeued": 0, "still_blocked": 0, "needs_data": 0, "review_manual": 0}
|
||||
for r in rows:
|
||||
try:
|
||||
content = json.loads(r["payload_json"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
|
||||
content["prestatii"] = resolved
|
||||
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:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, json.dumps({"unmapped": unmapped}, ensure_ascii=False), r["id"]),
|
||||
)
|
||||
stats["still_blocked"] += 1
|
||||
continue
|
||||
|
||||
# US-001 (PRD 5.11): ramura auto_send eliminata din reresolve.
|
||||
# Un cod rezolvat -> queued direct (review_manual ramane 0).
|
||||
|
||||
errors = validate_prezentare(content)
|
||||
if errors:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, json.dumps(errors, ensure_ascii=False), r["id"]),
|
||||
)
|
||||
stats["needs_data"] += 1
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='queued', payload_json=?, rar_error=NULL, "
|
||||
"retry_count=0, next_attempt_at=NULL, updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, r["id"]),
|
||||
)
|
||||
stats["requeued"] += 1
|
||||
return stats
|
||||
113
app/models.py
113
app/models.py
@@ -1,33 +1,65 @@
|
||||
"""Modele Pydantic pentru suprafata API.
|
||||
|
||||
ATENTIE: validarea completa (regex VIN ^[A-HJ-NPR-Z0-9]{17}$, nrInmatriculare,
|
||||
dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti, R-ODO/I-ODO -> odometruInitial
|
||||
obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este
|
||||
**T3** — aici sunt doar formele de baza. Vezi plan.md sect. 2 + roadmap T3.
|
||||
Aici sunt doar formele de baza + normalizare strip/upper. Validarea completa de
|
||||
continut (regex VIN, interval data, R-ODO/I-ODO -> odometruInitial, ordine
|
||||
odometru) este in app.validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class RarCredentials(BaseModel):
|
||||
"""Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza."""
|
||||
|
||||
email: str
|
||||
password: str
|
||||
# repr=False: str(creds) / loguri care fac repr pe model NU expun parola.
|
||||
password: str = Field(..., repr=False)
|
||||
|
||||
|
||||
class PrestatieItem(BaseModel):
|
||||
cod_prestatie: str = Field(..., description="cod din nomenclator RAR, ex. OE-1")
|
||||
"""O operatie de declarat. Contract hibrid:
|
||||
|
||||
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
|
||||
mapeaza in cod RAR prin operations_mapping. Cel putin unul dintre
|
||||
cod_prestatie / cod_op_service e obligatoriu (shape -> 422 daca lipsesc ambele).
|
||||
"""
|
||||
|
||||
cod_prestatie: str | None = Field(None, description="cod din nomenclator RAR, ex. OE-1")
|
||||
cod_op_service: str | None = Field(None, description="cod intern operatie ROAAUTO (mapat -> cod RAR)")
|
||||
denumire: str | None = Field(None, description="denumirea operatiei ROAAUTO (pentru fuzzy lookup la mapare)")
|
||||
|
||||
@field_validator("cod_prestatie")
|
||||
@classmethod
|
||||
def _norm_cod(cls, v: str | None) -> str | None:
|
||||
return v.strip().upper() if v else None
|
||||
|
||||
@field_validator("cod_op_service", "denumire")
|
||||
@classmethod
|
||||
def _norm_op(cls, v: str | None) -> str | None:
|
||||
return v.strip() if v else None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_one(self) -> "PrestatieItem":
|
||||
if not self.cod_prestatie and not self.cod_op_service:
|
||||
raise ValueError("fiecare prestatie are nevoie de cod_prestatie sau cod_op_service")
|
||||
return self
|
||||
|
||||
|
||||
class PrezentareIn(BaseModel):
|
||||
"""O prezentare de declarat la RAR (inainte de validarea T3)."""
|
||||
"""O prezentare de declarat la RAR.
|
||||
|
||||
Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de
|
||||
continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in
|
||||
app.validation.validate_prezentare si NU resping cererea — marcheaza
|
||||
`needs_data`.
|
||||
"""
|
||||
|
||||
vin: str
|
||||
nr_inmatriculare: str
|
||||
data_prestatie: str # YYYY-MM-DD; validare interval = T3
|
||||
data_prestatie: str # YYYY-MM-DD
|
||||
odometru_final: str # string per contract
|
||||
odometru_initial: str | None = None
|
||||
prestatii: list[PrestatieItem]
|
||||
@@ -35,20 +67,77 @@ class PrezentareIn(BaseModel):
|
||||
obs: str | None = None
|
||||
b64_image: str | None = None
|
||||
|
||||
@field_validator("vin", "nr_inmatriculare")
|
||||
@classmethod
|
||||
def _norm_upper(cls, v: str) -> str:
|
||||
return v.strip().upper()
|
||||
|
||||
@field_validator("data_prestatie", "odometru_final")
|
||||
@classmethod
|
||||
def _norm_strip(cls, v: str) -> str:
|
||||
return v.strip()
|
||||
|
||||
|
||||
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)
|
||||
# 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):
|
||||
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
|
||||
id_prezentare: int | None = None
|
||||
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):
|
||||
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]
|
||||
|
||||
33
app/nomenclator_seed.py
Normal file
33
app/nomenclator_seed.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Seed fallback pentru nomenclator_rar.
|
||||
|
||||
Nomenclatorul autoritativ se ia LIVE din RAR (`getNomenclatorPrestatii`) si e
|
||||
upsert-at de worker la fiecare login (vezi worker.refresh_nomenclator). Dar
|
||||
editorul de mapari + fuzzy lookup trebuie sa functioneze si inainte ca worker-ul
|
||||
sa fi rulat (dev, offline, primul boot). De aceea seed-uim aceste 18 coduri din
|
||||
contract (docs/api-rar-contract.md, verificat live 2026-06-15) DOAR daca tabela
|
||||
e goala; refresh-ul live le suprascrie cu textul lung oficial.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# (cod_prestatie, nume_prestatie) — 18 coduri, conform contractului live.
|
||||
FALLBACK_NOMENCLATOR: list[tuple[str, str]] = [
|
||||
("OE-1", "REPARATIE"),
|
||||
("OE-2", "INTRETINERE"),
|
||||
("OE-3", "REVIZIE PERIODICA"),
|
||||
("OE-4", "REGLARE FUNCTIONALA"),
|
||||
("OE-5", "MODIFICARE CONSTRUCTIVA"),
|
||||
("OE-6", "RECONSTRUCTIE"),
|
||||
("OE-7", "ACTUALIZARE SOFTWARE"),
|
||||
("OE-8", "INLOCUIRE SEZONIERA A ANVELOPELOR"),
|
||||
("OE-D", "AVARII GRAVE LA SISTEMUL DE DIRECTIE"),
|
||||
("OE-F", "AVARII GRAVE LA SISTEMUL DE FRANARE"),
|
||||
("OE-C", "AVARII GRAVE LA STRUCTURA DE REZISTENTA A CAROSERIEI"),
|
||||
("OE-S", "AVARII GRAVE LA STRUCTURA DE REZISTENTA A SASIULUI"),
|
||||
("OE-R", "AVARII GRAVE LA UN SISTEM DE RETINERE SI PROTECTIE IN CAZ DE ACCIDENT"),
|
||||
("OE-A", "AVARII GRAVE LA UN SISTEM AVANSAT DE ASISTENTA A CONDUCATORULUI AUTO (ADAS)"),
|
||||
("OE-I", "ISTORICUL INDICATIEI ODOMETRULUI (vehicule anterior inmatriculate in alte tari)"),
|
||||
("AITLV", "INREGISTRARE ATELIER INSPECTIE TAHOGRAFE / LIMITATOARE DE VITEZA"),
|
||||
("R-ODO", "REPARATIE ODOMETRU"),
|
||||
("I-ODO", "INLOCUIRE ODOMETRU"),
|
||||
]
|
||||
146
app/observ.py
Normal file
146
app/observ.py
Normal 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
59
app/operatii_seed.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Seeder corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
|
||||
|
||||
Artefactul `app/data/operatii-etichetate.json` e produs offline de
|
||||
`tools/mapare-llm/genereaza_seed.py` (etichetare LM Studio, o singura data) si comis
|
||||
in repo. La `init_db` il incarcam in `mapping_suggestions` cu INSERT OR IGNORE, ca
|
||||
SILVER sa nu mai fie gol in productie (sugestii exact-match + corpus k-NN reale).
|
||||
|
||||
Format seed: [{denumire, denumire_normalizata, cod, is_nul, source, confidence}].
|
||||
Reutilizeaza `shared_store.seed_suggestions` (normalizeaza cheia + impune NUL->cod NULL,
|
||||
INSERT OR IGNORE). NB (F10): confirmarile UMANE stau in `shared_mappings`, NU aici —
|
||||
deci INSERT OR IGNORE pastreaza codul LLM existent la re-seed (v1 = ignore, nu upsert).
|
||||
|
||||
SUGGESTION-ONLY (invariant #13): nimic din SILVER nu intra in resolve_prestatii/load_mapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from .shared_store import seed_suggestions
|
||||
|
||||
SEED_PATH = os.path.join(os.path.dirname(__file__), "data", "operatii-etichetate.json")
|
||||
|
||||
|
||||
def load_seed_file(path: str = SEED_PATH) -> list[dict]:
|
||||
"""Citeste artefactul seed. Lipsa / invalid -> [] (degradare gratioasa)."""
|
||||
if not path or not os.path.exists(path):
|
||||
return []
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (ValueError, OSError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def seed_operatii_etichetate(conn: sqlite3.Connection, path: str = SEED_PATH) -> int:
|
||||
"""Incarca seedul in mapping_suggestions (INSERT OR IGNORE). Intoarce nr. randuri inserate.
|
||||
|
||||
Mapeaza cheia seedului `cod` -> `cod_prestatie` (forma asteptata de seed_suggestions);
|
||||
`is_nul=True` forteaza cod NULL acolo. Idempotent: re-rularea nu dubleaza randuri.
|
||||
"""
|
||||
raw = load_seed_file(path)
|
||||
if not raw:
|
||||
return 0
|
||||
items = [
|
||||
{
|
||||
"denumire": e.get("denumire") or e.get("denumire_normalizata") or "",
|
||||
"cod_prestatie": e.get("cod"),
|
||||
"is_nul": bool(e.get("is_nul")),
|
||||
"source": e.get("source") or "llm_seed",
|
||||
"confidence": e.get("confidence") or 0.0,
|
||||
}
|
||||
for e in raw
|
||||
if isinstance(e, dict)
|
||||
]
|
||||
return seed_suggestions(conn, items)
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Constructor payload postPrezentare (schelet — T4 il completeaza).
|
||||
"""Constructor payload postPrezentare (T4).
|
||||
|
||||
Reguli din contract (docs/api-rar-contract.md):
|
||||
Reguli din contract (docs/api-rar-contract.md), confirmate live in T1:
|
||||
- status mereu "FINALIZATA".
|
||||
- tipPrestatie NU se trimite (server-generated GENERIC).
|
||||
- odometruFinal ca string.
|
||||
- odometruFinal ca string; odometruInitial ca string cand e prezent, altfel null.
|
||||
- sistemReparat trimis mereu (default "null").
|
||||
- prestatii: [{codPrestatie, idPrezentare: null}].
|
||||
- b64Image / odometruInitial optionale (se omit daca lipsesc).
|
||||
T4 adauga snapshot-test fata de exemplul oficial din contract.
|
||||
- obs / b64Image optionale — se OMIT din payload daca lipsesc.
|
||||
|
||||
Snapshot test fata de exemplul oficial: tests/test_payload.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,25 +16,41 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _upper(value: object) -> str:
|
||||
return str(value or "").strip().upper()
|
||||
|
||||
|
||||
def _str_or_none(value: object) -> str | None:
|
||||
"""str.strip() pentru valori non-goale; None pentru None / string gol."""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def _cod(p: object) -> str | None:
|
||||
cod = p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", None)
|
||||
return str(cod).strip().upper() if cod else None
|
||||
|
||||
|
||||
def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Mapeaza o prezentare interna -> payload exact pentru RAR postPrezentare."""
|
||||
prestatii = prezentare.get("prestatii") or []
|
||||
"""Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare."""
|
||||
payload: dict[str, Any] = {
|
||||
"vin": (prezentare.get("vin") or "").strip().upper(),
|
||||
"nrInmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(),
|
||||
"dataPrestatie": prezentare.get("data_prestatie"),
|
||||
"odometruFinal": str(prezentare.get("odometru_final") or "").strip(),
|
||||
"odometruInitial": prezentare.get("odometru_initial"),
|
||||
"vin": _upper(prezentare.get("vin")),
|
||||
"nrInmatriculare": _upper(prezentare.get("nr_inmatriculare")),
|
||||
"dataPrestatie": str(prezentare.get("data_prestatie") or "").strip(),
|
||||
# odometruFinal ramane string (nu folosim `or` ca sa nu pierdem "0").
|
||||
"odometruFinal": str(prezentare.get("odometru_final")).strip()
|
||||
if prezentare.get("odometru_final") is not None else "",
|
||||
"odometruInitial": _str_or_none(prezentare.get("odometru_initial")),
|
||||
"prestatii": [
|
||||
{
|
||||
"codPrestatie": (p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", None)),
|
||||
"idPrezentare": None,
|
||||
}
|
||||
for p in prestatii
|
||||
{"codPrestatie": _cod(p), "idPrezentare": None}
|
||||
for p in (prezentare.get("prestatii") or [])
|
||||
],
|
||||
"sistemReparat": prezentare.get("sistem_reparat") or "null",
|
||||
"status": "FINALIZATA",
|
||||
}
|
||||
# tipPrestatie: NICIODATA in payload (server-generated GENERIC).
|
||||
if prezentare.get("obs"):
|
||||
payload["obs"] = prezentare["obs"]
|
||||
if prezentare.get("b64_image"):
|
||||
|
||||
145
app/payload_view.py
Normal file
145
app/payload_view.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Extragere payload submission -> campuri afisabile.
|
||||
|
||||
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
|
||||
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
|
||||
eng review, DRY). Fara DB, fara request — primeste `payload_json` (text JSON
|
||||
plaintext, vezi `submissions.payload_json`) sau un dict deja parsat.
|
||||
|
||||
Defensiv prin constructie: nu arunca niciodata pe payload malformat — degradeaza
|
||||
la em-dash. Citeste cheile tolerant (canalele API si import pot diferi usor:
|
||||
`nr_inmatriculare` vs `numar`/`numarInmatriculare`; coercion Excel pe odometru/VIN).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
EMPTY = "—"
|
||||
|
||||
|
||||
def _clean_str(value: Any) -> str:
|
||||
"""str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe)."""
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _clean_odometru(value: Any) -> str:
|
||||
"""Odometru afisat curat: strip '.0' din coercion Excel (123456.0 -> 123456)."""
|
||||
s = _clean_str(value)
|
||||
if "." in s:
|
||||
before, after = s.split(".", 1)
|
||||
if after == "0" and before.lstrip("-").isdigit():
|
||||
return before
|
||||
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:
|
||||
"""Forma trunchiata a VIN-ului pentru tabel (integral ramane in detaliu).
|
||||
|
||||
VIN are 17 caractere; in tabel aratam ultimele 6 cu prefix elipsa ca sa
|
||||
incapa fara sa ascundem complet identitatea vehiculului.
|
||||
"""
|
||||
if not vin:
|
||||
return ""
|
||||
if len(vin) <= 8:
|
||||
return vin
|
||||
return "…" + vin[-6:]
|
||||
|
||||
|
||||
def _prima_prestatie(prestatii: Any) -> dict[str, Any]:
|
||||
"""Primul item de prestatie ca dict, defensiv (lista goala/non-dict -> {})."""
|
||||
if isinstance(prestatii, list):
|
||||
for item in prestatii:
|
||||
if isinstance(item, dict):
|
||||
return item
|
||||
return {}
|
||||
|
||||
|
||||
def _payload_dict(payload: str | dict | None) -> dict[str, Any]:
|
||||
"""Normalizeaza intrarea la dict. JSON invalid / tip neasteptat -> {}."""
|
||||
if payload is None:
|
||||
return {}
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
||||
"""Campuri afisabile dintr-un payload de submission.
|
||||
|
||||
Intoarce un dict cu chei stabile (toate string-uri, EMPTY cand lipsesc):
|
||||
vehicul_nr, vin, vin_scurt, operatie, data_prestatie, odometru, cod.
|
||||
|
||||
`operatie` = denumire prestatiei daca exista, altfel codul; `cod` = codul RAR
|
||||
(`cod_prestatie`) sau, in lipsa, codul intern (`cod_op_service`).
|
||||
"""
|
||||
data = _payload_dict(payload)
|
||||
|
||||
vin = _clean_str(data.get("vin"))
|
||||
nr = _clean_str(
|
||||
data.get("nr_inmatriculare")
|
||||
or data.get("numar")
|
||||
or data.get("numarInmatriculare")
|
||||
)
|
||||
odo = _clean_odometru(
|
||||
data.get("odometru_final")
|
||||
if data.get("odometru_final") is not None
|
||||
else data.get("odometru")
|
||||
)
|
||||
data_prest = _clean_str(data.get("data_prestatie") or data.get("dataPrestatie"))
|
||||
|
||||
item = _prima_prestatie(data.get("prestatii"))
|
||||
cod = _clean_str(item.get("cod_prestatie") or item.get("cod_op_service"))
|
||||
denumire = _clean_str(item.get("denumire"))
|
||||
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 {
|
||||
"vehicul_nr": nr or EMPTY,
|
||||
"vin": vin or EMPTY,
|
||||
"vin_scurt": _vin_scurt(vin) or EMPTY,
|
||||
"operatie": operatie or EMPTY,
|
||||
"data_prestatie": data_prest or EMPTY,
|
||||
"odometru": odo 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
130
app/plans.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Definitia planurilor de cont (sursa unica de adevar). Modul PUR, fara import DB/HTTP.
|
||||
|
||||
Pattern ca app/errors.py: catalog + helperi. Consumat de rutele de ingestie si dashboard.
|
||||
Nu importa DB, HTTP, sau orice alt modul intern cu efecte secundare.
|
||||
|
||||
Decizii implementare (PRD 5.17 / autoplan 2026-06-28):
|
||||
- FREE_MONTHLY_LIMIT: constanta unica (T-CEO-2), tunabila fara arqueologie de cod.
|
||||
- CONSUMED_STATUSES: decizie #20 — prestatie consumata = acceptata in coada.
|
||||
- effective_tier: `now` injectabil (decizie #2) pentru teste deterministe.
|
||||
- monthly_usage: pattern E7/5.15 (strftime localtime), `now` injectabil.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Limita lunara pentru planul Gratuit.
|
||||
# Decizie user T-CEO-2 (2026-06-28): o singura constanta, referita din PLANS.
|
||||
# Tunabila fara a modifica logica de enforcement.
|
||||
FREE_MONTHLY_LIMIT: int = 60
|
||||
|
||||
# Statusurile care consuma din cota lunara (decizie #20, 2026-06-28).
|
||||
# Prestatie consumata = acceptata in coada (queued/sending/sent), nu cele respinse/blocate.
|
||||
# Rationale: limita e pe ce trimitem la RAR, nu pe incercari esuate sau blocate.
|
||||
CONSUMED_STATUSES: tuple[str, ...] = ("queued", "sending", "sent")
|
||||
|
||||
# Sursa unica de adevar pentru planuri. Fiecare plan are:
|
||||
# label -- eticheta afisata in RO (UI, mesaje)
|
||||
# monthly_limit -- None = nelimitat; int = limita prestatii/luna
|
||||
# api_access -- True = acces import prin API (/v1/*); False = doar web dashboard
|
||||
#
|
||||
# Aliniat landing-ului comercial (PRD 5.17 US-001):
|
||||
# Gratuit: 60/luna, fara API
|
||||
# Standard: nelimitat, fara API
|
||||
# Pro: nelimitat, cu API
|
||||
# Premium: nelimitat, cu API (suport dedicat)
|
||||
PLANS: dict[str, dict] = {
|
||||
"free": {
|
||||
"label": "Gratuit",
|
||||
"monthly_limit": FREE_MONTHLY_LIMIT,
|
||||
"api_access": False,
|
||||
},
|
||||
"standard": {
|
||||
"label": "Standard",
|
||||
"monthly_limit": None,
|
||||
"api_access": False,
|
||||
},
|
||||
"pro": {
|
||||
"label": "Pro",
|
||||
"monthly_limit": None,
|
||||
"api_access": True,
|
||||
},
|
||||
"premium": {
|
||||
"label": "Premium",
|
||||
"monthly_limit": None,
|
||||
"api_access": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def effective_tier(account_row, now: datetime) -> str:
|
||||
"""Returneaza tier-ul efectiv al contului la momentul `now` (injectabil pentru determinism).
|
||||
|
||||
Daca `trial_until` e in viitor -> 'pro' (trial Pro activ).
|
||||
Altfel -> `tier`-ul de baza al contului.
|
||||
trial_until malformat/NULL -> fallback defensiv la tier de baza (nu arunca niciodata).
|
||||
|
||||
`now` TREBUIE injectat explicit (nu datetime.now() intern) — decizie #2 din autoplan.
|
||||
Suporta sqlite3.Row si dict.
|
||||
"""
|
||||
# Citire robusta: suporta sqlite3.Row (IndexError pe key absent) si dict (KeyError)
|
||||
try:
|
||||
tier = account_row["tier"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
tier = "free"
|
||||
try:
|
||||
trial_until_str = account_row["trial_until"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
trial_until_str = None
|
||||
|
||||
# Fallback defensiv la 'free' daca tier e None/gol
|
||||
if not tier:
|
||||
tier = "free"
|
||||
|
||||
if not trial_until_str:
|
||||
return tier
|
||||
|
||||
try:
|
||||
# Parseaza trial_until; stocam ca "YYYY-MM-DD HH:MM:SS" (UTC implicit) sau ISO
|
||||
tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T"))
|
||||
# Daca fara timezone -> assume UTC (cum stocam in DB)
|
||||
if tu.tzinfo is None:
|
||||
tu = tu.replace(tzinfo=timezone.utc)
|
||||
# Normalizeaza `now` la aware daca e naive
|
||||
now_cmp = now
|
||||
if now_cmp.tzinfo is None:
|
||||
now_cmp = now_cmp.replace(tzinfo=timezone.utc)
|
||||
if tu > now_cmp:
|
||||
return "pro"
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
pass # malformat -> fallback defensiv la tier de baza
|
||||
|
||||
return tier
|
||||
|
||||
|
||||
def monthly_usage(conn: sqlite3.Connection, account_id: int, now: datetime) -> int:
|
||||
"""Numara prestatiile contului acceptate in coada in luna calendaristica curenta.
|
||||
|
||||
Definitia 'luna curenta': strftime('%Y-%m', created_at, 'localtime') corespunde
|
||||
lunii lui `now` (acelasi pattern ca E7/5.15 din routes.py — consistent cu 'localtime').
|
||||
`now` injectabil pentru teste deterministe. Scoped strict pe account_id.
|
||||
created_at NULL/malformat -> exclus defensiv (nu arunca niciodata).
|
||||
|
||||
NOTA: containerul are /etc/localtime=UTC, deci 'localtime' = UTC in mediul de test.
|
||||
Testele de granita construiesc timestamp-uri relative la luna curenta calculata cu
|
||||
acelasi 'localtime', nu valori absolute care presupun +2/+3h.
|
||||
"""
|
||||
# Formatam `now` ca string SQLite si folosim acelasi modificator 'localtime' ca routes.py
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
placeholders = ",".join("?" * len(CONSUMED_STATUSES))
|
||||
row = conn.execute(
|
||||
f"SELECT COUNT(*) AS n FROM submissions "
|
||||
f"WHERE account_id = ? "
|
||||
f" AND status IN ({placeholders}) "
|
||||
f" AND created_at IS NOT NULL "
|
||||
f" AND strftime('%Y-%m', created_at, 'localtime') = strftime('%Y-%m', ?, 'localtime')",
|
||||
(account_id, *CONSUMED_STATUSES, now_str),
|
||||
).fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
@@ -19,12 +19,25 @@ from .config import Settings, get_settings
|
||||
|
||||
|
||||
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)
|
||||
self.status_code = status_code
|
||||
self.field_errors = field_errors or []
|
||||
self.rar_message = rar_message
|
||||
|
||||
|
||||
class RarAuthError(RarError):
|
||||
@@ -105,13 +118,22 @@ class RarClient:
|
||||
errors = body.get("data") if isinstance(body.get("data"), list) else []
|
||||
msg = body.get("message", "Validare esuata la RAR")
|
||||
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]:
|
||||
"""Lista prezentarilor finalizate (pentru reconciliere — T2).
|
||||
|
||||
Atentie: pe mediul TEST raspunsul NU contine `prestatii` (vezi contract).
|
||||
Portare din rar-forms.prg:720 / getAllPrezentariFinalizate.
|
||||
GET /prezentari/getAllPrezentariFinalizate -> data.content (listă).
|
||||
Verificat live: filtrele/paginarea NU functioneaza pe test (vezi contract),
|
||||
deci interogam fara parametri si filtram client-side. Pe test `prestatii`
|
||||
vine null in fiecare item — match-ul se face pe vin+dataPrestatie+odometruFinal.
|
||||
"""
|
||||
resp = self._client.get(
|
||||
"/prezentari/getAllPrezentariFinalizate",
|
||||
@@ -119,8 +141,14 @@ class RarClient:
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RarError(f"getFinalizate esuat (HTTP {resp.status_code})", status_code=resp.status_code)
|
||||
data = _safe_json(resp)
|
||||
return data.get("data", data) if isinstance(data, dict) else data
|
||||
body = _safe_json(resp)
|
||||
if isinstance(body, dict):
|
||||
data = body.get("data")
|
||||
if isinstance(data, dict) and isinstance(data.get("content"), list):
|
||||
return data["content"]
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
def _safe_json(resp: httpx.Response) -> Any:
|
||||
|
||||
91
app/rar_env.py
Normal file
91
app/rar_env.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Medii RAR per cont (PRD 5.20): disponibilitate + default efectiv.
|
||||
|
||||
Sursa UNICA de adevar pentru REQ-DISP / REQ-DEFAULT: vizibilitatea selector/toggle
|
||||
in UI, validarea tintei in API si decizia worker-ului citesc TOATE de aici, ca sa
|
||||
decida identic.
|
||||
|
||||
Un mediu ('test'|'prod') e *disponibil* pentru un cont daca e activat (bifa) SI are
|
||||
credentiale (slot per-mediu non-gol). Din disponibilitate decurge tot UX-ul:
|
||||
- 0 medii -> nicio tinta; trimiterea web e blocata, API cade pe ancora globala.
|
||||
- 1 mediu -> tinta implicita (acel mediu), fara selector.
|
||||
- 2 medii -> selector la import + toggle in statusbar + alegere in API.
|
||||
|
||||
Functii PURE (fara DB) peste un rand de cont (sqlite3.Row sau dict). Helperele cu
|
||||
`conn` incarca randul si deleaga.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
VALID_ENVS: tuple[str, str] = ("test", "prod")
|
||||
|
||||
|
||||
def _field(account: Any, key: str, default: Any = None) -> Any:
|
||||
"""Citire toleranta a unui camp de cont (dict sau sqlite3.Row, camp posibil absent)."""
|
||||
if account is None:
|
||||
return default
|
||||
if isinstance(account, dict):
|
||||
return account.get(key, default)
|
||||
try:
|
||||
return account[key] # sqlite3.Row
|
||||
except (IndexError, KeyError):
|
||||
return default
|
||||
|
||||
|
||||
def _are_creds(account: Any, env: str) -> bool:
|
||||
creds = _field(account, f"rar_creds_{env}_enc", None)
|
||||
return bool(creds and str(creds).strip())
|
||||
|
||||
|
||||
def _enabled(account: Any, env: str) -> bool:
|
||||
return int(_field(account, f"rar_{env}_enabled", 0) or 0) == 1
|
||||
|
||||
|
||||
def medii_disponibile(account: Any) -> list[str]:
|
||||
"""Subset din ('test','prod') = activat AND creds prezente. Ordine stabila test<prod."""
|
||||
return [env for env in VALID_ENVS if _enabled(account, env) and _are_creds(account, env)]
|
||||
|
||||
|
||||
def rar_env_efectiv(account: Any) -> str | None:
|
||||
"""Mediul tinta implicit al contului (REQ-DEFAULT).
|
||||
|
||||
Mereu unul din mediile disponibile: default-ul contului daca inca e disponibil,
|
||||
altfel singurul disponibil; daca 0 disponibile -> None (nicio tinta).
|
||||
"""
|
||||
disp = medii_disponibile(account)
|
||||
if not disp:
|
||||
return None
|
||||
default = _field(account, "rar_env_default", "prod")
|
||||
if default in disp:
|
||||
return default
|
||||
return disp[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpere cu conexiune #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_ACCOUNT_ENV_COLS = (
|
||||
"id, rar_test_enabled, rar_prod_enabled, "
|
||||
"rar_creds_test_enc, rar_creds_prod_enc, rar_env_default"
|
||||
)
|
||||
|
||||
|
||||
def load_account_env(conn: sqlite3.Connection, account_id: int) -> sqlite3.Row | None:
|
||||
"""Randul de cont cu exact coloanele de mediu (pentru medii_disponibile/rar_env_efectiv)."""
|
||||
from .mapping import account_or_default
|
||||
|
||||
return conn.execute(
|
||||
f"SELECT {_ACCOUNT_ENV_COLS} FROM accounts WHERE id=?",
|
||||
(account_or_default(account_id),),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def medii_disponibile_cont(conn: sqlite3.Connection, account_id: int) -> list[str]:
|
||||
return medii_disponibile(load_account_env(conn, account_id))
|
||||
|
||||
|
||||
def rar_env_efectiv_cont(conn: sqlite3.Connection, account_id: int) -> str | None:
|
||||
return rar_env_efectiv(load_account_env(conn, account_id))
|
||||
54
app/reconcile.py
Normal file
54
app/reconcile.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Reconciliere anti-duplicat pe raspuns pierdut (T2 — P1).
|
||||
|
||||
Daca un POST postPrezentare ajunge la RAR si RAR insereaza, dar raspunsul se
|
||||
pierde (timeout/retea), randul ramane `sending`. Un re-send orb ar crea duplicat
|
||||
(RAR ACCEPTA duplicate — confirmat live, vezi contract). Inainte de re-send,
|
||||
interogam lista finalizate si cautam o potrivire pe `vin + dataPrestatie +
|
||||
odometruFinal`. UNIQUE pe `submissions` NU acopera acest caz (e despre starea la RAR).
|
||||
|
||||
Functie pura, unit-testabila. odometruFinal e NUMAR in listarea RAR, string in
|
||||
payload-ul nostru -> comparam ca int.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _as_int(value: object) -> int | None:
|
||||
s = str(value if value is not None else "").strip()
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def match_finalizata(
|
||||
finalizate: list[dict[str, Any]],
|
||||
*,
|
||||
vin: str,
|
||||
data_prestatie: str,
|
||||
odometru_final: object,
|
||||
) -> int | None:
|
||||
"""Intoarce id-ul (data.id) unei prezentari finalizate care se potriveste, altfel None.
|
||||
|
||||
Match: vin (case-insensitive) + dataPrestatie (egal) + odometruFinal (egal ca int).
|
||||
Daca exista mai multe potriviri (RAR accepta duplicate), intoarce id-ul MAXIM
|
||||
(cel mai recent) — orice match dovedeste ca RAR are deja inregistrarea.
|
||||
"""
|
||||
want_vin = (vin or "").strip().upper()
|
||||
want_odo = _as_int(odometru_final)
|
||||
want_data = (data_prestatie or "").strip()
|
||||
|
||||
matches: list[int] = []
|
||||
for item in finalizate:
|
||||
if (item.get("vin") or "").strip().upper() != want_vin:
|
||||
continue
|
||||
if (str(item.get("dataPrestatie") or "").strip()) != want_data:
|
||||
continue
|
||||
if _as_int(item.get("odometruFinal")) != want_odo:
|
||||
continue
|
||||
item_id = _as_int(item.get("id"))
|
||||
if item_id is not None:
|
||||
matches.append(item_id)
|
||||
return max(matches) if matches else None
|
||||
221
app/schema.sql
221
app/schema.sql
@@ -1,19 +1,64 @@
|
||||
-- Schema SQLite (WAL) pentru gateway RAR AUTOPASS.
|
||||
-- Vezi plan.md sect. 5. NICIUN camp pentru parole RAR.
|
||||
-- Validarea completa (T3) si criptarea PII (P2) vin ulterior; in schelet
|
||||
-- payload-ul e stocat ca JSON text (camp payload_json), de inlocuit cu BLOB
|
||||
-- criptat + purge_after cand se face T7/criptare.
|
||||
-- Vezi plan.md sect. 5 + plan-treapta2.md sect. 4.
|
||||
-- Treapta 2: adauga conturi cu creds RAR durabile, tabele import, atestari.
|
||||
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Conturi ROAAUTO (clientii care folosesc gateway-ul).
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
cui TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
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
|
||||
-- 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'))
|
||||
);
|
||||
-- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi
|
||||
-- fara CUI (ex. default) se pot crea multiplu. Unicitate la nivel de index (nu check
|
||||
-- in helper) ca sa nu existe fereastra de coliziune intre doi create_account concurenti.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL;
|
||||
-- Cont implicit (id=1): auth API-key (CORE) inca neimplementat, deci ingestiile vin
|
||||
-- cu account_id NULL. Le atribuim contului default ca FK + UNIQUE(account_id,...) din
|
||||
-- operations_mapping sa fie valide; cand auth livreaza, account_id real va curge natural.
|
||||
INSERT OR IGNORE INTO accounts (id, name) VALUES (1, 'default');
|
||||
|
||||
-- Chei API per cont (separate de creds RAR). Stocam doar hash-ul.
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
@@ -50,18 +95,174 @@ CREATE TABLE IF NOT EXISTS submissions (
|
||||
account_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
|
||||
payload_json TEXT NOT NULL, -- TODO(P2): inlocuit cu BLOB criptat
|
||||
payload_json TEXT NOT NULL,
|
||||
-- Mediul RAR tinta al acestei trimiteri (PRD 5.20 US-001). DEFAULT 'test' e doar plasa
|
||||
-- pentru randuri net-noi care nu seteaza explicit; fiecare INSERT (API/import/web) seteaza
|
||||
-- rar_env explicit. Backfill din AUTOPASS_RAR_ENV global la migrare (NU lasa pe DEFAULT).
|
||||
rar_env TEXT NOT NULL DEFAULT 'test' CHECK (rar_env IN ('test', 'prod')),
|
||||
rar_creds_enc TEXT, -- creds RAR criptate (Fernet), sterse dupa primul login reusit
|
||||
rar_status_code INTEGER,
|
||||
rar_error TEXT,
|
||||
id_prezentare INTEGER, -- data.id intors de RAR la succes
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT, -- backoff: randul nu se ia inainte de acest moment (T2)
|
||||
sending_since TEXT, -- pentru lease/timeout pe randuri 'sending' orfane (T2)
|
||||
purge_after TEXT, -- sent + 90z (P2)
|
||||
purge_after TEXT, -- sent + 90z (T16)
|
||||
batch_id INTEGER, -- import batch (T7; NULL = canal API)
|
||||
row_index INTEGER, -- rand in batch (T7; NULL = canal API)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_submissions_account_status ON submissions(account_id, status);
|
||||
-- Nota: idx_submissions_batch se creeaza in _migrate (dupa ALTER care adauga batch_id pe DB veche).
|
||||
|
||||
-- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane).
|
||||
CREATE TABLE IF NOT EXISTS column_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
signature_coloane TEXT NOT NULL, -- hash/lista sortata a coloanelor fisierului
|
||||
json_mapare TEXT NOT NULL, -- {col_fisier: camp_canonic, ...} JSON
|
||||
format_data TEXT, -- ex. "DD.MM.YYYY"
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (account_id, signature_coloane)
|
||||
);
|
||||
|
||||
-- Loturi de import (fisiere incarcate).
|
||||
CREATE TABLE IF NOT EXISTS import_batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'staging'
|
||||
CHECK (status IN ('staging','committed','error')),
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
ok INTEGER NOT NULL DEFAULT 0,
|
||||
needs_mapping INTEGER NOT NULL DEFAULT 0,
|
||||
needs_data INTEGER NOT NULL DEFAULT 0,
|
||||
needs_review INTEGER NOT NULL DEFAULT 0,
|
||||
already_sent INTEGER NOT NULL DEFAULT 0,
|
||||
duplicate_in_file INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
purge_after TEXT -- created_at + 90z (T16)
|
||||
);
|
||||
|
||||
-- Randuri din lot de import (PII criptat cu Fernet).
|
||||
CREATE TABLE IF NOT EXISTS import_rows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id INTEGER NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
|
||||
row_index INTEGER NOT NULL,
|
||||
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'
|
||||
CHECK (resolved_status IN (
|
||||
'pending','ok','needs_mapping','needs_data',
|
||||
'needs_review','already_sent','duplicate_in_file'
|
||||
)),
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_import_rows_batch ON import_rows(batch_id);
|
||||
|
||||
-- Log atestare legala (confirmare import batch, L.142/2023).
|
||||
CREATE TABLE IF NOT EXISTS import_attestations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id INTEGER NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
|
||||
account_id INTEGER NOT NULL,
|
||||
confirmed_by TEXT, -- email/identifier utilizator
|
||||
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
rows_hash TEXT NOT NULL, -- sha256 peste valorile rezolvate confirmate
|
||||
n_confirmed INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Utilizatori web (email+parola, legati de un cont). Parola stocata doar ca scrypt hash.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL, -- hex scrypt(salt, parola)
|
||||
salt TEXT NOT NULL, -- hex secrets.token_bytes(16), per-user
|
||||
scrypt_params TEXT NOT NULL, -- eticheta versiune parametri: 'n16384_r8_p1'
|
||||
email_verified INTEGER NOT NULL DEFAULT 0, -- C19: pregatire viitor
|
||||
is_admin INTEGER NOT NULL DEFAULT 0, -- pregatire 3.3b
|
||||
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.
|
||||
CREATE TABLE IF NOT EXISTS worker_heartbeat (
|
||||
|
||||
164
app/security.py
Normal file
164
app/security.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Redactare credentiale (CORE securitate).
|
||||
|
||||
Corpul lui POST /v1/prezentari contine `rar_credentials.password` (creds RAR
|
||||
per-cerere, zero-storage). Aceste valori NU trebuie sa apara NICIODATA in:
|
||||
- raspunsuri de eroare (422 Pydantic echo-eaza `input` => parola) — vezi
|
||||
`app.main.validation_exception_handler`;
|
||||
- loguri / traceback-uri uvicorn — vezi `CredentialRedactingFilter`;
|
||||
- repr-ul modelelor (str(creds)) — vezi `RarCredentials` (Field repr=False).
|
||||
|
||||
Modulul e pur (fara DB/HTTP), unit-testabil direct.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
MASK = "***REDACTED***"
|
||||
|
||||
# Chei al caror continut e secret oriunde apar (case-insensitive). `denumire`
|
||||
# etc. raman in clar — doar credentialele si token-urile se mascheaza.
|
||||
SENSITIVE_KEYS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"parola",
|
||||
"pwd",
|
||||
"pass",
|
||||
"rar_credentials",
|
||||
"credentials",
|
||||
"token",
|
||||
"jwt",
|
||||
"authorization",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"x-api-key",
|
||||
"secret",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# 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:
|
||||
"""Copie a structurii cu valorile cheilor sensibile mascate, recursiv.
|
||||
|
||||
Pentru `rar_credentials`/`credentials` masheaza intregul subarbore (nu doar
|
||||
`password`) — un dict de creds e secret integral. Listele si dict-urile se
|
||||
parcurg in adancime; scalarii trec neatinsi.
|
||||
"""
|
||||
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
|
||||
else:
|
||||
out[k] = scrub(v)
|
||||
return out
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [scrub(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
# Mascare in text liber (mesaje de log, traceback-uri formatate). Acopera formele
|
||||
# uzuale: JSON ("password": "x"), kwargs (password='x'), Bearer <token>.
|
||||
_TEXT_PATTERNS = [
|
||||
# "password": "secret" / 'password' : 'secret' (JSON / dict repr)
|
||||
re.compile(
|
||||
r"""(?P<key>["']?(?:password|parola|pwd|pass|token|jwt|secret|api[_-]?key)["']?\s*[:=]\s*)"""
|
||||
r"""(?P<q>["'])(?P<val>(?:\\.|[^"'\\])*)(?P=q)""",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
# password=secret (fara ghilimele, pana la separator)
|
||||
re.compile(
|
||||
r"""(?P<key>\b(?:password|parola|pwd|token|jwt|secret|api[_-]?key)\s*=\s*)(?P<val>[^\s,;&)}\]]+)""",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
# Authorization: Bearer <token> / Bearer eyJ...
|
||||
re.compile(r"""(?P<key>Bearer\s+)(?P<val>[A-Za-z0-9._\-]+)""", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def scrub_text(text: str) -> str:
|
||||
"""Masheaza credentiale dintr-un sir liber (loguri, traceback)."""
|
||||
if not text:
|
||||
return text
|
||||
out = text
|
||||
for pat in _TEXT_PATTERNS:
|
||||
out = pat.sub(lambda m: m.group("key") + MASK, out)
|
||||
return out
|
||||
|
||||
|
||||
class CredentialRedactingFilter(logging.Filter):
|
||||
"""Filtru de logging care masheaza credentiale din orice record emis.
|
||||
|
||||
Atasat pe root + logger-ele uvicorn (vezi `install_log_redaction`). Lucreaza
|
||||
pe mesajul DEJA formatat (cu args interpolate), apoi goleste args ca
|
||||
formatter-ul sa nu reinterpoleze. Tot ce trece prin logging e curatat;
|
||||
parolele in variabile locale de traceback nu ajung in mesaj (Python nu
|
||||
formateaza locals implicit), deci raman protejate.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
try:
|
||||
msg = record.getMessage()
|
||||
except Exception:
|
||||
return True
|
||||
scrubbed = scrub_text(msg)
|
||||
if scrubbed != msg:
|
||||
record.msg = scrubbed
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def install_log_redaction() -> None:
|
||||
"""Instaleaza filtrul de redactare pe root + logger-ele uvicorn (idempotent)."""
|
||||
filt = CredentialRedactingFilter()
|
||||
targets = [
|
||||
logging.getLogger(), # root
|
||||
logging.getLogger("uvicorn"),
|
||||
logging.getLogger("uvicorn.error"),
|
||||
logging.getLogger("uvicorn.access"),
|
||||
]
|
||||
for lg in targets:
|
||||
if not any(isinstance(f, CredentialRedactingFilter) for f in lg.filters):
|
||||
lg.addFilter(filt)
|
||||
142
app/shared_store.py
Normal file
142
app/shared_store.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Store partajat pentru sugestii (SILVER) si mapari validate de oameni (GOLD cross-account).
|
||||
|
||||
Straturi (L14-S3, PRD 5.14):
|
||||
- mapping_suggestions (SILVER): sugestii LLM/embedding, citite DOAR de suggest/pending_unmapped,
|
||||
NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
|
||||
- shared_mappings (GOLD partajat): mapari validate de om din orice cont; pot pre-completa
|
||||
editorul (suggestion-only cross-account, F-A/#11); auto-send DOAR GOLD propriu
|
||||
(operations_mapping per-cont, Eng-F2).
|
||||
|
||||
Invariante:
|
||||
- INSERT OR IGNORE la seed: nu suprascrie randuri existente (#2).
|
||||
- NUL = is_nul=1, cod_prestatie NULL; NU se promoveaza la cod RAR (#4).
|
||||
- source/confidence pe fiecare rand (provenienta + rollback batch model prost, #5).
|
||||
- Wiring in resolve_prestatii/load_mapping vine in L14-S6; modulul de fata e API pur.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from .mapping import normalize_for_match
|
||||
|
||||
|
||||
def seed_suggestions(
|
||||
conn: sqlite3.Connection,
|
||||
items: list[dict[str, Any]],
|
||||
) -> int:
|
||||
"""Insereaza sugestii in mapping_suggestions (SILVER). INSERT OR IGNORE.
|
||||
|
||||
Nu suprascrie randuri deja existente (#2): re-rularea seeder-ului e sigura.
|
||||
Fiecare item trebuie sa contina:
|
||||
- 'denumire': str — text brut (se normalizeaza intern cu normalize_for_match)
|
||||
- 'source': str — 'llm', 'embedding', etc.
|
||||
Optional:
|
||||
- 'cod_prestatie': str | None — ignorat cand is_nul=True
|
||||
- 'is_nul': bool — True pt non-operatii (supresie, #4); cod_prestatie stocat NULL
|
||||
- 'confidence': float — 0..1 (default 0.0)
|
||||
|
||||
Returneaza numarul de randuri inserate efectiv (0 daca toate existau deja).
|
||||
"""
|
||||
inserted = 0
|
||||
for item in items:
|
||||
den_norm = normalize_for_match(item.get("denumire") or "")
|
||||
if not den_norm:
|
||||
continue
|
||||
is_nul = 1 if item.get("is_nul") else 0
|
||||
# NUL -> cod NULL obligatoriu (supresie stricta, #4)
|
||||
# Normalizeaza INAINTE de truthiness: un cod whitespace-only (" ") sau
|
||||
# ne-string trebuie sa devina NULL, nu '' (altfel rand non-NUL cu cod gol).
|
||||
cod = None
|
||||
if not is_nul:
|
||||
raw_cod = str(item.get("cod_prestatie") or "").strip().upper()
|
||||
cod = raw_cod or None
|
||||
source = str(item.get("source") or "llm")
|
||||
confidence = float(item.get("confidence") or 0.0)
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO mapping_suggestions
|
||||
(denumire_normalizata, cod_prestatie, is_nul, source, confidence)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(den_norm, cod, is_nul, source, confidence),
|
||||
)
|
||||
inserted += cur.rowcount
|
||||
return inserted
|
||||
|
||||
|
||||
def lookup_suggestion(
|
||||
conn: sqlite3.Connection,
|
||||
denumire: str,
|
||||
) -> sqlite3.Row | None:
|
||||
"""Cauta sugestie SILVER dupa denumire normalizata.
|
||||
|
||||
Returneaza randul din mapping_suggestions sau None daca nu exista.
|
||||
NOTA: apelantul trebuie sa verifice is_nul inainte de a folosi cod_prestatie.
|
||||
"""
|
||||
den_norm = normalize_for_match(denumire)
|
||||
if not den_norm:
|
||||
return None
|
||||
return conn.execute(
|
||||
"SELECT * FROM mapping_suggestions WHERE denumire_normalizata = ?",
|
||||
(den_norm,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def lookup_shared_gold(
|
||||
conn: sqlite3.Connection,
|
||||
denumire: str,
|
||||
) -> sqlite3.Row | None:
|
||||
"""Cauta mapare GOLD partajata dupa denumire normalizata.
|
||||
|
||||
Returneaza randul din shared_mappings sau None daca nu exista.
|
||||
NOTA (F-A/#11): acest GOLD partajat e suggestion-only cross-account;
|
||||
auto-send vine DOAR din operations_mapping (GOLD propriu per-cont).
|
||||
"""
|
||||
den_norm = normalize_for_match(denumire)
|
||||
if not den_norm:
|
||||
return None
|
||||
return conn.execute(
|
||||
"SELECT * FROM shared_mappings WHERE denumire_normalizata = ?",
|
||||
(den_norm,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def record_human_validation(
|
||||
conn: sqlite3.Connection,
|
||||
denumire: str,
|
||||
cod_prestatie: str,
|
||||
*,
|
||||
source: str = "human",
|
||||
provenance: str | None = None,
|
||||
confidence: float = 1.0,
|
||||
) -> None:
|
||||
"""Inregistreaza o mapare validata de om in GOLD partajat (shared_mappings).
|
||||
|
||||
Daca denumirea exista deja: incrementeaza confirmations + actualizeaza updated_at.
|
||||
Daca nu exista: insert nou cu confirmations=1.
|
||||
|
||||
Apelat la confirmarea umana a unei mapari (din editorul needs_mapping).
|
||||
Wiring efectiv vine in L14-S6 (dupa 5.15); aceasta functie e API-ul store.
|
||||
|
||||
NOTA: NU intra in operations_mapping (GOLD per-cont) — acela e gestionat
|
||||
separat de editorul existent. Ambele pot coexista.
|
||||
"""
|
||||
den_norm = normalize_for_match(denumire)
|
||||
if not den_norm:
|
||||
return
|
||||
cod = (cod_prestatie or "").strip().upper()
|
||||
if not cod:
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO shared_mappings
|
||||
(denumire_normalizata, cod_prestatie, source, provenance, confidence, confirmations)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(denumire_normalizata) DO UPDATE SET
|
||||
confirmations = confirmations + 1,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(den_norm, cod, source, provenance, confidence),
|
||||
)
|
||||
119
app/submissions_admin.py
Normal file
119
app/submissions_admin.py
Normal 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"]}
|
||||
167
app/users.py
Normal file
167
app/users.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Helper-e utilizatori web (email + parola scrypt).
|
||||
|
||||
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
|
||||
migrare cost viitoare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import sqlite3
|
||||
|
||||
SCRYPT_PARAMS = "n16384_r8_p1"
|
||||
_N = 2**14
|
||||
_R = 8
|
||||
_P = 1
|
||||
_DKLEN = 32
|
||||
_MAXMEM = 64 * 1024 * 1024
|
||||
|
||||
_PASSWORD_MIN = 10
|
||||
_PASSWORD_MAX = 128
|
||||
|
||||
|
||||
def _parse_scrypt_params(label: str) -> tuple[int, int, int] | None:
|
||||
"""Parseaza 'nN_rR_pP' -> (N, R, P). Returneaza None la format necunoscut/corupt."""
|
||||
try:
|
||||
parts = label.split("_")
|
||||
if len(parts) != 3 or parts[0][0] != "n" or parts[1][0] != "r" or parts[2][0] != "p":
|
||||
return None
|
||||
return (int(parts[0][1:]), int(parts[1][1:]), int(parts[2][1:]))
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _scrypt_hash(password: str, salt: bytes, n: int = _N, r: int = _R, p: int = _P) -> bytes:
|
||||
return hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=r,
|
||||
p=p,
|
||||
maxmem=_MAXMEM,
|
||||
dklen=_DKLEN,
|
||||
)
|
||||
|
||||
|
||||
def create_user(
|
||||
conn: sqlite3.Connection,
|
||||
account_id: int,
|
||||
email: str,
|
||||
password: str,
|
||||
is_admin: bool = False,
|
||||
) -> int:
|
||||
"""Creeaza un user nou si intoarce id-ul.
|
||||
|
||||
Valideaza ca: contul exista, parola intre 10 si 128 caractere, emailul nu e duplicat.
|
||||
Stocheaza DOAR hash scrypt + salt (hex), niciodata parola in clar.
|
||||
Email duplicat (case-insensitive, via UNIQUE COLLATE NOCASE) -> ValueError.
|
||||
|
||||
is_admin: daca True, userul e marcat ca admin (is_admin=1). Apelantul decide
|
||||
logica de bootstrap (count_admins==0 -> primul cont devine admin).
|
||||
"""
|
||||
email = email.strip()
|
||||
|
||||
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not acct:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
|
||||
if len(password) < _PASSWORD_MIN:
|
||||
raise ValueError(f"parola prea scurta (minim {_PASSWORD_MIN} caractere)")
|
||||
if len(password) > _PASSWORD_MAX:
|
||||
raise ValueError(f"parola prea lunga (maxim {_PASSWORD_MAX} caractere, anti-DoS)")
|
||||
|
||||
salt = secrets.token_bytes(16)
|
||||
pw_hash = _scrypt_hash(password, salt)
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (account_id, email, password_hash, salt, scrypt_params, is_admin) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(account_id, email, pw_hash.hex(), salt.hex(), SCRYPT_PARAMS, 1 if is_admin else 0),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
raise ValueError("email deja folosit")
|
||||
|
||||
return int(cur.lastrowid or 0)
|
||||
|
||||
|
||||
def count_admins(conn: sqlite3.Connection) -> int:
|
||||
"""Numara userii cu is_admin=1 din intreaga baza."""
|
||||
row = conn.execute("SELECT COUNT(*) AS n FROM users WHERE is_admin=1").fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
|
||||
def set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool = True) -> None:
|
||||
"""Seteaza/sterge rolul admin pe toti userii contului dat.
|
||||
|
||||
Ridica ValueError daca contul nu exista.
|
||||
Daca contul exista dar nu are useri, e no-op silentios.
|
||||
"""
|
||||
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not acct:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
conn.execute(
|
||||
"UPDATE users SET is_admin=? WHERE account_id=?",
|
||||
(1 if is_admin else 0, account_id),
|
||||
)
|
||||
|
||||
|
||||
def is_account_admin(conn: sqlite3.Connection, account_id: int) -> bool:
|
||||
"""Returneaza True daca cel putin un user al contului are is_admin=1."""
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM users WHERE account_id=? AND is_admin=1 LIMIT 1",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def list_admin_emails(conn: sqlite3.Connection) -> list[str]:
|
||||
"""Returneaza emailurile tuturor userilor cu is_admin=1."""
|
||||
rows = conn.execute(
|
||||
"SELECT email FROM users WHERE is_admin=1"
|
||||
).fetchall()
|
||||
return [row["email"] for row in rows]
|
||||
|
||||
|
||||
def verify_password(conn: sqlite3.Connection, email: str, password: str) -> int | None:
|
||||
"""Verifica parola pentru email. Intoarce account_id la potrivire, None altfel.
|
||||
|
||||
Nu distinge intre email inexistent si parola gresita (evita enumerare useri).
|
||||
Comparatie constant-time cu hmac.compare_digest.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT account_id, password_hash, salt, scrypt_params FROM users "
|
||||
"WHERE email=? COLLATE NOCASE",
|
||||
(email.strip(),),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
# Executa un hash dummy pentru a evita timing oracle pe email inexistent
|
||||
_scrypt_hash(password, b"\x00" * 16)
|
||||
return None
|
||||
|
||||
salt = bytes.fromhex(row["salt"])
|
||||
expected = bytes.fromhex(row["password_hash"])
|
||||
|
||||
params = _parse_scrypt_params(row["scrypt_params"] or "")
|
||||
if params is None:
|
||||
return None
|
||||
n, r, p = params
|
||||
actual = _scrypt_hash(password, salt, n=n, r=r, p=p)
|
||||
|
||||
if hmac.compare_digest(actual, expected):
|
||||
return int(row["account_id"])
|
||||
return None
|
||||
|
||||
|
||||
def get_user_by_email(conn: sqlite3.Connection, email: str) -> dict | None:
|
||||
"""Metadate user dupa email (FARA password_hash si salt)."""
|
||||
row = conn.execute(
|
||||
"SELECT id, account_id, email, is_admin, email_verified, created_at "
|
||||
"FROM users WHERE email=? COLLATE NOCASE",
|
||||
(email.strip(),),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
173
app/validation.py
Normal file
173
app/validation.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Validare de domeniu pentru prezentari (T3).
|
||||
|
||||
Replica regulile RAR (docs/api-rar-contract.md sect. "Reguli de validare") ÎNAINTE
|
||||
de enqueue, ca sa nu primim 4xx de la RAR. Spre deosebire de validarea de SHAPE
|
||||
(Pydantic, da 422 la JSON malformat), aceasta e validare de CONTINUT: esecurile NU
|
||||
resping cererea, ci marcheaza submission-ul `needs_data` (plan.md sect. 3 — masina de
|
||||
stari + failure registry). Asa apar in dashboard cu motiv, corectabile.
|
||||
|
||||
Functiile sunt pure (dict -> listă erori), unit-testabile fara DB/HTTP.
|
||||
Erorile au forma {field, message} — aceeasi ca raspunsul de eroare RAR.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from datetime import date
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.errors import eroare as _eroare
|
||||
|
||||
# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
|
||||
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
|
||||
# Numar inmatriculare: max 10, litere + cifre majuscule.
|
||||
NRINM_RE = re.compile(r"^[A-Z0-9]{1,10}$")
|
||||
# Coduri care fac odometruInitial obligatoriu.
|
||||
ODOMETER_CODES = {"R-ODO", "I-ODO"}
|
||||
# Interval dataPrestatie.
|
||||
MIN_DATA_PRESTATIE = date(2024, 12, 1)
|
||||
TZ_BUCURESTI = ZoneInfo("Europe/Bucharest")
|
||||
|
||||
|
||||
def _norm(value: object) -> str:
|
||||
return str(value or "").strip().upper()
|
||||
|
||||
|
||||
def _codes(prestatii: list | None) -> list[str]:
|
||||
out: list[str] = []
|
||||
for p in prestatii or []:
|
||||
cod = p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", None)
|
||||
if cod:
|
||||
out.append(str(cod).strip().upper())
|
||||
return out
|
||||
|
||||
|
||||
def _parse_int(value: object) -> int | None:
|
||||
s = str(value or "").strip()
|
||||
if s.isdigit():
|
||||
return int(s)
|
||||
return None
|
||||
|
||||
|
||||
def today_bucuresti() -> date:
|
||||
from datetime import datetime
|
||||
|
||||
return datetime.now(TZ_BUCURESTI).date()
|
||||
|
||||
|
||||
def validate_prezentare(content: dict) -> list[dict]:
|
||||
"""Intoarce lista erorilor de continut [{field, message}]. Goala = valid.
|
||||
|
||||
`content` = PrezentareIn.model_dump() (campuri snake_case interne).
|
||||
"""
|
||||
errors: list[dict] = []
|
||||
|
||||
# --- VIN ---
|
||||
vin = _norm(content.get("vin"))
|
||||
if not VIN_RE.match(vin):
|
||||
errors.append(_eroare(
|
||||
"VIN_FORMAT",
|
||||
field="vin",
|
||||
cauza="VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
|
||||
))
|
||||
|
||||
# --- nrInmatriculare ---
|
||||
nrinm = _norm(content.get("nr_inmatriculare"))
|
||||
if not NRINM_RE.match(nrinm):
|
||||
errors.append(_eroare(
|
||||
"NR_INMATRICULARE_FORMAT",
|
||||
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 ---
|
||||
raw_data = str(content.get("data_prestatie") or "").strip()
|
||||
try:
|
||||
d = date.fromisoformat(raw_data)
|
||||
except ValueError:
|
||||
errors.append(_eroare(
|
||||
"DATA_FORMAT",
|
||||
field="data_prestatie",
|
||||
cauza="Format data invalid; foloseste YYYY-MM-DD.",
|
||||
))
|
||||
d = None
|
||||
if d is not None:
|
||||
if d < MIN_DATA_PRESTATIE:
|
||||
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():
|
||||
errors.append(_eroare(
|
||||
"DATA_VIITOR",
|
||||
field="data_prestatie",
|
||||
cauza="Data prestatiei nu poate fi in viitor.",
|
||||
))
|
||||
|
||||
# --- odometruFinal (string numeric) ---
|
||||
odo_final = _parse_int(content.get("odometru_final"))
|
||||
if odo_final is None:
|
||||
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 ---
|
||||
codes = _codes(content.get("prestatii"))
|
||||
needs_initial = bool(set(codes) & ODOMETER_CODES)
|
||||
raw_initial = content.get("odometru_initial")
|
||||
has_initial = str(raw_initial or "").strip() != ""
|
||||
if needs_initial and not has_initial:
|
||||
errors.append(_eroare(
|
||||
"ODOMETRU_INITIAL_LIPSA",
|
||||
field="odometru_initial",
|
||||
cauza="odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.",
|
||||
))
|
||||
if has_initial:
|
||||
odo_initial = _parse_int(raw_initial)
|
||||
if odo_initial is None:
|
||||
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:
|
||||
errors.append(_eroare(
|
||||
"ODOMETRU_INITIAL_ORDINE",
|
||||
field="odometru_initial",
|
||||
cauza="odometruInitial trebuie sa fie <= odometruFinal.",
|
||||
))
|
||||
|
||||
# --- prestatii nevide ---
|
||||
if not codes:
|
||||
errors.append(_eroare(
|
||||
"PRESTATII_GOALE",
|
||||
field="prestatii",
|
||||
cauza="Lista de prestatii nu poate fi goala.",
|
||||
))
|
||||
|
||||
# --- b64Image: optional, dar daca e prezent trebuie base64 valid ---
|
||||
b64 = content.get("b64_image")
|
||||
if b64:
|
||||
if not _is_valid_base64(str(b64)):
|
||||
errors.append(_eroare(
|
||||
"B64_INVALID",
|
||||
field="b64_image",
|
||||
cauza="b64Image nu este base64 valid.",
|
||||
))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _is_valid_base64(value: str) -> bool:
|
||||
s = value.strip()
|
||||
if not s:
|
||||
return False
|
||||
try:
|
||||
base64.b64decode(s, validate=True)
|
||||
return True
|
||||
except (ValueError, base64.binascii.Error): # type: ignore[attr-defined]
|
||||
return False
|
||||
286
app/web/admin_routes.py
Normal file
286
app/web/admin_routes.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Panou admin web /admin.
|
||||
|
||||
Rute:
|
||||
GET /admin — listeaza conturi in asteptare + active (require_admin)
|
||||
POST /admin/activate — activeaza un cont (require_admin + CSRF, PRG)
|
||||
POST /admin/deactivate — dezactiveaza un cont, nu permite id=1 (require_admin + CSRF, PRG)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..accounts import account_is_complete, list_accounts, set_active, set_status, set_tier, set_trial, delete_account
|
||||
from ..config import get_settings
|
||||
from ..db import get_connection
|
||||
from ..plans import PLANS, effective_tier
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from ..web.session import require_admin
|
||||
|
||||
|
||||
def _plan_label(code: str | None) -> str:
|
||||
"""Eticheta RO a unui cod de plan (din PLANS). None/necunoscut -> '—'."""
|
||||
if not code:
|
||||
return "—"
|
||||
plan = PLANS.get(code)
|
||||
return plan["label"] if plan else code
|
||||
|
||||
|
||||
def _trial_zile_ramase(trial_until_str: str | None, now: datetime) -> int | None:
|
||||
"""Zile ramase din trial (rotunjit in sus), sau None daca nu e trial activ/malformat.
|
||||
|
||||
Acelasi parsing tolerant ca plans.effective_tier (UTC implicit pe valori naive).
|
||||
"""
|
||||
if not trial_until_str:
|
||||
return None
|
||||
try:
|
||||
tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T"))
|
||||
if tu.tzinfo is None:
|
||||
tu = tu.replace(tzinfo=timezone.utc)
|
||||
now_cmp = now if now.tzinfo else now.replace(tzinfo=timezone.utc)
|
||||
secunde = (tu - now_cmp).total_seconds()
|
||||
if secunde <= 0:
|
||||
return None
|
||||
# Rotunjire in sus la zile (o fractie de zi ramasa = inca 1 zi afisata).
|
||||
return int(secunde // 86400) + (1 if secunde % 86400 else 0)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
router = APIRouter()
|
||||
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||
|
||||
|
||||
def _ctx(request: Request, **extra) -> dict:
|
||||
settings = get_settings()
|
||||
return {"rar_env": settings.rar_env, "version": __version__, **extra}
|
||||
|
||||
|
||||
def _emails_by_account(conn) -> dict[int, str | None]:
|
||||
"""Intoarce primul email per account_id, intr-un singur query (fara N+1)."""
|
||||
rows = conn.execute(
|
||||
"SELECT account_id, email FROM users ORDER BY id"
|
||||
).fetchall()
|
||||
result: dict[int, str | None] = {}
|
||||
for row in rows:
|
||||
acc_id = int(row["account_id"])
|
||||
if acc_id not in result:
|
||||
result[acc_id] = row["email"]
|
||||
return result
|
||||
|
||||
|
||||
def _render_admin(request: Request, conn, *, error: str | None = None, status_code: int = 200):
|
||||
"""Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare."""
|
||||
accounts = list_accounts(conn)
|
||||
emails = _emails_by_account(conn)
|
||||
now = datetime.now(timezone.utc)
|
||||
for acct in accounts:
|
||||
# Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului
|
||||
acct["is_complete"] = account_is_complete(acct)
|
||||
acct["email"] = emails.get(acct["id"])
|
||||
# Plan EFECTIV (ce are contul acum): trial Pro activ ridica `free` la `pro`.
|
||||
# `tier` ramane sursa de adevar pentru drepturi; `requested_plan` e doar intentia de la signup.
|
||||
eff = effective_tier(acct, now)
|
||||
acct["tier_label"] = _plan_label(acct.get("tier")) # tier de baza (post-trial)
|
||||
acct["tier_efectiv_label"] = _plan_label(eff) # plan efectiv ACUM
|
||||
acct["trial_activ"] = eff != (acct.get("tier") or "free")
|
||||
acct["trial_zile"] = _trial_zile_ramase(acct.get("trial_until"), now)
|
||||
acct["requested_plan_label"] = _plan_label(acct.get("requested_plan"))
|
||||
# Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
||||
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
|
||||
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
|
||||
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(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
pending=pending,
|
||||
active=active,
|
||||
suspended=suspended,
|
||||
error=error,
|
||||
is_authenticated=True,
|
||||
is_admin=True,
|
||||
), status_code=status_code)
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
async def admin_get(request: Request):
|
||||
"""Panou admin: conturi in asteptare + active."""
|
||||
require_admin(request)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
return _render_admin(request, conn)
|
||||
finally:
|
||||
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)
|
||||
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,
|
||||
account_id: int = Form(...),
|
||||
tier: str = Form(...),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
"""Schimba planul (tier) unui cont din panou. require_admin + CSRF, PRG 303.
|
||||
|
||||
Reuseaza accounts.set_tier (valideaza tier-ul, protejeaza id=1, logheaza schimbarea).
|
||||
INCHEIE trial-ul (trial_until=NULL): alocarea manuala = plan real de-acum, cu efect
|
||||
imediat — altfel trial-ul Pro universal (30z la signup) ar masca alegerea pana la
|
||||
expirare (decizie user 2026-06-29). Tier invalid / cont protejat -> re-randare cu eroare.
|
||||
"""
|
||||
require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
try:
|
||||
# trial_until=None: alocarea manuala incheie trial-ul si aplica tier-ul ales acum.
|
||||
set_tier(conn, account_id, tier, trial_until=None)
|
||||
conn.commit()
|
||||
except ValueError as exc:
|
||||
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@router.post("/admin/set-trial", response_class=HTMLResponse)
|
||||
async def admin_set_trial(
|
||||
request: Request,
|
||||
account_id: int = Form(...),
|
||||
trial_days: int = Form(...),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
"""Acorda/prelungeste un trial Pro de N zile (de la acum), fara a schimba tier-ul de baza.
|
||||
|
||||
require_admin + CSRF, PRG 303. Reuseaza accounts.set_trial (protejeaza id=1, logheaza).
|
||||
trial_days <= 0 sau peste plafon -> re-randare panou cu eroare (422). Plafon defensiv 3650z.
|
||||
"""
|
||||
require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
if trial_days <= 0 or trial_days > 3650:
|
||||
return _render_admin(
|
||||
request, conn,
|
||||
error="Numarul de zile pentru trial trebuie sa fie intre 1 si 3650.",
|
||||
status_code=422,
|
||||
)
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
trial_until = (now + timedelta(days=trial_days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
set_trial(conn, account_id, trial_until)
|
||||
conn.commit()
|
||||
except ValueError as exc:
|
||||
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@router.post("/admin/deactivate", response_class=HTMLResponse)
|
||||
async def admin_deactivate(
|
||||
request: Request,
|
||||
account_id: int = Form(...),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
"""Dezactiveaza un cont. Nu permite dezactivarea contului default id=1. PRG: redirect 303."""
|
||||
require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
if account_id == 1:
|
||||
conn = get_connection()
|
||||
try:
|
||||
return _render_admin(
|
||||
request, conn,
|
||||
error="Contul default (id=1) nu poate fi dezactivat.",
|
||||
status_code=422,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
try:
|
||||
set_active(conn, account_id, False)
|
||||
except ValueError as exc:
|
||||
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
235
app/web/auth_routes.py
Normal file
235
app/web/auth_routes.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Rute autentificare web: /signup, /login, /logout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..accounts import VALID_TIERS, create_account
|
||||
from ..auth import create_api_key
|
||||
from ..config import get_settings
|
||||
from ..db import get_connection
|
||||
from ..email import notify_signup
|
||||
from ..users import count_admins, create_user, list_admin_emails, verify_password
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from ..web.ratelimit import check_rate_limit
|
||||
from ..web.session import clear_session, set_session
|
||||
|
||||
router = APIRouter()
|
||||
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||
|
||||
_RATE_MSG = "Prea multe cereri. Incearca mai tarziu."
|
||||
_PASSWORD_MIN = 10
|
||||
|
||||
|
||||
def _ctx(request: Request, **extra) -> dict:
|
||||
settings = get_settings()
|
||||
return {"rar_env": settings.rar_env, "version": __version__, **extra}
|
||||
|
||||
|
||||
# --- Signup ---
|
||||
|
||||
@router.get("/signup", response_class=HTMLResponse)
|
||||
async def signup_get(request: Request):
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request, csrf_token=get_csrf_token(request)
|
||||
))
|
||||
|
||||
|
||||
@router.post("/signup", response_class=HTMLResponse)
|
||||
async def signup_post(
|
||||
request: Request,
|
||||
name: str = Form(default=""),
|
||||
cui: str = Form(default=""),
|
||||
email: str = Form(default=""),
|
||||
parola: str = Form(default=""),
|
||||
plan: str = Form(default=""),
|
||||
consent: str = Form(default=""),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
# Planul CERUT (intentie, nu drept): pastram doar valori valide; orice altceva -> 'free'.
|
||||
# `tier`-ul real ramane 'free' la creare; planul ales se onoreaza dupa plata (admin/webhook).
|
||||
requested_plan = plan.strip().lower() if plan else ""
|
||||
if requested_plan not in VALID_TIERS:
|
||||
requested_plan = "free"
|
||||
|
||||
settings = get_settings()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s):
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=_RATE_MSG,
|
||||
name=name, cui=cui, email=email, plan=requested_plan,
|
||||
), status_code=429)
|
||||
|
||||
if len(parola) < _PASSWORD_MIN:
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.",
|
||||
name=name, cui=cui, email=email, plan=requested_plan,
|
||||
), 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,
|
||||
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
is_first = count_admins(conn) == 0
|
||||
account_id = create_account(
|
||||
conn, name, cui=cui_norm, email=email, active=False,
|
||||
requested_plan=requested_plan, consent_at=consent_at,
|
||||
)
|
||||
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
|
||||
api_key = create_api_key(conn, account_id)
|
||||
conn.execute("COMMIT")
|
||||
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:
|
||||
conn.execute("ROLLBACK")
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=str(exc),
|
||||
name=name, cui=cui, email=email, plan=requested_plan,
|
||||
), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
set_session(request, account_id, user_id)
|
||||
print(f"SIGNUP cont={account_id} email={email}", flush=True)
|
||||
|
||||
# Notificare email admin (best-effort, nu blocheaza signup-ul)
|
||||
try:
|
||||
conn2 = get_connection()
|
||||
try:
|
||||
admins = list_admin_emails(conn2)
|
||||
finally:
|
||||
conn2.close()
|
||||
notify_signup(admins, account_id, email)
|
||||
except Exception as exc_notify:
|
||||
print(f"SIGNUP-NOTIFY exceptie neasteptata cont={account_id}: {type(exc_notify).__name__}", flush=True)
|
||||
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
api_key=api_key,
|
||||
account_id=account_id,
|
||||
))
|
||||
|
||||
|
||||
# --- Login / Logout ---
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_get(request: Request):
|
||||
return _TMPL.TemplateResponse(request, "login.html", _ctx(
|
||||
request, csrf_token=get_csrf_token(request)
|
||||
))
|
||||
|
||||
|
||||
@router.post("/login", response_class=HTMLResponse)
|
||||
async def login_post(
|
||||
request: Request,
|
||||
email: str = Form(default=""),
|
||||
parola: str = Form(default=""),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
settings = get_settings()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_rate_limit("login:" + ip, settings.login_rate_max, settings.signup_rate_window_s):
|
||||
return _TMPL.TemplateResponse(request, "login.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=_RATE_MSG,
|
||||
), status_code=429)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
account_id = verify_password(conn, email, parola)
|
||||
if account_id is None:
|
||||
return _TMPL.TemplateResponse(request, "login.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error="Email sau parola incorecte.",
|
||||
), status_code=401)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE email=? COLLATE NOCASE", (email.strip(),)
|
||||
).fetchone()
|
||||
user_id = int(row["id"]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
set_session(request, account_id, user_id)
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/logout", response_class=HTMLResponse)
|
||||
async def logout_post(
|
||||
request: Request,
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
clear_session(request)
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
51
app/web/csrf.py
Normal file
51
app/web/csrf.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""CSRF token per-sesiune + validare.
|
||||
|
||||
Contract pentru rutele POST web:
|
||||
- Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
- Handler-ul POST apeleaza: verify_csrf(request, form.get("csrf_token"))
|
||||
- La nepotrivire/lipsa: CsrfError -> @app.exception_handler(CsrfError) -> 403
|
||||
|
||||
Token e per-sesiune (stabil pana la logout), generat lazy la primul acces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
|
||||
class CsrfError(Exception):
|
||||
"""Token CSRF lipsa sau invalid. Prins de exception_handler in main.py -> 403."""
|
||||
|
||||
|
||||
def get_csrf_token(request: Request) -> str:
|
||||
"""Intoarce tokenul CSRF al sesiunii, generandu-l daca lipseste."""
|
||||
token = request.session.get("csrf_token")
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
request.session["csrf_token"] = token
|
||||
return token
|
||||
|
||||
|
||||
def verify_csrf(request: Request, submitted: str | None) -> None:
|
||||
"""Verifica tokenul CSRF trimis in formular.
|
||||
|
||||
Gateaza pe MOD, nu pe account_id:
|
||||
- prod (web_auth_required=True): enforce pe TOATE rutele POST, inclusiv /login si
|
||||
/signup unde atacatorul ar putea forta victima sa se logheze in contul sau
|
||||
(login CSRF). GET-urile de formular genereaza token in sesiune via get_csrf_token.
|
||||
- dev/test (web_auth_required=False, fara account_id): skip transparent, testele
|
||||
existente raman verzi fara sa fie nevoie de token.
|
||||
- sesiune autentificata (account_id in sesiune): enforce indiferent de mod.
|
||||
"""
|
||||
settings = get_settings()
|
||||
enforce = settings.web_auth_required or request.session.get("account_id") is not None
|
||||
if not enforce:
|
||||
return # dev fara auth: CSRF neaplicabil
|
||||
expected = request.session.get("csrf_token")
|
||||
if not expected or not submitted or not hmac.compare_digest(expected.encode(), submitted.encode()):
|
||||
raise CsrfError("token CSRF invalid")
|
||||
375
app/web/integrare_examples.py
Normal file
375
app/web/integrare_examples.py
Normal 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),
|
||||
},
|
||||
}
|
||||
408
app/web/labels.py
Normal file
408
app/web/labels.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""Traducere stari tehnice in text uman + clasa CSS.
|
||||
|
||||
Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tipul returnat: (text_principal, subtext_tooltip, css_class)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Eticheta = Tuple[str, str, str]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Etichete stari submissions
|
||||
# Clasele CSS corespund celor definite in base.html:
|
||||
# s-queued (accent/albastru), s-sending (warn/galben), s-sent (ok/verde),
|
||||
# s-error, s-needs_data, s-needs_mapping (err/rosu).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STARI_SUBMISSION: dict[str, Eticheta] = {
|
||||
"queued": (
|
||||
"In asteptare sa fie trimise",
|
||||
"",
|
||||
"s-queued",
|
||||
),
|
||||
"sending": (
|
||||
"Se trimite acum",
|
||||
"",
|
||||
"s-sending",
|
||||
),
|
||||
"sent": (
|
||||
"Declarate la RAR (finalizate)",
|
||||
"Confirmate cu numar de prezentare; nu se mai pot modifica.",
|
||||
"s-sent",
|
||||
),
|
||||
"needs_mapping": (
|
||||
"Lipseste codul prestatiei",
|
||||
"Alege codul RAR in tab-ul Mapari.",
|
||||
"s-needs_mapping",
|
||||
),
|
||||
"needs_data": (
|
||||
"Date incomplete (respinse de RAR)",
|
||||
"Corecteaza randul si reimporta.",
|
||||
"s-needs_data",
|
||||
),
|
||||
"error": (
|
||||
"Eroare la trimitere",
|
||||
"Trimiterea a esuat si nu se mai reincearca automat. Vezi detaliul randului; "
|
||||
"daca tine de credentialele RAR, corecteaza-le in Cont.",
|
||||
"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:
|
||||
"""
|
||||
Returneaza (text, subtext, css_class) 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 STARI_SUBMISSION[status]
|
||||
except KeyError:
|
||||
raise KeyError(
|
||||
f"Starea de submission {status!r} nu are eticheta umana in labels.py. "
|
||||
"Adauga-o in STARI_SUBMISSION."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Etichete worker (viu / mort)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def eticheta_worker(viu: bool) -> Eticheta:
|
||||
"""
|
||||
Returneaza (text, subtext, css_class) pentru starea worker-ului.
|
||||
|
||||
viu=True => "Trimitere automata: activa" (clasa s-sent / verde)
|
||||
viu=False => "Trimitere automata: oprita" (clasa s-error / rosu)
|
||||
"""
|
||||
if viu:
|
||||
return (
|
||||
"Trimitere automata: activa",
|
||||
"Sistemul verifica coada si trimite la RAR la fiecare cateva secunde.",
|
||||
"s-sent",
|
||||
)
|
||||
return (
|
||||
"Trimitere automata: oprita",
|
||||
"Nimic nu pleaca spre RAR pana reporneste. Anunta administratorul.",
|
||||
"s-error",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Etichete conexiune RAR (ok / indisponibil)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def eticheta_rar(stare: str) -> Eticheta:
|
||||
"""
|
||||
Returneaza (text, subtext, css_class) pentru starea conexiunii cu RAR.
|
||||
|
||||
stare="ok" => "Legatura cu RAR: functionala" (s-sent / verde)
|
||||
stare="indisponibil" => "Legatura cu RAR: indisponibila" (s-error / rosu)
|
||||
"""
|
||||
if stare == "ok":
|
||||
return (
|
||||
"Legatura cu RAR: functionala",
|
||||
"Portalul AUTOPASS raspunde.",
|
||||
"s-sent",
|
||||
)
|
||||
return (
|
||||
"Legatura cu RAR: indisponibila",
|
||||
"Portalul RAR nu raspunde acum; coada se reia automat cand revine.",
|
||||
"s-error",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format data RAR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_data_rar(raw: object) -> str:
|
||||
"""Formateaza un timestamp ISO ca `dd.mm.yyyy hh24:mi:ss` (ora romaneasca).
|
||||
|
||||
- Valoare lipsa (None / "") -> "—".
|
||||
- ISO valid (cu sau fara timezone / 'Z' / microsecunde) -> data formatata,
|
||||
fara fractiuni de secunda.
|
||||
- Format invalid -> fallback grijuliu: intoarce stringul brut (nu arunca),
|
||||
ca operatorul sa vada totusi ceva, nu o pagina rupta.
|
||||
"""
|
||||
if raw is None:
|
||||
return "—"
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return "—"
|
||||
iso = s.replace("Z", "+00:00") if s.endswith("Z") else s
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso)
|
||||
except (ValueError, TypeError):
|
||||
return s
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Motiv uman din rar_error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def motiv_uman(status: str, rar_error: object) -> str:
|
||||
"""Transforma `rar_error` (JSON tehnic) intr-un motiv lizibil pentru coloana Motiv.
|
||||
|
||||
Formele intalnite (vezi router.py / mapping.py):
|
||||
- validare continut: list[{field, message}] -> mesajele concatenate.
|
||||
- operatie nemapata: {"unmapped": [{cod_op_service, denumire}]}.
|
||||
- auto-send oprit: {"auto_send": "..."}.
|
||||
- eroare RAR: text simplu sau dict generic.
|
||||
Fara rar_error -> "". Nu arunca niciodata (degradeaza la text brut trunchiat).
|
||||
"""
|
||||
if not rar_error:
|
||||
return ""
|
||||
raw = rar_error if isinstance(rar_error, str) else str(rar_error)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return raw[:160]
|
||||
|
||||
if isinstance(data, dict):
|
||||
if "unmapped" in data:
|
||||
ops = data.get("unmapped") or []
|
||||
nume = ", ".join(
|
||||
(o.get("cod_op_service") or "") for o in ops if isinstance(o, dict)
|
||||
).strip(", ")
|
||||
return f"Cod RAR lipsa pentru: {nume}" if nume else "Cod RAR lipsa"
|
||||
if "auto_send" in data:
|
||||
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()]
|
||||
return "; ".join(parti)[:200]
|
||||
|
||||
if isinstance(data, list):
|
||||
msgs: list[str] = []
|
||||
for e in data:
|
||||
if isinstance(e, dict):
|
||||
msgs.append(
|
||||
str(e.get("message") or e.get("msg") or "; ".join(str(v) for v in e.values()))
|
||||
)
|
||||
else:
|
||||
msgs.append(str(e))
|
||||
return "; ".join(m for m in msgs if m)[:200]
|
||||
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ETICHETA_ULTIMA_AUTENTIFICARE_RAR = "Ultima autentificare la RAR"
|
||||
33
app/web/middleware.py
Normal file
33
app/web/middleware.py
Normal 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
|
||||
31
app/web/ratelimit.py
Normal file
31
app/web/ratelimit.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Rate-limit in-proces cu fereastra glisanta.
|
||||
|
||||
Fara dependinta externa. Folosit de POST /signup cu cheia = IP client.
|
||||
Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
# ip/key -> lista de timestamps (time.monotonic) ale cererilor din fereastra activa
|
||||
_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
|
||||
"""Fereastra glisanta: returneaza True daca cererea e permisa, False la depasire.
|
||||
|
||||
Curata timestamp-urile expirate la fiecare apel (O(n) per cheie, acceptabil
|
||||
pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple;
|
||||
suficient pentru un singur proces uvicorn.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cutoff = now - window_s
|
||||
timestamps = _hits[key]
|
||||
# Sterge intrari expirate
|
||||
_hits[key] = [t for t in timestamps if t > cutoff]
|
||||
if len(_hits[key]) >= max_hits:
|
||||
return False
|
||||
_hits[key].append(now)
|
||||
return True
|
||||
4239
app/web/routes.py
4239
app/web/routes.py
File diff suppressed because it is too large
Load Diff
99
app/web/session.py
Normal file
99
app/web/session.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Helper-e sesiune web.
|
||||
|
||||
Mecanism require_login: NU un dependency FastAPI care intoarce RedirectResponse
|
||||
(acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb:
|
||||
- require_login() RIDICA LoginRequired
|
||||
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce
|
||||
RedirectResponse('/login', 303)
|
||||
Astfel handler-ul e intrerupt imediat la raise, independent de logica FastAPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from ..config import get_settings
|
||||
from ..mapping import DEFAULT_ACCOUNT_ID
|
||||
|
||||
|
||||
class LoginRequired(Exception):
|
||||
"""Ridica pentru a redirectiona la /login (prinsa de exception_handler in main.py)."""
|
||||
|
||||
|
||||
class AdminRequired(Exception):
|
||||
"""Ridica cand contul sesiunii nu are rol admin (prinsa de exception_handler in main.py)."""
|
||||
|
||||
|
||||
def current_account(request: Request) -> int | None:
|
||||
"""account_id din sesiune sau None daca nu e logat."""
|
||||
val = request.session.get("account_id")
|
||||
return int(val) if val is not None else None
|
||||
|
||||
|
||||
def current_user_id(request: Request) -> int | None:
|
||||
"""user_id din sesiune sau None (leaga import_attestations.confirmed_by)."""
|
||||
val = request.session.get("user_id")
|
||||
return int(val) if val is not None else None
|
||||
|
||||
|
||||
def web_account(request: Request) -> int | None:
|
||||
"""account_id pentru rutele web de CITIRE.
|
||||
|
||||
- sesiune activa -> contul sesiunii
|
||||
- fara sesiune + web_auth_required=False (dev) -> DEFAULT_ACCOUNT_ID (cont 1, back-compat)
|
||||
- fara sesiune + web_auth_required=True (prod) -> None
|
||||
|
||||
Rutele de SCRIERE trebuie sa foloseasca require_login() direct, nu web_account(),
|
||||
ca sa nu cada niciodata tacit pe contul 1 in prod.
|
||||
"""
|
||||
aid = current_account(request)
|
||||
if aid is not None:
|
||||
return aid
|
||||
settings = get_settings()
|
||||
if not settings.web_auth_required:
|
||||
return DEFAULT_ACCOUNT_ID
|
||||
return None
|
||||
|
||||
|
||||
def require_login(request: Request) -> int:
|
||||
"""Verifica sesiunea activa; ridica LoginRequired daca nu.
|
||||
|
||||
Intoarce account_id la succes. Aruncatorul (exception_handler din main.py)
|
||||
intercepteaza LoginRequired si intoarce RedirectResponse('/login', 303).
|
||||
"""
|
||||
aid = web_account(request)
|
||||
if aid is None:
|
||||
raise LoginRequired()
|
||||
return aid
|
||||
|
||||
|
||||
def require_admin(request: Request) -> int:
|
||||
"""Verifica ca userul logat are rol admin pe contul sesiunii.
|
||||
|
||||
Intai cheama require_login (nelogat -> LoginRequired -> /login redirect).
|
||||
Daca e logat dar nu e admin -> ridica AdminRequired.
|
||||
Intoarce account_id la succes.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
from ..db import get_connection
|
||||
from ..users import is_account_admin
|
||||
conn = get_connection()
|
||||
try:
|
||||
admin = is_account_admin(conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
if not admin:
|
||||
raise AdminRequired()
|
||||
return account_id
|
||||
|
||||
|
||||
def set_session(request: Request, account_id: int, user_id: int) -> None:
|
||||
"""Seteaza sesiunea dupa login. Curata mai intai (anti-fixare sesiune)."""
|
||||
request.session.clear()
|
||||
request.session["account_id"] = account_id
|
||||
request.session["user_id"] = user_id
|
||||
|
||||
|
||||
def clear_session(request: Request) -> None:
|
||||
"""Sterge sesiunea (logout)."""
|
||||
request.session.clear()
|
||||
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin.woff2
Normal file
Binary file not shown.
1
app/web/static/htmx.min.js
vendored
Normal file
1
app/web/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
app/web/static/romfast_logo.png
Normal file
BIN
app/web/static/romfast_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
79
app/web/templates/_acasa.html
Normal file
79
app/web/templates/_acasa.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<div id="acasa-section">
|
||||
|
||||
{# === Banner ne-blocant: cont incomplet (US-002) ===
|
||||
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 →</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 === #}
|
||||
{% set toti_esentiali = are_creds and are_trimiteri %}
|
||||
{% if not toti_esentiali %}
|
||||
<div class="card" style="margin-top:14px; padding:12px 16px;">
|
||||
<div style="display:flex; gap:20px; flex-wrap:wrap; align-items:center; font-size:13px;">
|
||||
<span class="muted" style="font-weight:600;">Primii pasi:</span>
|
||||
|
||||
{# Pas 1: Cont RAR (esential) #}
|
||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||
{% if are_creds %}
|
||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||
{% else %}
|
||||
<span class="muted" aria-hidden="true">○</span>
|
||||
{% endif %}
|
||||
<a href="/?tab=cont">Cont RAR</a>
|
||||
</span>
|
||||
|
||||
{# Pas 2: Cheie API (optional) #}
|
||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||
{% if are_cheie_folosita %}
|
||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||
{% else %}
|
||||
<span class="muted" aria-hidden="true">○</span>
|
||||
{% endif %}
|
||||
<a href="/?tab=cont">Cheie API</a>
|
||||
<span class="muted">(optional)</span>
|
||||
</span>
|
||||
|
||||
{# Pas 3: Import (esential) — marcat ca pas curent #}
|
||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||
{% if are_trimiteri %}
|
||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||
{% else %}
|
||||
<span class="s-queued" aria-hidden="true" style="font-weight:bold;">●</span>
|
||||
{% endif %}
|
||||
<strong>Import</strong> <span class="muted">(incarca fisierul sus)</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Sectiunea Trimiteri, permanenta sub upload.
|
||||
La first-run (zero trimiteri), randam un placeholder <section> gol/ascuns — necesar
|
||||
ca OOB swap-ul de la confirma sa gaseasca tinta valida in DOM si sa injecteze
|
||||
_coada.html fara reload complet. Fara placeholder, HTMX ignora silentios OOB-ul. #}
|
||||
{% if are_trimiteri %}
|
||||
{% include '_coada.html' %}
|
||||
{% else %}
|
||||
<section id="trimiteri-section" hidden></section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -1,5 +1,12 @@
|
||||
<div class="card banner {% if not blocked %}hidden{% endif %}"
|
||||
{% if not account_active %}
|
||||
<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">
|
||||
<strong>Cont in asteptare de activare.</strong>
|
||||
Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card banner {% if not blocked %}hidden{% endif %}"
|
||||
{% if account_active %}hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML"{% endif %}>
|
||||
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
|
||||
Plasa de siguranta pe pene RAR > 30h. Verifica coada mai jos.
|
||||
</div>
|
||||
|
||||
234
app/web/templates/_chips_prestatii.html
Normal file
234
app/web/templates/_chips_prestatii.html
Normal file
@@ -0,0 +1,234 @@
|
||||
{#
|
||||
_chips_prestatii.html — sectiunea de prestatii chips (E4, server-driven via /form-chips).
|
||||
|
||||
Re-randata de endpoint-ul /form-chips la fiecare add/remove de chip.
|
||||
Inclusa si din _form_editare.html pentru randarea initiala.
|
||||
|
||||
Starea chip-urilor traieste in input-uri hidden din form (NU in DB mid-edit).
|
||||
Fiecare operatie are un picker propriu cand e nemapata (E4 binding op<->cod).
|
||||
Reveal odometru initial semnalat prin data-has-r-odo="true" si chip-warn pe R-ODO/I-ODO.
|
||||
|
||||
Context vars (toate cu defaults):
|
||||
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire}
|
||||
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru picker
|
||||
has_r_odo — True daca orice chip e R-ODO sau I-ODO (server-computed)
|
||||
form_chips_url — URL pentru HTMX; default '/form-chips'
|
||||
chips_section_id — ID div (default 'chips-section')
|
||||
csrf_token — CSRF (trecut prin hx-include din form parinte)
|
||||
#}
|
||||
{% set _chips_url = form_chips_url or '/form-chips' %}
|
||||
{% set _sec_id = chips_section_id or 'chips-section' %}
|
||||
{% set _chips = prestatii_chips or [] %}
|
||||
{% set _has_ops = _chips | selectattr('cod_op_service') | list | length > 0 %}
|
||||
{# US-009: chips_submission_id e setat din _detaliu_ctx cand chips sunt randate in modalul de detaliu.
|
||||
Lipseste cand _chips_prestatii.html e rerandat via /form-chips (stateless, fara submission). #}
|
||||
{% set _sub_id = chips_submission_id if chips_submission_id is defined else none %}
|
||||
|
||||
<div id="{{ _sec_id }}" data-has-r-odo="{{ 'true' if has_r_odo else 'false' }}"
|
||||
aria-live="polite" aria-label="Prestatii cod RAR">
|
||||
|
||||
{# ===== Input-uri hidden pentru starea curenta a chip-urilor =====
|
||||
TOATE itemele emit 3 hidden inputs (cod poate fi "" pentru unmapped).
|
||||
Paralele index-by-index: cod_prestatie[i], chip_op_service[i], chip_denumire[i].
|
||||
Filtrate la submit de post_corectie_trimitere (coduri goale = neschimbate). #}
|
||||
{% for chip in _chips %}
|
||||
<input type="hidden" name="cod_prestatie" value="{{ chip.cod_prestatie or '' }}">
|
||||
<input type="hidden" name="chip_op_service" value="{{ chip.cod_op_service or '' }}">
|
||||
<input type="hidden" name="chip_denumire" value="{{ chip.denumire or '' }}">
|
||||
{% endfor %}
|
||||
|
||||
<div class="camp-slim" style="margin-bottom:8px;">
|
||||
<label>Prestatii — cod RAR pe fiecare operatie</label>
|
||||
|
||||
{% if _has_ops %}
|
||||
{# ===== Mod operatii: UN picker PE operatie (E4 binding) ===== #}
|
||||
{% for chip in _chips %}
|
||||
{% if chip.cod_op_service %}
|
||||
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
||||
{% set _nemapat = not chip.cod_prestatie %}
|
||||
<div class="op-row {% if _nemapat %}op-row-warn{% endif %}" style="margin-bottom:6px;">
|
||||
<span class="op-row-name">
|
||||
{{ chip.cod_op_service }}
|
||||
{% if chip.denumire and chip.denumire != chip.cod_op_service %}
|
||||
<span class="muted" style="font-weight:400;font-size:11px;"> — {{ chip.denumire }}</span>
|
||||
{% endif %}
|
||||
{% if _nemapat %}
|
||||
<span style="color:var(--warn);font-size:10px;font-weight:400;"> · lipsa cod</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
{% if chip.cod_prestatie %}
|
||||
{# ===== Operatie mapata: chip cu × ===== #}
|
||||
<span class="chip {% if _is_warn %}chip-warn{% endif %}"
|
||||
aria-label="Prestatie {{ chip.cod_prestatie }} adaugata pentru {{ chip.cod_op_service }}">
|
||||
{{ chip.cod_prestatie }}
|
||||
<button type="button" class="chip-del"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"remove","chips_remove_index":{{ loop.index0 }}}'
|
||||
aria-label="Sterge codul {{ chip.cod_prestatie }} pentru {{ chip.cod_op_service }}">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
{# US-009: "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
|
||||
(in modalul de detaliu, nu la re-randarea stateless via /form-chips).
|
||||
Reuse EXACT save_mapping + reresolve_account via endpoint dedicat.
|
||||
hx-include="closest form" propaga csrf_token din form-ul parinte. #}
|
||||
<span id="save-rule-slot-{{ loop.index0 }}" class="save-rule-slot">
|
||||
{% if _sub_id and chip.cod_op_service and chip.cod_prestatie %}
|
||||
<button type="button"
|
||||
style="font-size:10px;color:var(--muted);background:none;border:none;cursor:pointer;text-decoration:underline;padding:0;margin-left:4px;line-height:1;"
|
||||
hx-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
|
||||
hx-include="closest form"
|
||||
hx-target="#detaliu-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-vals='{"salveaza_op":{{ chip.cod_op_service | tojson }},"salveaza_cod":{{ chip.cod_prestatie | tojson }}}'
|
||||
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
|
||||
salveaza ca regula
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
{# ===== Operatie nemapata: picker galben cu "alege cod RAR" ===== #}
|
||||
<select name="chips_add_cod_{{ loop.index0 }}"
|
||||
id="picker-op-{{ loop.index0 }}"
|
||||
aria-label="Alege cod RAR pentru {{ chip.cod_op_service }}"
|
||||
style="min-width:160px;font-size:11px;height:26px;">
|
||||
<option value="">— alege cod RAR —</option>
|
||||
{% for n in (nomenclator_rar or []) %}
|
||||
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button"
|
||||
class="add-code"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"add","chips_add_op_index":{{ loop.index0 }}}'
|
||||
aria-label="Adauga cod RAR pentru {{ chip.cod_op_service }}">
|
||||
+ Adauga
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# ===== US-005 (5.16): Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #}
|
||||
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat).
|
||||
T-7 (5.16): containerul .chips se randeaza DOAR cand exista chips extra — altfel ramanea
|
||||
un chenar gol nefinisat sub randurile de operatie. #}
|
||||
{% set _extra_chips = _chips | rejectattr('cod_op_service') | selectattr('cod_prestatie') | list %}
|
||||
{% if _extra_chips %}
|
||||
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;">
|
||||
{% for chip in _extra_chips %}
|
||||
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
||||
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %}"
|
||||
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}">
|
||||
{{ chip.cod_prestatie }}
|
||||
<button type="button" class="chip-del"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
|
||||
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">×</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if nomenclator_rar %}
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;">
|
||||
<select name="chips_add_cod_flat"
|
||||
aria-label="Adauga cod RAR suplimentar"
|
||||
style="min-width:160px;font-size:11px;height:26px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
|
||||
<option value="">+ Adauga alta operatie / cod RAR</option>
|
||||
{% for n in nomenclator_rar %}
|
||||
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button"
|
||||
class="add-code"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"add_extra"}'
|
||||
aria-label="Adauga cod RAR suplimentar la trimitere">
|
||||
+
|
||||
</button>
|
||||
</span>
|
||||
{% else %}
|
||||
{# T-D1/T-E5 (5.16): empty state in mod operatii cand nomenclatorul lipseste #}
|
||||
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;margin-top:4px;">
|
||||
Nomenclator indisponibil — adaugarea de coduri suplimentare nu e posibila.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if chips_extra_error %}
|
||||
{# T-C1/T-E4 (5.16): semnal vizibil cand add_extra are select gol sau cod invalid #}
|
||||
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
|
||||
Selecteaza un cod RAR din lista inainte de a adauga.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
{# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #}
|
||||
<div class="chips" role="group" aria-label="Coduri RAR selectate">
|
||||
{% for chip in _chips %}
|
||||
{% if chip.cod_prestatie %}
|
||||
{% set _is_warn_flat = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
||||
<span class="chip {% if _is_warn_flat %}chip-warn{% endif %}"
|
||||
aria-label="Prestatie {{ chip.cod_prestatie }}">
|
||||
{{ chip.cod_prestatie }}
|
||||
<button type="button" class="chip-del"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
|
||||
aria-label="Sterge codul {{ chip.cod_prestatie }}">×</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{# Picker adaugare cod nou in mod plat #}
|
||||
{% if nomenclator_rar %}
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;">
|
||||
<select name="chips_add_cod_flat"
|
||||
aria-label="Adauga cod RAR nou"
|
||||
style="font-size:11px;height:22px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
|
||||
<option value="">+ cod</option>
|
||||
{% for n in nomenclator_rar %}
|
||||
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button"
|
||||
class="add-code"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"add_flat"}'
|
||||
aria-label="Adauga cod RAR selectat in lista">
|
||||
+
|
||||
</button>
|
||||
</span>
|
||||
{% else %}
|
||||
{# T-D1/T-E5 (5.16): empty state in mod plat cand nomenclatorul lipseste #}
|
||||
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;">
|
||||
Nomenclator indisponibil — nu se pot adauga coduri RAR momentan.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Hint discret fara chips (debut) #}
|
||||
{% if not _chips %}
|
||||
<div style="font-size:10px;color:var(--muted);padding:4px 0;">
|
||||
Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
98
app/web/templates/_coada.html
Normal file
98
app/web/templates/_coada.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{#
|
||||
_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">
|
||||
{# US-002 (5.16): titlul de sectiune vizibil ("Trimiterile tale") a fost eliminat —
|
||||
lista incepe direct sub filtre. Heading pastrat sr-only pentru a11y (section
|
||||
aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
|
||||
<h2 id="trimiteri-heading" class="sr-only">Trimiterile tale</h2>
|
||||
{% if blocate_total %}
|
||||
<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin:0 0 10px;">
|
||||
<span class="tab-badge" title="{{ blocate_total }} necesita atentie"
|
||||
style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ blocate_total }}</span>
|
||||
<span class="muted" style="font-size:var(--fs-sm);">de rezolvat</span>
|
||||
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
|
||||
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; flex-wrap:wrap; margin:0 0 10px;">
|
||||
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
|
||||
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bara de filtre: [quick-pills data STANGA] [cautare vehicul MIJLOC] [pills stare DREAPTA].
|
||||
Pill-urile de stare scriu campul hidden status si re-trimit form-ul (filtreazaStare).
|
||||
Quick-pills de data apeleaza setDataRange -> seteaza data_de/data_pana + re-submit. -->
|
||||
<form id="filtre-trimiteri"
|
||||
hx-get="/_fragments/submissions"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
|
||||
style="display:flex; gap:8px 12px; flex-wrap:wrap; align-items:center; margin-bottom:12px;">
|
||||
<input type="hidden" id="f-status" name="status" value="{{ status_filtru | default('', true) }}">
|
||||
{# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #}
|
||||
<input type="hidden" id="f-page" name="page" value="1">
|
||||
|
||||
{# === STANGA: Quick-pills de data (preset interval) + buton Custom ===
|
||||
Azi / 7 zile / 30 zile → seteaza interval preset si submitr automat.
|
||||
Custom → dezvaluie #custom-date-fields pentru introducere manuala (fara submit automat). #}
|
||||
<div style="flex:0 0 auto; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||
<div class="pills-categorii" id="quick-date-pills">
|
||||
<button type="button" class="pill-cat pill-data" data-range="azi"
|
||||
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>
|
||||
|
||||
{# === MIJLOC: cautare vehicul (nr/VIN) + buton Filtreaza === #}
|
||||
<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>
|
||||
|
||||
{# === DREAPTA: pill-uri de stare cu contoare; re-randate via OOB la reincarcarea tabelului === #}
|
||||
<span id="pills-categorii" class="pills-categorii" style="margin-left:auto; flex:0 0 auto;">
|
||||
{% include '_pills.html' %}
|
||||
</span>
|
||||
</form>
|
||||
|
||||
<!-- Tabelul se reincarca la: incarcarea paginii, actiunile tale (trimiteriChanged)
|
||||
si auto-refresh periodic din poller (date noi externe). -->
|
||||
<div id="submissions-wrap"
|
||||
hx-get="/_fragments/submissions"
|
||||
hx-trigger="load, trimiteriChanged from:body, reincarcaTrimiteri"
|
||||
hx-include="#filtre-trimiteri" hx-swap="innerHTML">
|
||||
<div class="empty">se incarca…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
149
app/web/templates/_cont.html
Normal file
149
app/web/templates/_cont.html
Normal file
@@ -0,0 +1,149 @@
|
||||
<div class="card" id="card-cont">
|
||||
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
|
||||
|
||||
<!-- Sectiunea: Plan curent (US-006 PRD 5.17) -->
|
||||
{% if plan_linie is defined %}
|
||||
<div id="sectiune-plan" style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
|
||||
<h3 style="font-size:var(--fs-sm); color:var(--muted); font-weight:500; margin:0 0 10px;
|
||||
text-transform:uppercase; letter-spacing:.04em;">Plan curent</h3>
|
||||
|
||||
<div style="font-size:var(--fs-md); font-weight:600; margin-bottom:6px;
|
||||
color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--ink){% endif %};">
|
||||
{{ plan_linie }}
|
||||
</div>
|
||||
|
||||
{% if monthly_limit_val is defined and monthly_limit_val is not none and effective_tier_name|default('') == 'free' %}
|
||||
<div style="font-size:var(--fs-sm); color:var(--muted); margin-bottom:8px;">
|
||||
Planul Gratuit include {{ monthly_limit_val }} prestatii/luna prin dashboard-ul web.
|
||||
{% if plan_limita_atinsa|default(false) %}
|
||||
Limita lunara a fost atinsa — trimiterile noi sunt blocate pana la inceputul lunii urmatoare.
|
||||
{% elif plan_warn|default(false) %}
|
||||
Te apropii de limita lunara.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="font-size:var(--fs-sm); color:var(--muted); padding:8px 10px;
|
||||
border:1px solid var(--line); border-radius:6px; margin-top:4px;">
|
||||
Vrei sa treci pe Standard, Pro sau Premium?
|
||||
Contacteaza-ne pentru alocare manuala — nu exista inca plata self-service.
|
||||
<strong>Pro</strong> adauga import prin API; <strong>Standard</strong> si
|
||||
<strong>Premium</strong> ridica limita de volum.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sectiunea: Date firma (US-002) -->
|
||||
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
|
||||
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Date firma</h3>
|
||||
|
||||
{% 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 -->
|
||||
<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>
|
||||
|
||||
{% if api_key %}
|
||||
<div class="flash" style="margin-bottom:12px;">Cheia a fost rotita. Salveaz-o acum — nu o vei mai putea vedea.</div>
|
||||
|
||||
<div class="card" style="font-family:monospace; word-break:break-all; font-size:14px; background:#0f1115; margin:0 0 8px;">
|
||||
{{ api_key }}
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
data-key="{{ api_key }}"
|
||||
onclick="navigator.clipboard.writeText(this.dataset.key).then(()=>this.textContent='Copiat!')">
|
||||
Copiaza cheia
|
||||
</button>
|
||||
|
||||
<p style="font-size:13px; color:var(--warn); margin:10px 0 0;">
|
||||
Atentie: la urmatoarea vizita aceasta cheie dispare. Daca o pierzi, roteste din nou.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if rot_eroare %}
|
||||
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ rot_eroare }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form hx-post="/cont/roteste-cheie"
|
||||
hx-target="#card-cont"
|
||||
hx-swap="outerHTML"
|
||||
style="margin-top:{% if api_key %}12px{% else %}0{% endif %};">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--warn); border-color:var(--warn);">
|
||||
Roteste cheia API
|
||||
</button>
|
||||
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Cheia veche se revoca imediat.</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Sectiunea: Credentiale RAR -->
|
||||
<div>
|
||||
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Credentiale RAR (portal AUTOPASS)</h3>
|
||||
|
||||
{% if are_creds %}
|
||||
<div class="flash" style="margin-bottom:12px;">Credentiale RAR configurate.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if creds_mesaj %}
|
||||
<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if creds_eroare %}
|
||||
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ creds_eroare }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form hx-post="/cont/rar-creds"
|
||||
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);">Email RAR</label><br>
|
||||
<input type="email" name="rar_email" required style="width:100%; max-width:340px;"
|
||||
placeholder="email@service.ro">
|
||||
</p>
|
||||
<p style="margin:0 0 12px;">
|
||||
<label style="font-size:13px; color:var(--muted);">Parola RAR</label><br>
|
||||
<input type="password" name="rar_parola" required style="width:100%; max-width:340px;"
|
||||
autocomplete="new-password">
|
||||
</p>
|
||||
<button type="submit">Salveaza credentiale RAR</button>
|
||||
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Parola stocata criptat, niciodata in clar.</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
74
app/web/templates/_editare_preview_modal.html
Normal file
74
app/web/templates/_editare_preview_modal.html
Normal 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>
|
||||
36
app/web/templates/_eroare.html
Normal file
36
app/web/templates/_eroare.html
Normal 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 %}
|
||||
113
app/web/templates/_form_editare.html
Normal file
113
app/web/templates/_form_editare.html
Normal 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 %}
|
||||
350
app/web/templates/_integrare.html
Normal file
350
app/web/templates/_integrare.html
Normal 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 & 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>
|
||||
9
app/web/templates/_integrare_test_rezultat.html
Normal file
9
app/web/templates/_integrare_test_rezultat.html
Normal 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 %}
|
||||
106
app/web/templates/_jurnal.html
Normal file
106
app/web/templates/_jurnal.html
Normal 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">‹ 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 ›</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty">Niciun eveniment pe filtrul curent.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
80
app/web/templates/_macros.html
Normal file
80
app/web/templates/_macros.html
Normal 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 %}
|
||||
323
app/web/templates/_mapari.html
Normal file
323
app/web/templates/_mapari.html
Normal file
@@ -0,0 +1,323 @@
|
||||
{% import '_macros.html' as ui %}
|
||||
<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 %}
|
||||
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
|
||||
|
||||
{% if pending %}
|
||||
<div data-dt="10">
|
||||
<div class="dt-tools">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Sectiunea 2: Mapari operatii salvate (operations_mapping) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Mapari operatii salvate</h2>
|
||||
|
||||
{% if not saved_mappings %}
|
||||
<div class="empty">
|
||||
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
|
||||
</div>
|
||||
{% else %}
|
||||
<div data-dt="10">
|
||||
<div class="dt-tools">
|
||||
<input type="search" data-dt-search class="dt-search"
|
||||
placeholder="Cauta operatie sau cod RAR..." aria-label="Cauta in maparile salvate">
|
||||
</div>
|
||||
<div class="tablewrap tabel-card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Operatie</th>
|
||||
<th>Cod RAR</th>
|
||||
<th>Actiuni</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for m in saved_mappings %}
|
||||
{# data-dt-row = haystack de cautare (randul contine un <select> cu tot nomenclatorul). #}
|
||||
<tr data-dt-row="{{ m.cod_op_service }} {{ m.cod_prestatie }} {{ m.nume_prestatie or '' }}">
|
||||
<td data-eticheta="Operatie">
|
||||
<form id="map-salv-{{ loop.index }}" hx-post="/mapari/salvate" 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="{{ m.cod_op_service }}">
|
||||
</form>
|
||||
<form id="map-del-{{ loop.index }}" hx-post="/mapari/salvate/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
hx-confirm="Stergi maparea pentru {{ m.cod_op_service }}?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
|
||||
</form>
|
||||
<div><strong>{{ m.cod_op_service }}</strong></div>
|
||||
<div class="muted map-acum" style="font-size:12px;">
|
||||
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td data-eticheta="Cod RAR">
|
||||
<select name="cod_prestatie" form="map-salv-{{ loop.index }}" required
|
||||
aria-label="Cod RAR pentru {{ m.cod_op_service }}">
|
||||
{% for n in nomenclator %}
|
||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
|
||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
|
||||
{# Butoane act_btn (desktop: text; mobil: iconita 44px).
|
||||
data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
|
||||
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
|
||||
{{ 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 %}
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 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» → 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">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
|
||||
|
||||
{% if not column_formats %}
|
||||
<div class="empty">
|
||||
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
|
||||
se retine aici si se reaplica automat la fisierele cu acelasi antet.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
|
||||
</p>
|
||||
|
||||
<div data-dt="10">
|
||||
<div class="dt-tools">
|
||||
<input type="search" data-dt-search class="dt-search"
|
||||
placeholder="Cauta coloana sau camp..." aria-label="Cauta in formatele de coloane">
|
||||
</div>
|
||||
<div class="tablewrap tabel-card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Coloane</th>
|
||||
<th>Mapari (coloana → camp)</th>
|
||||
<th>Format data</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for f in column_formats %}
|
||||
<tr>
|
||||
<td style="white-space:nowrap;" data-eticheta="Coloane">
|
||||
<strong>{{ f.columns | length }} coloane</strong>
|
||||
</td>
|
||||
<td class="muted" style="font-size:12px; white-space:normal; max-width:340px;" data-eticheta="Mapari (coloana → camp)">
|
||||
{% for col, camp in f.mappings.items() %}
|
||||
<span class="sugg">{{ col }}</span> → {{ camp }}{% if not loop.last %}; {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td data-eticheta="Format data">
|
||||
<form id="fmt-edit-{{ loop.index }}" hx-post="/formate-coloane/editeaza"
|
||||
hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
style="display:flex; gap:6px; align-items:center;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="format_id" value="{{ f.id }}">
|
||||
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
|
||||
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
|
||||
<button type="submit">Salveaza data</button>
|
||||
</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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
181
app/web/templates/_mapcoloane.html
Normal file
181
app/web/templates/_mapcoloane.html
Normal file
@@ -0,0 +1,181 @@
|
||||
<div id="import-section">
|
||||
{% 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">
|
||||
<h2 style="font-size:var(--fs-md); margin:0 0 12px;">
|
||||
Mapare coloane —
|
||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||
</h2>
|
||||
|
||||
{% if eroare_mapare %}
|
||||
<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 %}>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="muted" style="margin:0 0 12px; font-size:var(--fs-sm);">
|
||||
Asociaza fiecare coloana din fisier cu campul canonic corespunzator.
|
||||
Maparea se retine automat pentru fisiere cu acelasi antet.
|
||||
</p>
|
||||
|
||||
{# Tabel orizontal preview: antet + prima inregistrare (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"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
|
||||
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||
<label for="format-data" style="font-size:var(--fs-sm); color:var(--muted);">
|
||||
Format data
|
||||
</label>
|
||||
<input type="text" id="format-data" name="format_data"
|
||||
value="{{ format_data or 'DD.MM.YYYY' }}"
|
||||
placeholder="ex: DD.MM.YYYY"
|
||||
style="max-width:160px;"
|
||||
aria-describedby="format-data-hint">
|
||||
<span id="format-data-hint" class="muted" style="font-size:var(--fs-xs);">
|
||||
sau YYYY-MM-DD, MM/DD/YYYY etc.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Tabel mapare: coloana din fisier | exemplu | camp RAR (mockup 5.16 / US-013) #}
|
||||
<div class="tablewrap" style="margin-bottom:16px;">
|
||||
<table style="border-collapse:collapse; width:100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="font-size:var(--fs-xs); width:34%; padding:6px 10px; text-align:left;
|
||||
background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
|
||||
font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
|
||||
Coloana din fisier
|
||||
</th>
|
||||
<th style="font-size:var(--fs-xs); width:28%; padding:6px 10px; text-align:left;
|
||||
background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
|
||||
font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
|
||||
Exemplu
|
||||
</th>
|
||||
<th style="font-size:var(--fs-xs); padding:6px 10px; text-align:left;
|
||||
background:var(--card2); border-bottom:1px solid var(--line); color:var(--muted);
|
||||
font-weight:600; text-transform:uppercase; letter-spacing:.04em;">
|
||||
Camp RAR
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for col in columns %}
|
||||
{%- set sugg = fuzzy_suggestions.get(col, []) -%}
|
||||
{%- set best = sugg[0].camp_canonic if sugg else '' -%}
|
||||
{%- set ns = namespace(samples=[]) -%}
|
||||
{%- for row in sample_rows -%}
|
||||
{%- if row.get(col) is not none and row.get(col) != '' -%}
|
||||
{%- set ns.samples = ns.samples + [row[col] | string] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
<tr style="border-bottom:1px solid var(--line);">
|
||||
<td style="padding:9px 10px; vertical-align:top;">
|
||||
<input type="hidden" name="colname" value="{{ col }}">
|
||||
<strong style="font-family:var(--font-mono); font-size:var(--fs-sm);">{{ col }}</strong>
|
||||
{% if sugg %}
|
||||
<div class="muted" style="font-size:var(--fs-xs); margin-top:3px;">
|
||||
sugestie: <span class="sugg">{{ sugg[0].camp_canonic }}
|
||||
({{ sugg[0].score | round | int }}%)</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding:9px 10px; vertical-align:top;">
|
||||
{% if ns.samples %}
|
||||
<span style="font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted);">
|
||||
{{ ns.samples[:2] | join(", ") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="muted" style="font-size:var(--fs-xs);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding:9px 10px; vertical-align:top;">
|
||||
<label for="canon-{{ loop.index }}"
|
||||
style="display:block; font-size:var(--fs-xs); color:var(--muted); margin-bottom:3px;">
|
||||
Camp canonic
|
||||
</label>
|
||||
<select id="canon-{{ loop.index }}" name="canon"
|
||||
style="width:100%; font-size:var(--fs-base); min-height:38px;">
|
||||
<option value="">— ignorat —</option>
|
||||
{% for field_key, field_label in canonical_fields %}
|
||||
<option value="{{ field_key }}"
|
||||
{% if field_key == best %}selected{% endif %}>
|
||||
{{ field_key }} — {{ field_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
<button type="submit"
|
||||
{% if not prima_inreg %}disabled aria-disabled="true"{% endif %}
|
||||
style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}">
|
||||
Salveaza si continua la preview
|
||||
</button>
|
||||
{% if not prima_inreg %}
|
||||
<span style="font-size: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
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style="margin-top:12px;">
|
||||
<a href="/" class="muted" style="font-size:var(--fs-sm);">Incarca alt fisier</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
24
app/web/templates/_nomenclator.html
Normal file
24
app/web/templates/_nomenclator.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{# 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 %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Cod</th>
|
||||
<th>Denumire</th>
|
||||
<th>Actualizat</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td><span class="pill">{{ r.cod_prestatie }}</span></td>
|
||||
<td style="white-space:normal;">{{ r.nume_prestatie }}</td>
|
||||
<td class="muted">{{ r.updated_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">Nomenclator gol. Worker-ul il umple la primul login RAR reusit.</div>
|
||||
{% endif %}
|
||||
15
app/web/templates/_pills.html
Normal file
15
app/web/templates/_pills.html
Normal 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 %}
|
||||
305
app/web/templates/_preview_import.html
Normal file
305
app/web/templates/_preview_import.html
Normal file
@@ -0,0 +1,305 @@
|
||||
{% 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' %}
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
|
||||
<h2 style="font-size:var(--fs-md); margin:0;">
|
||||
Preview —
|
||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||
</h2>
|
||||
<span class="muted" style="margin-left:auto; font-size:var(--fs-sm);">{{ total }} randuri</span>
|
||||
</div>
|
||||
|
||||
{% if 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 %}>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rezumat stari cu etichete umane cu majuscula (id stabil pentru OOB swap) -->
|
||||
{% 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" 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>
|
||||
|
||||
<!-- Butoane filtrare stare — text uman, data-filter pastreaza codul tehnic -->
|
||||
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;" role="group"
|
||||
aria-label="Filtrare dupa stare">
|
||||
<button type="button" class="filter-btn" data-filter="all"
|
||||
style="min-height:36px; font-size:var(--fs-sm); padding:4px 12px;">
|
||||
Toate ({{ total }})
|
||||
</button>
|
||||
{% for status_key, label in status_labels %}
|
||||
{%- set cnt = summary.get(status_key, 0) -%}
|
||||
{% if cnt > 0 %}
|
||||
<button type="button" class="filter-btn" data-filter="{{ status_key }}"
|
||||
style="min-height:36px; font-size:var(--fs-sm); padding:4px 12px;
|
||||
background:transparent; border-color:var(--line); color:var(--ink);">
|
||||
{{ label }} ({{ cnt }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- 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 %}
|
||||
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
|
||||
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
|
||||
<p class="muted" style="margin:0 0 12px; font-size:var(--fs-sm);">
|
||||
Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e
|
||||
preselectata) si salveaza — randurile blocate trec automat in
|
||||
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
|
||||
</p>
|
||||
<form hx-post="/_import/{{ import_id }}/mapare-operatii"
|
||||
hx-target="#import-section" hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
{% for e in unmapped_ops %}
|
||||
{%- set top = e.suggestions[0] if e.suggestions else None -%}
|
||||
{%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%}
|
||||
<div class="maprow" style="align-items:flex-end; margin-bottom:10px;">
|
||||
<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="randuri blocate">{{ e.blocked }} randuri</span></div>
|
||||
{% if e.denumire and e.denumire != e.cod_op_service %}
|
||||
<div class="muted">{{ e.denumire }}</div>
|
||||
{% endif %}
|
||||
{% 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" 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>
|
||||
{% endfor %}
|
||||
<div style="margin-top:12px;">
|
||||
<button type="submit" style="min-height:44px;">Salveaza maparile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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"
|
||||
hx-post="/_import/{{ import_id }}/confirma"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<div class="sticky-bar">
|
||||
<div style="flex:1; min-width:280px;">
|
||||
<!-- Banner declarant — direct deasupra input-ului N -->
|
||||
<div class="banner warn" style="margin-bottom:10px; padding:8px 12px; border-radius:6px;"
|
||||
role="note" aria-live="polite">
|
||||
Confirmand, TU esti declarantul acestor
|
||||
<strong id="n-display">{{ summary.get('ok', 0) }}</strong>
|
||||
prezentari la RAR (ireversibil).
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||
<label for="n-confirmat"
|
||||
style="font-size:var(--fs-sm); color:var(--muted);">
|
||||
Confirma numarul
|
||||
</label>
|
||||
<input type="number" id="n-confirmat" name="n_confirmat"
|
||||
value="{{ summary.get('ok', 0) }}"
|
||||
min="0" required
|
||||
style="max-width:80px;"
|
||||
aria-describedby="n-hint">
|
||||
<span id="n-hint" class="muted" style="font-size:var(--fs-xs);">
|
||||
din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:6px; align-self:flex-end;">
|
||||
<button type="submit"
|
||||
id="confirm-btn"
|
||||
style="min-height:44px; padding:10px 28px; font-size:var(--fs-md);"
|
||||
{% if not summary.get('ok', 0) %}disabled title="Niciun rand ok de trimis"{% endif %}>
|
||||
Trimite la RAR
|
||||
</button>
|
||||
{% if summary.get('needs_data', 0) or summary.get('needs_mapping', 0) or summary.get('needs_review', 0) %}
|
||||
<a href="/v1/import/{{ import_id }}/export-failed" download
|
||||
style="font-size:var(--fs-xs); text-align:center;">
|
||||
descarca randuri cu probleme (CSV)
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</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;">
|
||||
<a href="#" class="muted" style="font-size:var(--fs-sm);"
|
||||
hx-get="/_import/reset" hx-target="#import-section" hx-swap="outerHTML">Incarca alt fisier</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
/* 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';
|
||||
|
||||
/* 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() {
|
||||
var total = getOk();
|
||||
var inp = document.getElementById('n-confirmat');
|
||||
var disp = document.getElementById('n-display');
|
||||
var btn = document.getElementById('confirm-btn');
|
||||
if (inp) inp.value = total;
|
||||
if (disp) disp.textContent = total;
|
||||
var hintOk = document.getElementById('n-hint-ok');
|
||||
if (hintOk) hintOk.textContent = total;
|
||||
if (btn) btn.disabled = (total === 0);
|
||||
}
|
||||
|
||||
/* Filtrare randuri dupa stare.
|
||||
Cand niciun rand nu e vizibil, afiseaza mesajul #preview-zero-message. */
|
||||
function filterRows(status) {
|
||||
var visible = 0;
|
||||
document.querySelectorAll('tbody tr[data-status]').forEach(function(tr) {
|
||||
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) {
|
||||
var active = b.dataset.filter === status;
|
||||
b.style.background = active ? 'var(--accent)' : '';
|
||||
b.style.borderColor = active ? 'var(--accent)' : '';
|
||||
b.style.color = active ? '#fff' : '';
|
||||
});
|
||||
}
|
||||
|
||||
/* Expune functiile global pentru onclick/hx-on inline si OOB swap */
|
||||
window.updateN = updateN;
|
||||
window.filterRows = filterRows;
|
||||
|
||||
/* Filtru implicit "Toate" activ la incarcare */
|
||||
filterRows('all');
|
||||
updateN();
|
||||
|
||||
/* Evidentiere rand dupa reincarcarea preview-ului (window.__randSalvat setat de
|
||||
listener-ul 'randSalvat' din base.html): scroll + flash, ca userul sa vada CARE
|
||||
rand s-a schimbat si sa nu ramana cu impresia ca "nu s-a intamplat nimic". */
|
||||
if (window.__randSalvat) {
|
||||
var d = window.__randSalvat; window.__randSalvat = null;
|
||||
var r = document.getElementById('preview-row-' + d.rowIndex);
|
||||
if (r) {
|
||||
r.scrollIntoView({block:'center', behavior:'smooth'});
|
||||
void r.offsetWidth;
|
||||
r.classList.add('rand-actualizat');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
111
app/web/templates/_preview_rand.html
Normal file
111
app/web/templates/_preview_rand.html
Normal 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 %}
|
||||
157
app/web/templates/_status.html
Normal file
157
app/web/templates/_status.html
Normal file
@@ -0,0 +1,157 @@
|
||||
<div id="status-bar" class="status-bar card"
|
||||
hx-get="/_fragments/status?tab={{ tab_activ | default('acasa') }}"
|
||||
hx-trigger="every 15s, trimiteriChanged from:body"
|
||||
hx-swap="outerHTML"
|
||||
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
|
||||
|
||||
{# Banner cont in asteptare de activare (mereu vizibil cand contul e inactiv) #}
|
||||
{% if not account_active %}
|
||||
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn);
|
||||
background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px; font-size:13px;">
|
||||
<strong>Cont in asteptare de activare.</strong>
|
||||
Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR
|
||||
porneste automat dupa activare de catre administrator.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# US-006 (5.17) — Banner one-time trial->Gratuit (T-DES-1): afisat la prima incarcare
|
||||
dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage.
|
||||
Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #}
|
||||
{% if trial_expirat_recent|default(false) %}
|
||||
<div id="banner-trial-expirat"
|
||||
role="status"
|
||||
style="margin-bottom:10px; padding:7px 12px;
|
||||
border-left:3px solid var(--warn);
|
||||
background:color-mix(in srgb, var(--warn) 10%, var(--card));
|
||||
border-radius:6px; font-size:var(--fs-sm);
|
||||
display:flex; align-items:center; justify-content:space-between; gap:8px;">
|
||||
<span>Trial Pro expirat — esti pe Gratuit, 60/luna</span>
|
||||
<button onclick="sessionStorage.setItem('tfx','1'); document.getElementById('banner-trial-expirat').style.display='none';"
|
||||
style="background:transparent; border:none; color:var(--muted); cursor:pointer;
|
||||
font-size:18px; padding:0 4px; line-height:1; flex-shrink:0;"
|
||||
aria-label="Inchide bannerul">×</button>
|
||||
</div>
|
||||
<script>(function(){ if(sessionStorage.getItem('tfx')){ var el=document.getElementById('banner-trial-expirat'); if(el) el.style.display='none'; } })();</script>
|
||||
{% endif %}
|
||||
|
||||
{# === US-003 (PRD 5.16): Banda de stare RAR — NUMAI cand BLOCAT (rosu, lat de 100%).
|
||||
OK = dot verde in antet (base.html); banda nu mai apare cand totul e ok.
|
||||
Elementul id="strip-sanatate" ramane in DOM mereu, dar goleste continutul cand OK,
|
||||
astfel "hidden" + fara continut eroare in sursa = nu pica testele de prezenta id-ului.
|
||||
#}
|
||||
{% if sanatate_ok %}
|
||||
<div id="strip-sanatate" role="status" aria-live="polite" hidden></div>
|
||||
{% else %}
|
||||
<div id="strip-sanatate"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;
|
||||
padding:10px 14px; border-radius:8px; margin-bottom:14px;
|
||||
background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);">
|
||||
<div style="display:flex; align-items:center; gap:9px;">
|
||||
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--err);">✗</span>
|
||||
<span style="font-weight:700; font-size:13px;">{{ sanatate_text }}</span>
|
||||
</div>
|
||||
<span style="font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted); white-space:nowrap;">
|
||||
{{ eticheta_ultima_auth }}: {{ last_login }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === US-002 (PRD 5.16): 5 carduri-contor separate (desktop) + bara compacta (mobil <=560px).
|
||||
Total / Luna asta / Azi / In coada / De corectat.
|
||||
#}
|
||||
{# Desktop: 5 carduri side-by-side. display:flex + layout stau in CSS (.contoare-desktop in
|
||||
base.html), NU inline, ca media query-ul <=560px sa le poata ascunde pe mobil (bara compacta). #}
|
||||
<div class="contoare-desktop">
|
||||
|
||||
{# Total trimise (all-time) #}
|
||||
<div class="contor-card" style="flex:1; min-width:100px;">
|
||||
<div class="contor-cifra">{{ counts_sent }}</div>
|
||||
<div class="contor-label">Total</div>
|
||||
</div>
|
||||
|
||||
{# Luna asta #}
|
||||
<div class="contor-card" style="flex:1; min-width:100px;">
|
||||
<div class="contor-cifra s-accent">{{ sent_month }}</div>
|
||||
<div class="contor-label">Luna asta</div>
|
||||
</div>
|
||||
|
||||
{# Azi #}
|
||||
<div class="contor-card" style="flex:1; min-width:80px;">
|
||||
<div class="contor-cifra s-accent">{{ sent_today }}</div>
|
||||
<div class="contor-label">Azi</div>
|
||||
</div>
|
||||
|
||||
{# In coada #}
|
||||
<div class="contor-card" style="flex:1; min-width:80px;">
|
||||
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
|
||||
<div class="contor-label">In coada</div>
|
||||
</div>
|
||||
|
||||
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
|
||||
<a href="/" class="contor-card"
|
||||
style="flex:1; min-width:80px; text-decoration:none; display:block; cursor:pointer;"
|
||||
aria-label="De corectat: {{ blocate_total }} — click pentru lista de trimiteri">
|
||||
<div class="contor-cifra {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
|
||||
<div class="contor-label">De corectat</div>
|
||||
</a>
|
||||
|
||||
</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) %}
|
||||
<a href="/?tab=cont" style="font-size:var(--fs-xs); font-weight:400; color:var(--accent);">Detalii plan</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
36
app/web/templates/_stepper.html
Normal file
36
app/web/templates/_stepper.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{#
|
||||
_stepper.html — Antet wizard import COMPACT (PUR vizual). PRD 5.13.
|
||||
Parametru: `pas` (integer 1-4). Clasele .stepper-* sunt definite in base.html.
|
||||
>=1024px: bara slim orizontala (.stepper-track). <1024px: forma colapsata
|
||||
"Pasul N din 4 - Titlu" + bara de progres (.stepper-collapsed).
|
||||
Utilizare: {% set pas = 1 %}{% include '_stepper.html' %}
|
||||
#}
|
||||
{%- set _pasi_import = [
|
||||
(1, "Incarca fisier", "Trage un fisier xlsx/csv aici sau foloseste butonul de alegere."),
|
||||
(2, "Potriveste coloanele", "Spune-ne ce coloana din fisier corespunde cu ce camp RAR."),
|
||||
(3, "Verifica", "Verifica randurile inainte sa le trimiti la RAR."),
|
||||
(4, "Confirma trimiterea", "Confirma numarul de prezentari — actiunea e ireversibila."),
|
||||
] -%}
|
||||
{%- set _activ = _pasi_import[pas - 1] -%}
|
||||
<div class="stepper">
|
||||
{# Desktop (>=1024px): bara slim orizontala. #}
|
||||
<nav class="stepper-track" aria-label="Pasii importului">
|
||||
{% for nr, titlu, ajutor in _pasi_import %}
|
||||
{%- if nr < pas %}{% set cls = "is-done" %}{% set aria = "" %}
|
||||
{%- elif nr == pas %}{% set cls = "is-active" %}{% set aria = ' aria-current="step"' %}
|
||||
{%- else %}{% set cls = "" %}{% set aria = "" %}{% endif %}
|
||||
<div class="stepper-step {{ cls }}"{{ aria | safe }}>
|
||||
<span class="stepper-nr">{% if nr < pas %}✓{% else %}{{ nr }}{% endif %}</span>
|
||||
<span class="stepper-tx">{{ titlu }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{# Tableta/mobil (<1024px): colapsat "Pasul N din 4 - Titlu" + progres. #}
|
||||
<div class="stepper-collapsed">
|
||||
<div class="stepper-current">Pasul {{ pas }} din 4 <span class="muted">· {{ _activ[1] }}</span></div>
|
||||
<div class="stepper-progress" role="progressbar" aria-valuenow="{{ pas }}" aria-valuemin="1" aria-valuemax="4"
|
||||
aria-label="Pasul {{ pas }} din 4"><span style="width:{{ (pas / 4 * 100) | round | int }}%;"></span></div>
|
||||
</div>
|
||||
{# Ajutorul pasului activ — o singura linie, sub bara (valabil pe ambele forme). #}
|
||||
<p class="stepper-help">{{ _activ[2] }}</p>
|
||||
</div>
|
||||
@@ -1,20 +1,260 @@
|
||||
{% if rows %}
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Stare</th><th>idPrezentare</th><th>HTTP RAR</th><th>Retry</th><th>Actualizat</th><th>Motiv</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r.id }}</td>
|
||||
<td><span class="pill s-{{ r.status }}">{{ r.status }}</span></td>
|
||||
<td>{{ r.id_prezentare or '—' }}</td>
|
||||
<td>{{ r.rar_status_code or '—' }}</td>
|
||||
<td>{{ r.retry_count }}</td>
|
||||
<td class="muted">{{ r.updated_at }}</td>
|
||||
<td class="muted">{{ (r.rar_error or '')[:80] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty">Coada e goala. Trimite o prezentare prin <code>POST /v1/prezentari</code>.</div>
|
||||
{#
|
||||
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 %}
|
||||
{# Form bulk cu DOUA actiuni: (1) aplica cod RAR la selectate (bulk-fix, US-010),
|
||||
(2) sterge selectate (sterge-bulk). Selectia opereaza DOAR pe randuri blocate
|
||||
(gestionabil); sent/sending/queued nu au checkbox (read-only).
|
||||
Butonul "Aplica cod" foloseste hx-post propriu (override form action).
|
||||
hx-disinherit="hx-confirm" pe form => butonul aplica-cod NU mosteneste confirmare. #}
|
||||
<form id="bulk-trimiteri"
|
||||
hx-post="/trimiteri/sterge-bulk"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Stergi definitiv trimiterile selectate?"
|
||||
hx-disinherit="hx-confirm"
|
||||
style="margin:0;">
|
||||
<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 %}
|
||||
{# 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-target="#detaliu-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
role="button" tabindex="0"
|
||||
aria-haspopup="dialog"
|
||||
style="cursor:pointer;"
|
||||
title="Click pentru detaliul complet">
|
||||
|
||||
{# Zona checkbox — nu declanseaza modalul (stopPropagation).
|
||||
Vizibila DOAR pe randurile gestionabile (error/needs_data/needs_mapping).
|
||||
Latimea fixa previne reflow la prezenta/absenta checkbox-ului. #}
|
||||
<div style="flex:0 0 22px; display:flex; align-items:center;" onclick="event.stopPropagation();">
|
||||
{% if r.gestionabil %}
|
||||
<input type="checkbox" name="submission_id" value="{{ r.id }}"
|
||||
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Bloc text principal — stanga, ocupa spatiul ramas. Rand de 2 linii (spec 5.16):
|
||||
L1 = placuta (identificator primar); L2 = cod RAR · operatie · data prestatie. #}
|
||||
<div style="flex:1 1 auto; min-width:0;">
|
||||
|
||||
{# Linia 1: nr. inmatriculare (placuta) — identificatorul primar pe care il
|
||||
scaneaza operatorul. .slim-vin reumplut (acelasi nume de clasa, churn minim).
|
||||
Fallback cand placuta lipseste ('—'): VIN scurt, apoi mesaj neutru
|
||||
(nu randa em-dash izolat ca identificator). #}
|
||||
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
|
||||
<div class="slim-vin">{{ r.prez.vehicul_nr }}</div>
|
||||
{% elif r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
||||
<div class="slim-vin muted">{{ r.prez.vin_scurt }}</div>
|
||||
{% else %}
|
||||
<div class="slim-vin muted">fara numar</div>
|
||||
{% endif %}
|
||||
|
||||
{# Linia 2: cod RAR (sau 'nemapat') · operatie (ink, ellipsis) · data prestatie.
|
||||
Separatorul "·" e injectat prin CSS intre celule. Operatia primeste ellipsis
|
||||
ca randul sa NU treaca pe a 3-a linie nici la 390px.
|
||||
VIN integral, #id_prezentare si secundele traiesc in modalul de detaliu. #}
|
||||
<div class="slim-meta slim-rand2">
|
||||
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
|
||||
<span class="cod-rar-cod">{{ r.prez.cod_rar }}</span>
|
||||
{% else %}
|
||||
<span class="cod-rar-cod cod-rar-sub muted">nemapat</span>
|
||||
{% endif %}
|
||||
<span class="slim-op">{{ r.prez.operatie }}</span>
|
||||
{% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %}
|
||||
<span class="slim-data muted">{{ r.prez.data_prestatie }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Micro-linie umana a problemei — text mic s-error, DOAR pe stari de problema
|
||||
(loud-on-exception D6). Randul normal/finalizat ramane strict 2 linii.
|
||||
Token tipografic --fs-xs (>=12px, scala 5.16). #}
|
||||
{% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
|
||||
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
</ul>
|
||||
</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>
|
||||
{% 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">
|
||||
«
|
||||
</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)">
|
||||
«
|
||||
</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">
|
||||
»
|
||||
</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)">
|
||||
»
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% elif filtru_activ %}
|
||||
<div class="empty">
|
||||
Nimic pe filtrul curent.
|
||||
<a href="#"
|
||||
onclick="var f=document.getElementById('filtre-trimiteri'); if(f) f.reset(); return true;"
|
||||
hx-get="/_fragments/submissions" hx-target="#submissions-wrap" hx-swap="innerHTML">
|
||||
sterge filtrele
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
Nicio trimitere inca —
|
||||
<a href="/?tab=acasa">incepe cu un import</a>
|
||||
sau trimite o prezentare prin API.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
220
app/web/templates/_trimitere_detaliu.html
Normal file
220
app/web/templates/_trimitere_detaliu.html
Normal file
@@ -0,0 +1,220 @@
|
||||
{% from "_eroare.html" import card_erori %}
|
||||
{% import '_macros.html' as ui %}
|
||||
{# Detaliu editabil in-place. Fragmentul se swap-uieste in corpul modalului global
|
||||
(#detaliu-modal-body). Heading-ul poarta id-ul folosit de aria-labelledby al dialogului.
|
||||
Operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
|
||||
(fallback „nemapat"), fara eticheta separata „Cod RAR". #}
|
||||
{% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
|
||||
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
|
||||
|
||||
{# === 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>
|
||||
{% endif %}
|
||||
|
||||
{# === Bloc eroare blocanta — DOAR in read-only.
|
||||
In editare, cardul 3-niveluri e inlocuit cu: erori per-camp in macro `camp`
|
||||
(text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #}
|
||||
{% if not editabil and erori_3n %}
|
||||
<div style="margin:0 0 14px;">
|
||||
{{ card_erori(erori_3n) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === Mapare inline — alege cod RAR pentru operatiile nemapate.
|
||||
Cand nemapate_inline, linia „Operatie: X · nemapat" apare in formularul de mai jos
|
||||
(cod_afis = nemapat), iar aici e picker-ul; dupa mapare, re-render arata codul rezolvat. === #}
|
||||
{% if nemapate_inline %}
|
||||
<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 %}
|
||||
|
||||
{# === 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 %}
|
||||
{% set err_map = {} %}
|
||||
{% for e in corectie_errors %}{% if e.field %}{% set _ = err_map.update({e.field: e.message}) %}{% endif %}{% endfor %}
|
||||
{# fix_map gol pentru Trimiteri (fix-hints vin din preview, nu din corectii de trimitere). #}
|
||||
{% 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 %}
|
||||
<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>
|
||||
{% 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 %}
|
||||
|
||||
{# === Campurile vehicul/data/odo + erori/fix + buton — partial DRY (US-005). === #}
|
||||
{% include "_form_editare.html" %}
|
||||
</form>
|
||||
{% else %}
|
||||
{# Context read-only pentru randuri ne-editabile (sent/sending/queued/error). #}
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:12px 24px;">
|
||||
<div style="grid-column:1 / -1;">
|
||||
<div class="muted" style="font-size:12px;">Numar inmatriculare</div><div>{{ prez.vehicul_nr }}</div>
|
||||
</div>
|
||||
<div style="grid-column:1 / -1;">
|
||||
<div class="muted" style="font-size:12px;">VIN (serie sasiu)</div>
|
||||
<div style="word-break:break-all;">{{ prez.vin }}</div>
|
||||
</div>
|
||||
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }} · {{ cod_afis }}</div></div>
|
||||
{# Operatie service (cod intern + denumire), distinct de operatia RAR.
|
||||
op_service_cod="" cand lipseste → randul absent (fara "—"). #}
|
||||
{% if prez.op_service_cod %}
|
||||
<div><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 %}
|
||||
<div><div class="muted" style="font-size:12px;">Data prestatie</div><div>{{ prez.data_prestatie }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">Odometru final</div><div>{{ prez.odometru }}</div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === Actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT === #}
|
||||
{% if status == 'error' or gestionabil %}
|
||||
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
|
||||
{# Error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
|
||||
{# campuri vehicul, dar se poate schimba cod_prestatie prin acelasi formular). #}
|
||||
{% if status == 'error' %}
|
||||
<form hx-post="/trimitere/{{ id }}/repune"
|
||||
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
||||
hx-disabled-elt="find button" style="margin:0 0 10px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{# Select cod_prestatie optional in formularul /repune (doar pentru error). #}
|
||||
{% 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. #}
|
||||
146
app/web/templates/_upload.html
Normal file
146
app/web/templates/_upload.html
Normal file
@@ -0,0 +1,146 @@
|
||||
<div id="import-section">
|
||||
{% set pas = 1 %}{% include '_stepper.html' %}
|
||||
{# Bara de upload accentuata (border de accent) ca sa ramana punctul
|
||||
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 %}
|
||||
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if eroare_upload %}
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
{% if sheets %}
|
||||
<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.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="upload-form"
|
||||
hx-post="/_import/upload"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-indicator="#upload-spinner">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
|
||||
{% if sheets %}
|
||||
<div style="margin-bottom:12px;">
|
||||
<label for="sheet-select"
|
||||
style="display:block; margin-bottom:4px; font-size:13px; color:var(--muted);">
|
||||
Foaie de lucru
|
||||
</label>
|
||||
<select id="sheet-select" name="sheet_name" style="min-width:200px;">
|
||||
{% for s in sheets %}
|
||||
<option value="{{ s }}">{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% 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"
|
||||
role="region" aria-label="Zona de incarcare fisier">
|
||||
{% if not sheets %}
|
||||
<p style="font-size:var(--fs-lg); margin:0 0 4px; font-weight:600;">Primul fisier? Trage-l aici.</p>
|
||||
<p class="muted" style="margin:0 0 16px; font-size:var(--fs-sm);">xlsx sau csv, max 5000 randuri</p>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 16px; font-size:var(--fs-md);">
|
||||
Incarca fisierul din nou dupa ce ai ales foaia.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<input id="file-input" type="file" name="file" accept=".xlsx,.xls,.csv"
|
||||
style="display:none;" aria-label="Selecteaza fisier xlsx sau csv">
|
||||
<button type="button" id="upload-btn"
|
||||
style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);">
|
||||
Alege fisier (xlsx/csv)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="muted" style="margin:8px 0 0; font-size:var(--fs-xs);">
|
||||
NU se trimite nimic la RAR pana confirmi explicit.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<span id="upload-spinner" class="htmx-indicator muted"
|
||||
style="font-size:var(--fs-sm); margin-top:6px; display:inline;">
|
||||
se parseaza fisierul...
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var btn = document.getElementById('upload-btn');
|
||||
var fi = document.getElementById('file-input');
|
||||
var dz = document.getElementById('drop-zone');
|
||||
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;
|
||||
|
||||
btn.addEventListener('click', function() { fi.click(); });
|
||||
|
||||
fi.addEventListener('change', function() {
|
||||
if (fi.files.length > 0) frm.requestSubmit();
|
||||
});
|
||||
|
||||
dz.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
dz.classList.add('drag-over');
|
||||
});
|
||||
dz.addEventListener('dragleave', function(e) {
|
||||
if (!dz.contains(e.relatedTarget)) dz.classList.remove('drag-over');
|
||||
});
|
||||
dz.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
dz.classList.remove('drag-over');
|
||||
var f = (e.dataTransfer.files || [])[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(f);
|
||||
fi.files = dt.files;
|
||||
} catch (_) {}
|
||||
frm.requestSubmit();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
188
app/web/templates/admin.html
Normal file
188
app/web/templates/admin.html
Normal file
@@ -0,0 +1,188 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Conturi clienti — Gateway RAR AUTOPASS{% endblock %}
|
||||
{% 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 }}">⋯</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 ' 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;">
|
||||
<h2 style="margin:0;">Conturi clienti</h2>
|
||||
<a href="/" class="cardlink muted">Inapoi la dashboard</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{{ lifecycle_block("Conturi in asteptare", pending, "pending",
|
||||
['activate', 'block', 'archive', 'delete'],
|
||||
['activate', 'block', 'archive', 'delete']) }}
|
||||
|
||||
{{ lifecycle_block("Conturi active", active, "active",
|
||||
['block', 'archive', 'delete'],
|
||||
['block', 'archive', 'delete']) }}
|
||||
|
||||
{# Conturi suspendate (blocate/arhivate): reactivare sau stergere. Stare reala in pill. #}
|
||||
{{ lifecycle_block("Conturi blocate / arhivate", suspended, "suspended",
|
||||
['activate', 'delete'],
|
||||
['activate', 'delete']) }}
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Selectie + bara bulk, scoped pe fiecare bloc (pending/active) prin data-block.
|
||||
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 %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card banner {% if not blocked %}hidden{% endif %}"
|
||||
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
|
||||
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
|
||||
Plasa de siguranta pe pene RAR > 30h. Verifica coada mai jos.
|
||||
<!-- Bara de status: mereu vizibila -->
|
||||
<div id="status-bar" class="status-bar card"
|
||||
hx-get="/_fragments/status?tab={{ active_tab }}"
|
||||
hx-trigger="load, every 15s, trimiteriChanged from:body"
|
||||
hx-swap="outerHTML">
|
||||
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex; gap:24px; flex-wrap:wrap;">
|
||||
<div><div class="muted">Worker</div><div class="{{ 's-sent' if worker_alive else 's-error' }}">
|
||||
{{ 'viu' if worker_alive else 'mort' }}</div></div>
|
||||
<div><div class="muted">Ultimul login RAR</div><div>{{ last_login or '—' }}</div></div>
|
||||
<div><div class="muted">In coada</div><div>{{ counts.get('queued', 0) }}</div></div>
|
||||
<div><div class="muted">Trimise</div><div class="s-sent">{{ counts.get('sent', 0) }}</div></div>
|
||||
<div><div class="muted">Blocate</div><div class="{{ 's-error' if blocked else '' }}">{{ blocked }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="font-size:14px; margin:0 0 12px;">Coada submissions</h2>
|
||||
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||
<div class="empty">se incarca…</div>
|
||||
</div>
|
||||
<!-- Panou activ: randat server-side la full load (Acasa implicit, sau ?tab= prin meniu) -->
|
||||
<div id="tab-panel" class="tab-panel">
|
||||
{{ panel_html | safe }}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
464
app/web/templates/landing.html
Normal file
464
app/web/templates/landing.html
Normal file
@@ -0,0 +1,464 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ROA AUTOPASS — declari prestațiile la RAR din câteva click-uri</title>
|
||||
<meta name="description" content="Încarci fișierul tău cu operațiile service-ului, completezi o dată codurile RAR și le salvezi. ROMFAST trimite prestațiile la RAR AUTOPASS în locul tău, fără tastat manual. Conform Legii 142/2023.">
|
||||
<style>
|
||||
|
||||
/* US-001/US-008 (PRD 5.16): IBM Plex eliminat complet — stive font sistem standard web.
|
||||
Tokenurile --font-ui / --font-mono definite in :root (sursa unica de adevar). */
|
||||
:root{--font-ui:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--font-mono:ui-monospace,"SF Mono","Cascadia Code","Segoe UI Mono","Roboto Mono",Menlo,Consolas,monospace;}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;}
|
||||
body{font-family:var(--font-ui);-webkit-font-smoothing:antialiased;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);}
|
||||
body[data-theme="grafit"]{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--hbg:rgba(15,18,24,.88);--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--mut:#5c6473}
|
||||
body[data-theme="cobalt"]{--bg:#080d1c;--card:#111a33;--card2:#0b1226;--text:#e9ecfb;--sub:#8a93b8;--line:#1d2747;--line2:#161f3a;--accent:#4068FF;--hbg:rgba(8,13,28,.9);--okt:#2fd0a6;--infot:#8aa0ff;--errt:#f06a7a;--mut:#5a6390}
|
||||
body[data-theme="cupru"]{--bg:#15110b;--card:#211a12;--card2:#15110b;--text:#efe6d6;--sub:#a89a85;--line:#36291c;--line2:#281e14;--accent:#D98A3D;--hbg:rgba(21,17,11,.9);--okt:#67b98c;--infot:#dfa45c;--errt:#e2685a;--mut:#6d5f4c}
|
||||
body[data-theme="hartie"]{--bg:#f3efe6;--card:#fffdf7;--card2:#f3efe6;--text:#1e1a13;--sub:#6a6052;--line:#e2dccc;--line2:#ece6d9;--accent:#1F5FBF;--hbg:rgba(255,253,247,.92);--okt:#1c7d5d;--infot:#1F5FBF;--errt:#bd463c;--mut:#9a8f7d}
|
||||
.page{width:100%;max-width:1280px;margin:0 auto;background:var(--bg,#0f1218);color:var(--text,#e6e9ef);overflow:hidden;}
|
||||
a{text-decoration:none;}
|
||||
input[type=range]{-webkit-appearance:none;appearance:none;background:transparent;}
|
||||
input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:99px;background:var(--line,#262b36);}
|
||||
input[type=range]::-moz-range-track{height:6px;border-radius:99px;background:var(--line,#262b36);}
|
||||
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;margin-top:-6px;border-radius:99px;background:var(--accent,#2E74D6);cursor:pointer;border:none;}
|
||||
input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:99px;background:var(--accent,#2E74D6);cursor:pointer;border:none;}
|
||||
|
||||
@media (max-width:900px){
|
||||
.lp-nav{display:none!important;}
|
||||
.lp-header{padding:0 18px!important;}
|
||||
.lp-h1{font-size:32px!important;line-height:1.1!important;}
|
||||
.page [style*="grid-template-columns"]{grid-template-columns:1fr!important;}
|
||||
.page [style*="padding:80px 40px"]{padding:48px 20px!important;}
|
||||
.page [style*="padding:0 40px 80px"]{padding:0 20px 48px!important;}
|
||||
.page [style*="padding:56px 40px 80px"]{padding:36px 20px 48px!important;}
|
||||
.page [style*="padding:44px"]{padding:28px!important;}
|
||||
.page [style*="padding:56px 40px"]{padding:40px 22px!important;}
|
||||
.page [style*="height:68px"]{height:60px!important;}
|
||||
.page [style*="gap:56px"]{gap:32px!important;}
|
||||
.page [style*="gap:48px"]{gap:28px!important;}
|
||||
}
|
||||
@media (max-width:560px){
|
||||
.lp-h1{font-size:27px!important;}
|
||||
.page [style*="padding:10px 40px"]{padding:10px 18px!important;}
|
||||
.lp-header{padding:0 12px!important;}
|
||||
#theme-label{display:none!important;}
|
||||
.lp-hactions{gap:8px!important;}
|
||||
.lp-hactions button{height:38px!important;padding:0 11px!important;font-size:13px!important;}
|
||||
}
|
||||
@media (max-width:430px){
|
||||
.lp-hactions a.auth-login-link{display:none!important;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
<script>try{var _t=localStorage.getItem('lp-theme');if(_t&&['grafit','cobalt','cupru','hartie'].indexOf(_t)>=0)document.body.setAttribute('data-theme',_t);}catch(e){}</script>
|
||||
<main class="page">
|
||||
<!-- HEADER -->
|
||||
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
|
||||
<div style="display:flex;flex-direction:column;line-height:1.05;">
|
||||
<span style="font:700 17px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);">ROA AUTOPASS</span>
|
||||
<span style="font:500 11px var(--font-ui);letter-spacing:.04em;color:var(--sub,#8b93a7);">Gateway RAR</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:28px;">
|
||||
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
|
||||
<a href="#cum-functioneaza" style="color:inherit;text-decoration:none;">Cum funcționează</a><a href="#api" style="color:inherit;text-decoration:none;">API</a><a href="#pret" style="color:inherit;text-decoration:none;">Preț</a>
|
||||
</div>
|
||||
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
|
||||
<button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px var(--font-ui);cursor:pointer;">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||
<span id="theme-label">Grafit</span>
|
||||
</button>
|
||||
<a href="/login" class="auth-login-link" style="display:inline-flex;align-items:center;height:44px;padding:0 18px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;text-decoration:none;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Autentificare</a>
|
||||
<button data-act="auth" data-tab="register" style="height:44px;padding:0 18px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HERO -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
|
||||
<div>
|
||||
<h1 class="lp-h1" style="font:700 50px/1.06 var(--font-ui);letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară prestațiile la RAR AUTOPASS, automat</h1>
|
||||
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;max-width:480px;">Încarci fișierul tău așa cum îl ai, potrivești o dată operațiile cu codurile RAR, și trimitem prestațiile la RAR AUTOPASS în locul tău. Fără tastat câmp cu câmp.</p>
|
||||
<div style="margin-bottom:32px;">
|
||||
<p style="display:flex;align-items:center;gap:8px;font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin:0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg><span><span style="color:#1F9D5C;">Gratuit</span> până la 60 de trimiteri/lună</span></p>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:22px;">
|
||||
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
||||
<button style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</button>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);flex-wrap:wrap;">
|
||||
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
|
||||
<span style="color:var(--line,#262b36);">·</span>
|
||||
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Datele tale criptate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard mockup -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);">
|
||||
<div>
|
||||
<div style="font:700 14px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri RAR AUTOPASS</div>
|
||||
<div style="font:400 12px var(--font-mono);color:var(--sub,#8b93a7);margin-top:2px;">Service Auto Vâlcea · 28 iun 2026</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<div style="display:flex;align-items:center;gap:5px;padding:4px 9px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 11px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Live</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line,#262b36);">
|
||||
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:var(--text,#e6e9ef);">847</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Trimise luna asta</div></div>
|
||||
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:var(--accent,#2E74D6);">12</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">În coadă</div></div>
|
||||
<div style="flex:1;background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:8px;padding:10px 12px;"><div style="font:700 20px var(--font-ui);color:#E05D5D;">2</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">De corectat</div></div>
|
||||
</div>
|
||||
<div style="padding:6px 0;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
|
||||
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">WBA8E9...K7F2</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Inspecție tehnică · 09:42</div></div>
|
||||
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 12px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
|
||||
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">WVWZZZ...3M1</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Revizie periodică · 09:38</div></div>
|
||||
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,var(--accent,#2E74D6) 14%,transparent);font:500 12px var(--font-ui);color:var(--infot,#6ea2ec);"><span style="width:6px;height:6px;border-radius:99px;background:var(--accent,#2E74D6);"></span>În coadă</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2,#1f2530);">
|
||||
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">VF1RFB...A88</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Sistem frânare · 09:31</div></div>
|
||||
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);font:500 12px var(--font-ui);color:var(--errt,#E05D5D);"><span style="width:6px;height:6px;border-radius:99px;background:#E05D5D;"></span>Eroare VIN</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 18px;">
|
||||
<div><div style="font:500 13px var(--font-mono);color:var(--text,#e6e9ef);">ZAR937...C04</div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);">Schimb ulei · 09:24</div></div>
|
||||
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 13%,transparent);font:500 12px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:6px;height:6px;border-radius:99px;background:#2FBF8F;"></span>Trimis</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROBLEM + CALCULATOR (combinat) -->
|
||||
<div style="padding:80px 40px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
|
||||
<div style="text-align:center;max-width:760px;margin:0 auto 40px;">
|
||||
<h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 2–3 minute pe RAR AUTOPASS.<br><span style="color:var(--errt,#E05D5D);">Minutele acelea sunt bani.</span></h2>
|
||||
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi. Mută cursorul la volumul service-ului tău și vezi cât te costă.</p>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:stretch;">
|
||||
<!-- STANGA: formularul RAR AUTOPASS -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:24px;display:flex;flex-direction:column;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">RAR AUTOPASS · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px var(--font-mono);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Confirmă VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Data prestației</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">2026-06-22</div></div>
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Număr înmatriculare</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">CT88NOE</div></div>
|
||||
</div>
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Observații</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-ui);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">REVIZIE; SCHIMBARE PLĂCUȚE FRÂNĂ</div></div>
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Prestații</div><div style="min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);"><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>REVIZIE PERIODICĂ</span><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>ÎNTREȚINERE</span></div></div>
|
||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Valoarea citită a odometrului</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">39000</div></div>
|
||||
</div>
|
||||
<button style="margin-top:14px;height:34px;padding:0 14px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--card2,#0f1218));border:none;color:#fff;opacity:.55;font:600 12px var(--font-ui);cursor:not-allowed;align-self:flex-start;">Salvează Prezentarea</button>
|
||||
<div style="margin-top:auto;padding-top:12px;font:400 12px var(--font-ui);color:var(--sub,#8b93a7);text-align:center;">se repetă pentru fiecare comandă · zi de zi</div>
|
||||
</div>
|
||||
|
||||
<!-- DREAPTA: calculatorul (slidere + cifre) -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:34px;display:flex;flex-direction:column;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;">
|
||||
<div>
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri/lună</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);" id="out-pres">100</span></div>
|
||||
<input type="range" min="50" max="1500" step="10" value="100" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
||||
</div>
|
||||
<div>
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Manoperă</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);"><span id="out-rate">60</span><span style="font:500 12px var(--font-ui);color:var(--sub,#8b93a7);"> lei/h</span></span></div>
|
||||
<input type="range" min="30" max="200" step="5" value="60" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:color-mix(in srgb,#E05D5D 9%,var(--card2,#0f1218));border:1px solid color-mix(in srgb,#E05D5D 28%,var(--line,#262b36));border-radius:10px;padding:22px 24px;">
|
||||
<div style="font:600 11px var(--font-ui);color:var(--errt,#E05D5D);letter-spacing:.08em;text-transform:uppercase;margin-bottom:14px;">Pierdut pe raportare manuală</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;">
|
||||
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiMonth">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">lei/lună</div></div>
|
||||
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="hMonth">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">ore/lună</div></div>
|
||||
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiYear">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">lei/an</div></div>
|
||||
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="days">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">zile/an</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:20px;padding-top:18px;border-top:1px solid var(--line,#262b36);">
|
||||
<div style="display:flex;align-items:center;gap:9px;font:600 14px var(--font-ui);color:var(--okt,#2FBF8F);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Cu ROA AUTOPASS: câteva secunde pentru tot lotul</div>
|
||||
<div style="font:400 13px/1.55 var(--font-ui);color:var(--sub,#8b93a7);margin-top:6px;">Recuperezi ~<span data-calc="leiMonth">0</span> lei/lună și timpul îl pui pe mașini, nu pe formulare.</div>
|
||||
</div>
|
||||
<div style="margin-top:14px;display:flex;align-items:center;gap:8px;font:400 12px var(--font-ui);color:var(--mut,#5c6473);"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>Estimat la ~2,5 minute de tastat manual pentru fiecare trimitere.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LEGE / AMENZI -->
|
||||
<div style="padding:56px 40px 80px;">
|
||||
<div style="display:flex;gap:20px;align-items:flex-start;background:color-mix(in srgb,#E0A93B 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E0A93B 34%,var(--line,#262b36));border-radius:12px;padding:26px 28px;">
|
||||
<div style="width:44px;height:44px;flex-shrink:0;border-radius:8px;background:color-mix(in srgb,#E0A93B 16%,transparent);display:flex;align-items:center;justify-content:center;color:#E0A93B;"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9.5C7.1 20.3 4 16.4 4 12V7l8-4z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></div>
|
||||
<div>
|
||||
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Evită riscul amenzilor — transmite automat la RAR Auto-Pass</div>
|
||||
<p style="font:400 14px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Conform <strong style="color:var(--text,#e6e9ef);font-weight:600;">Legii nr. 142/2023</strong> și <strong style="color:var(--text,#e6e9ef);font-weight:600;">OMTI nr. 210/2024</strong>, service-urile auto autorizate RAR trebuie să transmită, la finalizarea fiecărei prestații, informațiile obligatorii (VIN, kilometraj și, după caz, date privind intervențiile asupra odometrului și reparațiile rezultate din avarii grave). Nerespectarea obligației se sancționează cu amendă între <span style="color:var(--errt,#E05D5D);font-weight:600;">2.000 și 5.000 lei</span>, iar transmiterea unor informații eronate cu amendă între <span style="color:#E0A93B;font-weight:600;">1.000 și 2.000 lei</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SOLVE -->
|
||||
<div id="cum-functioneaza" style="padding:80px 40px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
|
||||
<div style="max-width:780px;margin:0 auto;text-align:center;">
|
||||
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2>
|
||||
<p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA AUTOPASS îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
|
||||
</div>
|
||||
<div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 var(--font-ui);color:var(--sub,#8b93a7);">
|
||||
<span style="text-decoration:line-through;text-decoration-color:var(--errt,#E05D5D);text-decoration-thickness:2px;">2–3 minute de tastat pentru fiecare comandă</span><span style="color:var(--text,#e6e9ef);font-weight:700;"> → câteva secunde pentru tot lotul.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API INTEGRATION -->
|
||||
<div id="api" style="padding:56px 40px 80px;">
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;">
|
||||
<div>
|
||||
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu</div>
|
||||
<h2 style="font:700 30px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Ai deja un soft de service? Conectează-l automat</h2>
|
||||
<p style="font:400 15px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Softul tău se poate conecta și direct la API-ul RAR Auto-Pass. Cu ROMFAST primești în plus asistență la maparea automată a operațiilor tale (prin mai multe metode) și salvarea mapărilor pentru trimiterile viitoare — totul printr-un singur apel, cu cheie API per cont.</p>
|
||||
<button style="height:44px;padding:0 20px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi documentația API <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button>
|
||||
</div>
|
||||
<div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;gap:7px;padding:11px 14px;border-bottom:1px solid #262b36;">
|
||||
<span style="width:11px;height:11px;border-radius:99px;background:#E05D5D;"></span><span style="width:11px;height:11px;border-radius:99px;background:#E0A93B;"></span><span style="width:11px;height:11px;border-radius:99px;background:#2FBF8F;"></span>
|
||||
<span style="font:400 12px var(--font-mono);color:#8b93a7;margin-left:8px;">request.sh</span>
|
||||
</div>
|
||||
<pre style="margin:0;padding:18px;font:400 13px/1.7 var(--font-mono);color:#e6e9ef;overflow-x:auto;"><span style="color:#2FBF8F;">POST</span> /v1/prezentari
|
||||
<span style="color:#8b93a7;">Authorization:</span> <span style="color:#E0A93B;">rfak_••••••••</span>
|
||||
<span style="color:#8b93a7;">Content-Type:</span> application/json
|
||||
|
||||
{
|
||||
<span style="color:#6ea2ec;">"vin"</span>: <span style="color:#2FBF8F;">"WBA8E9C5..."</span>,
|
||||
<span style="color:#6ea2ec;">"cod_prestatie"</span>: <span style="color:#2FBF8F;">"ITP-01"</span>,
|
||||
<span style="color:#6ea2ec;">"odometru"</span>: <span style="color:#E0A93B;">142500</span>
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TRIAL BENEFIT -->
|
||||
<div style="padding:0 40px 80px;">
|
||||
<div style="display:flex;align-items:center;gap:22px;background:color-mix(in srgb,#2FBF8F 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#2FBF8F 32%,var(--line,#262b36));border-radius:14px;padding:30px 34px;flex-wrap:wrap;">
|
||||
<div style="width:48px;height:48px;flex-shrink:0;border-radius:10px;background:color-mix(in srgb,#2FBF8F 16%,transparent);display:flex;align-items:center;justify-content:center;color:var(--okt,#2FBF8F);"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/><circle cx="12" cy="12" r="4.5"/></svg></div>
|
||||
<div style="flex:1;min-width:240px;">
|
||||
<div style="font:700 19px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);margin-bottom:5px;"><span style="color:var(--okt,#2FBF8F);">30 de zile Pro gratuit</span> la fiecare cont nou</div>
|
||||
<p style="font:400 14px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Începi direct cu tot ce are planul Pro — import API, categorisire automată și suport rapid. După 30 de zile treci automat pe Gratuit, fără plată și fără întreruperi.</p>
|
||||
</div>
|
||||
<button data-act="auth" data-tab="register" data-plan="pro" style="height:48px;padding:0 24px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;white-space:nowrap;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Începe gratuit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PRICING -->
|
||||
<div id="pret" style="padding:0 40px 80px;">
|
||||
<div style="text-align:center;margin-bottom:44px;">
|
||||
<h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
|
||||
<p style="font:400 15px var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Alege planul potrivit volumului tău. Poți schimba sau anula oricând.</p>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:stretch;">
|
||||
<!-- Gratuit -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div>
|
||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
|
||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Până la 60 de trimiteri/lună</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Trimiteri nelimitate</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, în 48h</div>
|
||||
</div>
|
||||
<button data-act="auth" data-tab="register" data-plan="free" style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Creează cont gratuit</button>
|
||||
</div>
|
||||
<!-- Standard -->
|
||||
<div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;display:flex;flex-direction:column;">
|
||||
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Popular</div>
|
||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Standard</div>
|
||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">49 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
|
||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Trimiteri nelimitate</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 24h</div>
|
||||
</div>
|
||||
<button style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="standard">Creează cont gratuit</button>
|
||||
</div>
|
||||
<!-- Pro -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div>
|
||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
|
||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Nelimitat + acces API</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 8h</div>
|
||||
</div>
|
||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="pro">Creează cont gratuit</button>
|
||||
</div>
|
||||
<!-- Premium -->
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div>
|
||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></div>
|
||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Integrare în softul tău</div>
|
||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic + onboarding dedicat</div>
|
||||
</div>
|
||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="premium">Creează cont gratuit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PRIVACY -->
|
||||
<div style="padding:80px 40px;border-top:1px solid var(--line,#262b36);">
|
||||
<div style="margin:0 auto;display:grid;grid-template-columns:minmax(240px,330px) 1fr;gap:48px;align-items:center;">
|
||||
<h2 style="font:700 30px/1.2 var(--font-ui);letter-spacing:-.02em;margin:0;color:var(--text,#e6e9ef);">Datele clienților tăi nu devin marfă</h2>
|
||||
<div style="display:flex;flex-wrap:wrap;">
|
||||
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
|
||||
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Reținem doar strict necesarul</div>
|
||||
<div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Doar datele de care e nevoie ca să trimitem la RAR — nimic adunat în plus, nici la conturile gratuite.</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
|
||||
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Doar pentru scopul declarat</div>
|
||||
<div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Maparea și trimiterea la RAR. Nu le vindem și nu le dăm mai departe.</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:200px;padding:4px 28px;border-left:1px solid var(--line,#262b36);">
|
||||
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Se șterg la 3 luni</div>
|
||||
<div style="font:400 14px/1.55 var(--font-ui);color:var(--sub,#8b93a7);">Automat, fără să ceri — sau chiar acum, cu un singur click.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AUTH / REGISTER -->
|
||||
<div id="inregistrare" style="padding:80px 40px;border-top:1px solid var(--line,#262b36);background:color-mix(in srgb,var(--accent,#2E74D6) 5%,var(--bg,#0f1218));">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;margin:0 auto;align-items:center;">
|
||||
<div>
|
||||
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Creează cont</div>
|
||||
<h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2>
|
||||
<p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit. Imediat poți încărca primul fișier sau conecta softul de service.</p>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Pro gratuit 30 de zile, apoi automat pe Gratuit</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023 și OMTI 210/2024</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Datele cu caracter personal criptate (GDPR)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:32px;box-shadow:0 20px 50px -24px rgba(0,0,0,.5);">
|
||||
<div style="display:flex;gap:28px;border-bottom:1px solid var(--line,#262b36);margin-bottom:24px;">
|
||||
<button type="button" data-act="tab" data-tab="register" class="auth-tab is-active" style="position:relative;background:none;border:none;padding:0 0 12px;font:700 15px var(--font-ui);color:var(--text,#e6e9ef);cursor:pointer;">Creează cont<span class="tab-underline" style="position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--accent,#2E74D6);"></span></button>
|
||||
<button type="button" data-act="tab" data-tab="login" class="auth-tab" style="position:relative;background:none;border:none;padding:0 0 12px;font:700 15px var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;">Autentificare<span class="tab-underline" style="position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--accent,#2E74D6);"></span></button>
|
||||
</div>
|
||||
<form method="post" action="/signup" data-pane="register">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Companie</span><input type="text" name="name" required placeholder="SC Service Auto SRL" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
|
||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="free" selected>Gratuit — 0 lei/lună</option><option value="standard">Standard — 49 lei/lună</option><option value="pro">Pro — 59 lei/lună</option><option value="premium">Premium — la cerere</option></select></label>
|
||||
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
|
||||
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button>
|
||||
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div>
|
||||
</form>
|
||||
<form method="post" action="/login" data-pane="login" style="display:none;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||
<label style="display:block;margin-bottom:10px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required placeholder="Parola ta" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||
<div style="text-align:right;margin-bottom:18px;"><a href="/login" style="font:400 13px var(--font-ui);color:var(--accent,#2E74D6);cursor:pointer;">Ai uitat parola?</a></div>
|
||||
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease;">Autentificare</button>
|
||||
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Nu ai cont? <a data-act="tab" data-tab="register" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Creează unul gratuit</a></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;">
|
||||
<div style="font:700 18px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div>
|
||||
<div style="display:flex;gap:26px;font:400 14px var(--font-ui);color:var(--sub,#8b93a7);">
|
||||
<span>Termeni</span><span>Confidențialitate / GDPR</span><span>Documentație API</span><span>Contact</span>
|
||||
</div>
|
||||
<div style="font:400 13px var(--font-ui);color:var(--mut,#5c6473);">© 2026 ROMFAST</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
(function(){
|
||||
var THEMES=[['grafit','Grafit'],['cobalt','Cobalt'],['cupru','Cupru'],['hartie','Hârtie']];
|
||||
var body=document.body;
|
||||
function curIndex(){var t=body.getAttribute('data-theme');for(var i=0;i<THEMES.length;i++){if(THEMES[i][0]===t)return i;}return 0;}
|
||||
function applyTheme(i){i=((i%THEMES.length)+THEMES.length)%THEMES.length;body.setAttribute('data-theme',THEMES[i][0]);var l=document.getElementById('theme-label');if(l)l.textContent=THEMES[i][1];try{localStorage.setItem('lp-theme',THEMES[i][0]);}catch(e){}}
|
||||
applyTheme(curIndex());
|
||||
|
||||
// style-hover: framework-ul de design folosea atributul style-hover; il aplicam la hover.
|
||||
function parseStyle(str){var o={};str.split(';').forEach(function(p){var idx=p.indexOf(':');if(idx>0)o[p.slice(0,idx).trim()]=p.slice(idx+1).trim();});return o;}
|
||||
document.querySelectorAll('[style-hover]').forEach(function(el){
|
||||
var hov=parseStyle(el.getAttribute('style-hover'));var keys=Object.keys(hov);var saved={};
|
||||
el.addEventListener('mouseenter',function(){keys.forEach(function(k){saved[k]=el.style.getPropertyValue(k);el.style.setProperty(k,hov[k]);});});
|
||||
el.addEventListener('mouseleave',function(){keys.forEach(function(k){el.style.setProperty(k,saved[k]);});});
|
||||
});
|
||||
|
||||
// Calculator: cost raportare manuala (2,5 min/prestatie).
|
||||
var pres=document.getElementById('calc-pres'),rate=document.getElementById('calc-rate');
|
||||
var nf=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:0});
|
||||
var nf1=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:1});
|
||||
function recalc(){
|
||||
var p=+pres.value,r=+rate.value,minPer=2.5;
|
||||
var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
|
||||
document.getElementById('out-pres').textContent=p;
|
||||
document.getElementById('out-rate').textContent=r;
|
||||
var map={leiMonth:nf.format(Math.round(leiMonth)),hMonth:nf.format(Math.round(hMonth)),leiYear:nf.format(Math.round(leiMonth*12)),days:nf.format(Math.round((hMonth*12)/8))};
|
||||
Object.keys(map).forEach(function(k){document.querySelectorAll('[data-calc="'+k+'"]').forEach(function(n){n.textContent=map[k];});});
|
||||
}
|
||||
if(pres&&rate){pres.addEventListener('input',recalc);rate.addEventListener('input',recalc);recalc();}
|
||||
|
||||
// Tab-uri autentificare/inregistrare.
|
||||
function setTab(tab){
|
||||
document.querySelectorAll('[data-pane]').forEach(function(f){f.style.display=(f.getAttribute('data-pane')===tab)?'':'none';});
|
||||
document.querySelectorAll('.auth-tab').forEach(function(b){
|
||||
var on=b.getAttribute('data-tab')===tab;b.classList.toggle('is-active',on);
|
||||
b.style.color=on?'var(--text,#e6e9ef)':'var(--sub,#8b93a7)';
|
||||
var u=b.querySelector('.tab-underline');if(u)u.style.display=on?'':'none';
|
||||
});
|
||||
}
|
||||
setTab('register');
|
||||
|
||||
document.addEventListener('click',function(e){
|
||||
var t=e.target.closest('[data-act]');if(!t)return;
|
||||
var act=t.getAttribute('data-act');
|
||||
if(act==='theme'){applyTheme(curIndex()+1);}
|
||||
else if(act==='tab'){e.preventDefault();setTab(t.getAttribute('data-tab'));}
|
||||
else if(act==='auth'){
|
||||
e.preventDefault();
|
||||
setTab(t.getAttribute('data-tab')||'register');
|
||||
var plan=t.getAttribute('data-plan'),sel=document.getElementById('plan-select');
|
||||
if(plan&&sel)sel.value=plan;
|
||||
var a=document.getElementById('inregistrare');if(a)a.scrollIntoView({behavior:'smooth',block:'start'});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
59
app/web/templates/login.html
Normal file
59
app/web/templates/login.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Autentificare — ROA AUTOPASS{% endblock %}
|
||||
{% block content %}
|
||||
{# US-010 (PRD 5.16): /login — layout 2 coloane branduit.
|
||||
Stanga: logo + tagline + puncte de incredere.
|
||||
Dreapta: formular de autentificare (neschimbat: CSRF, POST /login, link signup).
|
||||
Pe mobil (<640px): se stivuiesc, partea dreapta (formular) iese prima. #}
|
||||
<div class="login-2col" style="max-width:860px; margin:32px auto;">
|
||||
{# Antet minimal deja randat in base.html (fara RAR dot, fara burger, fara account_name) #}
|
||||
<div class="login-shell">
|
||||
{# === Formular autentificare === #}
|
||||
<div class="login-form-col">
|
||||
<h3 style="font-size:var(--fs-xl); margin:0 0 4px;">Autentificare</h3>
|
||||
<p style="font-size:var(--fs-sm); color:var(--muted); margin:0 0 22px;">
|
||||
Intra in contul service-ului tau.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="banner" style="margin-bottom:14px; padding:8px 12px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="camp-slim">
|
||||
<label for="lf-email">Email</label>
|
||||
<input id="lf-email" type="email" name="email" required autocomplete="email">
|
||||
</div>
|
||||
<div class="camp-slim" style="margin-bottom:14px;">
|
||||
<label for="lf-parola">Parola</label>
|
||||
<input id="lf-parola" type="password" name="parola" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary-full">Intra in cont</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">
|
||||
Cont nou? <a href="/signup" style="color:var(--accent);">Inregistreaza service-ul</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* US-010 PRD 5.16: layout /login profesional 2 coloane. */
|
||||
.login-shell {
|
||||
display:grid; grid-template-columns:1fr;
|
||||
border:1px solid var(--line); border-radius:16px; overflow:hidden;
|
||||
background:var(--card); max-width:460px; margin:0 auto;
|
||||
}
|
||||
.login-form-col { padding:40px 38px; display:flex; flex-direction:column; justify-content:center; }
|
||||
.btn-primary-full { width:100%; min-height:46px; font-family:var(--font-ui); font-size:var(--fs-md);
|
||||
font-weight:600; background:var(--accent); color:#fff; border:none;
|
||||
border-radius:8px; cursor:pointer; margin-top:4px; }
|
||||
.btn-primary-full:hover { filter:brightness(1.08); }
|
||||
.login-foot { text-align:center; font-size:var(--fs-sm); color:var(--muted); margin-top:18px; }
|
||||
@media (max-width:640px) {
|
||||
.login-form-col { padding:28px 22px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
93
app/web/templates/signup.html
Normal file
93
app/web/templates/signup.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inregistrare — Gateway RAR AUTOPASS{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card auth-card" style="max-width:480px;margin:40px auto;">
|
||||
{% if api_key %}
|
||||
<div class="flash">Contul a fost creat. Salveaza cheia API acum — nu o vei mai putea vedea.</div>
|
||||
|
||||
<div class="card" style="font-family:monospace;word-break:break-all;font-size:14px;background:#0f1115;margin:12px 0;">
|
||||
{{ api_key }}
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
data-key="{{ api_key }}"
|
||||
onclick="navigator.clipboard.writeText(this.dataset.key).then(()=>this.textContent='Copiat!')">
|
||||
Copiaza cheia
|
||||
</button>
|
||||
|
||||
<p style="font-size:13px;color:var(--warn);margin-top:12px;">
|
||||
Atentie: la refresh sau la urmatoarea vizita aceasta cheie dispare.
|
||||
Recuperare posibila doar prin rotire cheie (CLI admin).
|
||||
</p>
|
||||
|
||||
<div class="banner warn" style="margin-top:16px;">
|
||||
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">
|
||||
<input type="checkbox" id="saved-check" style="margin-top:3px;">
|
||||
Am salvat cheia in siguranta
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p id="cta-dashboard" style="display:none;margin-top:16px;">
|
||||
<a href="/">Mergi la dashboard</a> — configureaza creds RAR si pregateste importul.
|
||||
Trimiterea catre RAR porneste automat dupa activarea contului de catre admin.
|
||||
</p>
|
||||
<script>
|
||||
document.getElementById('saved-check').addEventListener('change', function() {
|
||||
document.getElementById('cta-dashboard').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<h2 style="margin-top:0;">Creează cont nou</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# Format aliniat la formularul de inregistrare din landing (#inregistrare): aceleasi campuri,
|
||||
etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro/premium),
|
||||
normalizate server-side. #}
|
||||
<form method="post" action="/signup">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label style="display:block;margin-bottom:14px;">
|
||||
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Companie</span>
|
||||
<input type="text" name="name" value="{{ name or '' }}" required placeholder="SC Service Auto SRL"
|
||||
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||
</label>
|
||||
<label style="display:block;margin-bottom:14px;">
|
||||
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">CUI</span>
|
||||
<input type="text" name="cui" value="{{ cui or '' }}" required placeholder="RO12345678"
|
||||
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-mono);outline:none;">
|
||||
</label>
|
||||
<label style="display:block;margin-bottom:14px;">
|
||||
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Email</span>
|
||||
<input type="email" name="email" value="{{ email or '' }}" required placeholder="nume@service.ro"
|
||||
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||
</label>
|
||||
<label style="display:block;margin-bottom:14px;">
|
||||
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Parolă</span>
|
||||
<input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere"
|
||||
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||
</label>
|
||||
<label style="display:block;margin-bottom:16px;">
|
||||
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Pachet ales</span>
|
||||
<select name="plan"
|
||||
style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;cursor:pointer;">
|
||||
<option value="free"{% if not plan or plan == 'free' %} selected{% endif %}>Gratuit — 0 lei/lună</option>
|
||||
<option value="standard"{% if plan == 'standard' %} selected{% endif %}>Standard — 49 lei/lună</option>
|
||||
<option value="pro"{% if plan == 'pro' %} selected{% endif %}>Pro — 59 lei/lună</option>
|
||||
<option value="premium"{% if plan == 'premium' %} selected{% endif %}>Premium — la cerere</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--muted);cursor:pointer;">
|
||||
<input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent);width:16px;height:16px;flex-shrink:0;">
|
||||
Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).
|
||||
</label>
|
||||
<button type="submit"
|
||||
style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;">Creează cont gratuit</button>
|
||||
</form>
|
||||
<p style="text-align:center;font-size:13px;margin-top:16px;">
|
||||
Ai deja cont? <a href="/login">Autentificare</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,13 +1,22 @@
|
||||
"""Worker RAR — proces propriu (NU task asyncio in uvicorn; plan.md sect. 4).
|
||||
"""Worker RAR — proces propriu (NU task asyncio in uvicorn).
|
||||
|
||||
Bucla: heartbeat -> claim atomic (BEGIN IMMEDIATE) -> login -> postPrezentare -> update.
|
||||
Bucla: heartbeat -> recupereaza orfane -> claim atomic -> login -> postPrezentare -> update.
|
||||
Ruleaza ca proces separat sub `restart: always` (docker compose).
|
||||
|
||||
Schelet — ce E implementat: heartbeat, claim atomic anti-race, login cu token
|
||||
cache, postPrezentare cu maparea erorilor de validare (400 -> needs_data).
|
||||
Ce NU e inca (marcat TODO): reconcilierea anti-duplicat pe raspuns pierdut (T2),
|
||||
retry/backoff exponential (T2), lease/timeout pe randuri 'sending' orfane (T2),
|
||||
livrarea creds per-cerere de la ROAAUTO (T2 — in schelet folosim creds <test> local).
|
||||
- claim atomic anti-race (BEGIN IMMEDIATE), respecta next_attempt_at (backoff).
|
||||
- reconciliere anti-duplicat pe raspuns pierdut: pe eroare tranzitorie/timeout SAU pe
|
||||
rand 'sending' orfan (worker mort mid-POST), interogheaza finalizate si match pe
|
||||
vin+dataPrestatie+odometruFinal; daca exista -> 'sent' (NU re-trimite).
|
||||
- retry/backoff exponential pe erori tranzitorii; peste worker_max_retries -> 'error'.
|
||||
- lease/timeout pe randuri 'sending' orfane.
|
||||
- re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h.
|
||||
|
||||
Creds per-cerere: fiecare submission poarta creds RAR CRIPTATE (rar_creds_enc).
|
||||
Worker-ul face login per CONT cu acele creds, cache-uieste JWT (30h) in memorie si
|
||||
STERGE creds-urile contului dupa primul login reusit. Token-ul in memorie acopera
|
||||
restul trimiterilor; la restart token-ul se pierde si contul re-logheaza la urmatorul
|
||||
submission care aduce creds proaspete (degradare acceptata).
|
||||
Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc.
|
||||
|
||||
Pornire: python -m app.worker
|
||||
"""
|
||||
@@ -18,10 +27,18 @@ import json
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..config import get_settings, load_test_credentials
|
||||
from ..db import get_connection, init_db, write_heartbeat
|
||||
import httpx
|
||||
|
||||
from .. import errors
|
||||
from ..config import Settings, get_settings, load_test_credentials
|
||||
from ..crypto import decrypt_creds
|
||||
from ..db import get_connection, init_db, read_heartbeat, write_heartbeat
|
||||
from ..observ import log_event, set_source
|
||||
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
|
||||
from ..payload import build_rar_payload
|
||||
from ..reconcile import match_finalizata
|
||||
from ..rar_client import RarAuthError, RarClient, RarError
|
||||
|
||||
_running = True
|
||||
@@ -32,16 +49,133 @@ def _stop(signum: int, frame: object) -> None:
|
||||
_running = False
|
||||
|
||||
|
||||
def claim_one(conn) -> dict | None:
|
||||
"""Claim atomic al unui rand 'queued' -> 'sending'. Intoarce randul sau None.
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
BEGIN IMMEDIATE ia lock de scriere imediat, deci doi workeri nu pot lua
|
||||
acelasi rand. (Un singur worker in v1, dar claim-ul ramane corect la scalare.)
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
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:
|
||||
return min(settings.worker_retry_base_s * (2 ** max(0, retry_count - 1)), settings.worker_retry_max_s)
|
||||
|
||||
|
||||
def _is_transient(exc: Exception) -> bool:
|
||||
"""Eroare in care POST-ul poate sa fi ajuns/sa nu fi ajuns la RAR -> reconciliere + retry."""
|
||||
if isinstance(exc, (httpx.TimeoutException, httpx.TransportError)):
|
||||
return True
|
||||
if isinstance(exc, RarError) and not isinstance(exc, RarAuthError):
|
||||
code = exc.status_code
|
||||
return code is None or code >= 500 or code in (408, 429)
|
||||
return False
|
||||
|
||||
|
||||
# --- 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:
|
||||
if status == "sent":
|
||||
# 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(
|
||||
"UPDATE submissions SET status=?, rar_status_code=?, rar_error=?, id_prezentare=?, "
|
||||
f"sending_since=NULL, updated_at=datetime('now'), purge_after={purge_expr} WHERE id=?",
|
||||
(status, rar_status_code, rar_error, id_prezentare, submission_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status=?, rar_status_code=?, rar_error=?, id_prezentare=?, "
|
||||
"sending_since=NULL, updated_at=datetime('now') WHERE id=?",
|
||||
(status, rar_status_code, rar_error, id_prezentare, submission_id),
|
||||
)
|
||||
|
||||
|
||||
# Purge interval in secunde (odata pe ora, nu prea agresiv)
|
||||
_PURGE_INTERVAL_S = 3600
|
||||
|
||||
|
||||
def purge_expired(conn) -> dict[str, int]:
|
||||
"""Sterge randurile expirate (purge_after < now).
|
||||
|
||||
Submissions `sent` SI blocate (error/needs_data/needs_mapping) expirate;
|
||||
import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal).
|
||||
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(
|
||||
"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(
|
||||
"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 {
|
||||
"submissions_purged": cur_sub.rowcount,
|
||||
"batches_purged": cur_batch.rowcount,
|
||||
"events_purged": cur_events.rowcount,
|
||||
}
|
||||
|
||||
|
||||
def requeue_with_backoff(conn, settings: Settings, submission_id: int, *, reason: str) -> None:
|
||||
"""Re-pune randul in coada cu retry++ si next_attempt_at = now + backoff."""
|
||||
row = conn.execute("SELECT retry_count FROM submissions WHERE id=?", (submission_id,)).fetchone()
|
||||
new_retry = int(row["retry_count"]) + 1 if row else 1
|
||||
if new_retry > settings.worker_max_retries:
|
||||
mark(conn, submission_id, "error", rar_error=f"esuat dupa {new_retry-1} reincercari: {reason}")
|
||||
print(f"[worker] submission {submission_id} -> error (max retries): {reason}", flush=True)
|
||||
return
|
||||
next_at = _iso(_now() + timedelta(seconds=_backoff_seconds(settings, new_retry)))
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='queued', retry_count=?, next_attempt_at=?, "
|
||||
"rar_error=?, sending_since=NULL, updated_at=datetime('now') WHERE id=?",
|
||||
(new_retry, next_at, reason, submission_id),
|
||||
)
|
||||
print(f"[worker] submission {submission_id} -> requeue (retry {new_retry}, next {next_at}): {reason}", flush=True)
|
||||
|
||||
|
||||
def claim_one(conn) -> dict | None:
|
||||
"""Claim atomic 'queued' -> 'sending', respectand next_attempt_at. Intoarce randul sau None.
|
||||
|
||||
Randul include `account_id` si `rar_creds_enc` (creds RAR criptate) pentru
|
||||
login-ul per-cont din `run`.
|
||||
"""
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, payload_json FROM submissions WHERE status='queued' ORDER BY id LIMIT 1"
|
||||
"SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc "
|
||||
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
||||
"WHERE s.status='queued' "
|
||||
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
||||
# 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",
|
||||
(_iso(_now()),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
conn.execute("COMMIT")
|
||||
@@ -52,99 +186,139 @@ def claim_one(conn) -> dict | None:
|
||||
(row["id"],),
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
return {"id": row["id"], "content": json.loads(row["payload_json"])}
|
||||
return {
|
||||
"id": row["id"],
|
||||
"account_id": row["account_id"] if row["account_id"] is not None else DEFAULT_ACCOUNT_ID,
|
||||
"creds_enc": row["rar_creds_enc"],
|
||||
"content": json.loads(row["payload_json"]),
|
||||
}
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
|
||||
def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status=?, rar_status_code=?, rar_error=?, id_prezentare=?, "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(status, rar_status_code, rar_error, id_prezentare, submission_id),
|
||||
)
|
||||
def reconcile_and_mark(conn, rar: RarClient, token: str, submission_id: int, content: dict) -> bool:
|
||||
"""Cauta la RAR o prezentare deja inregistrata pentru acest continut. Marcheaza 'sent' daca exista.
|
||||
|
||||
|
||||
def process_one(conn, rar: RarClient, token: str, claimed: dict) -> None:
|
||||
"""Trimite o prezentare claimed. Mapeaza rezultatul pe masina de stari.
|
||||
|
||||
TODO(T2): inainte de re-send pe un rand ramas 'sending' (raspuns pierdut),
|
||||
interogheaza get_finalizate pe VIN+dataPrestatie+odometruFinal si marcheaza
|
||||
'sent' daca exista deja (anti-duplicat). UNIQUE NU acopera acest caz.
|
||||
Intoarce True daca a gasit (si a marcat sent), False altfel.
|
||||
"""
|
||||
finalizate = rar.get_finalizate(token)
|
||||
found_id = match_finalizata(
|
||||
finalizate,
|
||||
vin=content.get("vin", ""),
|
||||
data_prestatie=content.get("data_prestatie", ""),
|
||||
odometru_final=content.get("odometru_final"),
|
||||
)
|
||||
if found_id is not None:
|
||||
mark(conn, submission_id, "sent", rar_status_code=200, id_prezentare=found_id,
|
||||
rar_error="reconciliat (raspuns pierdut)")
|
||||
_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 False
|
||||
|
||||
|
||||
def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: dict) -> str:
|
||||
"""Trimite o prezentare claimed. Intoarce starea finala (pentru teste/loguri)."""
|
||||
sid = claimed["id"]
|
||||
payload = build_rar_payload(claimed["content"])
|
||||
account_id = claimed.get("account_id")
|
||||
content = claimed["content"]
|
||||
payload = build_rar_payload(content)
|
||||
try:
|
||||
data = rar.post_prezentare(token, payload)
|
||||
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"
|
||||
except RarError as exc:
|
||||
if exc.status_code == 400:
|
||||
# Validare esuata la RAR -> needs_data (nu re-trimite orb).
|
||||
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)
|
||||
print(f"[worker] submission {sid} -> needs_data: {detail}", flush=True)
|
||||
else:
|
||||
# TODO(T2): retry/backoff in loc de error direct pe 5xx/tranzitoriu.
|
||||
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_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"
|
||||
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):
|
||||
return _handle_transient(conn, settings, rar, token, sid, content, str(exc))
|
||||
# 4xx nerecuperabil (nu 400/401/408/429) -> error.
|
||||
mark(conn, sid, "error", rar_status_code=exc.status_code, rar_error=str(exc))
|
||||
_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"
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
return _handle_transient(conn, settings, rar, token, sid, content, f"retea: {exc}")
|
||||
|
||||
|
||||
def run() -> int:
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
def _handle_transient(conn, settings: Settings, rar: RarClient, token: str, sid: int, content: dict, reason: str) -> str:
|
||||
"""Eroare ambigua: POST-ul poate sa fi ajuns la RAR. Reconciliaza intai; altfel retry/backoff."""
|
||||
try:
|
||||
if reconcile_and_mark(conn, rar, token, sid, content):
|
||||
return "sent"
|
||||
except RarError as exc:
|
||||
# Reconcilierea insasi a esuat -> nu putem confirma; tratam ca tranzitoriu si retry.
|
||||
reason = f"{reason}; reconciliere esuata: {exc}"
|
||||
requeue_with_backoff(conn, settings, sid, reason=reason)
|
||||
return "requeued"
|
||||
|
||||
settings = get_settings()
|
||||
init_db()
|
||||
conn = get_connection()
|
||||
|
||||
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True)
|
||||
def recover_orphans(conn, settings: Settings, rar: RarClient, token: str, account_id: int | None = None) -> int:
|
||||
"""Randuri 'sending' mai vechi de lease (worker mort mid-POST). Reconciliaza; altfel requeue.
|
||||
|
||||
creds = load_test_credentials() if settings.worker_use_test_creds else None
|
||||
rar: RarClient | None = None
|
||||
token: str | None = None
|
||||
|
||||
while _running:
|
||||
`account_id` filtreaza la orfanii unui cont (login-ul e per-cont); None = toti
|
||||
(compat teste / single-account).
|
||||
"""
|
||||
# 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:
|
||||
orphans = conn.execute(
|
||||
"SELECT id, payload_json FROM submissions WHERE status='sending' "
|
||||
"AND (sending_since IS NULL OR sending_since <= datetime('now', ?)) AND account_id=?",
|
||||
(lease, account_id),
|
||||
).fetchall()
|
||||
else:
|
||||
orphans = conn.execute(
|
||||
"SELECT id, payload_json FROM submissions WHERE status='sending' "
|
||||
"AND (sending_since IS NULL OR sending_since <= datetime('now', ?))",
|
||||
(lease,),
|
||||
).fetchall()
|
||||
recovered = 0
|
||||
for row in orphans:
|
||||
sid = row["id"]
|
||||
content = json.loads(row["payload_json"])
|
||||
try:
|
||||
depth_detail = f"poll (queue={_queue_depth(conn)})"
|
||||
write_heartbeat(conn, detail=depth_detail)
|
||||
|
||||
if not settings.worker_send_enabled:
|
||||
time.sleep(settings.worker_poll_interval_s)
|
||||
continue
|
||||
|
||||
claimed = claim_one(conn)
|
||||
if claimed is None:
|
||||
time.sleep(settings.worker_poll_interval_s)
|
||||
continue
|
||||
|
||||
if not creds:
|
||||
# TODO(T2): canalul real de creds per-cerere de la ROAAUTO.
|
||||
mark(conn, claimed["id"], "error", rar_error="creds RAR indisponibile (T2)")
|
||||
continue
|
||||
|
||||
# Login lazy + token cache (JWT 30h). Re-login la expirare = T2.
|
||||
if rar is None or token is None:
|
||||
rar = RarClient(settings)
|
||||
token = rar.login(creds["email"], creds["password"])
|
||||
write_heartbeat(conn, rar_login_ok=True, detail="login RAR ok")
|
||||
|
||||
process_one(conn, rar, token, claimed)
|
||||
|
||||
except RarAuthError as exc:
|
||||
print(f"[worker] login esuat: {exc}", flush=True)
|
||||
token = None # forteaza re-login data viitoare
|
||||
time.sleep(settings.worker_poll_interval_s)
|
||||
except Exception as exc: # noqa: BLE001 — loop top-level, nu cadem la o eroare punctuala
|
||||
print(f"[worker] eroare neasteptata: {exc}", flush=True)
|
||||
time.sleep(settings.worker_poll_interval_s)
|
||||
|
||||
if rar is not None:
|
||||
rar.close()
|
||||
conn.close()
|
||||
print("[worker] oprit curat", flush=True)
|
||||
return 0
|
||||
if reconcile_and_mark(conn, rar, token, sid, content):
|
||||
recovered += 1
|
||||
else:
|
||||
# Nu e la RAR -> sigur de re-trimis.
|
||||
requeue_with_backoff(conn, settings, sid, reason="orfan recuperat (nu exista la RAR)")
|
||||
recovered += 1
|
||||
except RarError as exc:
|
||||
print(f"[worker] orfan {sid}: reconciliere esuata ({exc}); il las pentru data viitoare", flush=True)
|
||||
return recovered
|
||||
|
||||
|
||||
def _queue_depth(conn) -> int:
|
||||
@@ -152,5 +326,273 @@ def _queue_depth(conn) -> int:
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
|
||||
def _refresh_nomenclator(conn, rar: RarClient, token: str) -> None:
|
||||
"""Ia nomenclatorul live din RAR si il upsert-eaza local. Erorile NU opresc worker-ul."""
|
||||
try:
|
||||
items = rar.get_nomenclator(token)
|
||||
n = upsert_nomenclator(conn, items)
|
||||
print(f"[worker] nomenclator refresh: {n} coduri", flush=True)
|
||||
except Exception as exc: # noqa: BLE001 — refresh best-effort, nu blocheaza trimiterea
|
||||
print(f"[worker] nomenclator refresh esuat (continui): {exc}", flush=True)
|
||||
|
||||
|
||||
class AccountSessions:
|
||||
"""Sesiuni RAR per cont: login lazy cu creds din submission + cache JWT (30h).
|
||||
|
||||
La primul login reusit pentru un cont sterge creds-urile criptate ale contului
|
||||
(token-ul in memorie acopera restul). Pe 401 mid-sesiune se invalideaza sesiunea
|
||||
-> re-login la urmatorul submission cu creds.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self._sessions: dict[int, tuple[RarClient, str]] = {}
|
||||
|
||||
def get_token(self, conn, account_id: int, creds: dict | None) -> str | None:
|
||||
"""Token valid pentru cont. Login daca lipseste din cache si avem creds; altfel None."""
|
||||
sess = self._sessions.get(account_id)
|
||||
if sess is not None:
|
||||
return sess[1]
|
||||
if not creds or not creds.get("email") or not creds.get("password"):
|
||||
return None
|
||||
rar = RarClient(self.settings)
|
||||
try:
|
||||
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:
|
||||
rar.close()
|
||||
raise
|
||||
self._sessions[account_id] = (rar, token)
|
||||
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.
|
||||
# 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 API pur: purjarea e identica cu Treapta 1 (neatinsa).
|
||||
conn.execute(
|
||||
"UPDATE submissions SET rar_creds_enc=NULL WHERE account_id=? AND rar_creds_enc IS NOT NULL",
|
||||
(account_id,),
|
||||
)
|
||||
# Nomenclator live (autoritativ) la fiecare login proaspat.
|
||||
_refresh_nomenclator(conn, rar, token)
|
||||
return token
|
||||
|
||||
def rar(self, account_id: int) -> RarClient:
|
||||
return self._sessions[account_id][0]
|
||||
|
||||
def active(self) -> list[tuple[int, RarClient, str]]:
|
||||
return [(acct, rar, tok) for acct, (rar, tok) in self._sessions.items()]
|
||||
|
||||
def invalidate(self, account_id: int) -> None:
|
||||
sess = self._sessions.pop(account_id, None)
|
||||
if sess is not None:
|
||||
sess[0].close()
|
||||
|
||||
def close_all(self) -> None:
|
||||
for rar, _tok in self._sessions.values():
|
||||
rar.close()
|
||||
self._sessions.clear()
|
||||
|
||||
|
||||
def _creds_for(claimed: dict, settings: Settings) -> dict | None:
|
||||
"""Creds pentru un submission: decripteaza enc-ul lipit; altfel creds <test> (dev)."""
|
||||
creds = decrypt_creds(claimed.get("creds_enc"))
|
||||
if creds:
|
||||
return creds
|
||||
if settings.worker_use_test_creds:
|
||||
return load_test_credentials()
|
||||
return None
|
||||
|
||||
|
||||
def _creds_from_account(conn, account_id: int) -> dict | None:
|
||||
"""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
|
||||
sau upload web fara creds), worker-ul re-citeste din cont si poate re-login oricand.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (account_id,)
|
||||
).fetchone()
|
||||
if row and row["rar_creds_enc"]:
|
||||
return decrypt_creds(row["rar_creds_enc"])
|
||||
return None
|
||||
|
||||
|
||||
def _keepalive_target(conn, settings: Settings) -> tuple[int | None, dict | None]:
|
||||
"""Un cont cu creds durabile pentru login-ul de proba (sau creds <test> in dev).
|
||||
|
||||
Sare conturile ale caror creds NU se decripteaza sub cheia curenta — in dev
|
||||
`start.sh both` genereaza o cheie efemera noua la fiecare pornire, deci creds-urile
|
||||
durabile criptate sub cheia veche dau decrypt -> None. Fallback la creds <test>.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT id, rar_creds_enc FROM accounts "
|
||||
"WHERE rar_creds_enc IS NOT NULL ORDER BY id"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
creds = decrypt_creds(row["rar_creds_enc"])
|
||||
if creds and creds.get("email") and creds.get("password"):
|
||||
return row["id"], creds
|
||||
if settings.worker_use_test_creds:
|
||||
return DEFAULT_ACCOUNT_ID, load_test_credentials()
|
||||
return None, None
|
||||
|
||||
|
||||
def _maybe_keepalive(conn, settings: Settings, sessions: "AccountSessions", state: dict) -> None:
|
||||
"""Login de proba periodic cand coada e goala — verifica reachability RAR si
|
||||
pastreaza last_rar_login_ok proaspat ca dashboard-ul sa nu afiseze fals
|
||||
'RAR inaccesibil' doar din lipsa de trafic.
|
||||
|
||||
Sondeaza la cel mult o data pe interval (si pe succes, si pe esec): pe succes
|
||||
heartbeat-ul se reimprospateaza singur; pe esec real (RAR jos) last_rar_login_ok
|
||||
ramane vechi -> dashboard-ul degradeaza corect. Forteaza login real (invalideaza
|
||||
sesiunea cache-uita) ca proba sa fie autentica, nu un token vechi din cache.
|
||||
"""
|
||||
interval = settings.worker_rar_keepalive_interval_s
|
||||
if interval <= 0:
|
||||
return
|
||||
hb = read_heartbeat(conn)
|
||||
last = hb["last_rar_login_ok"] if hb else None
|
||||
if last:
|
||||
try:
|
||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds()
|
||||
if age < interval:
|
||||
return # login inca proaspat — nimic de facut
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
now_ts = time.time()
|
||||
if now_ts - state["last_attempt"] < interval:
|
||||
return # deja am incercat recent (nu hartui RAR daca e jos)
|
||||
state["last_attempt"] = now_ts
|
||||
|
||||
account_id, creds = _keepalive_target(conn, settings)
|
||||
if account_id is None or not creds:
|
||||
return # niciun cont cu creds durabile — nimic de sondat
|
||||
sessions.invalidate(account_id) # forteaza login real, nu token din cache
|
||||
try:
|
||||
sessions.get_token(conn, account_id, creds) # reimprospateaza last_rar_login_ok la succes
|
||||
except RarAuthError:
|
||||
pass # creds invalide — deja logat in get_token (WARNING)
|
||||
except Exception as exc:
|
||||
# RAR indisponibil: last_rar_login_ok ramane vechi (corect). Nu propaga.
|
||||
log_event("rar_keepalive", nivel="WARNING", account_id=account_id,
|
||||
mesaj=f"keepalive RAR esuat (cont {account_id}): {type(exc).__name__}",
|
||||
context={"rezultat": "esuat"}, conn=conn, sursa="worker")
|
||||
|
||||
|
||||
def run() -> int:
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
|
||||
settings = get_settings()
|
||||
set_source("worker") # evenimentele worker-ului au sursa=worker (fisier app-worker.log)
|
||||
init_db()
|
||||
conn = get_connection()
|
||||
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True)
|
||||
|
||||
sessions = AccountSessions(settings)
|
||||
_last_purge_time: float = 0.0
|
||||
_keepalive_state = {"last_attempt": 0.0}
|
||||
|
||||
while _running:
|
||||
try:
|
||||
write_heartbeat(conn, detail=f"poll (queue={_queue_depth(conn)})")
|
||||
|
||||
# Purjare periodica (odata pe ora) — NU mai frecvent.
|
||||
now_ts = time.time()
|
||||
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
|
||||
stats = purge_expired(conn)
|
||||
if stats["submissions_purged"] or stats["batches_purged"] or stats["events_purged"]:
|
||||
print(
|
||||
f"[worker] purjare: {stats['submissions_purged']} submissions, "
|
||||
f"{stats['batches_purged']} batches, {stats['events_purged']} evenimente sterse",
|
||||
flush=True,
|
||||
)
|
||||
_last_purge_time = now_ts
|
||||
|
||||
if not settings.worker_send_enabled:
|
||||
time.sleep(settings.worker_poll_interval_s)
|
||||
continue
|
||||
|
||||
claimed = claim_one(conn)
|
||||
if claimed is None:
|
||||
# Nimic de trimis: recupereaza orfanii conturilor deja logate.
|
||||
for acct, rar, tok in sessions.active():
|
||||
recover_orphans(conn, settings, rar, tok, account_id=acct)
|
||||
# Login de proba periodic ca dashboard-ul sa nu afiseze fals
|
||||
# "RAR inaccesibil" din lipsa de trafic (vezi _maybe_keepalive).
|
||||
_maybe_keepalive(conn, settings, sessions, _keepalive_state)
|
||||
time.sleep(settings.worker_poll_interval_s)
|
||||
continue
|
||||
|
||||
sid = claimed["id"]
|
||||
account_id = claimed["account_id"]
|
||||
# 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.
|
||||
creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id)
|
||||
|
||||
try:
|
||||
token = sessions.get_token(conn, account_id, creds)
|
||||
except RarAuthError as exc:
|
||||
# Creds gresite (login 401): NU se face retry.
|
||||
mark(conn, sid, "error", rar_status_code=401,
|
||||
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
|
||||
|
||||
if token is None:
|
||||
# Fara creds disponibile (token pierdut la restart + creds sterse).
|
||||
# Re-pune in coada cu backoff; ROAAUTO re-trimite creds proaspete.
|
||||
requeue_with_backoff(conn, settings, sid, reason="creds RAR indisponibile (astept re-trimitere)")
|
||||
continue
|
||||
|
||||
rar = sessions.rar(account_id)
|
||||
# Recupereaza orfanii contului inainte de trimitere (acelasi token).
|
||||
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:
|
||||
process_one(conn, settings, rar, token, claimed)
|
||||
except RarAuthError as exc:
|
||||
# Token expirat mid-sesiune: invalideaza sesiunea, re-pune randul.
|
||||
print(f"[worker] cont {account_id} token expirat: {exc}; re-login data viitoare", flush=True)
|
||||
sessions.invalidate(account_id)
|
||||
requeue_with_backoff(conn, settings, sid, reason="token RAR expirat")
|
||||
|
||||
except Exception as exc: # noqa: BLE001 — loop top-level: o eroare punctuala nu opreste worker-ul
|
||||
print(f"[worker] eroare neasteptata: {exc}", flush=True)
|
||||
time.sleep(settings.worker_poll_interval_s)
|
||||
|
||||
sessions.close_all()
|
||||
conn.close()
|
||||
print("[worker] oprit curat", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run())
|
||||
|
||||
48
app/worker/healthcheck.py
Normal file
48
app/worker/healthcheck.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Liveness probe pentru worker (T6) — folosit de healthcheck-ul Docker.
|
||||
|
||||
Worker-ul nu e server HTTP, deci `restart: always` prinde doar procesul MORT,
|
||||
nu si worker-ul AGATAT (proces viu care nu mai bate heartbeat). Acest probe
|
||||
citeste `worker_heartbeat` din DB si pica daca ultimul beat e mai vechi decat
|
||||
`worker_heartbeat_stale_s` -> Docker restarteaza containerul worker.
|
||||
|
||||
Utilizare (compose healthcheck): python -m app.worker.healthcheck
|
||||
Exit 0 = sanatos, 1 = invechit/lipsa.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..config import Settings, get_settings
|
||||
from ..db import get_connection, read_heartbeat
|
||||
|
||||
|
||||
def worker_healthy(conn, settings: Settings, *, now: datetime | None = None) -> bool:
|
||||
"""True daca ultimul heartbeat e mai proaspat decat pragul de invechire."""
|
||||
hb = read_heartbeat(conn)
|
||||
if hb is None or not hb["last_beat"]:
|
||||
return False
|
||||
try:
|
||||
last = datetime.fromisoformat(hb["last_beat"])
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
now = now or datetime.now(timezone.utc)
|
||||
return (now - last).total_seconds() <= settings.worker_heartbeat_stale_s
|
||||
|
||||
|
||||
def main() -> int:
|
||||
settings = get_settings()
|
||||
conn = get_connection()
|
||||
try:
|
||||
ok = worker_healthy(conn, settings)
|
||||
finally:
|
||||
conn.close()
|
||||
if not ok:
|
||||
print("[healthcheck] worker invechit sau nepornit", flush=True)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,19 +1,30 @@
|
||||
# Gateway RAR AUTOPASS — un container API + un container worker, acelasi image,
|
||||
# acelasi volum SQLite persistent (plan.md sect. 4 + 9). restart: always pe ambele.
|
||||
#
|
||||
# CRITIC: AUTOPASS_CREDS_KEY trebuie PARTAJATA intre api si worker — API cripteaza
|
||||
# creds-urile RAR, worker-ul le decripteaza. Chei diferite -> worker nu poate
|
||||
# decripta -> submission-uri blocate "creds indisponibile". Seteaz-o in .env
|
||||
# (vezi .env.example): compose o citeste automat. Lipsa -> compose pica explicit.
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8010
|
||||
volumes:
|
||||
- autopass-data:/data
|
||||
environment:
|
||||
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_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false}
|
||||
# Embeddings (sugestie mapare, Stratul 2): prima cerere /mapari lazy-load-eaza
|
||||
# modelul ~230MB. Doar API-ul il incarca (worker-ul nu). Default off.
|
||||
AUTOPASS_EMBEDDINGS_ENABLED: ${AUTOPASS_EMBEDDINGS_ENABLED:-false}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/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)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -25,12 +36,37 @@ services:
|
||||
- autopass-data:/data
|
||||
environment:
|
||||
AUTOPASS_DB_PATH: /data/autopass.db
|
||||
AUTOPASS_RAR_ENV: test
|
||||
# Send dezactivat by default; activeaza pentru proba end-to-end.
|
||||
AUTOPASS_WORKER_SEND_ENABLED: "false"
|
||||
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-test}
|
||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||
# Send activ by default (prod); pe staging seteaza AUTOPASS_WORKER_SEND_ENABLED=false
|
||||
# in Dokploy ca worker-ul sa NU trimita declaratii reale la RAR (Legea 142/2023).
|
||||
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:-true}
|
||||
restart: always
|
||||
depends_on:
|
||||
- api
|
||||
# T6: probe pe heartbeat-ul din DB — prinde worker-ul AGATAT (proces viu, beat
|
||||
# invechit), pe care restart:always singur nu-l vede. start_period acopera bootul.
|
||||
# ATENTIE: in compose simplu, "unhealthy" doar marcheaza containerul — NU il
|
||||
# restarteaza (restart:always reactioneaza la EXIT). Sidecar-ul `autoheal` de
|
||||
# mai jos vede label-ul si chiar restarteaza worker-ul cand pica probe-ul.
|
||||
labels:
|
||||
autoheal: "true"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-m", "app.worker.healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Restarteaza orice container marcat unhealthy cu label autoheal=true (worker-ul
|
||||
# agatat). Alternativa: Docker Swarm (restart on unhealthy nativ).
|
||||
autoheal:
|
||||
image: willfarrell/autoheal:latest
|
||||
restart: always
|
||||
environment:
|
||||
AUTOHEAL_CONTAINER_LABEL: autoheal
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
volumes:
|
||||
autopass-data:
|
||||
|
||||
120
docs/CONTEXT.md
120
docs/CONTEXT.md
@@ -1,120 +0,0 @@
|
||||
# Context proiect — Gateway RAR AutoPass (migrare ROAAUTO din VFP în Web API)
|
||||
|
||||
> Fișier de continuitate între sesiuni. Citește-l înainte de a relua lucrul.
|
||||
> Ultima actualizare: 2026-06-15.
|
||||
>
|
||||
> ⚠️ **SURSA DE ADEVĂR pentru contractul RAR = `docs/api-rar-contract.md`** (verificat live).
|
||||
> Acolo unde planurile (`docs/plans/*`) diferă, contractul are dreptate. Vezi „Corecții față de planuri".
|
||||
|
||||
## Reluare pe alt calculator (portabil)
|
||||
|
||||
Tot ce ai nevoie pentru a continua e în acest repo (acest fișier + `docs/plans/`).
|
||||
|
||||
```
|
||||
git clone git@gitea.romfast.ro:romfast/rar-autopass.git
|
||||
```
|
||||
|
||||
Remote-ul Gitea (org `romfast`) merge prin SSH: `git@gitea.romfast.ro:romfast/<repo>.git`.
|
||||
Push-to-create e activ (un `git push -u origin main` creează repo-ul automat).
|
||||
După clonare: copiază `settings.xml.example` → `settings.xml` și completează credențialele
|
||||
(NU se comite). Apoi citește mai jos.
|
||||
|
||||
## Ce este acest repo
|
||||
|
||||
Arhiva **bazei Visual FoxPro** existente (clasa `RarAutoPass`, ROAAUTO) care declară
|
||||
prestațiile de service la **RAR AUTOPASS** (Legea 142/2023, OM 210/2024), **plus**
|
||||
planurile pentru rescrierea ca **Web API central (Python / FastAPI)**.
|
||||
|
||||
Codul VFP de aici este **punctul de plecare / sursa de adevăr de contract** pentru
|
||||
versiunea web. Nu se mai dezvoltă; se portează.
|
||||
|
||||
## Stare actuală (iunie 2026)
|
||||
|
||||
- Integrarea VFP **funcționează** și e **testată pe endpoint-ul de test RAR**, dar
|
||||
**nu e pusă la clienți** încă.
|
||||
- Comunică direct cu RAR prin `MSXML2.ServerXMLHTTP` din `rar_autopass.prg` / `rar-forms.prg`.
|
||||
- Maparea operație→`codPrestatie` în `mapare_prestatii.DBF`; nomenclator în `prestatii_rar.DBF`;
|
||||
jurnal în `rar_log.DBF`; credențiale (în clar) în `settings.xml`.
|
||||
- ⚠️ `settings.xml` conținea o **parolă de test reală** (`marius.mutu@romfast.ro`).
|
||||
E **exclus din git** (`.gitignore`) și înlocuit cu `settings.xml.example`.
|
||||
**De rotit parola** — a fost expusă în istoricul SVN vechi.
|
||||
|
||||
## Fișiere-cheie (VFP) și ce reutilizăm
|
||||
|
||||
| Fișier | Rol | Se portează în |
|
||||
|---|---|---|
|
||||
| `rar_autopass.prg` | clasa `RarAutoPass`: login+JWT, nomenclator, postPrezentare, cancel | `app/rar_client.py` |
|
||||
| `rar-forms.prg` | UI + timer auto-process (`OnAutoProcessTimer`) | logica → worker; timer → re-push ROAAUTO |
|
||||
| `export_comenzi.prg` | citește comenzi/operații, construiește payload | client subțire: `POST /v1/prezentari` |
|
||||
| `rar_advanced.prg` | export Excel (oglindă pentru treapta 2) | referință import xlsx/csv |
|
||||
| `mapare_prestatii.DBF` | cod_op_service → codPrestatie | `operations_mapping` (via `tools/import_dbf.py`) |
|
||||
| `prestatii_rar.DBF` | nomenclator {codPrestatie, numePrestatie} | `nomenclator_rar` (via `tools/import_dbf.py`) |
|
||||
| `Documentatie Serviciu AutoPass_Final.txt`, `Document informativ RAR- Autopass.txt` | spec oficial RAR (vechi, are typo-uri) | înlocuit de `docs/api-rar-contract.md` |
|
||||
| `docs/api-rar-documentatie-oficiala.md` | răspuns oficial programatori RAR | sintetizat în `docs/api-rar-contract.md` |
|
||||
| `docs/api-rar-contract.md` | **contract verificat live — sursa de adevăr** | referință pentru `app/` |
|
||||
|
||||
## Planul (în `docs/plans/`)
|
||||
|
||||
**`plan.md`** — **planul unic executabil** (sursă unică). Consolidează designul de produs +
|
||||
implementarea, aliniat la contractul verificat live. Conține: arhitectură, reguli contract,
|
||||
validare, mașina de stări, componente, securitate, failure modes, **Roadmap de execuție (T1-T7 +
|
||||
pașii rămași)**, verificare E2E, NOT in scope, decizii blocate, și anexa de produs/SaaS.
|
||||
|
||||
> Continuă cu **`docs/plans/plan.md`** → secțiunea „Roadmap de execuție". Pasul blocant următor = **T1**
|
||||
> (un `postPrezentare` real pe test). Fostele `plan-eng-review.md` + `plan-design-review.md` au fost
|
||||
> consolidate în `plan.md` (review-urile eng + design au intervenit pe el).
|
||||
|
||||
## Arhitectura țintă (rezumat)
|
||||
|
||||
```
|
||||
ROAAUTO (VFP, client subțire) ──HTTPS──▶ Gateway FastAPI (central, 1 container)
|
||||
trimite comanda + creds RAR API: validare → mapare op→cod → enqueue (PII criptat)
|
||||
◀── {submissionId, status} ─────────────┘
|
||||
WORKER (proces separat): claim atomic → login RAR → postPrezentare → retry
|
||||
Dashboard (Jinja2+HTMX): monitorizare live din RAR + stare coadă + editor mapări
|
||||
ROAAUTO (timer) ──▶ GET /v1/prezentari?status=error → re-push (durabilitate pene lungi)
|
||||
```
|
||||
|
||||
Stack: Python/FastAPI + SQLite (WAL) + httpx. Deploy: LXC Proxmox + Cloudflare Tunnel (start) → VPS (~5€/lună).
|
||||
Open-source pe github.com/romfast, AGPL-3.0 (⚠️ decide CLA din ziua 1 dacă vrei dual-license).
|
||||
|
||||
## „The Assignment" (spike) — REZOLVAT în mare parte (2026-06-15)
|
||||
|
||||
Detalii complete în `docs/api-rar-contract.md`. Rezumat:
|
||||
1. **JWT TTL = 108000s = 30 ORE** (nu „scurt"). → worker-ul singur poate relua peste pene lungi;
|
||||
re-push ROAAUTO devine secundar. Reconsideră arhitectura de robustețe din planuri.
|
||||
2. **`b64Image` (poza) = OPȚIONALĂ** (confirmat oficial). Open question „sursa pozei" închisă.
|
||||
3. **`tipPrestatie` = generat de server** (`GENERIC`), nu se trimite. `sistemReparat` se trimite
|
||||
(poate fi `"null"`); valorile reale rămân de probat.
|
||||
4. **`needs_data` determinist:** `odometruInitial` obligatoriu doar dacă `prestatii` conține
|
||||
`R-ODO` sau `I-ODO`.
|
||||
|
||||
Rămas: **un singur `postPrezentare` real pe test** (mesaje de eroare exacte + `data.id`). Vezi contract.
|
||||
|
||||
## De făcut după spike (din `plan.md`, Roadmap de execuție + Verificare)
|
||||
|
||||
1. `tools/import_dbf.py --dry-run` pe `mapare_prestatii.DBF` + `prestatii_rar.DBF` (raport întâi, apoi import).
|
||||
2. Schelet repo: `app/api/v1`, `app/rar_client.py`, `app/worker`, `app/web`, SQLite (WAL), `docker compose up`, `/healthz` verde.
|
||||
3. `POST /v1/prezentari` cu o comandă reală (test) → worker trimite → `FINALIZATA` la RAR + în dashboard.
|
||||
4. Test idempotency (re-trimitere identică → același `submissionId`, fără dublu la RAR).
|
||||
5. `needs_mapping` / `needs_data` (nu se trimite incomplet); `error` + re-push.
|
||||
6. Verifică: SQLite fără câmp parolă; după `sent` PII criptat + `purge_after`; loguri fără parole.
|
||||
7. Teste: unit (mapare, hash idempotency, validare odometru), integration (claim atomic, retry), E2E test RAR.
|
||||
|
||||
## Decizii deja blocate (nu le re-deschide fără motiv)
|
||||
|
||||
- Idempotency = **hash de conținut pe server**, UNIQUE (RAR n-are câmp nr. comandă, acceptă duplicate).
|
||||
- **Reținere temporară 90 zile** a payload-ului **criptat**, apoi purjare (defensibilitate vs privacy).
|
||||
- Odometru repair: **strict + stare `needs_data`** (nu trimite incomplet).
|
||||
- Cherry-picks în v1: alertă submission-uri blocate, `/healthz`+`/metrics`, sugestie fuzzy mapare, export audit CSV.
|
||||
- URL-urile RAR: **sursa de adevăr = VFP testat**, NU spec-ul (are typo-uri de copy/paste).
|
||||
|
||||
## Open questions rămase (actualizat 2026-06-15)
|
||||
|
||||
1. ~~Sursa pozei odometrului~~ — **închis** (poză opțională, vezi contract).
|
||||
2. ~~`tipPrestatie` valori~~ — **închis pentru request** (generat de server). Rămâne: ce valori
|
||||
reale acceptă `sistemReparat` (în afară de `"null"`).
|
||||
3. Un singur user RAR per agent economic sau mai mulți (afectează `idUser`/`idAgent` / filtrare monitorizare).
|
||||
4. Monetizare/direcție SaaS — de reluat după ce prima prezentare reală merge la primul client.
|
||||
5. Anulare/corecție: **nu există flux API** (records `FINALIZATA`); corecția = email suport RAR. De
|
||||
reflectat în UX dashboard (nu promite anulare).
|
||||
299
docs/ROADMAP.md
Normal file
299
docs/ROADMAP.md
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
# Contract RAR AUTOPASS — sursa de adevăr (verificat live)
|
||||
|
||||
> **Acesta este documentul autoritativ pentru contractul API RAR AUTOPASS.**
|
||||
> Înlocuiește presupunerile din `docs/plans/*` acolo unde diferă. Dacă un plan
|
||||
> Înlocuiește presupunerile din planurile vechi acolo unde diferă. Dacă un plan
|
||||
> contrazice acest fișier, **acest fișier are dreptate**.
|
||||
>
|
||||
> Surse:
|
||||
@@ -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
|
||||
(stare `needs_data`) ca să 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)
|
||||
|
||||
| cod | nume |
|
||||
@@ -211,14 +341,36 @@ Aplicate deja pe ambele medii (test + producție):
|
||||
- Corecția datelor eronate (după FINALIZATA) = solicitare la **suport.autopass@rarom.ro**
|
||||
(pe test nu e cazul). **Nu există flux API de corecție/anulare pentru records-urile noastre.**
|
||||
|
||||
## Monitorizare (citire prezentări)
|
||||
## Monitorizare (citire prezentări) — VERIFICAT LIVE 2026-06-15
|
||||
|
||||
- Pe **mediul de test**: la interogarea listei de prezentări finalizate **NU primești și `prestatii`** în răspuns.
|
||||
- Pe **producție**: prestațiile sunt disponibile; lista poate fi filtrată după keyword / interval
|
||||
de date și exportată în Excel.
|
||||
- Implicație dashboard: nu te baza pe `prestatii` din listă pe test; le ai în `submissions` local.
|
||||
**Rută:** `GET /prezentari/getAllPrezentariFinalizate` (Bearer). Confirmat live.
|
||||
**Răspuns:** `{statusCode, message, data: {totalCount, content: [...]}}` — listă în `data.content`.
|
||||
|
||||
## Corecții față de `docs/plans/*` (citește înainte de a refolosi planurile)
|
||||
Fiecare item din `content` (live):
|
||||
```json
|
||||
{
|
||||
"id": 68514, "dataPrestatie": "2026-06-15", "vin": "WVWZZZ1KZAW000123",
|
||||
"odometruFinal": 123456, "idAgent": 40, "tipPrestatie": null,
|
||||
"odometruInitial": null, "idUser": 6766, "sistemReparat": null, "obs": "...",
|
||||
"nrInmatriculare": "B999TST", "listaPrestatii": null, "status": "FINALIZATA",
|
||||
"prestatii": null, "b64Image": null
|
||||
}
|
||||
```
|
||||
|
||||
- **`odometruFinal` e NUMĂR** (int) în listare (deși la `postPrezentare` se trimite string). Reconcilierea
|
||||
compară ca int.
|
||||
- Pe **test**: `prestatii` vine `null` (confirmă: nu te baza pe `prestatii` din listă — le ai local în `submissions`).
|
||||
- **Filtrele NU funcționează pe test**: `?vin=`, `?search=`, `?keyword=`, `?dataPrestatie=` sunt IGNORATE
|
||||
(întorc tot setul). `?page=&size=` rup răspunsul (non-JSON). → **fetch tot setul, filtrează client-side.**
|
||||
Pe prod doc-ul promite filtrare keyword/dată + export Excel (de re-verificat pe prod).
|
||||
- **RAR acceptă DUPLICATE**: live există 2 perechi de records identice pe `vin+dataPrestatie+odometruFinal`
|
||||
(id 63622≡63625, 63623≡63626). De aceea reconcilierea pe răspuns pierdut e necesară, iar matcher-ul
|
||||
alege **id-ul maxim** când există mai multe potriviri.
|
||||
|
||||
> Reconciliere (T2): înainte de re-send pe un rând `sending`, GET finalizate, match pe
|
||||
> `vin + dataPrestatie + odometruFinal(int)`; dacă există → marchează `sent` cu id-ul găsit, NU re-trimite.
|
||||
|
||||
## Corecții față de planurile inițiale (context istoric)
|
||||
|
||||
1. **JWT „scurt" → de fapt 30 de ORE.** Planurile (`plan-design-review` §„Gestiunea credențialelor",
|
||||
`plan-eng-review` §worker) presupun JWT scurt și mută durabilitatea pe re-push din ROAAUTO
|
||||
@@ -243,6 +395,86 @@ Aplicate deja pe ambele medii (test + producție):
|
||||
7. **URL nomenclator confirmat** = `/nomenclator/getNomenclatorPrestatii` (nu varianta din
|
||||
operationId Swagger). Constatarea #5 din `plan-eng-review` (URL-uri din VFP, nu din spec) — confirmată.
|
||||
|
||||
## API gateway (ROAAUTO -> gateway): mapare operatii (hibrid, 2026-06-15)
|
||||
|
||||
Aceasta e suprafata **gateway-ului**, nu RAR. Un item din `prestatii` la
|
||||
`POST /v1/prezentari` poate veni in doua forme (cel putin una obligatorie):
|
||||
|
||||
| Camp item | Note |
|
||||
|---|---|
|
||||
| `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`. |
|
||||
| `denumire` | denumirea operatiei ROAAUTO; folosita pentru fuzzy lookup in editor. |
|
||||
|
||||
Daca lipsesc **ambele** coduri -> `422` (shape). Op cu `cod_op_service` necunoscut
|
||||
(fara mapare) -> submission `needs_mapping` (NU se trimite la RAR), apare in editorul
|
||||
web. La salvarea maparii, submission-urile blocate pe acel cod se re-rezolva automat
|
||||
(-> `queued`, sau `needs_data` daca regula odometru/continut cere asta). Codul RAR
|
||||
rezolvat se scrie inapoi in `payload_json`, deci payload builder + worker raman
|
||||
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:
|
||||
- `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).
|
||||
- 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).
|
||||
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.
|
||||
Auth API-key (CORE) inca neimplementat -> `account_id` curge ca `NULL` si e atribuit
|
||||
contului default `id=1` (seed in schema); cand auth livreaza, account_id real curge natural.
|
||||
|
||||
## Regula de scope pe cont (B8, PRD 3.2)
|
||||
|
||||
Orice GET nou pe `/v1/*` care atinge `submissions` sau `operations_mapping` **PORNESTE**
|
||||
cu `account_id: int = Depends(resolve_account_id)` si clauza de scope pe cont in SQL.
|
||||
Varianta globala (fara scope) e exceptie justificata explicit — singurul exemplu actual
|
||||
este `GET /v1/nomenclator` (cache de referinta RAR fara PII, partajat intre conturi).
|
||||
|
||||
Pentru `submissions` (account_id nullable): foloseste `account_scope_clause(account_id)`
|
||||
din `app/mapping.py` care produce `(account_id = ? OR (account_id IS NULL AND ? = 1))`.
|
||||
Randurile legacy cu `account_id IS NULL` apartin contului 1 (OV-2, back-compat).
|
||||
|
||||
Pentru `operations_mapping` (account_id NOT NULL): `WHERE account_id = ?` simplu.
|
||||
|
||||
## Open questions rămase (actualizat)
|
||||
|
||||
1. ~~Sursa pozei odometrului~~ — **închis** (poză opțională).
|
||||
@@ -262,5 +494,18 @@ Record de test creat: `data.id = 68514` (FINALIZATA, permanent pe test). Confirm
|
||||
- header `User-Agent` obligatoriu (altfel 403 WAF).
|
||||
|
||||
Rămas neprobat: ce alte valori `sistemReparat` (în afară de `"null"`) acceptă (Open Q #2).
|
||||
|
||||
## Note integrare — planuri de cont (PRD 5.17)
|
||||
|
||||
**Poți dezvolta și testa pe planul Gratuit** fără niciun upgrade — `POST /v1/prezentari/valideaza`
|
||||
(dry-run) e permis pe orice plan, nu face enqueue și nu consumă cotă lunară. Primești același
|
||||
răspuns de validare (câmpuri, cod_prestatie, rezolvare operație) ca la trimiterea reală.
|
||||
|
||||
**Trimiterea reală cere planul Pro** (sau trial Pro activ): rutele `POST /v1/prezentari`,
|
||||
`POST /v1/import` și `POST /v1/import/{id}/commit` sunt gate-uite pe `api_access=True`
|
||||
(Pro/Premium). Un cont Free/Standard primește `403 PLAN_FARA_API`. Contactează-ne pentru upgrade.
|
||||
|
||||
Planul Gratuit are limită de **60 prezentări/lună** (indiferent de canal). La depășire: `422 PLAN_LIMITA_LUNARA`.
|
||||
Planul Pro nu are limită de volum. `GET /v1/nomenclator` rămâne public pe orice plan (exploatare pre-upgrade).
|
||||
</content>
|
||||
</invoke>
|
||||
|
||||
233
docs/design.md
Normal file
233
docs/design.md
Normal 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 `14–16px`. Gap intre controale pe o linie `8–12px`.
|
||||
|
||||
### 2.4 Radius
|
||||
|
||||
| Valoare | Uz |
|
||||
|---------|-----|
|
||||
| `6px` | controale: butoane, input, select |
|
||||
| `7–8px` | 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) |
|
||||
| `768–1024px` | tableta | **card-uri** pentru tabelele actionabile (Trimiteri, Preview, Mapari), UN card pe rand (nu 2/rand); tabelele dense read-only raman cu scroll contained |
|
||||
| `< 768px` | mobil | un card pe rand, o coloana, tinte touch 44px |
|
||||
|
||||
CSS custom properties NU functioneaza in `@media`; pragul se scrie literal
|
||||
(`@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 8–10px,
|
||||
stivuite vertical, gap intern 7–8px.
|
||||
|
||||
### 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 12–13px 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
112
docs/landing-page-prompt.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Prompt landing page — claude.ai/design
|
||||
|
||||
Referinta pentru generarea unui mockup de landing page comerciala (homepage public) a
|
||||
produsului. De copiat ca atare in claude.ai/design. Tokenii de culoare/tipografie/control sunt
|
||||
preluati din `design.md` (sursa de adevar a sistemului de design) — orice modificare la paleta
|
||||
sau scara de control se face intai acolo, apoi se reflecta aici.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
Creează un mockup pentru o LANDING PAGE comercială (homepage public) a unui produs SaaS B2B românesc.
|
||||
|
||||
== PRODUS ==
|
||||
Nume: Gateway RAR AUTOPASS (de la ROMFAST).
|
||||
Ce face: un serviciu web prin care atelierele service-auto din România își declară automat
|
||||
prestațiile la sistemul RAR AUTOPASS — o obligație legală (Legea 142/2023, OM 210/2024).
|
||||
Înlocuiește raportarea manuală. Publicul țintă: administratori și recepționeri de service auto,
|
||||
oameni ne-tehnici, ocupați. Mesajul cheie: "Conformitate RAR fără bătaie de cap — încarci un
|
||||
fișier sau conectezi softul de service, noi trimitem la RAR în siguranță."
|
||||
|
||||
Tonul: serios, de încredere, instituțional dar modern. NU startup glumeț, NU emoji.
|
||||
Limba: română (cu diacritice în text vizibil). Fără emoji nicăieri.
|
||||
|
||||
== OBIECTIV PAGINĂ ==
|
||||
Convinge un service auto că produsul rezolvă obligația legală RAR simplu și sigur, și să se
|
||||
înregistreze. Acțiune dominantă: "Creează cont" / "Autentificare".
|
||||
Cârlig comercial principal: GRATUIT pentru service-urile mici, până la 100 de prezentări pe lună.
|
||||
|
||||
== SISTEM DE CULORI (folosește exact aceste valori — temă întunecată ca implicit) ==
|
||||
Fundal pagină: #0f1218
|
||||
Suprafață card: #181c24
|
||||
Text principal: #e6e9ef
|
||||
Text secundar: #8b93a7
|
||||
Borduri/linii: #262b36
|
||||
Accent (CTA/link): #2E74D6 (azur ROMFAST)
|
||||
Succes: #2FBF8F
|
||||
Atenție: #E0A93B
|
||||
Eroare: #E05D5D
|
||||
Oferă și o variantă pe temă LIGHT: fundal #f5f7fa, card #ffffff, text #1a1d24,
|
||||
text secundar #5c6473, linii #e2e5ea, accent #1F66C9.
|
||||
|
||||
== TIPOGRAFIE ==
|
||||
Font UI: IBM Plex Sans (weights 400/500/700).
|
||||
Font mono (pentru coduri, VIN, ID-uri tehnice dacă apar): IBM Plex Mono.
|
||||
Titlu hero mare și greu (700, letter-spacing ușor negativ). Corp 15–16px, secundar 12–13px.
|
||||
|
||||
== STIL CONTROALE ==
|
||||
Butoane: radius 6px, padding ~10px 18px, înălțime ~44px.
|
||||
- Primar: fundal #2E74D6, text alb, hover ușor mai luminos.
|
||||
- Secundar: transparent, text deschis, bordură #262b36.
|
||||
Carduri: fundal #181c24, bordură 1px #262b36, radius 10px, plat (umbră doar la elemente plutitoare).
|
||||
Pill/badge: radius 99px. Iconițe simple liniare, monocrome, NU ilustrații colorate gen 3D.
|
||||
Aspect plat, sobru, mult spațiu de respirație. Densitate compactă dar aerisită.
|
||||
|
||||
== STRUCTURĂ PAGINĂ (de sus în jos) ==
|
||||
1. HEADER fix: stânga logo "ROMFAST" (text wordmark, accent azur pe "FAST"); dreapta două
|
||||
butoane: "Autentificare" (secundar) + "Creează cont" (primar). Comutator temă opțional.
|
||||
|
||||
2. HERO: titlu puternic (ex. "Declară prestațiile la RAR AUTOPASS, automat"), subtitlu de o frază
|
||||
despre conformitate legală fără efort. Sub titlu, un badge/pill verde vizibil:
|
||||
"Gratuit până la 100 de prezentări pe lună". Apoi un buton primar mare "Creează cont gratuit"
|
||||
+ un buton secundar "Vezi cum funcționează". Sub butoane, o linie mică de încredere
|
||||
(ex. "Conform Legii 142/2023 · datele tale criptate · fără card bancar"). În dreapta hero:
|
||||
un mockup vizual al dashboardului aplicației (un card cu o listă de "Trimiteri" cu pill-uri de
|
||||
stare colorate: Trimis=verde, În coadă=albastru, Eroare=roșu).
|
||||
|
||||
3. BANDĂ DE ÎNCREDERE: "Construit de ROMFAST" + mențiune că înlocuiește integrarea/raportarea manuală.
|
||||
|
||||
4. SECȚIUNE "Cum funcționează" — 3 pași cu iconițe liniare:
|
||||
(1) Încarci fișierul (xlsx/csv) sau conectezi softul de service prin API,
|
||||
(2) Verifici și mapezi coloanele o singură dată (le ținem minte pentru data viitoare),
|
||||
(3) Trimitem automat la RAR, tu urmărești starea live.
|
||||
|
||||
5. SECȚIUNE BENEFICII — de ce merită această interfață (6 carduri scurte, fiecare cu iconiță liniară):
|
||||
- "Zero raportare manuală" — încarci un fișier, gata; nu mai introduci prezentări una câte una în portalul RAR.
|
||||
- "Mapare reținută" — potrivești coloanele o singură dată per format; fișierele următoare se completează singure.
|
||||
- "Anti-duplicat" — verificare automată ca aceeași prezentare să nu ajungă de două ori la RAR (RAR nu permite anulare).
|
||||
- "Validare înainte de trimitere" — erorile (VIN, cod prestație, kilometraj) sunt prinse și explicate înainte să meargă la RAR.
|
||||
- "Date criptate (GDPR)" — datele cu caracter personal sunt criptate și șterse automat după perioada legală.
|
||||
- "Stare live" — vezi în timp real ce s-a trimis, ce e în coadă și ce trebuie corectat.
|
||||
|
||||
6. SECȚIUNE INTEGRARE API (pentru service-uri cu soft propriu / ROAAUTO):
|
||||
Card mai mare, pe două coloane. Stânga: titlu "Ai deja un soft de service? Conectează-l direct."
|
||||
text scurt despre faptul că nu mai e nevoie de export manual — softul tău trimite prezentările
|
||||
automat printr-un singur apel API, cu cheie API per cont. Dreapta: un mic bloc de cod cu font
|
||||
mono (IBM Plex Mono) care arată un exemplu de request, ex.:
|
||||
POST /v1/prezentari
|
||||
Authorization: rfak_••••••••
|
||||
{ "vin": "...", "cod_prestatie": "...", "odometru": ... }
|
||||
Sub el un link/buton secundar "Vezi documentația API".
|
||||
|
||||
7. SECȚIUNE PREȚ — simplă, 2 planuri unul lângă altul:
|
||||
- "Gratuit": "0 lei/lună · până la 100 de prezentări/lună · import web + API · toate funcțiile de bază".
|
||||
Marcat ca recomandat pentru service-uri mici. Buton primar "Începe gratuit".
|
||||
- "Volum mare": "Pentru service-uri cu peste 100 de prezentări/lună · contactează-ne".
|
||||
Buton secundar "Contact".
|
||||
Accent pe ideea că pentru un service mic nu costă nimic, fără card bancar la înscriere.
|
||||
|
||||
8. CTA FINAL: card mare centrat "Începe să declari la RAR în câteva minute" + buton primar "Creează cont gratuit".
|
||||
|
||||
9. FOOTER: linkuri (Termeni, Confidențialitate/GDPR, Documentație API, Contact), © ROMFAST, mențiune legală.
|
||||
|
||||
== RESPONSIVE ==
|
||||
Arată DOUĂ artboard-uri: desktop (1280px) și mobil (375px). Pe mobil: o coloană, butoanele CTA
|
||||
full-width, header colapsat, cardurile de preț stivuite vertical. Ținte de atins minim 44px pe mobil.
|
||||
|
||||
== ACCESIBILITATE ==
|
||||
Contrast text minim 4.5:1. Starea comunicată prin text + culoare (nu doar culoare) — pill-urile de
|
||||
stare au etichetă text, nu doar pastilă colorată.
|
||||
|
||||
Livrează mockup-ul în tema întunecată ca varianta principală, plus un preview al heroului pe tema light.
|
||||
```
|
||||
194
docs/mockups/prd-5.15-mockups.html
Normal file
194
docs/mockups/prd-5.15-mockups.html
Normal file
@@ -0,0 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.15 — mockup-uri piese lipsa (D6 strip / E4 picker / odo reveal)</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;}
|
||||
/* grafit (default dark) — aceleasi tokenuri ca landing.html */
|
||||
:root{--bg:#0f1218;--card:#181c24;--card2:#0f1218;--text:#e6e9ef;--sub:#8b93a7;--line:#262b36;--line2:#1f2530;--accent:#2E74D6;--okt:#2FBF8F;--infot:#6ea2ec;--errt:#E05D5D;--warn:#E0A93B;}
|
||||
body{margin:0;padding:32px;background:#0b0e13;font-family:'IBM Plex Sans',system-ui,sans-serif;-webkit-font-smoothing:antialiased;color:var(--text);}
|
||||
h1{font:700 22px 'IBM Plex Sans';margin:0 0 4px;}
|
||||
.pgsub{font:400 13px 'IBM Plex Sans';color:var(--sub);margin:0 0 28px;}
|
||||
.seclabel{font:500 13px 'IBM Plex Sans';color:var(--sub);letter-spacing:.04em;text-transform:uppercase;margin:34px 0 14px;border-top:1px solid var(--line);padding-top:18px;}
|
||||
.frames{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;}
|
||||
.frlabel{font:500 12px 'IBM Plex Sans';color:var(--sub);margin-bottom:10px;}
|
||||
/* componente slim */
|
||||
.counter{flex:1;background:var(--card2);border:1px solid var(--line);border-radius:8px;padding:10px 12px;}
|
||||
.cnum{font:700 22px 'IBM Plex Sans';line-height:1;}
|
||||
.clabel{font:400 11px 'IBM Plex Sans';color:var(--sub);margin-top:5px;}
|
||||
.csub{font:400 10px 'IBM Plex Mono';color:var(--sub);margin-top:3px;}
|
||||
.row{display:flex;align-items:center;justify-content:space-between;padding:11px 18px;border-bottom:1px solid var(--line2);}
|
||||
.vin{font:500 13px 'IBM Plex Mono';color:var(--text);}
|
||||
.meta{font:400 11px 'IBM Plex Sans';color:var(--sub);margin-top:3px;}
|
||||
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;font:500 12px 'IBM Plex Sans';}
|
||||
.dot{width:6px;height:6px;border-radius:99px;}
|
||||
.lab{font:400 11px 'IBM Plex Sans';color:var(--sub);margin-bottom:4px;}
|
||||
.fld{height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);font:400 12px 'IBM Plex Mono';color:var(--text);}
|
||||
.fldsans{font-family:'IBM Plex Sans';}
|
||||
.chip{display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent) 18%,transparent);color:var(--accent);font:600 11px 'IBM Plex Mono';}
|
||||
.chipx{opacity:.7;cursor:pointer;}
|
||||
.chipbox{min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:var(--card2);}
|
||||
.addcode{display:inline-flex;align-items:center;height:22px;padding:0 7px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;color:var(--accent);font:500 10px 'IBM Plex Sans';cursor:pointer;}
|
||||
.oprow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);margin-bottom:8px;}
|
||||
.opname{font:500 12px 'IBM Plex Sans';color:var(--text);}
|
||||
.picker{height:26px;display:inline-flex;align-items:center;gap:6px;padding:0 8px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);font:500 11px 'IBM Plex Sans';cursor:pointer;}
|
||||
.saverule{font:400 10px 'IBM Plex Sans';color:var(--okt);margin-top:3px;display:inline-flex;align-items:center;gap:4px;}
|
||||
.btn{margin-top:6px;height:34px;padding:0 16px;border-radius:6px;background:var(--accent);border:none;color:#fff;font:600 12px 'IBM Plex Sans';cursor:pointer;align-self:flex-start;}
|
||||
.form{display:flex;flex-direction:column;gap:11px;padding:18px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>PRD 5.15 — mockup-uri pentru piesele fara design</h1>
|
||||
<p class="pgsub">Tema grafit (dark), tokenuri identice cu landing.html. Trei piese pe care mockup-urile existente nu le acopera: stripul de sanatate D6, pickerul prestatii E4 (op↔cod), si reveal-ul odometru initial.</p>
|
||||
|
||||
<!-- ===================== D6 STRIP SANATATE ===================== -->
|
||||
<div class="seclabel">1 · D6 — strip sanatate mereu-vizibil (deasupra contoarelor)</div>
|
||||
<div class="frames">
|
||||
|
||||
<div>
|
||||
<div class="frlabel">Stare BLOCAT (rosu) — declaratiile NU pleaca</div>
|
||||
<div style="width:600px;background:var(--bg);border:1px solid var(--line);border-radius:12px;overflow:hidden;">
|
||||
<div style="padding:14px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-radius:8px;background:color-mix(in srgb,var(--errt) 16%,var(--card));border:1px solid color-mix(in srgb,var(--errt) 40%,transparent);margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:center;gap:9px;">
|
||||
<span aria-hidden="true" style="font:700 15px 'IBM Plex Sans';color:var(--errt);">✗</span>
|
||||
<span style="font:700 13px 'IBM Plex Sans';color:var(--text);">Blocat: worker oprit — declaratiile NU pleaca</span>
|
||||
</div>
|
||||
<span style="font:400 11px 'IBM Plex Mono';color:var(--sub);white-space:nowrap;">Ultima autentificare RAR: azi 08:14</span>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<div style="font:700 17px 'IBM Plex Sans';color:var(--text);">Trimiteri RAR AUTOPASS</div>
|
||||
<div style="font:400 12px 'IBM Plex Sans';color:var(--sub);margin-top:2px;">Service Auto Valcea · 28 iun 2026</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:14px;">
|
||||
<div class="counter"><div class="cnum" style="color:var(--text);">847</div><div class="clabel">Trimise (total)</div><div class="csub">luna 124 · azi 9</div></div>
|
||||
<div class="counter"><div class="cnum" style="color:var(--accent);">12</div><div class="clabel">In coada</div></div>
|
||||
<div class="counter"><div class="cnum" style="color:var(--errt);">2</div><div class="clabel">De corectat</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="row"><div><div class="vin">WBA8E9...K7F2</div><div class="meta">Inspectie tehnica · 09:42</div></div><span class="pill" style="background:color-mix(in srgb,var(--okt) 13%,transparent);color:var(--okt);"><span class="dot" style="background:var(--okt);"></span>Trimis</span></div>
|
||||
<div class="row"><div><div class="vin">WVWZZZ...3M1</div><div class="meta">Revizie periodica · 09:38</div></div><span class="pill" style="background:color-mix(in srgb,var(--accent) 14%,transparent);color:var(--infot);"><span class="dot" style="background:var(--accent);"></span>In coada</span></div>
|
||||
<div class="row" style="border-bottom:none;"><div><div class="vin">VF1RFB...A88</div><div class="meta">Sistem franare · 09:31</div></div><span class="pill" style="background:color-mix(in srgb,var(--errt) 14%,transparent);color:var(--errt);"><span class="dot" style="background:var(--errt);"></span>De corectat</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="frlabel">Stare OK (verde) — declaratiile curg</div>
|
||||
<div style="width:600px;background:var(--bg);border:1px solid var(--line);border-radius:12px;overflow:hidden;">
|
||||
<div style="padding:14px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-radius:8px;background:color-mix(in srgb,var(--okt) 13%,transparent);border:1px solid color-mix(in srgb,var(--okt) 30%,transparent);margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:center;gap:9px;">
|
||||
<span aria-hidden="true" style="font:700 15px 'IBM Plex Sans';color:var(--okt);">✓</span>
|
||||
<span style="font:600 13px 'IBM Plex Sans';color:var(--text);">Declaratiile curg normal</span>
|
||||
</div>
|
||||
<span style="font:400 11px 'IBM Plex Mono';color:var(--sub);white-space:nowrap;">Ultima autentificare RAR: azi 09:12</span>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<div style="font:700 17px 'IBM Plex Sans';color:var(--text);">Trimiteri RAR AUTOPASS</div>
|
||||
<div style="font:400 12px 'IBM Plex Sans';color:var(--sub);margin-top:2px;">Service Auto Valcea · 28 iun 2026</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:14px;">
|
||||
<div class="counter"><div class="cnum" style="color:var(--text);">847</div><div class="clabel">Trimise (total)</div><div class="csub">luna 124 · azi 9</div></div>
|
||||
<div class="counter"><div class="cnum" style="color:var(--accent);">3</div><div class="clabel">In coada</div></div>
|
||||
<div class="counter"><div class="cnum" style="color:var(--sub);">0</div><div class="clabel">De corectat</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="row"><div><div class="vin">WBA8E9...K7F2</div><div class="meta">Inspectie tehnica · 09:42</div></div><span class="pill" style="background:color-mix(in srgb,var(--okt) 13%,transparent);color:var(--okt);"><span class="dot" style="background:var(--okt);"></span>Trimis</span></div>
|
||||
<div class="row" style="border-bottom:none;"><div><div class="vin">ZAR937...C04</div><div class="meta">Schimb ulei · 09:24</div></div><span class="pill" style="background:color-mix(in srgb,var(--okt) 13%,transparent);color:var(--okt);"><span class="dot" style="background:var(--okt);"></span>Trimis</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ===================== E4 PICKER PRESTATII ===================== -->
|
||||
<div class="seclabel">2 · E4 — formular editare slim: VIN unic + Observatii + picker prestatii PE operatie</div>
|
||||
<div class="frames">
|
||||
<div>
|
||||
<div class="frlabel">Editare trimitere (needs_data)</div>
|
||||
<div style="width:640px;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;">
|
||||
<div class="form">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:2px;">
|
||||
<span style="font:500 12px 'IBM Plex Mono';color:var(--sub);">corecteaza · needs_data</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,var(--errt) 14%,transparent);color:var(--errt);font:600 11px 'IBM Plex Sans';">Date incomplete</span>
|
||||
</div>
|
||||
<div><div class="lab">VIN (serie sasiu)</div><div class="fld">U1234567890123456</div></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:11px;">
|
||||
<div><div class="lab">Data prestatiei</div><div class="fld">2026-06-22</div></div>
|
||||
<div><div class="lab">Numar inmatriculare</div><div class="fld">CT88NOE</div></div>
|
||||
</div>
|
||||
<div><div class="lab">Observatii (operatiile efectuate)</div><div class="fld fldsans" style="height:auto;min-height:48px;align-items:flex-start;padding:8px 10px;">Revizie; schimbare placute frana</div></div>
|
||||
<div>
|
||||
<div class="lab">Prestatii — cod RAR pe fiecare operatie</div>
|
||||
<div class="oprow">
|
||||
<span class="opname">REVIZIE PERIODICA</span>
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
<span class="chip"><span class="chipx">×</span>REV2</span>
|
||||
<span class="picker">+ alt cod</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="saverule">✓ salveaza regula REVIZIE PERIODICA → REV2 (auto-rezolva data viitoare)</div>
|
||||
<div class="oprow" style="border-color:color-mix(in srgb,var(--warn) 45%,var(--line));margin-top:10px;">
|
||||
<span class="opname">SCHIMBARE PLACUTE FRANA <span style="color:var(--warn);font:500 10px 'IBM Plex Sans';">· lipsa cod</span></span>
|
||||
<span class="picker" style="border-style:solid;border-color:var(--warn);color:var(--warn);">alege cod RAR ▾</span>
|
||||
</div>
|
||||
<div style="font:400 10px 'IBM Plex Sans';color:var(--sub);margin-top:8px;">Fara operatie (corectie pura): chip-uri de coduri libere · dedupare per-pereche (op,cod), nu doar dupa cod.</div>
|
||||
</div>
|
||||
<button class="btn">Salveaza si retrimite</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== ODO REVEAL ===================== -->
|
||||
<div class="seclabel">3 · Reveal odometru initial — apare doar la coduri R-ODO / I-ODO (server-driven, E6)</div>
|
||||
<div class="frames">
|
||||
|
||||
<div>
|
||||
<div class="frlabel">Inainte · niciun R-ODO → odometru initial ascuns</div>
|
||||
<div style="width:480px;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;">
|
||||
<div class="form">
|
||||
<div><div class="lab">Prestatii</div>
|
||||
<div class="chipbox"><span class="chip"><span class="chipx">×</span>REV2</span><span class="addcode">+ cod</span></div>
|
||||
</div>
|
||||
<div><div class="lab">Odometru final</div><div class="fld">39000</div></div>
|
||||
<div style="font:400 10px 'IBM Plex Sans';color:var(--sub);font-style:italic;">Odometru initial se cere doar pentru coduri R-ODO / I-ODO.</div>
|
||||
<button class="btn">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="frlabel">Dupa · adaugi R-ODO → campul apare</div>
|
||||
<div style="width:480px;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;">
|
||||
<div class="form">
|
||||
<div><div class="lab">Prestatii</div>
|
||||
<div class="chipbox">
|
||||
<span class="chip"><span class="chipx">×</span>REV2</span>
|
||||
<span class="chip" style="background:color-mix(in srgb,var(--warn) 22%,transparent);color:var(--warn);"><span class="chipx">×</span>R-ODO</span>
|
||||
<span class="addcode">+ cod</span>
|
||||
</div>
|
||||
</div>
|
||||
<div><div class="lab">Odometru final</div><div class="fld">39000</div></div>
|
||||
<div style="border-left:2px solid var(--warn);padding-left:10px;margin-left:-12px;">
|
||||
<div class="lab" style="color:var(--warn);">Odometru initial · necesar pentru R-ODO</div>
|
||||
<div class="fld" style="border-color:color-mix(in srgb,var(--warn) 50%,var(--line));">12500</div>
|
||||
</div>
|
||||
<button class="btn">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
221
docs/mockups/prd-5.16-dashboard-mobil.html
Normal file
221
docs/mockups/prd-5.16-dashboard-mobil.html
Normal file
@@ -0,0 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.16 — Dashboard mobil 390px (RAR dot in antet + meniu)</title>
|
||||
<style>
|
||||
:root{
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px; --fs-lg:18px; --fs-xl:20px; --fs-2xl:28px;
|
||||
--bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7;
|
||||
--line:#262b36; --line2:#1f2530; --accent:#6ea2ec; --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D;
|
||||
--hbg:rgba(15,18,24,.95);
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0; background:#05070b; font-family:var(--font-ui); -webkit-font-smoothing:antialiased; padding:24px;}
|
||||
.mono{font-family:var(--font-mono);} .muted{color:var(--muted);}
|
||||
.stage{display:flex; gap:34px; justify-content:center; align-items:flex-start; flex-wrap:wrap;}
|
||||
.cap{text-align:center; color:#9aa3b2; font-size:13px; margin-top:10px; max-width:390px;}
|
||||
.phone{width:390px; background:var(--bg); color:var(--ink); border-radius:30px; border:10px solid #20242c; overflow:hidden; box-shadow:0 30px 70px -20px rgba(0,0,0,.7);}
|
||||
.phone .screen{height:720px; overflow:hidden; position:relative;}
|
||||
.scroll{height:100%; overflow:auto;}
|
||||
|
||||
header{position:sticky; top:0; z-index:5; display:flex; align-items:center; justify-content:space-between; gap:8px; height:56px; padding:0 12px; background:var(--hbg); backdrop-filter:blur(8px); border-bottom:1px solid var(--line);}
|
||||
.logo-fallback{display:inline-flex; align-items:center; gap:4px; font-weight:800; font-size:var(--fs-base);}
|
||||
.logo-fallback .rom{color:#D1342F;} .logo-fallback .fast{color:var(--accent);}
|
||||
.h-center{flex:1; text-align:center; line-height:1.1; min-width:0;}
|
||||
.h-title{font-size:var(--fs-sm); font-weight:700;} .h-title .accent{color:var(--accent);}
|
||||
.tier{display:inline-block; margin-left:5px; padding:0 7px; border-radius:99px; font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--accent); background:color-mix(in srgb,var(--accent) 16%,transparent); vertical-align:middle;}
|
||||
.h-sub{font-size:11px; color:var(--muted); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
|
||||
.h-sub .svc{color:var(--ink); font-weight:600;}
|
||||
.h-right{display:flex; align-items:center; gap:7px;}
|
||||
/* RAR online = dot compact in antet (title pe hover); blocat => rosu */
|
||||
.rar-dot{width:38px; height:38px; border-radius:9px; border:1px solid color-mix(in srgb,var(--ok) 35%,var(--line)); background:color-mix(in srgb,var(--ok) 10%,transparent); display:inline-flex; align-items:center; justify-content:center; cursor:default;}
|
||||
.rar-dot .d{width:11px; height:11px; border-radius:99px; background:var(--ok); box-shadow:0 0 0 4px color-mix(in srgb,var(--ok) 22%,transparent);}
|
||||
.icon-btn{width:40px; height:40px; border-radius:9px; border:1px solid var(--line); background:transparent; color:var(--ink); cursor:pointer; display:inline-flex; align-items:center; justify-content:center;}
|
||||
|
||||
.body{padding:12px; display:flex; flex-direction:column; gap:12px;}
|
||||
|
||||
/* CARDURI compacte — doar numere, un rand */
|
||||
.stats{display:flex; background:var(--card2); border:1px solid var(--line); border-radius:11px; overflow:hidden;}
|
||||
.stat{flex:1; text-align:center; padding:10px 4px; border-right:1px solid var(--line2);}
|
||||
.stat:last-child{border-right:none;}
|
||||
.stat .n{font-size:var(--fs-xl); font-weight:700; line-height:1;}
|
||||
.stat .l{font-size:11px; color:var(--muted); margin-top:4px;}
|
||||
.s-ok{color:var(--ok);} .s-acc{color:var(--accent);} .s-err{color:var(--err);}
|
||||
|
||||
/* IMPORT colapsat */
|
||||
.import-collapse{border:1px solid var(--line); border-radius:11px; background:var(--card); overflow:hidden;}
|
||||
.import-collapse>summary{list-style:none; cursor:pointer; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:13px 14px; font-size:var(--fs-base); font-weight:600; color:var(--ink); min-height:48px;}
|
||||
.import-collapse>summary::-webkit-details-marker{display:none;}
|
||||
.import-collapse>summary .ic-l{display:flex; align-items:center; gap:9px;}
|
||||
.import-collapse .plus{display:inline-flex; width:24px; height:24px; align-items:center; justify-content:center; border-radius:7px; background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent); font-size:17px; line-height:1;}
|
||||
.import-collapse>summary .chev{font-size:var(--fs-sm); color:var(--muted);}
|
||||
|
||||
/* NAV */
|
||||
.subnav{display:flex; gap:6px; border-bottom:1px solid var(--line);}
|
||||
.subnav a{flex:1; text-align:center; font-size:var(--fs-sm); font-weight:600; padding:10px 0; border-radius:9px 9px 0 0; color:var(--muted); text-decoration:none; border:1px solid transparent; border-bottom:none; margin-bottom:-1px;}
|
||||
.subnav a.active{color:var(--ink); background:var(--card); border-color:var(--line); border-bottom:1px solid var(--card);}
|
||||
.badge{display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:5px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;}
|
||||
|
||||
/* LISTA — filtre se ASEAZA pe randuri (wrap), FARA linie de scroll */
|
||||
.panel{background:var(--card); border:1px solid var(--line); border-radius:0 11px 11px 11px; overflow:hidden;}
|
||||
.filtre{display:flex; gap:7px; flex-wrap:wrap; padding:11px 12px; border-bottom:1px solid var(--line2);}
|
||||
.pillf{font-size:var(--fs-sm); padding:7px 14px; border-radius:99px; border:1px solid var(--line); background:transparent; color:var(--muted);}
|
||||
.pillf.on{background:color-mix(in srgb,var(--accent) 16%,transparent); border-color:transparent; color:var(--accent); font-weight:600;}
|
||||
.rand{display:flex; align-items:center; justify-content:space-between; gap:10px; padding:13px 13px; border-bottom:1px solid var(--line2); min-height:56px;}
|
||||
.rand:last-child{border-bottom:none;}
|
||||
.slim-vin{font-family:var(--font-mono); font-size:var(--fs-md); font-weight:500;}
|
||||
.slim-meta{font-size:var(--fs-sm); color:var(--muted); margin-top:3px;}
|
||||
.pill{display:inline-flex; align-items:center; gap:6px; padding:5px 11px; border-radius:99px; font-size:var(--fs-sm); font-weight:500; flex-shrink:0;}
|
||||
.pill .pdot{width:7px; height:7px; border-radius:99px;}
|
||||
.pill.sent{background:color-mix(in srgb,var(--ok) 14%,transparent); color:var(--ok);} .pill.sent .pdot{background:var(--ok);}
|
||||
.pill.coada{background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent);} .pill.coada .pdot{background:var(--accent);}
|
||||
.pill.err{background:color-mix(in srgb,var(--err) 14%,transparent); color:var(--err);} .pill.err .pdot{background:var(--err);}
|
||||
|
||||
/* meniu burger deschis */
|
||||
.scrim{position:absolute; inset:0; background:rgba(0,0,0,.45); z-index:8;}
|
||||
.menu{position:absolute; top:52px; right:10px; width:240px; background:var(--card); border:1px solid var(--line); border-radius:12px; box-shadow:0 20px 50px -16px rgba(0,0,0,.7); padding:7px; z-index:9;}
|
||||
.menu-status{display:flex; align-items:center; gap:9px; padding:11px 11px; font-size:var(--fs-base); font-weight:600; color:var(--ok);}
|
||||
.menu-status .d{width:10px; height:10px; border-radius:99px; background:var(--ok); box-shadow:0 0 0 4px color-mix(in srgb,var(--ok) 22%,transparent);}
|
||||
.menu-status small{font-weight:400; color:var(--muted); font-family:var(--font-mono); font-size:11px;}
|
||||
.menu-plan{display:flex; align-items:center; justify-content:space-between; padding:6px 11px 8px; font-size:var(--fs-sm); color:var(--muted);}
|
||||
.menu-plan b{color:var(--accent);} .menu-plan .trial{font-size:11px;}
|
||||
.menu a{display:flex; align-items:center; justify-content:space-between; padding:12px 11px; border-radius:8px; font-size:var(--fs-base); color:var(--ink); text-decoration:none;}
|
||||
.menu a:active{background:var(--card2);}
|
||||
.menu hr{border:none; border-top:1px solid var(--line); margin:5px 4px;}
|
||||
|
||||
/* ecran editare full-screen */
|
||||
.modal-head{display:flex; align-items:center; justify-content:space-between; height:56px; padding:0 12px; border-bottom:1px solid var(--line); background:var(--hbg); position:sticky; top:0; z-index:5;}
|
||||
.modal-head .t{font-size:var(--fs-md); font-weight:700;}
|
||||
.field{margin-bottom:14px;}
|
||||
.field label{display:block; font-size:var(--fs-sm); color:var(--muted); margin-bottom:6px;}
|
||||
.field input, .field textarea, .field select{width:100%; font-family:var(--font-ui); font-size:var(--fs-md); color:var(--ink); background:var(--card2); border:1px solid var(--line); border-radius:9px; padding:11px 13px; min-height:46px;}
|
||||
.field input.mono{font-family:var(--font-mono);}
|
||||
.grid2{display:grid; grid-template-columns:1fr 1fr; gap:10px;}
|
||||
.op-row{padding:11px 0; border-bottom:1px solid var(--line2);}
|
||||
.op-name{font-size:var(--fs-md); font-weight:600; display:block; margin-bottom:8px;} .op-name small{font-weight:400; color:var(--muted); font-size:var(--fs-sm);}
|
||||
.op-ctl{display:flex; align-items:center; gap:8px;}
|
||||
.chip{display:inline-flex; align-items:center; gap:6px; font-family:var(--font-mono); font-size:var(--fs-sm); background:color-mix(in srgb,var(--accent) 18%,transparent); color:var(--accent); padding:7px 11px; border-radius:8px;}
|
||||
.chip button{background:none; border:none; color:inherit; cursor:pointer; font-size:var(--fs-md);}
|
||||
.addcode{width:100%; font-size:var(--fs-sm); border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line)); background:transparent; color:var(--accent); border-radius:9px; padding:11px; cursor:pointer; margin-top:10px;}
|
||||
.actrow{display:flex; flex-direction:column; gap:10px; margin-top:18px;}
|
||||
.btn-primary{width:100%; font-size:var(--fs-md); font-weight:600; height:46px; background:var(--accent); color:#fff; border:none; border-radius:9px; cursor:pointer;}
|
||||
.btn-ghost{width:100%; font-size:var(--fs-md); height:46px; background:transparent; color:var(--ink); border:1px solid var(--line); border-radius:9px; cursor:pointer;}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
<div class="stage">
|
||||
|
||||
<!-- ECRAN 1: DASHBOARD curat (RAR dot in antet, fara linie de scroll la filtre) -->
|
||||
<div>
|
||||
<div class="phone"><div class="screen"><div class="scroll">
|
||||
<header>
|
||||
<span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span>
|
||||
<div class="h-center">
|
||||
<div class="h-title">ROMFAST <span class="accent">AUTOPASS</span><span class="tier">Pro</span></div>
|
||||
<div class="h-sub">Service auto: <span class="svc">Service Auto Vâlcea SRL</span></div>
|
||||
</div>
|
||||
<div class="h-right">
|
||||
<span class="rar-dot" title="RAR online · ultima autentificare 28.06.2026 09:41"><span class="d"></span></span>
|
||||
<button class="icon-btn" title="Temă: Grafit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg></button>
|
||||
<button class="icon-btn" title="Meniu">☰</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="n s-ok">847</div><div class="l">Total</div></div>
|
||||
<div class="stat"><div class="n s-ok">124</div><div class="l">Lună</div></div>
|
||||
<div class="stat"><div class="n s-ok">9</div><div class="l">Azi</div></div>
|
||||
<div class="stat"><div class="n s-acc">12</div><div class="l">Coadă</div></div>
|
||||
<div class="stat"><div class="n s-err">2</div><div class="l">Corectat</div></div>
|
||||
</div>
|
||||
<details class="import-collapse">
|
||||
<summary><span class="ic-l"><span class="plus">+</span> Importă fișier (XLSX / CSV)</span><span class="chev">▾</span></summary>
|
||||
</details>
|
||||
<div>
|
||||
<div class="subnav">
|
||||
<a href="#" class="active">Trimiteri</a>
|
||||
<a href="#">Mapări <span class="badge">2</span></a>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="filtre">
|
||||
<button class="pillf on">Toate</button>
|
||||
<button class="pillf">În coadă</button>
|
||||
<button class="pillf">Trimise</button>
|
||||
<button class="pillf">De corectat</button>
|
||||
</div>
|
||||
<div class="rand"><div><div class="slim-vin">WBA8E9...K7F2</div><div class="slim-meta">Inspecție tehnică · 09:42</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">WVWZZZ...3M1</div><div class="slim-meta">Revizie periodică · 09:38</div></div><span class="pill coada"><span class="pdot"></span>În coadă</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">VF1RFB...A88</div><div class="slim-meta">Sistem frânare · 09:31</div></div><span class="pill err"><span class="pdot"></span>De corectat</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">ZAR937...C04</div><div class="slim-meta">Schimb ulei · 09:24</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div></div>
|
||||
<div class="cap">390px · Acasă — RAR online = dot în antet (dată/oră pe hover), filtre fără linie de scroll</div>
|
||||
</div>
|
||||
|
||||
<!-- ECRAN 2: meniu burger deschis (RAR online si aici) -->
|
||||
<div>
|
||||
<div class="phone"><div class="screen">
|
||||
<header>
|
||||
<span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span>
|
||||
<div class="h-center">
|
||||
<div class="h-title">ROMFAST <span class="accent">AUTOPASS</span><span class="tier">Pro</span></div>
|
||||
<div class="h-sub">Service auto: <span class="svc">Service Auto Vâlcea SRL</span></div>
|
||||
</div>
|
||||
<div class="h-right">
|
||||
<span class="rar-dot" title="RAR online"><span class="d"></span></span>
|
||||
<button class="icon-btn" title="Temă: Grafit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg></button>
|
||||
<button class="icon-btn" title="Închide meniu">×</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="scrim"></div>
|
||||
<div class="menu">
|
||||
<div class="menu-status"><span class="d"></span> RAR online <small>· 09:41</small></div>
|
||||
<div class="menu-plan">Plan: <b>Pro</b> <span class="trial">trial · 18 zile</span></div>
|
||||
<hr>
|
||||
<a href="#">Trimiteri</a>
|
||||
<a href="#">Mapări <span class="badge">2</span></a>
|
||||
<hr>
|
||||
<a href="#">Nomenclator</a>
|
||||
<hr>
|
||||
<a href="#">Cont</a>
|
||||
<a href="#">Integrare</a>
|
||||
<a href="#">Jurnal</a>
|
||||
<hr>
|
||||
<a href="#">Ieși din cont</a>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="cap">390px · Meniu burger — RAR online + Plan (Pro) + separatoare între secțiuni</div>
|
||||
</div>
|
||||
|
||||
<!-- ECRAN 3: editare full-screen (trimitere nefinalizata) -->
|
||||
<div>
|
||||
<div class="phone"><div class="screen"><div class="scroll">
|
||||
<div class="modal-head"><span class="t">Corectează trimiterea</span><button class="icon-btn" title="Închide">×</button></div>
|
||||
<div class="body" style="gap:0;">
|
||||
<div class="field"><label>VIN (serie șasiu)</label><input class="mono" value="VF1RFB00A88142073"></div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>Data prestației</label><input class="mono" value="2026-06-22"></div>
|
||||
<div class="field"><label>Nr. înmatriculare</label><input class="mono" value="CT88NOE"></div>
|
||||
</div>
|
||||
<div class="field"><label>Observații (operațiile efectuate)</label><textarea rows="2">Schimbare plăcuțe frână față</textarea></div>
|
||||
<div class="field" style="margin-bottom:6px;">
|
||||
<label>Prestații — cod RAR pe fiecare operație</label>
|
||||
<div class="op-row"><span class="op-name">REVIZIE PERIODICĂ <small>— la 15.000 km</small></span><div class="op-ctl"><span class="chip">REV2 <button>×</button></span></div></div>
|
||||
<div class="op-row" style="border-left:2px solid var(--warn); padding-left:10px;"><span class="op-name">SCHIMB PLĂCUȚE FRÂNĂ <small style="color:var(--warn)">— lipsă cod</small></span><div class="op-ctl"><select><option>— alege cod RAR —</option><option>FRN1 — Sistem de frânare</option></select></div></div>
|
||||
<button class="addcode">+ Adaugă altă operație / cod RAR</button>
|
||||
</div>
|
||||
<div class="actrow"><button class="btn-primary">Salvează și retrimite</button><button class="btn-ghost">Renunță</button></div>
|
||||
</div>
|
||||
</div></div></div>
|
||||
<div class="cap">390px · Editare full-screen — trimitere nefinalizată (picker cod+denumire, Renunță)</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
241
docs/mockups/prd-5.16-dashboard.html
Normal file
241
docs/mockups/prd-5.16-dashboard.html
Normal file
@@ -0,0 +1,241 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.16 — Dashboard aplicatie (compact, minimalist)</title>
|
||||
<style>
|
||||
:root{
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px; --fs-lg:18px; --fs-xl:20px; --fs-2xl:28px;
|
||||
--bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7;
|
||||
--line:#262b36; --line2:#1f2530; --accent:#6ea2ec; --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D;
|
||||
--hbg:rgba(15,18,24,.9);
|
||||
}
|
||||
body[data-theme="hartie"]{ --bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052; --line:#e2dccc; --line2:#ece6d9; --accent:#1F5FBF; --ok:#1c7d5d; --warn:#b45309; --err:#bd463c; --hbg:rgba(255,253,247,.92); }
|
||||
body[data-theme="cobalt"]{ --bg:#080d1c; --card:#111a33; --card2:#0b1226; --ink:#e9ecfb; --muted:#8a93b8; --line:#1d2747; --line2:#161f3a; --accent:#8aa0ff; --ok:#2fd0a6; --err:#f06a7a; --hbg:rgba(8,13,28,.92); }
|
||||
body[data-theme="cupru"]{ --bg:#15110b; --card:#211a12; --card2:#15110b; --ink:#efe6d6; --muted:#a89a85; --line:#36291c; --line2:#281e14; --accent:#dfa45c; --ok:#67b98c; --err:#e2685a; --hbg:rgba(21,17,11,.92); }
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0; background:var(--bg); color:var(--ink); font-family:var(--font-ui); font-size:var(--fs-base); line-height:1.55; -webkit-font-smoothing:antialiased;}
|
||||
.mono{font-family:var(--font-mono);} .muted{color:var(--muted);}
|
||||
|
||||
/* HEADER branded (numele service e DOAR aici, nu se mai duplica jos) */
|
||||
header{position:sticky; top:0; z-index:5; display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:16px; height:64px; padding:0 22px; background:var(--hbg); backdrop-filter:blur(8px); border-bottom:1px solid var(--line);}
|
||||
.logo-fallback{display:inline-flex; align-items:center; gap:6px; font-weight:800; font-size:var(--fs-lg);}
|
||||
.logo-fallback .rom{color:#D1342F;} .logo-fallback .fast{color:var(--accent);}
|
||||
.h-center{text-align:center; line-height:1.15;}
|
||||
.h-title{font-size:var(--fs-md); font-weight:700;} .h-title .accent{color:var(--accent);}
|
||||
.h-sub{font-size:var(--fs-xs); color:var(--muted); margin-top:2px;} .h-sub .svc{color:var(--ink); font-weight:600;}
|
||||
.env{display:inline-block; margin-left:8px; padding:1px 7px; border-radius:99px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--warn); background:color-mix(in srgb,var(--warn) 16%,transparent);}
|
||||
/* badge tip cont (Gratuit/Standard/Pro/Premium) */
|
||||
.tier{display:inline-block; margin-left:6px; padding:1px 8px; border-radius:99px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--accent); background:color-mix(in srgb,var(--accent) 16%,transparent);}
|
||||
.h-right{display:flex; align-items:center; justify-content:flex-end; gap:10px;}
|
||||
/* dot RAR online compact in antet (inlocuieste banda) — datetime pe title/hover */
|
||||
.rar-chip{display:flex; align-items:center; gap:8px; height:38px; padding:0 13px; border-radius:99px; border:1px solid color-mix(in srgb,var(--ok) 35%,var(--line)); background:color-mix(in srgb,var(--ok) 10%,transparent); color:var(--ok); font-size:var(--fs-sm); font-weight:600; cursor:default;}
|
||||
.rar-chip.blocat{border-color:color-mix(in srgb,var(--err) 45%,var(--line)); background:color-mix(in srgb,var(--err) 12%,transparent); color:var(--err);}
|
||||
.rar-chip .dot{width:9px; height:9px; border-radius:99px; background:currentColor; box-shadow:0 0 0 4px color-mix(in srgb,currentColor 22%,transparent);}
|
||||
.tema-btn{display:flex; align-items:center; gap:8px; height:38px; padding:0 13px; border-radius:8px; background:transparent; border:1px solid var(--line); color:var(--muted); font-family:var(--font-ui); font-size:var(--fs-sm); cursor:pointer;}
|
||||
.tema-btn:hover{border-color:var(--accent); color:var(--ink);}
|
||||
.ver{font-size:var(--fs-xs); color:var(--muted);}
|
||||
.icon-btn{width:38px; height:38px; border-radius:8px; border:1px solid var(--line); background:transparent; color:var(--ink); font-size:18px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; position:relative;}
|
||||
/* meniu burger deschis (mockup) — contine si starea RAR */
|
||||
.menu{position:absolute; top:46px; right:0; width:230px; background:var(--card); border:1px solid var(--line); border-radius:10px; box-shadow:0 18px 40px -16px rgba(0,0,0,.6); padding:6px; z-index:10; text-align:left;}
|
||||
.menu-status{display:flex; align-items:center; gap:8px; padding:9px 10px; font-size:var(--fs-sm); font-weight:600; color:var(--ok);}
|
||||
.menu-status small{font-weight:400; color:var(--muted); font-family:var(--font-mono); font-size:11px;}
|
||||
.menu-plan{display:flex; align-items:center; justify-content:space-between; padding:8px 10px 4px; font-size:var(--fs-sm); color:var(--muted);}
|
||||
.menu-plan b{color:var(--accent);}
|
||||
.menu-plan .trial{font-size:11px; color:var(--muted);}
|
||||
.menu a{display:flex; align-items:center; justify-content:space-between; padding:9px 10px; border-radius:7px; font-size:var(--fs-sm); color:var(--ink); text-decoration:none;}
|
||||
.menu a:hover{background:var(--card2);}
|
||||
.menu hr{border:none; border-top:1px solid var(--line); margin:5px 4px;}
|
||||
.menu .badge{display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;}
|
||||
|
||||
.wrap{max-width:1000px; margin:0 auto; padding:16px 22px 70px; display:flex; flex-direction:column; gap:14px;}
|
||||
|
||||
/* Banda de stare — APARE DOAR cand e blocat (zero-silent-failures) */
|
||||
.strip{display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 16px; border-radius:10px;
|
||||
background:color-mix(in srgb, var(--ok) 13%, transparent); border:1px solid color-mix(in srgb, var(--ok) 30%, transparent);}
|
||||
.strip.blocat{background:color-mix(in srgb, var(--err) 13%, transparent); border-color:color-mix(in srgb, var(--err) 35%, transparent); color:var(--err);}
|
||||
.strip-left{display:flex; align-items:center; gap:10px; font-weight:700; font-size:var(--fs-md);}
|
||||
.strip .dot{width:10px; height:10px; border-radius:99px; background:var(--ok); flex-shrink:0; box-shadow:0 0 0 4px color-mix(in srgb, var(--ok) 22%, transparent);}
|
||||
.strip.blocat .dot{background:var(--err); box-shadow:0 0 0 4px color-mix(in srgb, var(--err) 22%, transparent);}
|
||||
.strip-right{font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted);}
|
||||
|
||||
/* 2. CARDURI contor — standalone, fara titlu de sectiune */
|
||||
.contoare{display:grid; grid-template-columns:repeat(5,1fr); gap:10px;}
|
||||
.contor-card{background:var(--card2); border:1px solid var(--line); border-radius:10px; padding:14px 16px;}
|
||||
.contor-card.primar{border-color:color-mix(in srgb,var(--ok) 40%,var(--line));}
|
||||
.contor-cifra{font-size:var(--fs-2xl); font-weight:700; line-height:1;}
|
||||
.contor-label{font-size:var(--fs-sm); color:var(--muted); margin-top:7px;}
|
||||
.s-ok{color:var(--ok);} .s-acc{color:var(--accent);} .s-err{color:var(--err);}
|
||||
|
||||
/* 3. IMPORT colapsat */
|
||||
.import-collapse{border:1px solid var(--line); border-radius:10px; background:var(--card); overflow:hidden;}
|
||||
.import-collapse>summary{list-style:none; cursor:pointer; display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px 16px; font-size:var(--fs-sm); font-weight:600; color:var(--ink);}
|
||||
.import-collapse>summary::-webkit-details-marker{display:none;}
|
||||
.import-collapse>summary .ic-l{display:flex; align-items:center; gap:10px;}
|
||||
.import-collapse .plus{display:inline-flex; width:24px; height:24px; align-items:center; justify-content:center; border-radius:7px; background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent); font-size:17px; line-height:1;}
|
||||
.import-collapse>summary .ic-r{font-size:var(--fs-xs); color:var(--muted);}
|
||||
.import-collapse[open]>summary{border-bottom:1px solid var(--line);}
|
||||
.import-body{display:flex; align-items:center; justify-content:space-between; gap:14px; padding:16px; border:1px dashed color-mix(in srgb,var(--accent) 45%,var(--line)); border-radius:10px; margin:12px;}
|
||||
.import-body .u-tx{font-size:var(--fs-md); font-weight:600;}
|
||||
.import-body .u-sub{font-size:var(--fs-sm); color:var(--muted); margin-top:2px;}
|
||||
.btn-primary{font-family:var(--font-ui); font-size:var(--fs-md); font-weight:600; height:42px; padding:0 20px; background:var(--accent); color:#fff; border:none; border-radius:8px; cursor:pointer;}
|
||||
|
||||
/* 4. NAV tab-uri Trimiteri / Mapari */
|
||||
.subnav{display:flex; gap:6px; border-bottom:1px solid var(--line);}
|
||||
.subnav a{font-size:var(--fs-sm); font-weight:600; padding:9px 16px; border-radius:8px 8px 0 0; color:var(--muted); text-decoration:none; border:1px solid transparent; border-bottom:none; margin-bottom:-1px;}
|
||||
.subnav a.active{color:var(--ink); background:var(--card); border-color:var(--line); border-bottom:1px solid var(--card);}
|
||||
.subnav .badge{display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;}
|
||||
|
||||
/* 5. LISTA (fara titlu/subtitlu de sectiune) */
|
||||
.panel{background:var(--card); border:1px solid var(--line); border-radius:0 12px 12px 12px; overflow:hidden;}
|
||||
.filtre{display:flex; gap:8px; padding:12px 16px; flex-wrap:wrap; border-bottom:1px solid var(--line2);}
|
||||
.pillf{font-size:var(--fs-sm); padding:6px 13px; border-radius:99px; border:1px solid var(--line); background:transparent; color:var(--muted); cursor:pointer;}
|
||||
.pillf.on{background:color-mix(in srgb,var(--accent) 16%,transparent); border-color:transparent; color:var(--accent); font-weight:600;}
|
||||
.rand{display:flex; align-items:center; justify-content:space-between; padding:13px 16px; border-bottom:1px solid var(--line2); cursor:pointer;}
|
||||
.rand:hover{background:color-mix(in srgb,var(--accent) 6%,transparent);}
|
||||
.rand:last-child{border-bottom:none;}
|
||||
.slim-vin{font-family:var(--font-mono); font-size:var(--fs-md); font-weight:500;}
|
||||
.slim-meta{font-size:var(--fs-sm); color:var(--muted); margin-top:3px;}
|
||||
.pill{display:inline-flex; align-items:center; gap:7px; padding:5px 12px; border-radius:99px; font-size:var(--fs-sm); font-weight:500;}
|
||||
.pill .pdot{width:7px; height:7px; border-radius:99px;}
|
||||
.pill.sent{background:color-mix(in srgb,var(--ok) 14%,transparent); color:var(--ok);} .pill.sent .pdot{background:var(--ok);}
|
||||
.pill.coada{background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent);} .pill.coada .pdot{background:var(--accent);}
|
||||
.pill.err{background:color-mix(in srgb,var(--err) 14%,transparent); color:var(--err);} .pill.err .pdot{background:var(--err);}
|
||||
|
||||
/* MODAL editare trimitere nefinalizata (la click pe rand) */
|
||||
.editmodal{max-width:560px; background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden;}
|
||||
.editmodal .mhead{display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid var(--line);}
|
||||
.editmodal .mhead .t{font-size:var(--fs-md); font-weight:700;}
|
||||
.editmodal .mbody{padding:18px;}
|
||||
.field{margin-bottom:14px;}
|
||||
.field label{display:block; font-size:var(--fs-sm); color:var(--muted); margin-bottom:6px;}
|
||||
.field input, .field textarea, .field select{width:100%; font-family:var(--font-ui); font-size:var(--fs-md); color:var(--ink); background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:9px 12px; min-height:40px;}
|
||||
.field input.mono{font-family:var(--font-mono);}
|
||||
.grid2{display:grid; grid-template-columns:1fr 1fr; gap:12px;}
|
||||
.op-row{display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 0; border-bottom:1px solid var(--line2);}
|
||||
.op-name{font-size:var(--fs-md); font-weight:600;} .op-name small{font-weight:400; color:var(--muted); font-size:var(--fs-sm);}
|
||||
.chip{display:inline-flex; align-items:center; gap:6px; font-family:var(--font-mono); font-size:var(--fs-sm); background:color-mix(in srgb,var(--accent) 18%,transparent); color:var(--accent); padding:5px 10px; border-radius:7px;}
|
||||
.chip button{background:none; border:none; color:inherit; cursor:pointer; font-size:var(--fs-md);}
|
||||
.addcode{font-size:var(--fs-sm); border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line)); background:transparent; color:var(--accent); border-radius:7px; padding:6px 12px; cursor:pointer;}
|
||||
.btn-ghost{font-size:var(--fs-md); height:42px; padding:0 18px; background:transparent; color:var(--ink); border:1px solid var(--line); border-radius:8px; cursor:pointer;}
|
||||
.actrow{display:flex; gap:10px; margin-top:16px;}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
|
||||
<header>
|
||||
<div><span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span></div>
|
||||
<div class="h-center">
|
||||
<div class="h-title">ROMFAST <span class="accent">AUTOPASS</span><span class="env">test</span><span class="tier">Pro</span></div>
|
||||
<div class="h-sub">Service auto: <span class="svc">Service Auto Vâlcea SRL</span></div>
|
||||
</div>
|
||||
<div class="h-right">
|
||||
<div class="rar-chip" title="Ultima autentificare RAR: 28.06.2026 09:41"><span class="dot"></span> RAR online</div>
|
||||
<button class="tema-btn" onclick="cycle()">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||
<span id="t-label">Grafit</span>
|
||||
</button>
|
||||
<span class="ver">v5.16</span>
|
||||
<button class="icon-btn" title="Meniu cont">☰
|
||||
<div class="menu">
|
||||
<div class="menu-status"><span class="rar-chip" style="height:auto;padding:0;border:none;background:none;"><span class="dot"></span></span> RAR online <small>· 09:41</small></div>
|
||||
<div class="menu-plan">Plan: <b>Pro</b> <span class="trial">trial · 18 zile rămase</span></div>
|
||||
<hr>
|
||||
<a href="#">Trimiteri</a>
|
||||
<a href="#">Mapări <span class="badge">2</span></a>
|
||||
<hr>
|
||||
<a href="#">Nomenclator</a>
|
||||
<hr>
|
||||
<a href="#">Cont</a>
|
||||
<a href="#">Integrare</a>
|
||||
<a href="#">Jurnal</a>
|
||||
<hr>
|
||||
<a href="#">Ieși din cont</a>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<!-- CARDURI (fara titlu de sectiune; RAR online e acum dot in antet) -->
|
||||
<div class="contoare">
|
||||
<div class="contor-card primar"><div class="contor-cifra s-ok">847</div><div class="contor-label">Total trimise</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-ok">124</div><div class="contor-label">Luna asta</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-ok">9</div><div class="contor-label">Azi</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-acc">12</div><div class="contor-label">În coadă</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-err">2</div><div class="contor-label">De corectat</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. IMPORT colapsat -->
|
||||
<details class="import-collapse">
|
||||
<summary>
|
||||
<span class="ic-l"><span class="plus">+</span> Importă fișier (XLSX / CSV)</span>
|
||||
<span class="ic-r">trage-l aici sau apasă pentru a deschide ▾</span>
|
||||
</summary>
|
||||
<div class="import-body">
|
||||
<div><div class="u-tx">Încarcă un fișier sau trage-l aici</div><div class="u-sub">Mapezi coloanele o singură dată — apoi trimitem la RAR automat.</div></div>
|
||||
<button class="btn-primary">Alege fișier</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 4 + 5. NAV + LISTA -->
|
||||
<div>
|
||||
<div class="subnav">
|
||||
<a href="#" class="active">Trimiteri</a>
|
||||
<a href="#">Mapări <span class="badge">2</span></a>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="filtre">
|
||||
<button class="pillf on">Toate</button>
|
||||
<button class="pillf">În coadă</button>
|
||||
<button class="pillf">Trimise</button>
|
||||
<button class="pillf">De corectat</button>
|
||||
</div>
|
||||
<div class="rand"><div><div class="slim-vin">WBA8E9...K7F2</div><div class="slim-meta">Inspecție tehnică · 09:42</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">WVWZZZ...3M1</div><div class="slim-meta">Revizie periodică · 09:38</div></div><span class="pill coada"><span class="pdot"></span>În coadă</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">VF1RFB...A88</div><div class="slim-meta">Sistem frânare · 09:31</div></div><span class="pill err"><span class="pdot"></span>De corectat</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">ZAR937...C04</div><div class="slim-meta">Schimb ulei · 09:24</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">JTDBR...9920</div><div class="slim-meta">Inspecție tehnică · 09:18</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DOAR cand e BLOCAT: banda rosie reapare (zero-silent-failures) -->
|
||||
<div style="margin-top:18px; font-size:var(--fs-xs); text-transform:uppercase; letter-spacing:.08em; color:var(--err); font-weight:700;">Stare BLOCAT — banda apare DOAR atunci (worker oprit / RAR inaccesibil)</div>
|
||||
<div class="strip blocat">
|
||||
<span class="strip-left"><span class="dot"></span> Blocat: RAR inaccesibil — declarațiile NU pleacă</span>
|
||||
<span class="strip-right">Ultima autentificare RAR: 28.06.2026 09:41</span>
|
||||
</div>
|
||||
|
||||
<!-- MODAL editare: apare la click pe o trimitere nefinalizata (needs_data / needs_mapping / error) -->
|
||||
<div style="margin-top:22px; font-size:var(--fs-xs); text-transform:uppercase; letter-spacing:.08em; color:var(--accent); font-weight:700;">Modal editare — la click pe o trimitere nefinalizată (needs_data / needs_mapping)</div>
|
||||
<div class="editmodal" style="margin-top:8px;">
|
||||
<div class="mhead"><span class="t">Corectează trimiterea</span><button class="icon-btn" title="Închide">×</button></div>
|
||||
<div class="mbody">
|
||||
<div class="field"><label>VIN (serie șasiu)</label><input class="mono" value="VF1RFB00A88142073"></div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>Data prestației</label><input class="mono" value="2026-06-22"></div>
|
||||
<div class="field"><label>Număr înmatriculare</label><input class="mono" value="CT88NOE"></div>
|
||||
</div>
|
||||
<div class="field"><label>Observații (operațiile efectuate)</label><textarea rows="2">Schimbare plăcuțe frână față</textarea></div>
|
||||
<div class="field">
|
||||
<label>Prestații — cod RAR pe fiecare operație</label>
|
||||
<div class="op-row"><span class="op-name">REVIZIE PERIODICĂ <small>— la 15.000 km</small></span><span class="chip">REV2 <button>×</button></span></div>
|
||||
<div class="op-row" style="border-left:2px solid var(--warn); padding-left:10px;"><span class="op-name">SCHIMB PLĂCUȚE FRÂNĂ <small style="color:var(--warn)">— lipsă cod</small></span><select><option>— alege cod RAR —</option><option>FRN1 — Sistem de frânare</option><option>REV2 — Revizie periodică</option></select></div>
|
||||
<div style="margin-top:10px;"><button class="addcode">+ Adaugă altă operație / cod RAR</button></div>
|
||||
</div>
|
||||
<div class="actrow"><button class="btn-primary">Salvează și retrimite</button><button class="btn-ghost">Renunță</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var THEMES=[['grafit','Grafit'],['cobalt','Cobalt'],['cupru','Cupru'],['hartie','Hârtie']];
|
||||
var i=0;
|
||||
function cycle(){ i=(i+1)%THEMES.length; document.body.setAttribute('data-theme',THEMES[i][0]); document.getElementById('t-label').textContent=THEMES[i][1]; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
187
docs/mockups/prd-5.16-fonturi-system-stack.html
Normal file
187
docs/mockups/prd-5.16-fonturi-system-stack.html
Normal file
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.16 — Preview fonturi system-stack + scala tipografica</title>
|
||||
<style>
|
||||
/* ============================================================
|
||||
PROPUNERE 5.16: fonturi STANDARD WEB (system font stack).
|
||||
ZERO fisiere de font descarcate. Arata nativ pe fiecare OS.
|
||||
Inlocuieste IBM Plex self-hostat din /static/fonts.
|
||||
============================================================ */
|
||||
:root{
|
||||
/* Stive de font standard web (fara @font-face, fara /static/fonts) */
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||
|
||||
/* SCALA TIPOGRAFICA UNIFORMA (sursa unica de adevar; azi e ad-hoc 10/11/13px) */
|
||||
--fs-xs: 12px; /* meta, sub-linii mono, hint-uri (azi: 10px) */
|
||||
--fs-sm: 13.5px; /* label-uri formular, pill-uri (azi: 11px) */
|
||||
--fs-base: 15px; /* text body implicit (azi: ~13px) */
|
||||
--fs-md: 16px; /* input-uri, text card (azi: 13px) */
|
||||
--fs-lg: 18px; /* titluri de sectiune mici */
|
||||
--fs-xl: 20px; /* sub-titluri */
|
||||
--fs-2xl: 28px; /* cifra contor (azi: 22px) */
|
||||
--fs-3xl: 34px; /* titlu pagina */
|
||||
--lh-tight: 1.25;
|
||||
--lh-body: 1.55;
|
||||
|
||||
/* paleta grafit (din DESIGN.md) — doar pentru context vizual */
|
||||
--bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7;
|
||||
--line:#262b36; --line2:#1f2530; --accent:#6ea2ec; --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D;
|
||||
}
|
||||
body[data-theme="hartie"]{
|
||||
--bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052;
|
||||
--line:#e2dccc; --line2:#ece6d9; --accent:#1F5FBF; --ok:#1c7d5d; --warn:#b45309; --err:#bd463c;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{
|
||||
margin:0; background:var(--bg); color:var(--ink);
|
||||
font-family:var(--font-ui);
|
||||
font-size:var(--fs-base); line-height:var(--lh-body);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{max-width:1100px; margin:0 auto; padding:28px 22px 80px;}
|
||||
.mono{font-family:var(--font-mono);}
|
||||
h1{font-size:var(--fs-3xl); line-height:var(--lh-tight); margin:0 0 6px; letter-spacing:-.02em;}
|
||||
.lead{color:var(--muted); font-size:var(--fs-md); margin:0 0 22px;}
|
||||
.sec{font-size:var(--fs-lg); margin:34px 0 12px; padding-bottom:6px; border-bottom:1px solid var(--line);}
|
||||
.toolbar{display:flex; gap:10px; align-items:center; margin-bottom:8px;}
|
||||
.toolbar button{font-family:var(--font-ui); font-size:var(--fs-sm); height:36px; padding:0 14px;
|
||||
border-radius:7px; border:1px solid var(--line); background:var(--card); color:var(--ink); cursor:pointer;}
|
||||
.note{font-size:var(--fs-sm); color:var(--muted); margin:2px 0 0;}
|
||||
|
||||
/* ---- carduri-contor (aerisite, text mai mare) ---- */
|
||||
.contoare{display:grid; grid-template-columns:repeat(3,1fr); gap:14px;}
|
||||
.contor-card{background:var(--card2); border:1px solid var(--line); border-radius:12px; padding:18px 18px;}
|
||||
.contor-cifra{font-size:var(--fs-2xl); font-weight:700; line-height:1;}
|
||||
.contor-label{font-size:var(--fs-sm); color:var(--muted); margin-top:8px;}
|
||||
.contor-sub{font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted); margin-top:4px;}
|
||||
.s-ok{color:var(--ok);} .s-acc{color:var(--accent);} .s-err{color:var(--err);} .s-muted{color:var(--muted);}
|
||||
|
||||
/* ---- strip sanatate cu DOT (nu bifa) pentru RAR online ---- */
|
||||
.strip{display:flex; align-items:center; justify-content:space-between; gap:12px;
|
||||
padding:12px 16px; border-radius:10px; margin-bottom:14px;
|
||||
background:color-mix(in srgb, var(--ok) 13%, transparent);
|
||||
border:1px solid color-mix(in srgb, var(--ok) 30%, transparent);}
|
||||
.strip-left{display:flex; align-items:center; gap:10px; font-weight:700; font-size:var(--fs-md);}
|
||||
.dot{width:10px; height:10px; border-radius:99px; background:var(--ok); flex-shrink:0;
|
||||
box-shadow:0 0 0 4px color-mix(in srgb, var(--ok) 22%, transparent);}
|
||||
.dot.live{animation:pulse 2s ease-in-out infinite;}
|
||||
@keyframes pulse{0%,100%{opacity:1;} 50%{opacity:.55;}}
|
||||
.strip-right{font-family:var(--font-mono); font-size:var(--fs-xs); color:var(--muted);}
|
||||
|
||||
/* ---- lista slim ---- */
|
||||
.lista{background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden; margin-top:14px;}
|
||||
.rand{display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid var(--line2);}
|
||||
.rand:last-child{border-bottom:none;}
|
||||
.slim-vin{font-family:var(--font-mono); font-size:var(--fs-md); font-weight:500;}
|
||||
.slim-meta{font-size:var(--fs-sm); color:var(--muted); margin-top:3px;}
|
||||
.pill{display:inline-flex; align-items:center; gap:7px; padding:5px 12px; border-radius:99px; font-size:var(--fs-sm); font-weight:500;}
|
||||
.pill .pdot{width:7px; height:7px; border-radius:99px;}
|
||||
.pill.sent{background:color-mix(in srgb,var(--ok) 14%,transparent); color:var(--ok);}
|
||||
.pill.sent .pdot{background:var(--ok);}
|
||||
.pill.coada{background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent);}
|
||||
.pill.coada .pdot{background:var(--accent);}
|
||||
.pill.err{background:color-mix(in srgb,var(--err) 14%,transparent); color:var(--err);}
|
||||
.pill.err .pdot{background:var(--err);}
|
||||
|
||||
/* ---- formular editare slim ---- */
|
||||
.form-card{background:var(--card); border:1px solid var(--line); border-radius:12px; padding:22px; margin-top:14px; max-width:560px;}
|
||||
.camp{margin-bottom:14px;}
|
||||
.camp label{display:block; font-size:var(--fs-sm); color:var(--muted); margin-bottom:6px;}
|
||||
.camp input, .camp textarea, .camp select{
|
||||
width:100%; font-family:var(--font-ui); font-size:var(--fs-md); color:var(--ink);
|
||||
background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:9px 12px; min-height:40px;}
|
||||
.camp input.mono{font-family:var(--font-mono);}
|
||||
.grid2{display:grid; grid-template-columns:1fr 1fr; gap:12px;}
|
||||
.op-row{display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 0; border-bottom:1px solid var(--line2);}
|
||||
.op-name{font-size:var(--fs-md); font-weight:600;}
|
||||
.op-name small{font-weight:400; color:var(--muted); font-size:var(--fs-sm);}
|
||||
.chip{display:inline-flex; align-items:center; gap:6px; font-family:var(--font-mono); font-size:var(--fs-sm);
|
||||
background:color-mix(in srgb,var(--accent) 18%,transparent); color:var(--accent); padding:5px 10px; border-radius:7px;}
|
||||
.chip button{background:none; border:none; color:inherit; cursor:pointer; font-size:var(--fs-md); line-height:1;}
|
||||
.addcode{font-size:var(--fs-sm); border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));
|
||||
background:transparent; color:var(--accent); border-radius:7px; padding:6px 12px; cursor:pointer;}
|
||||
.btn-primary{font-family:var(--font-ui); font-size:var(--fs-md); font-weight:600; height:42px; padding:0 20px;
|
||||
background:var(--accent); color:#fff; border:none; border-radius:8px; cursor:pointer;}
|
||||
.btn-ghost{font-family:var(--font-ui); font-size:var(--fs-md); height:42px; padding:0 18px;
|
||||
background:transparent; color:var(--ink); border:1px solid var(--line); border-radius:8px; cursor:pointer;}
|
||||
|
||||
/* tabel scala — referinta rapida */
|
||||
table.scala{width:100%; border-collapse:collapse; font-size:var(--fs-sm); margin-top:8px;}
|
||||
table.scala td{padding:7px 10px; border-bottom:1px solid var(--line2);}
|
||||
table.scala td:first-child{font-family:var(--font-mono); color:var(--accent); white-space:nowrap;}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
<div class="wrap">
|
||||
<div class="toolbar">
|
||||
<button onclick="document.body.setAttribute('data-theme', document.body.getAttribute('data-theme')==='grafit'?'hartie':'grafit')">Comuta tema (grafit / hartie)</button>
|
||||
<span class="note">Fonturi: <span class="mono">system-ui, -apple-system, Segoe UI, Roboto…</span> — zero fisiere descarcate.</span>
|
||||
</div>
|
||||
|
||||
<h1>Gateway RAR AUTOPASS</h1>
|
||||
<p class="lead">Preview tipografie 5.16 — font stack nativ + scala uniforma, carduri aerisite, text mai mare.</p>
|
||||
|
||||
<div class="sec">Scala tipografica unica (tokeni)</div>
|
||||
<table class="scala">
|
||||
<tr><td>--fs-xs 12px</td><td style="font-size:var(--fs-xs)">Meta, hint-uri, sub-linii mono (azi 10px — prea mic)</td></tr>
|
||||
<tr><td>--fs-sm 13.5px</td><td style="font-size:var(--fs-sm)">Label-uri formular, pill-uri de stare (azi 11px)</td></tr>
|
||||
<tr><td>--fs-base 15px</td><td style="font-size:var(--fs-base)">Text body implicit pe toate paginile</td></tr>
|
||||
<tr><td>--fs-md 16px</td><td style="font-size:var(--fs-md)">Input-uri, VIN mono, text de card (azi 13px)</td></tr>
|
||||
<tr><td>--fs-2xl 28px</td><td style="font-size:var(--fs-2xl);font-weight:700">Cifra contor (azi 22px)</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="sec">Dashboard — strip sanatate (DOT, nu bifa) + carduri-contor</div>
|
||||
<div class="strip">
|
||||
<span class="strip-left"><span class="dot live"></span> RAR online · declaratiile curg normal</span>
|
||||
<span class="strip-right">Ultima autentificare RAR: 28.06.2026 09:41</span>
|
||||
</div>
|
||||
<div class="contoare">
|
||||
<div class="contor-card"><div class="contor-cifra s-ok">847</div><div class="contor-label">Trimise (total)</div><div class="contor-sub">luna 124 · azi 9</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-acc">12</div><div class="contor-label">In coada</div></div>
|
||||
<div class="contor-card"><div class="contor-cifra s-muted">0</div><div class="contor-label">De corectat</div></div>
|
||||
</div>
|
||||
|
||||
<div class="sec">Lista trimiteri — rand slim</div>
|
||||
<div class="lista">
|
||||
<div class="rand"><div><div class="slim-vin">WBA8E9...K7F2</div><div class="slim-meta">Inspectie tehnica · 09:42</div></div><span class="pill sent"><span class="pdot"></span>Trimis</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">WVWZZZ...3M1</div><div class="slim-meta">Revizie periodica · 09:38</div></div><span class="pill coada"><span class="pdot"></span>In coada</span></div>
|
||||
<div class="rand"><div><div class="slim-vin">VF1RFB...A88</div><div class="slim-meta">Sistem franare · 09:31</div></div><span class="pill err"><span class="pdot"></span>De corectat</span></div>
|
||||
</div>
|
||||
|
||||
<div class="sec">Formular editare — denumiri operatii in picker + adaugare operatie</div>
|
||||
<div class="form-card">
|
||||
<div class="camp"><label>VIN (serie sasiu)</label><input class="mono" value="WBA8E9C5K7F20143"></div>
|
||||
<div class="grid2">
|
||||
<div class="camp"><label>Data prestatiei</label><input class="mono" value="2026-06-22"></div>
|
||||
<div class="camp"><label>Numar inmatriculare</label><input class="mono" value="CT88NOE"></div>
|
||||
</div>
|
||||
<div class="camp"><label>Observatii (operatiile efectuate)</label><textarea rows="2">Revizie; schimbare placute frana</textarea></div>
|
||||
|
||||
<div class="camp">
|
||||
<label>Prestatii — cod RAR pe fiecare operatie</label>
|
||||
<div class="op-row">
|
||||
<span class="op-name">REVIZIE PERIODICA <small>— revizie la 15.000 km</small></span>
|
||||
<span style="display:flex;gap:8px;align-items:center;"><span class="chip">REV2 <button>×</button></span></span>
|
||||
</div>
|
||||
<div class="op-row" style="border-left:2px solid var(--warn); padding-left:10px;">
|
||||
<span class="op-name">SCHIMB PLACUTE FRANA <small style="color:var(--warn)">— lipsa cod</small></span>
|
||||
<select><option>— alege cod RAR —</option><option>FRN1 — Sistem de franare</option><option>REV2 — Revizie periodica</option></select>
|
||||
</div>
|
||||
<div style="margin-top:10px;"><button class="addcode">+ Adauga alta operatie / cod RAR</button></div>
|
||||
<p class="note">Picker-ul arata <strong>cod + denumire</strong> (FRN1 — Sistem de franare), nu doar codul.</p>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:10px; margin-top:18px;">
|
||||
<button class="btn-primary">Salveaza si retrimite</button>
|
||||
<button class="btn-ghost">Renunta</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note" style="margin-top:30px;">Nota: tema/culorile sunt doar context. Subiectul acestui preview e <strong>fontul</strong> (system-ui) si <strong>scala</strong> (dimensiuni mai mari, uniforme). Deschide pe Windows si pe Mac ca sa vezi cum cade fontul nativ pe fiecare.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
173
docs/mockups/prd-5.16-header-login-tema.html
Normal file
173
docs/mockups/prd-5.16-header-login-tema.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PRD 5.16 — Header profesional + /login + selector tema stil landing</title>
|
||||
<style>
|
||||
:root{
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px; --fs-lg:18px; --fs-xl:20px; --fs-2xl:28px;
|
||||
--bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7;
|
||||
--line:#262b36; --line2:#1f2530; --accent:#6ea2ec; --ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D;
|
||||
--hbg:rgba(15,18,24,.88);
|
||||
}
|
||||
body[data-theme="hartie"]{
|
||||
--bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052;
|
||||
--line:#e2dccc; --line2:#ece6d9; --accent:#1F5FBF; --ok:#1c7d5d; --warn:#b45309; --err:#bd463c;
|
||||
--hbg:rgba(255,253,247,.9);
|
||||
}
|
||||
body[data-theme="cobalt"]{ --bg:#080d1c; --card:#111a33; --card2:#0b1226; --ink:#e9ecfb; --muted:#8a93b8; --line:#1d2747; --line2:#161f3a; --accent:#8aa0ff; --ok:#2fd0a6; --err:#f06a7a; --hbg:rgba(8,13,28,.9); }
|
||||
body[data-theme="cupru"]{ --bg:#15110b; --card:#211a12; --card2:#15110b; --ink:#efe6d6; --muted:#a89a85; --line:#36291c; --line2:#281e14; --accent:#dfa45c; --ok:#67b98c; --err:#e2685a; --hbg:rgba(21,17,11,.9); }
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0; background:var(--bg); color:var(--ink); font-family:var(--font-ui); font-size:var(--fs-base); -webkit-font-smoothing:antialiased;}
|
||||
.mono{font-family:var(--font-mono);}
|
||||
.muted{color:var(--muted);}
|
||||
|
||||
/* ===== HEADER aplicatie (logat) — profesional, branded ===== */
|
||||
header{
|
||||
display:grid; grid-template-columns:1fr auto 1fr; align-items:center;
|
||||
gap:16px; height:64px; padding:0 22px; background:var(--card); border:1px solid var(--line); border-radius:12px;
|
||||
}
|
||||
/* antet MINIMAL pe /login (neautentificat): doar logo + titlu + tema */
|
||||
.login-topbar{display:flex; align-items:center; justify-content:space-between; gap:16px; height:60px; padding:0 22px; background:var(--card); border:1px solid var(--line); border-radius:12px 12px 0 0; border-bottom:none;}
|
||||
.login-topbar .lt-brand{display:flex; align-items:center; gap:10px; font-weight:700; font-size:var(--fs-md);}
|
||||
.login-topbar .lt-brand .accent{color:var(--accent);}
|
||||
.h-left{display:flex; align-items:center; gap:12px;}
|
||||
.logo{height:32px; width:auto; display:block;}
|
||||
/* wordmark fallback in mockup (in app: PNG real ROMFAST) */
|
||||
.logo-fallback{display:inline-flex; align-items:center; gap:7px; font-weight:800; letter-spacing:-.01em; font-size:var(--fs-lg);}
|
||||
.logo-fallback .rom{color:#D1342F;} .logo-fallback .fast{color:var(--accent);}
|
||||
.h-center{text-align:center; line-height:1.15;}
|
||||
.h-title{font-size:var(--fs-md); font-weight:700; letter-spacing:.01em;}
|
||||
.h-title .accent{color:var(--accent);}
|
||||
.h-sub{font-size:var(--fs-xs); color:var(--muted); margin-top:2px;}
|
||||
.h-sub .svc{color:var(--ink); font-weight:600;}
|
||||
.env{display:inline-block; margin-left:8px; padding:1px 7px; border-radius:99px; font-size:10px; font-weight:700;
|
||||
text-transform:uppercase; letter-spacing:.04em; color:var(--warn); background:color-mix(in srgb,var(--warn) 16%,transparent);}
|
||||
.tier{display:inline-block; margin-left:6px; padding:1px 8px; border-radius:99px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--accent); background:color-mix(in srgb,var(--accent) 16%,transparent);}
|
||||
.h-right{display:flex; align-items:center; justify-content:flex-end; gap:10px;}
|
||||
.rar-chip{display:flex; align-items:center; gap:8px; height:38px; padding:0 13px; border-radius:99px; border:1px solid color-mix(in srgb,var(--ok) 35%,var(--line)); background:color-mix(in srgb,var(--ok) 10%,transparent); color:var(--ok); font-size:var(--fs-sm); font-weight:600; cursor:default;}
|
||||
.rar-chip .dot{width:9px; height:9px; border-radius:99px; background:currentColor; box-shadow:0 0 0 4px color-mix(in srgb,currentColor 22%,transparent);}
|
||||
|
||||
/* selector tema STIL LANDING: pill cu icon + eticheta tema curenta */
|
||||
.tema-btn{display:flex; align-items:center; gap:8px; height:38px; padding:0 13px; border-radius:8px;
|
||||
background:transparent; border:1px solid var(--line); color:var(--muted); font-family:var(--font-ui);
|
||||
font-size:var(--fs-sm); cursor:pointer; transition:border-color .15s, color .15s;}
|
||||
.tema-btn:hover{border-color:var(--accent); color:var(--ink);}
|
||||
.tema-btn svg{flex-shrink:0;}
|
||||
.ver{font-size:var(--fs-xs); color:var(--muted);}
|
||||
.icon-btn{width:38px; height:38px; border-radius:8px; border:1px solid var(--line); background:transparent;
|
||||
color:var(--ink); font-size:18px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center;}
|
||||
|
||||
.wrap{max-width:1100px; margin:0 auto; padding:24px 22px 60px;}
|
||||
.sec{font-size:var(--fs-lg); margin:30px 0 12px; padding-bottom:6px; border-bottom:1px solid var(--line);}
|
||||
.note{font-size:var(--fs-sm); color:var(--muted);}
|
||||
.toolbar{display:flex; gap:10px; align-items:center; margin:14px 0;}
|
||||
.toolbar button{font-family:var(--font-ui); font-size:var(--fs-sm); height:34px; padding:0 12px; border-radius:7px; border:1px solid var(--line); background:var(--card); color:var(--ink); cursor:pointer;}
|
||||
|
||||
/* ===== /login profesional ===== */
|
||||
.login-shell{min-height:520px; display:grid; grid-template-columns:1.1fr .9fr; border:1px solid var(--line); border-radius:16px; overflow:hidden; background:var(--card);}
|
||||
.login-aside{padding:40px 38px; background:linear-gradient(160deg, color-mix(in srgb,var(--accent) 14%,var(--card)), var(--card)); border-right:1px solid var(--line); display:flex; flex-direction:column; justify-content:center;}
|
||||
.login-brand{display:flex; align-items:center; gap:10px; margin-bottom:22px;}
|
||||
.login-brand .logo-fallback{font-size:var(--fs-xl);}
|
||||
.login-aside h2{font-size:var(--fs-2xl); line-height:1.2; margin:0 0 12px; letter-spacing:-.02em;}
|
||||
.login-aside p{font-size:var(--fs-md); color:var(--muted); line-height:1.6; margin:0 0 18px; max-width:380px;}
|
||||
.trust{display:flex; flex-direction:column; gap:9px; margin-top:6px;}
|
||||
.trust div{display:flex; align-items:center; gap:9px; font-size:var(--fs-sm); color:var(--ink);}
|
||||
.trust svg{flex-shrink:0; color:var(--ok);}
|
||||
.login-form{padding:40px 38px; display:flex; flex-direction:column; justify-content:center;}
|
||||
.login-form h3{font-size:var(--fs-xl); margin:0 0 4px;}
|
||||
.login-form .lead{font-size:var(--fs-sm); color:var(--muted); margin:0 0 22px;}
|
||||
.field{margin-bottom:16px;}
|
||||
.field label{display:block; font-size:var(--fs-sm); color:var(--muted); margin-bottom:6px;}
|
||||
.field input{width:100%; font-family:var(--font-ui); font-size:var(--fs-md); color:var(--ink); background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:11px 13px; min-height:44px;}
|
||||
.field input:focus{outline:2px solid var(--accent); border-color:var(--accent);}
|
||||
.btn-primary{width:100%; height:46px; font-family:var(--font-ui); font-size:var(--fs-md); font-weight:600; background:var(--accent); color:#fff; border:none; border-radius:8px; cursor:pointer; margin-top:4px;}
|
||||
.row-between{display:flex; align-items:center; justify-content:space-between; margin:-4px 0 18px;}
|
||||
.link{color:var(--accent); font-size:var(--fs-sm); text-decoration:none;}
|
||||
.login-foot{text-align:center; font-size:var(--fs-sm); color:var(--muted); margin-top:18px;}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="grafit">
|
||||
|
||||
<div class="wrap">
|
||||
<div class="toolbar">
|
||||
<span class="note">Comuta tema cu butonul de tema (stil landing: icon + eticheta).</span>
|
||||
</div>
|
||||
|
||||
<!-- ===== A. Antet aplicatie — LOGAT ===== -->
|
||||
<div class="sec">Antet aplicatie — LOGAT (branded)</div>
|
||||
<header>
|
||||
<div class="h-left">
|
||||
<span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span>
|
||||
<span class="note" style="font-size:var(--fs-xs)">(in app: PNG logo real)</span>
|
||||
</div>
|
||||
<div class="h-center">
|
||||
<div class="h-title">ROMFAST <span class="accent">AUTOPASS</span><span class="env">test</span><span class="tier">Pro</span></div>
|
||||
<div class="h-sub">Service auto: <span class="svc">Service Auto Vâlcea SRL</span></div>
|
||||
</div>
|
||||
<div class="h-right">
|
||||
<div class="rar-chip" title="Ultima autentificare RAR: 28.06.2026 09:41"><span class="dot"></span> RAR online</div>
|
||||
<button class="tema-btn" onclick="cycle()">
|
||||
<svg id="t-ic" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||
<span id="t-label">Grafit</span>
|
||||
</button>
|
||||
<span class="ver">v5.16</span>
|
||||
<button class="icon-btn" title="Meniu cont">☰</button>
|
||||
</div>
|
||||
</header>
|
||||
<p class="note">Doar cand esti LOGAT: titlu <strong>ROMFAST AUTOPASS</strong> + badge plan
|
||||
(<span class="mono">accounts.tier</span>) + sub titlu numele service-ului (<span class="mono">accounts.name</span>);
|
||||
dreapta dot <strong>RAR online</strong> + selector tema + meniu cont. Toate gate-uite pe
|
||||
<span class="mono">is_authenticated</span>.</p>
|
||||
|
||||
<!-- ===== B. /login — NEAUTENTIFICAT (antet minimal) ===== -->
|
||||
<div class="sec">Pagina /login — NEAUTENTIFICAT (antet minimal)</div>
|
||||
<div class="login-topbar">
|
||||
<span class="lt-brand"><span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span> ROMFAST <span class="accent">AUTOPASS</span></span>
|
||||
<button class="tema-btn" onclick="cycle()">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||
<span id="t-label2">Grafit</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="login-shell" style="border-radius:0 0 16px 16px; border-top:none;">
|
||||
<div class="login-aside">
|
||||
<div class="login-brand"><span class="logo-fallback"><span class="rom">ROM</span><span class="fast">FAST</span></span></div>
|
||||
<h2>ROMFAST <span style="color:var(--accent)">AUTOPASS</span></h2>
|
||||
<p>Declară prestațiile de service-auto la RAR AUTOPASS, automat. Conform Legii 142/2023.</p>
|
||||
<div class="trust">
|
||||
<div><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M20 6L9 17l-5-5"/></svg> Conform Legii 142/2023 și OMTI 210/2024</div>
|
||||
<div><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg> Datele tale criptate, șterse la 3 luni</div>
|
||||
<div><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg> Parte din familia ROA — Romfast Applications</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-form">
|
||||
<h3>Autentificare</h3>
|
||||
<p class="lead">Intră în contul service-ului tău.</p>
|
||||
<div class="field"><label>Email</label><input type="email" value="contact@service-valcea.ro"></div>
|
||||
<div class="field"><label>Parolă</label><input type="password" value="••••••••••"></div>
|
||||
<div class="row-between"><span></span><a class="link" href="#">Ai uitat parola?</a></div>
|
||||
<button class="btn-primary">Intră în cont</button>
|
||||
<div class="login-foot">Cont nou? <a class="link" href="/signup">Înregistrează service-ul</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note">Antetul de <span class="mono">/login</span> NU are dot RAR, nume service sau badge plan —
|
||||
utilizatorul nu e logat inca. Doar logo + titlu <strong>ROMFAST AUTOPASS</strong> + selector tema.
|
||||
(RAR/service/plan/meniu apar abia dupa autentificare.)</p>
|
||||
|
||||
<div class="sec">Landing — butonul „Autentificare" duce la /login</div>
|
||||
<p class="note">Pe landing, „Autentificare" (azi deschide modalul de register din landing pe tab-ul
|
||||
login) devine un link real către <span class="mono">/login</span> (pagina de mai sus). „Creează cont"
|
||||
rămâne neschimbat. Selectorul de teme din landing e exact modelul pe care îl preia aplicația.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var THEMES=[['grafit','Grafit'],['cobalt','Cobalt'],['cupru','Cupru'],['hartie','Hârtie']];
|
||||
var i=0;
|
||||
function cycle(){ i=(i+1)%THEMES.length; document.body.setAttribute('data-theme',THEMES[i][0]); document.getElementById('t-label').textContent=THEMES[i][1]; var l2=document.getElementById('t-label2'); if(l2)l2.textContent=THEMES[i][1]; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user