Compare commits
33 Commits
2ec3292382
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a9ad71cdd | ||
|
|
37172ae2f9 | ||
| 1091745789 | |||
|
|
cc8c712ed2 | ||
|
|
251666feb6 | ||
|
|
9b5006f26c | ||
|
|
e80a20c856 | ||
|
|
dc6c1db439 | ||
|
|
ec5ca5ba84 | ||
|
|
3ba35abed2 | ||
|
|
c2beede084 | ||
|
|
56ebe1f7cb | ||
|
|
0bc618763e | ||
|
|
c6e29aa65d | ||
|
|
5a2c976d46 | ||
|
|
6674d0e3e1 | ||
|
|
809823a8ae | ||
|
|
65b6e4694d | ||
|
|
705ad030fe | ||
|
|
a46e364594 | ||
|
|
d7adeaf382 | ||
|
|
e2627c1aa4 | ||
|
|
44f261e269 | ||
|
|
86408887e5 | ||
|
|
164f90d603 | ||
|
|
0a96bd6a8e | ||
|
|
63b6cbc01d | ||
|
|
fa52468a80 | ||
|
|
39d2ba3dd0 | ||
|
|
eade702a52 | ||
|
|
789e3f54be | ||
|
|
458c3fd43c | ||
|
|
3b5cf7a7d9 |
37
.env.example
37
.env.example
@@ -8,20 +8,55 @@ AUTOPASS_CREDS_KEY=
|
|||||||
|
|
||||||
# --- Auth API-key ---
|
# --- Auth API-key ---
|
||||||
# true = orice /v1/* cere cheie valida (prod). false = dev (fara cheie -> cont id=1).
|
# true = orice /v1/* cere cheie valida (prod). false = dev (fara cheie -> cont id=1).
|
||||||
|
# OBLIGATORIU in docker-compose.yml (:?) — vezi nota din compose.
|
||||||
AUTOPASS_REQUIRE_API_KEY=false
|
AUTOPASS_REQUIRE_API_KEY=false
|
||||||
|
|
||||||
# --- Worker ---
|
# --- Worker ---
|
||||||
# Send catre RAR. false = nu trimite (default, sigur pentru probe). true = end-to-end.
|
# Send catre RAR. false = nu trimite (default, sigur pentru probe). true = end-to-end.
|
||||||
|
# OBLIGATORIU in docker-compose.yml (:?) — vezi nota din compose.
|
||||||
AUTOPASS_WORKER_SEND_ENABLED=false
|
AUTOPASS_WORKER_SEND_ENABLED=false
|
||||||
# Dev: foloseste creds <test> din settings.xml cand submission-ul nu are creds criptate.
|
# Dev: foloseste creds <test> din settings.xml cand submission-ul nu are creds criptate.
|
||||||
AUTOPASS_WORKER_USE_TEST_CREDS=false
|
AUTOPASS_WORKER_USE_TEST_CREDS=false
|
||||||
|
|
||||||
# --- RAR ---
|
# --- RAR ---
|
||||||
# test | prod
|
# test | prod. Ancora globala, NU tinta trimiterilor: dupa PRD 5.20 mediul RAR e
|
||||||
|
# per submission/cont (worker-ul trimite dupa submissions.rar_env). Variabila asta
|
||||||
|
# e doar fallback API pentru conturi fara medii disponibile, backfill la migrare
|
||||||
|
# si afisaj dashboard. OBLIGATORIE in docker-compose.yml (:?) — vezi nota din compose.
|
||||||
AUTOPASS_RAR_ENV=test
|
AUTOPASS_RAR_ENV=test
|
||||||
|
|
||||||
|
# --- Sesiuni web ---
|
||||||
|
# Secret semnat cookie sesiune, PARTAJAT intre restart-uri (fara el, fiecare
|
||||||
|
# redeploy delogheaza toti userii). Genereaza:
|
||||||
|
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
# OBLIGATORIU in docker-compose.yml (:?).
|
||||||
|
AUTOPASS_SESSION_SECRET=
|
||||||
|
# True (prod, implicit in compose): cookie de sesiune cu flag Secure (necesita HTTPS).
|
||||||
|
# False: doar pentru smoke-test local pe HTTP simplu (browserul dropeaza cookie-ul
|
||||||
|
# Secure pe HTTP, deci login-ul ar esua silentios cu true).
|
||||||
|
AUTOPASS_SESSION_HTTPS_ONLY=true
|
||||||
|
|
||||||
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
||||||
# false = dezactivat (default; /mapari instant, sugestii din GOLD/SILVER + fuzzy).
|
# false = dezactivat (default; /mapari instant, sugestii din GOLD/SILVER + fuzzy).
|
||||||
# true = sugestii semantice. Prima cerere /mapari lazy-load-eaza modelul fastembed/ONNX
|
# 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.
|
# (~230MB pe disc) sincron -> hang la prima cerere. Doar API-ul il incarca.
|
||||||
AUTOPASS_EMBEDDINGS_ENABLED=false
|
AUTOPASS_EMBEDDINGS_ENABLED=false
|
||||||
|
|
||||||
|
# --- Backup SQLite criptat (T2/P0-4, vezi docs/backup.md) ---
|
||||||
|
# TRIGGER DUR: backup-ul trebuie configurat INAINTE de prima declaratie reala in
|
||||||
|
# prod. Baza e sistemul legal de evidenta (L.142/2023); FINALIZATA e terminal la
|
||||||
|
# RAR, deci pierderea volumului fara backup produce duplicate necorectabile.
|
||||||
|
#
|
||||||
|
# Parola de criptare (gpg AES256) a backup-urilor — obligatorie, tools/backup_db.sh
|
||||||
|
# refuza sa scrie un backup necriptat. Foloseste fisierul (secret montat), NU variabila
|
||||||
|
# inline, in prod:
|
||||||
|
AUTOPASS_BACKUP_PASSPHRASE_FILE=
|
||||||
|
# Alternativa (ex. teste locale) — parola inline, evita in prod:
|
||||||
|
AUTOPASS_BACKUP_PASSPHRASE=
|
||||||
|
# Director destinatie backup-uri (pe volumul persistent, NU efemer).
|
||||||
|
AUTOPASS_BACKUP_DIR=/data/backups
|
||||||
|
# Cate backup-uri locale criptate se pastreaza (cele mai vechi se sterg).
|
||||||
|
AUTOPASS_BACKUP_KEEP=14
|
||||||
|
# Tinta rclone optionala pentru upload off-site (ex. remote:bucket/autopass). Fara ea,
|
||||||
|
# backup-ul ramane doar local pe volum — sarit explicit cu log, nu esec silentios.
|
||||||
|
AUTOPASS_BACKUP_RCLONE_REMOTE=
|
||||||
|
|||||||
114
CLAUDE.md
114
CLAUDE.md
@@ -1,104 +1,88 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
Ghid Claude Code pentru acest repo.
|
||||||
|
|
||||||
## Ce este
|
## 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).
|
Gateway web (Python/FastAPI) care preia prezentari service-auto si le declara la **RAR AUTOPASS** (L.142/2023, OM 210/2024). Inlocuieste integrarea VFP `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).
|
## Stil
|
||||||
|
|
||||||
## Surse de adevar (citeste-le inainte sa modifici comportament)
|
- Limba proiectului: **romana** (cod, comentarii, commit-uri, docs). Fara emoji.
|
||||||
|
- **Comentarii scurte, strict functionale.** Doar ce nu reiese din cod. FARA referinte la PRD-uri, stories (US-xxx), review-uri sau istoricul deciziei.
|
||||||
|
|
||||||
- `docs/api-rar-contract.md` — contractul RAR. **Unde un plan/cod difera de contract, contractul are dreptate.**
|
## Surse de adevar
|
||||||
- `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/`).
|
|
||||||
|
- `docs/api-rar-contract.md` — contractul RAR; la divergenta, contractul are dreptate.
|
||||||
|
- `docs/ROADMAP.md` — progres + proces de lucru; sesiunile noi pornesc de aici. Se modifica doar "Stadiu Implementare"; detaliile stau in `docs/prd/`.
|
||||||
|
|
||||||
## Comenzi
|
## Comenzi
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip3 install -r requirements.txt # Python 3.12+
|
pip3 install -r requirements.txt # Python 3.12+
|
||||||
|
|
||||||
# Rulare locala (dev): API + worker sunt PROCESE SEPARATE
|
# Dev: API + worker = procese SEPARATE
|
||||||
uvicorn app.main:app --reload --port 8010 # API: dashboard /, Swagger /docs, /healthz, /metrics
|
uvicorn app.main:app --reload --port 8010 # dashboard /, /docs, /healthz, /metrics
|
||||||
python3 -m app.worker # worker (necesar doar pentru a procesa coada)
|
python3 -m app.worker # proceseaza coada
|
||||||
|
|
||||||
# Wrapper-ul start.sh ambaleaza mediu (test/prod) + rol (api/worker/both/finalizate)
|
# start.sh = mediu (test/prod) + rol (api/worker/both/finalizate); loguri in .run/
|
||||||
./start.sh test both --send # API + worker, trimite efectiv la RAR test (loguri in .run/)
|
./start.sh test both --send # trimite efectiv la RAR test
|
||||||
./start.sh test finalizate # listeaza prezentarile inregistrate la RAR (verificare independenta)
|
./start.sh test finalizate # listeaza prezentarile inregistrate la RAR
|
||||||
./start.sh status # stare procese + /healthz
|
./start.sh status | stop
|
||||||
./start.sh stop # opreste procesele pornite cu "both"
|
|
||||||
./start-test.sh / ./start-prod.sh # fixeaza mediul, forwardeaza rolul
|
./start-test.sh / ./start-prod.sh # fixeaza mediul, forwardeaza rolul
|
||||||
|
|
||||||
# Teste (pytest; folosesc FastAPI TestClient + SQLite temporar). Testele live RAR sunt
|
# Teste (TestClient + SQLite temporar); testele live RAR sunt skip implicit (marker `live`)
|
||||||
# skip implicit (marker `live`) — `pytest -q` nu atinge endpointul real.
|
|
||||||
python3 -m pytest -q
|
python3 -m pytest -q
|
||||||
python3 -m pytest tests/test_worker_reconcile.py -q # un fisier
|
python3 -m pytest tests/test_worker_reconcile.py::test_x -q
|
||||||
python3 -m pytest tests/test_worker_reconcile.py::test_x -q # un singur test
|
AUTOPASS_LIVE_RAR=1 python3 -m pytest tests/test_live_rar.py -q # LIVE opt-in: creeaza FINALIZATA; cere settings.xml cu creds <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):
|
# Chei API (admin, doar CLI); cheia rfak_... se afiseaza O SINGURA DATA
|
||||||
# reproduce lantul mapare inline -> queued -> worker -> sent -> verificare in finalizate.
|
python3 -m tools.apikey create --account 2 | list | rotate | revoke
|
||||||
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)
|
docker compose up --build # deploy: api + worker + autoheal, acelasi image + volum SQLite
|
||||||
python3 -m tools.apikey create --account 2 # cheie afisata O SINGURA DATA (rfak_...)
|
# prod = Dokploy cu autodeploy la push; reguli in docs/deploy-dokploy.md
|
||||||
python3 -m tools.apikey list|rotate|revoke
|
|
||||||
|
|
||||||
# Docker (deploy): api + worker + autoheal, acelasi image + volum SQLite
|
|
||||||
docker compose up --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Arhitectura
|
## Arhitectura
|
||||||
|
|
||||||
**Doua procese peste acelasi SQLite (WAL) persistent**, care comunica EXCLUSIV prin tabela `submissions`:
|
**Doua procese peste acelasi SQLite (WAL)**, 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".
|
- **API** (`app.main:app`) — API v1 (`app/api/v1/`), dashboard HTMX (`app/web/` + `templates/`), `/healthz`, `/metrics`. Worker-ul NU ruleaza 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.
|
- **Worker** (`app/worker/__main__.py`) — bucla: heartbeat → recuperare orfane → claim atomic (`BEGIN IMMEDIATE`) → login RAR per cont → `postPrezentare` → update. Retry/backoff exponential, reconciliere anti-duplicat, lease pe `sending` orfane, re-login la JWT expirat (TTL 30h). Send OFF implicit (`AUTOPASS_WORKER_SEND_ENABLED=false`).
|
||||||
|
|
||||||
**Doua canale de intrare** convergent in coada `submissions`:
|
**Doua canale de intrare** in coada: `POST /v1/prezentari` (ROAAUTO/soft propriu) si import web xlsx/csv → mapare coloane → preview → commit (`app/import_parse.py`, `import_router.py`).
|
||||||
- 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.
|
Flux: validare (`validation.py`) → mapare operatie→cod (`mapping.py`) → enqueue cu PII criptat → worker trimite → dashboard live.
|
||||||
|
|
||||||
### Invariante critice (usor de stricat)
|
### Invariante critice
|
||||||
|
|
||||||
- **`AUTOPASS_CREDS_KEY` trebuie sa fie ACEEASI intre API si worker.** API cripteaza creds RAR (Fernet), worker le decripteaza. Chei diferite → worker nu poate decripta → trimiterile esueaza. `start.sh both` genereaza o cheie efemera partajata; pentru prod pune una persistenta in `.env`. (`crypto.py`)
|
- **`AUTOPASS_CREDS_KEY` identica intre API si worker** (Fernet, `crypto.py`): chei diferite → worker nu decripteaza creds → trimiterile esueaza. `start.sh both` genereaza cheie efemera partajata; in prod, cheie persistenta in `.env`.
|
||||||
- **Idempotenta = hash de continut canonic** server-side (`idempotency.py`), pentru ca RAR accepta duplicate si nu are nr. comanda. `build_key` normalizeaza INTOTDEAUNA `account_id` la `account_or_default` (None == 1) INAINTE de hash — altfel acelasi rand logic primeste chei diferite pe canalele API vs import (OV-2). `canonicalize_row` normeaza VIN/nr/odometru (strip ".0" din coercion Excel) inainte de validare si de cheie.
|
- **Idempotenta = hash de continut canonic** server-side (`idempotency.py`) — RAR accepta duplicate, fara nr. comanda. `build_key` normalizeaza `account_id` la `account_or_default` (None == 1) INAINTE de hash, altfel acelasi rand primeste chei diferite pe API vs import. `canonicalize_row` normeaza VIN/nr/odometru (strip ".0" din Excel) inainte de validare si 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.
|
- **`FINALIZATA` e terminal la RAR** (fara anulare/corectie). Pe eroare **ambigua** (timeout/TransportError/502/503/504/429/408) sau `sending` orfan, worker-ul cauta in finalizate (vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara re-trimitere (`reconcile.py`). **EXCEPTIE: RAR 500 cu mesaj** (`RarError.rar_message`, ex. `ORA-12899`) = esec DEFINITIV → fara reconciliere/retry, `error` cu mesajul RAR (`RAR_EROARE_SERVER`); altfel ar marca fals `sent` un record PARTIAL lasat de RAR (ne-tranzactional).
|
||||||
- **Creds RAR per cont**: durabile in `accounts.rar_creds_enc` (canal web, fallback re-login) SAU efemere in `submissions.rar_creds_enc` (canal API, sterse dupa primul login reusit). Worker incearca submission-ul intai, apoi fallback la cont. Purjarea sterge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_enc`.
|
- **Creds RAR per cont**: durabile in `accounts` (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 contul. Purjarea sterge DOAR creds efemere din `submissions`.
|
||||||
- **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).
|
- **Auth API-key** (`auth.py`): identifica contul ROAAUTO (separat de creds RAR); stocam doar SHA-256. `AUTOPASS_REQUIRE_API_KEY`: `false` (dev) → fara cheie = cont id=1, cheie invalida → 401; `true` (prod) → obligatorie pe `/v1/*` protejat. POST-urile, importul si GET-urile de listare (API + fragmente dashboard) sunt account-scoped, 404-before-leak pe id strain. `GET /v1/nomenclator` public intentionat (coduri RAR publice, fara PII).
|
||||||
- **Admin bootstrap fara hardcodare**: la `/signup`, primul user din TOATA baza (`count_admins()==0`, verificat in tranzactie) primeste `is_admin=1` automat; orice signup ulterior e neadmin. `is_admin` da acces la `/admin` (panou global, toate conturile) — verificat pe `account_id`-ul sesiunii, dar neconditionat DE care `account_id` a primit userul (semnul de "cine e admin" nu are legatura cu contul `id=1`). Contul `id=1` e doar bucket-ul de fallback pentru trafic API neautentificat in dev (`AUTOPASS_REQUIRE_API_KEY=false`) — separat de conceptul de admin. Fix manual daca primul signup s-a consumat deja gresit: `python3 -m tools.account set-admin --account N`.
|
- **Admin bootstrap**: primul user din TOATA baza la `/signup` (`count_admins()==0`, in tranzactie) devine `is_admin=1`; restul nu. `is_admin` da acces la `/admin` (panou global) si e independent de contul id=1 (acela e doar fallback dev pentru API neautentificat). Fix manual: `python3 -m tools.account set-admin --account N`.
|
||||||
- **Mapare coloane retinuta per `(account_id, signature_coloane)`** (`column_mappings`): la urmatorul fisier cu aceleasi coloane, pentru acelasi cont, maparea se reaplica automat. Un cont poate avea mai multe formate memorate simultan.
|
- **Mapare coloane memorata per `(account_id, signature_coloane)`** (`column_mappings`): reaplicata automat la fisiere cu aceleasi coloane; un cont poate avea mai multe formate.
|
||||||
- **Mapare operatie→cod**: prestatie poate veni cu `cod_prestatie` (cod RAR direct) sau `cod_op_service` (cod intern) + `denumire`. Nerezolvat → submission `needs_mapping` (nu se trimite), apare in editorul web cu sugestie fuzzy; la salvarea maparii se re-rezolva automat submission-urile blocate.
|
- **Mapare operatie→cod**: prestatia vine cu `cod_prestatie` (cod RAR) sau `cod_op_service` + `denumire`. Nerezolvat → `needs_mapping` (nu se trimite), editor web cu sugestie fuzzy; salvarea maparii 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`.
|
- **Excludere de la declarare** (`operations_mapping.exclus=1`; optiunea "Nu se declara la RAR" = sentinel `__NEDECLARAT__` in select-urile de mapare): operatia NU pleaca la RAR. Precedenta in `resolve_prestatii`: cod explicit valid pe rand > exclus > mapare exacta > reguli text. Itemii adnotati `exclus` sunt scosi din payload/cheie cu `split_prestatii_excluse` inainte de enqueue; rand de import cu toate operatiile excluse → stare `excluded` ("Nedeclarat", nu se comite); submission cu toate excluse → `needs_data` cu motiv explicit. `save_mapping` peste o regula exclusa reseteaza `exclus=0`.
|
||||||
- **WAF RAR da 403 fara User-Agent de browser** — toate apelurile httpx trimit `User-Agent: Mozilla/5.0` (`config.py`, confirmat live).
|
- **`cod_prestatie` VALIDAT fata de nomenclator la ingestie** (`resolve_prestatii(..., valid_codes)`): cod necunoscut NU se trimite raw — e promovat la `cod_op_service` (denumire=cod) si intra la mapat. Motiv (confirmat live): RAR accepta doar coduri din nomenclator (max 5 car.); cod necunoscut → HTTP 500 `ORA-12899`, iar RAR ne-tranzactional lasa record PARTIAL `FINALIZATA` pe care reconcilierea l-ar marca fals `sent`. La cod nemapat: `on_unmapped_error` (boolean top-level pe `POST /v1/prezentari` + `/valideaza`) = `false` → editor/`needs_mapping`; `true` → respins fara enqueue (`submission_id=null` + `erori`). Precedenta: cerere > `accounts.on_unmapped_error_default` > `false`.
|
||||||
- **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`).
|
- **WAF RAR da 403 fara User-Agent de browser** — httpx trimite mereu `User-Agent: Mozilla/5.0` (`config.py`).
|
||||||
- **Retentie**: `submissions` sent + `import_batches` primesc `purge_after = now + 90 zile`; worker-ul purjeaza odata pe ora (T16, GDPR/L.142).
|
- **422 fara echo de credentiale**: handler-ul din `main.py` pastreaza type/loc/msg dar DROP-a `input`/`ctx` (altfel reflecta `rar_credentials.password`).
|
||||||
|
- **Retentie**: `submissions` sent + `import_batches` → `purge_after = now + 90 zile`; worker purjeaza orar (GDPR/L.142).
|
||||||
|
|
||||||
### Masina de stari submissions
|
### Masina de stari submissions
|
||||||
|
|
||||||
`queued → sending → sent` (succes, cu `id_prezentare` de la RAR). Ramuri: `needs_mapping` (cod nerezolvat), `needs_data` (RAR 400, validare continut), `error` (max retries / 4xx nerecuperabil / **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`.
|
`queued → sending → sent` (cu `id_prezentare` de la RAR). Ramuri: `needs_mapping`, `needs_data` (RAR 400), `error` (max retries / 4xx nerecuperabil / RAR 500 cu mesaj / creds invalide sau login 401 — fara retry pe creds gresite). Backoff: `next_attempt_at = now + base*2^retry`, plafonat. Schema: `app/schema.sql`.
|
||||||
|
|
||||||
## Mod non-interactiv
|
## 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.
|
Vezi `/workspace/CLAUDE.md`: cu `claude -p`, fisierele noi merg DOAR in `/workspace/.claude-work/<task>/`; modificarile la fisiere existente raman in locatia originala.
|
||||||
|
|
||||||
## Skill routing
|
## Skill routing
|
||||||
|
|
||||||
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
|
Cand cererea se potriveste unui skill, invoca-l prin Skill tool; la indoiala, invoca-l.
|
||||||
|
|
||||||
Key routing rules:
|
- Idei produs → /office-hours · Strategie → /plan-ceo-review · Arhitectura → /plan-eng-review
|
||||||
- Product ideas/brainstorming → invoke /office-hours
|
- Design system/plan → /design-consultation, /plan-design-review · Pipeline complet → /autoplan
|
||||||
- Strategy/scope → invoke /plan-ceo-review
|
- Bug-uri → /investigate · QA site → /qa, /qa-only · Code review → /review · Polish vizual → /design-review
|
||||||
- Architecture → invoke /plan-eng-review
|
- Ship/deploy/PR → /ship, /land-and-deploy · Salvare/reluare context → /context-save, /context-restore · Spec → /spec
|
||||||
- Design system/plan review → invoke /design-consultation or /plan-design-review
|
|
||||||
- Full review pipeline → invoke /autoplan
|
|
||||||
- Bugs/errors → invoke /investigate
|
|
||||||
- QA/testing site behavior → invoke /qa or /qa-only
|
|
||||||
- Code review/diff check → invoke /review
|
|
||||||
- Visual polish → invoke /design-review
|
|
||||||
- Ship/deploy/PR → invoke /ship or /land-and-deploy
|
|
||||||
- Save progress → invoke /context-save
|
|
||||||
- Resume context → invoke /context-restore
|
|
||||||
- Author a backlog-ready spec/issue → invoke /spec
|
|
||||||
|
|||||||
26
Dockerfile
26
Dockerfile
@@ -11,9 +11,19 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# tzdata = necesar pentru ca 'localtime' din SQLite sa rezolve Europe/Bucharest.
|
# tzdata = necesar pentru ca 'localtime' din SQLite sa rezolve Europe/Bucharest.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
# gnupg = criptare backup SQLite (tools/backup_db.sh, T2/P0-4) — NU e inclus implicit
|
||||||
|
# in imaginea slim (doar gzip e pachet esential Debian). rclone NU e instalat aici
|
||||||
|
# intentionat: upload-ul remote e optional (AUTOPASS_BACKUP_RCLONE_REMOTE) si scriptul
|
||||||
|
# sare peste el explicit daca binarul lipseste.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends tzdata gnupg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# User neprivilegiat (uid/gid stabil): orice RCE viitor are raza de explozie
|
||||||
|
# limitata, nu root in container. HOME e necesar explicit — fastembed descarca
|
||||||
|
# modelul (~230MB) in $HOME la prima folosire (AUTOPASS_EMBEDDINGS_ENABLED=true).
|
||||||
|
RUN groupadd -g 10001 app && useradd -u 10001 -g app -d /home/app -m app
|
||||||
|
ENV HOME=/home/app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
@@ -22,9 +32,19 @@ COPY tools ./tools
|
|||||||
|
|
||||||
# Date persistente (SQLite WAL) pe volum montat.
|
# Date persistente (SQLite WAL) pe volum montat.
|
||||||
ENV AUTOPASS_DB_PATH=/data/autopass.db
|
ENV AUTOPASS_DB_PATH=/data/autopass.db
|
||||||
|
# Log text rotativ (app/config.py:log_dir, RotatingFileHandler) pe acelasi volum
|
||||||
|
# persistent, NU in /app/.run — altfel non-root n-are voie sa scrie in /app.
|
||||||
|
ENV AUTOPASS_LOG_DIR=/data/logs
|
||||||
|
|
||||||
|
# chown INAINTE de VOLUME — schimbarile de proprietar dupa directiva VOLUME se
|
||||||
|
# pierd la build. Migrare one-off pentru un volum EXISTENT deja detinut de root:
|
||||||
|
# docker compose run --rm --user root api chown -R app:app /data
|
||||||
|
RUN mkdir -p /data && chown app:app /data
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
EXPOSE 8000
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8010
|
||||||
|
|
||||||
# Default = API. Worker-ul suprascrie command in compose.
|
# Default = API. Worker-ul suprascrie command in compose.
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -59,6 +59,24 @@ pe semnatura coloanelor, per cont) → **preview** (fiecare rand: `ok` / `needs_
|
|||||||
Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, `Odometru final`,
|
Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, `Odometru final`,
|
||||||
`Odometru initial`, `Operatie`, `Observatii`. Fiecare cont poate avea mai multe formate memorate.
|
`Odometru initial`, `Operatie`, `Observatii`. Fiecare cont poate avea mai multe formate memorate.
|
||||||
|
|
||||||
|
## Mapari si sugestii de cod RAR
|
||||||
|
|
||||||
|
O operatie fara cod RAR valid (din import sau din API) opreste randul in **Lipsa cod**
|
||||||
|
(`needs_mapping`) — nimic nu pleaca la RAR pana nu confirma un om codul. Sistemul propune
|
||||||
|
insa singur un cod ("sugestia principala"), calculat pe loc din denumirea operatiei:
|
||||||
|
|
||||||
|
- **Cand:** de fiecare data cand un rand ajunge in `needs_mapping` — sugestia apare imediat
|
||||||
|
in raspunsul API (`motiv` + `nemapate[].sugestie`), in tab-ul **Mapari** (precompletata
|
||||||
|
in select) si in `GET /v1/mapari/pending`.
|
||||||
|
- **Cum (in ordinea increderii):** mapari confirmate de oameni in alte conturi (GOLD partajat) →
|
||||||
|
clasificari LLM pre-calculate (SILVER) → similaritate semantica (embeddings k-NN) →
|
||||||
|
potrivire fuzzy pe numele din nomenclatorul RAR. Prima sursa care raspunde castiga;
|
||||||
|
textele care nu sunt operatii (ITP, taxe, discounturi) nu primesc sugestie.
|
||||||
|
- **Sugestia NU se aplica singura** — declararea la RAR e definitiva, deci prima asociere
|
||||||
|
operatie→cod cere confirmarea ta (tab Mapari sau `POST /v1/mapari`). Din acel moment maparea
|
||||||
|
e memorata: aparitiile viitoare se rezolva automat, iar **toate** trimiterile deja blocate
|
||||||
|
pe acel cod se deblocheaza pe loc si intra in coada.
|
||||||
|
|
||||||
## API v1 (curl)
|
## API v1 (curl)
|
||||||
|
|
||||||
Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`.
|
Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`.
|
||||||
|
|||||||
62
TODOS.md
62
TODOS.md
@@ -2,6 +2,68 @@
|
|||||||
|
|
||||||
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare.
|
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare.
|
||||||
|
|
||||||
|
## Din /autoplan PRD 5.23 (2026-07-07)
|
||||||
|
|
||||||
|
- [ ] **Bulk in preview: "aplica maparea la toate cele N randuri identice"** — o decizie de mapare
|
||||||
|
repetata per rand e adevaratul consumator de timp; editorul unificat nu o rezolva. Ce: dupa o
|
||||||
|
mapare in preview/modal, ofera aplicarea la toate randurile din batch cu aceeasi operatie
|
||||||
|
nemapata. De ce: reduce deschiderile de modal (drumul 10x — vocea externa CEO). Pro: taie direct
|
||||||
|
timpul de corectie pe importuri mari. Contra: atinge panoul de preview (non-goal in 5.23).
|
||||||
|
Context: `reresolve_account` exista deja pentru re-rezolvare dupa `save_mapping` — de verificat
|
||||||
|
daca nu acopera deja cazul la nivel de cont; daca da, TODO-ul devine doar UI de confirmare.
|
||||||
|
Effort: M→S (CC). Prioritate: P2. Depinde de: 5.23 livrat.
|
||||||
|
- [ ] **Metrici de baza pentru bucla de mapare** — cate deschideri de modal per import, % randuri
|
||||||
|
auto-rezolvate, % sugestii acceptate. De ce: fara ele nu stim daca editorul (5.23) sau motorul
|
||||||
|
(5.14/5.18) merita urmatoarea investitie. Pro: decizie de produs pe date. Contra: telemetrie
|
||||||
|
noua. Context: contorizare in `/form-chips` + import commit; expunere in /metrics existent.
|
||||||
|
Effort: S. Prioritate: P3.
|
||||||
|
- [ ] **A11y touch targets pe chips (× sub 44px)** — UI-ul compact 11-12px face tintele de atins
|
||||||
|
mici pe mobil. De ce: operare de pe telefon in service. Pro: mai putine mis-tap-uri. Contra:
|
||||||
|
chips mai inalti = mai putine pe rand. Context: decizie 5.23 = consecventa cu designul compact
|
||||||
|
existent; de revizitat la un audit a11y global. Effort: S. Prioritate: P3.
|
||||||
|
|
||||||
|
## Din executie + code-review 5.23 (2026-07-08)
|
||||||
|
|
||||||
|
- [ ] **Curata calea legacy `/trimitere/{id}/mapeaza`** — formular per-operatie separat, cu propriile
|
||||||
|
"Sugestii:", care coexista cu editorul unificat de chips. Nu interfereaza (dispare cand tinta e
|
||||||
|
setata), dar e cod mort de UX vechi. Context: semnalat de worker la fix-ul de randare initiala.
|
||||||
|
Effort: S. Prioritate: P3.
|
||||||
|
- [ ] **Autocomplete/cautare in selectul de coduri RAR** — non-goal explicit in 5.23 (select nativ
|
||||||
|
pastrat). De reevaluat daca nomenclatorul creste peste ~18 coduri fixe. Effort: M. Prioritate: P3.
|
||||||
|
- [ ] **Race buton Salveaza in timpul unui `/form-chips` in zbor** — sectiunea chips e inerta pe
|
||||||
|
`.htmx-request`, dar butonul Salveaza al formularului parinte nu e blocat; un submit foarte rapid
|
||||||
|
ar putea trimite un `chips_state` neactualizat. Improbabil (dedup + fereastra mica). Context:
|
||||||
|
finding PLAUSIBLE din code-review. Effort: S. Prioritate: P3.
|
||||||
|
- [ ] **Conflict mapare din tab Mapari vs item marcat `exclus` din chips** — un item marcat exclus
|
||||||
|
explicit din editor domina o mapare salvata ulterior din tab-ul Mapari (`resolve_prestatii`
|
||||||
|
precedenta item-exclus > mapare cont); utilizatorul poate sa nu inteleaga de ce maparea "nu tine".
|
||||||
|
De clarificat semantica sau de adaugat un semnal in UI. Context: finding PLAUSIBLE din code-review.
|
||||||
|
Effort: S. Prioritate: P3.
|
||||||
|
|
||||||
|
## Din /autoplan PRD 5.21 (2026-07-06)
|
||||||
|
|
||||||
|
- [ ] **CLI administrare cache embeddings (`python3 -m tools.embcache stats|clear|rebuild`)** — v1 foloseste
|
||||||
|
`DELETE FROM embedding_cache` manual (decizie Open Q1). De construit cand operarea manuala devine
|
||||||
|
frecventa sau cand apare al doilea operator. Effort: S. (CEO, low.)
|
||||||
|
- [ ] **Precompute vectori la build (artefact in imaginea Docker)** — modelul e pinned si corpusul seed e
|
||||||
|
comis, deci vectorii pot fi precalculati la build; ar face si PRIMUL start pe volum proaspat ieftin
|
||||||
|
(azi doar al doilea beneficiaza). Effort: M (pipeline build + fallback la runtime). (Outside voice CEO, medium.)
|
||||||
|
- [ ] **Graceful reload / zero-downtime restart** — procesul vechi serveste pana cel nou e cald; ar elimina
|
||||||
|
COMPLET fereastra post-restart, inclusiv incarcarea modelului (~10-20s) pe care cache-ul n-o poate evita.
|
||||||
|
Infra (Dokploy/compose), nu app. Effort: M. (Outside voice CEO, medium.)
|
||||||
|
- [ ] **Latenta per-query `suggest_nearest` (cosine pur Python)** — bucla Python peste 17k x 384 la fiecare
|
||||||
|
cautare; de MASURAT intai; daca >100ms, matrice numpy + dot product (efort mic). Designul curent e validat
|
||||||
|
pana la ~50k randuri de corpus (prag documentat in PRD 5.21 A12). (Eng F5, medium.)
|
||||||
|
|
||||||
|
## Din hardening 80/20 (/autoplan, 2026-07-03)
|
||||||
|
|
||||||
|
- [ ] **Traefik IP-allowlist pe /v1 + /metrics in fereastra de lansare** (T4) — cu zero clienti API,
|
||||||
|
suprafata anonima de ingestie n-are apelant legitim extern (ROAAUTO propriu vine de la IP cunoscut).
|
||||||
|
O regula Traefik declarativa (allowlist IP ROAAUTO + admin) elimina complet ingestia anonima in
|
||||||
|
perioada cea mai riscanta. Deferat: P0-1 (auth obligatoriu pe /v1) + T3 (startup guard) acopera deja
|
||||||
|
gaura principala. De activat cand: primesc trafic de scanare pe /v1 SAU inainte de expunere cu presa.
|
||||||
|
Effort: S (config Traefik). Depinde de: acces la config Traefik in Dokploy.
|
||||||
|
|
||||||
## Din PRD 5.12 (2026-06-26)
|
## Din PRD 5.12 (2026-06-26)
|
||||||
|
|
||||||
- [ ] **Mai multi utilizatori per firma (flux de invitatie / alaturare la cont)** — azi CUI e unic, deci
|
- [ ] **Mai multi utilizatori per firma (flux de invitatie / alaturare la cont)** — azi CUI e unic, deci
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def create_account(
|
|||||||
# Planul cerut: pastram doar valori valide; orice altceva -> NULL (defensiv).
|
# Planul cerut: pastram doar valori valide; orice altceva -> NULL (defensiv).
|
||||||
req_plan = requested_plan if requested_plan in VALID_TIERS else None
|
req_plan = requested_plan if requested_plan in VALID_TIERS else None
|
||||||
try:
|
try:
|
||||||
# Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z.
|
# Trial Pro automat la creare: tier='free' + trial_until=now+30z.
|
||||||
trial_until = (
|
trial_until = (
|
||||||
(datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
|
(datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
)
|
)
|
||||||
@@ -150,6 +150,40 @@ def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_auto_send(conn: sqlite3.Connection, account_id: int) -> bool:
|
||||||
|
"""Comutatorul Auto per cont: `accounts.auto_send_enabled` ca bool.
|
||||||
|
|
||||||
|
Cont inexistent -> False (Auto OFF: sigur, tine randurile). Nu comita (ca set_active).
|
||||||
|
"""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT auto_send_enabled FROM accounts WHERE id=?", (account_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
return bool(row["auto_send_enabled"])
|
||||||
|
|
||||||
|
|
||||||
|
def set_auto_send(conn: sqlite3.Connection, account_id: int, enabled: bool) -> None:
|
||||||
|
"""Comuta `accounts.auto_send_enabled`. Idempotent (set pe aceeasi
|
||||||
|
valoare nu arunca). Scoped pe cont. Nu comita — respecta conventia fisierului (apelantul
|
||||||
|
comita, ca la set_active/set_status). Cont inexistent -> UPDATE fara efect (no-op sigur).
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET auto_send_enabled=? WHERE id=?",
|
||||||
|
(1 if enabled else 0, account_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def held_for_account(conn: sqlite3.Connection, account_id: int) -> int:
|
||||||
|
"""Snapshot-ul `held` la ingestie. SURSA UNICA (chokepoint) folosita de
|
||||||
|
TOATE situ-rile de ingestie care scriu `status='queued'`.
|
||||||
|
|
||||||
|
Intoarce 0 (nu tine) daca `accounts.auto_send_enabled=1`, altfel 1 (tine randul). Cont
|
||||||
|
inexistent -> 1 (sigur: tine randul, nu-l trimite fara comutator explicit).
|
||||||
|
"""
|
||||||
|
return 0 if get_auto_send(conn, account_id) else 1
|
||||||
|
|
||||||
|
|
||||||
def set_tier(
|
def set_tier(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
account_id: int,
|
account_id: int,
|
||||||
@@ -181,7 +215,7 @@ def set_tier(
|
|||||||
"UPDATE accounts SET tier=?, trial_until=? WHERE id=?",
|
"UPDATE accounts SET tier=?, trial_until=? WHERE id=?",
|
||||||
(tier, trial_until, account_id),
|
(tier, trial_until, account_id),
|
||||||
)
|
)
|
||||||
# Audit in app_events (decizie PRD 5.17 US-008, fara PII nou)
|
# Audit in app_events (fara PII nou)
|
||||||
try:
|
try:
|
||||||
from .observ import log_event
|
from .observ import log_event
|
||||||
log_event(
|
log_event(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Router import Treapta 2 — upload fisier xlsx/csv, preview, commit, export.
|
"""Router import — upload fisier xlsx/csv, preview, commit, export.
|
||||||
|
|
||||||
Endpointuri:
|
Endpointuri:
|
||||||
POST /v1/import — upload fisier, staging in import_batches/import_rows
|
POST /v1/import — upload fisier, staging in import_batches/import_rows
|
||||||
@@ -32,6 +32,7 @@ from pydantic import BaseModel, Field
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from ... import errors
|
from ... import errors
|
||||||
|
from ...accounts import held_for_account
|
||||||
from ...auth import require_api_access, resolve_account_id
|
from ...auth import require_api_access, resolve_account_id
|
||||||
from ...crypto import decrypt_creds, encrypt_creds
|
from ...crypto import decrypt_creds, encrypt_creds
|
||||||
from ...db import get_connection
|
from ...db import get_connection
|
||||||
@@ -48,11 +49,13 @@ from ...mapping import (
|
|||||||
_emite_text_rule_hits,
|
_emite_text_rule_hits,
|
||||||
account_or_default,
|
account_or_default,
|
||||||
has_no_auto_send,
|
has_no_auto_send,
|
||||||
|
load_excluded_ops,
|
||||||
load_mapping_meta,
|
load_mapping_meta,
|
||||||
load_nomenclator_codes,
|
load_nomenclator_codes,
|
||||||
load_text_rules,
|
load_text_rules,
|
||||||
normalize_for_match,
|
normalize_for_match,
|
||||||
resolve_prestatii,
|
resolve_prestatii,
|
||||||
|
split_prestatii_excluse,
|
||||||
)
|
)
|
||||||
from ...validation import validate_prezentare
|
from ...validation import validate_prezentare
|
||||||
from ...rar_env import MediuIndisponibil, rar_env_efectiv_cont, rezolva_rar_env
|
from ...rar_env import MediuIndisponibil, rar_env_efectiv_cont, rezolva_rar_env
|
||||||
@@ -134,11 +137,12 @@ def _resolve_row_for_preview(
|
|||||||
valid_codes: set[str] | None = None,
|
valid_codes: set[str] | None = None,
|
||||||
text_rules: list[dict] | None = None,
|
text_rules: list[dict] | None = None,
|
||||||
reviewed: bool = False,
|
reviewed: bool = False,
|
||||||
|
excluded_ops: set[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Rezolva un rand din import pentru preview: aplica mapare coloane + validare.
|
"""Rezolva un rand din import pentru preview: aplica mapare coloane + validare.
|
||||||
|
|
||||||
Intoarce un dict cu:
|
Intoarce un dict cu:
|
||||||
resolved_status: ok/needs_mapping/needs_data/needs_review
|
resolved_status: ok/needs_mapping/needs_data/needs_review/excluded
|
||||||
resolved: valorile finale rezolvate (VIN, data, km, prestatii)
|
resolved: valorile finale rezolvate (VIN, data, km, prestatii)
|
||||||
errors: lista erori validare
|
errors: lista erori validare
|
||||||
flags: motive needs_review
|
flags: motive needs_review
|
||||||
@@ -189,7 +193,7 @@ def _resolve_row_for_preview(
|
|||||||
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||||
|
|
||||||
# obs derive-on-empty (D7/E3 PRD 5.15): daca obs e gol si avem operatie,
|
# obs derive-on-empty: daca obs e gol si avem operatie,
|
||||||
# copiem denumirea operatiei in obs (nu o mutam — op_service ramane neatins).
|
# copiem denumirea operatiei in obs (nu o mutam — op_service ramane neatins).
|
||||||
# DERIVE-ON-EMPTY: doar cand obs e gol, ca sa fie idempotent la re-preview/re-editare.
|
# DERIVE-ON-EMPTY: doar cand obs e gol, ca sa fie idempotent la re-preview/re-editare.
|
||||||
obs_curent = str(mapped.get("obs") or "").strip()
|
obs_curent = str(mapped.get("obs") or "").strip()
|
||||||
@@ -217,7 +221,7 @@ def _resolve_row_for_preview(
|
|||||||
|
|
||||||
# Rezolvare prestatii
|
# Rezolvare prestatii
|
||||||
prestatii = mapped.get("prestatii") or []
|
prestatii = mapped.get("prestatii") or []
|
||||||
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
|
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
|
||||||
mapped["prestatii"] = resolved
|
mapped["prestatii"] = resolved
|
||||||
|
|
||||||
# Determinare stare
|
# Determinare stare
|
||||||
@@ -229,13 +233,27 @@ def _resolve_row_for_preview(
|
|||||||
"flags": all_flags,
|
"flags": all_flags,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validare continut
|
# Operatii excluse de la declarare: ies DOAR din payload-ul trimis la RAR (si din
|
||||||
errors = validate_prezentare(mapped)
|
# cheia de idempotenta), NU din `mapped["prestatii"]` persistat/afisat — editorul
|
||||||
|
# (chips modal) trebuie sa arate chip-ul Nedeclarat la redeschidere (round-trip).
|
||||||
|
# Toate excluse -> rand 'excluded' (nu se trimite, nu blocheaza).
|
||||||
|
declarabile, excluse = split_prestatii_excluse(resolved)
|
||||||
|
if excluse and not declarabile:
|
||||||
|
return {
|
||||||
|
"resolved_status": "excluded",
|
||||||
|
"resolved": mapped,
|
||||||
|
"errors": [],
|
||||||
|
"flags": all_flags,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validare continut: doar partea declarabila conteaza (validate_prezentare
|
||||||
|
# ignora oricum itemii fara cod_prestatie, dar `mapped` ramane intact pentru afisare).
|
||||||
|
errors = validate_prezentare({**mapped, "prestatii": declarabile} if excluse else mapped)
|
||||||
|
|
||||||
if all_flags and not reviewed:
|
if all_flags and not reviewed:
|
||||||
# needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala.
|
# needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala.
|
||||||
# Daca reviewed=True (operatorul a confirmat explicit valorile in modal), sarim
|
# Daca reviewed=True (operatorul a confirmat explicit valorile in modal), sarim
|
||||||
# acest return si continuam spre ok/needs_data (US-007, PRD 5.12).
|
# acest return si continuam spre ok/needs_data.
|
||||||
return {
|
return {
|
||||||
"resolved_status": "needs_review",
|
"resolved_status": "needs_review",
|
||||||
"resolved": mapped,
|
"resolved": mapped,
|
||||||
@@ -243,8 +261,6 @@ def _resolve_row_for_preview(
|
|||||||
"flags": all_flags,
|
"flags": all_flags,
|
||||||
}
|
}
|
||||||
|
|
||||||
# US-001 (PRD 5.11): ramura auto_send eliminata din preview.
|
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return {
|
return {
|
||||||
"resolved_status": "needs_data",
|
"resolved_status": "needs_data",
|
||||||
@@ -267,8 +283,9 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any], rar
|
|||||||
return build_key(account_id, canon, rar_env)
|
return build_key(account_id, canon, rar_env)
|
||||||
|
|
||||||
|
|
||||||
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
|
# Campuri de continut editabile in preview. Prestatiile (chips operatie<->cod RAR)
|
||||||
# aici (raman in panoul de mapare). obs = text liber, se trateaza ca non-canonic
|
# se editeaza tot in modal, dar se persista separat (param `prestatii` din
|
||||||
|
# apply_row_override), nu prin EDIT_FIELDS. obs = text liber, se trateaza ca non-canonic
|
||||||
# (doar .strip(), fara canonicalize_row) — urmeaza ramura `else` din _merge_override.
|
# (doar .strip(), fara canonicalize_row) — urmeaza ramura `else` din _merge_override.
|
||||||
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs")
|
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs")
|
||||||
|
|
||||||
@@ -319,9 +336,13 @@ def apply_row_override(
|
|||||||
account_id: int | None,
|
account_id: int | None,
|
||||||
row_index: int,
|
row_index: int,
|
||||||
fields: dict[str, str | None],
|
fields: dict[str, str | None],
|
||||||
|
prestatii: list[dict[str, str]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Persista override-ul canonic pentru un rand de preview (mutatie PURA de stocare).
|
"""Persista override-ul canonic pentru un rand de preview (mutatie PURA de stocare).
|
||||||
|
|
||||||
|
`prestatii`: None = neatins; lista = inlocuieste integral prestatiile randului
|
||||||
|
(chips editate in modal, deja validate fata de nomenclator de apelant).
|
||||||
|
|
||||||
NU recalculeaza statusul si NU atinge `submissions` — preview-ul rederiva statusul
|
NU recalculeaza statusul si NU atinge `submissions` — preview-ul rederiva statusul
|
||||||
prin `_resolve_row_for_preview` (un singur clasificator, fara drift).
|
prin `_resolve_row_for_preview` (un singur clasificator, fara drift).
|
||||||
|
|
||||||
@@ -359,8 +380,10 @@ def apply_row_override(
|
|||||||
current = dec
|
current = dec
|
||||||
|
|
||||||
new_override = _merge_override(current, fields)
|
new_override = _merge_override(current, fields)
|
||||||
|
if prestatii is not None:
|
||||||
|
new_override["prestatii"] = prestatii
|
||||||
enc = encrypt_creds(new_override) if new_override else None
|
enc = encrypt_creds(new_override) if new_override else None
|
||||||
# D#9 (PRD 5.12): resetam reviewed=0 la orice schimbare de valoare — operatorul
|
# Resetam reviewed=0 la orice schimbare de valoare — operatorul
|
||||||
# trebuie sa reconfirme dupa editare. NU conditionam pe reviewed curent: orice override
|
# trebuie sa reconfirme dupa editare. NU conditionam pe reviewed curent: orice override
|
||||||
# (chiar si revert la valoarea initiala) anuleaza confirmarea implicita.
|
# (chiar si revert la valoarea initiala) anuleaza confirmarea implicita.
|
||||||
conn.execute("UPDATE import_rows SET override_json=?, reviewed=0 WHERE id=?", (enc, row["rid"]))
|
conn.execute("UPDATE import_rows SET override_json=?, reviewed=0 WHERE id=?", (enc, row["rid"]))
|
||||||
@@ -764,9 +787,10 @@ def preview_import(
|
|||||||
# Incarca maparea de operatii o singura data
|
# Incarca maparea de operatii o singura data
|
||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
# Validare nomenclator + reguli text + excluderi incarcate O DATA, inainte de bucla.
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
excluded_ops = load_excluded_ops(conn, acct)
|
||||||
|
|
||||||
# Mediul RAR efectiv al contului — folosit la calculul cheii de idempotenta
|
# Mediul RAR efectiv al contului — folosit la calculul cheii de idempotenta
|
||||||
# la preview (trebuie sa coincida cu ce va folosi commit-ul fara rar_env explicit).
|
# la preview (trebuie sa coincida cu ce va folosi commit-ul fara rar_env explicit).
|
||||||
@@ -822,13 +846,22 @@ def preview_import(
|
|||||||
override=overrides[i] or None,
|
override=overrides[i] or None,
|
||||||
valid_codes=valid_codes,
|
valid_codes=valid_codes,
|
||||||
text_rules=text_rules,
|
text_rules=text_rules,
|
||||||
|
excluded_ops=excluded_ops,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculeaza cheia de idempotenta pentru randurile ok/needs_review
|
# Calculeaza cheia de idempotenta pentru randurile ok/needs_review
|
||||||
key = None
|
key = None
|
||||||
if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
||||||
try:
|
try:
|
||||||
key = _build_idempotency_key(account_id, resolved_info["resolved"], preview_env)
|
# Cheia foloseste DOAR partea declarabila (itemii exclusi nu intra in
|
||||||
|
# hash) — identic cu cheia calculata la commit (linia cu
|
||||||
|
# split_prestatii_excluse din commit_import), altfel preview-ul
|
||||||
|
# arata alt duplicat decat cel real.
|
||||||
|
_key_resolved = resolved_info["resolved"]
|
||||||
|
_decl_key, _exc_key = split_prestatii_excluse(_key_resolved.get("prestatii"))
|
||||||
|
if _exc_key:
|
||||||
|
_key_resolved = {**_key_resolved, "prestatii": _decl_key}
|
||||||
|
key = _build_idempotency_key(account_id, _key_resolved, preview_env)
|
||||||
keys_for_lookup.append(key)
|
keys_for_lookup.append(key)
|
||||||
if key not in key_to_index:
|
if key not in key_to_index:
|
||||||
key_to_index[key] = []
|
key_to_index[key] = []
|
||||||
@@ -964,7 +997,7 @@ def commit_import(
|
|||||||
if batch["status"] == "committed":
|
if batch["status"] == "committed":
|
||||||
raise HTTPException(status_code=409, detail="batch deja comis")
|
raise HTTPException(status_code=409, detail="batch deja comis")
|
||||||
|
|
||||||
# D#8 (PRD 5.12): gate commit derivat din DB `reviewed` pe AMBELE canale.
|
# Gate commit derivat din DB `reviewed` pe AMBELE canale.
|
||||||
# API: reviewed_rows pastrat (contract stabil) dar seteaza reviewed=1 in DB inainte
|
# API: reviewed_rows pastrat (contract stabil) dar seteaza reviewed=1 in DB inainte
|
||||||
# de interogare. Randurile needs_review cu reviewed=1 sunt incluse in comit.
|
# de interogare. Randurile needs_review cu reviewed=1 sunt incluse in comit.
|
||||||
if req.reviewed_rows:
|
if req.reviewed_rows:
|
||||||
@@ -1031,7 +1064,7 @@ def commit_import(
|
|||||||
if n_total_ok == 0:
|
if n_total_ok == 0:
|
||||||
raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.")
|
raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.")
|
||||||
|
|
||||||
# Rezolva mediul RAR tinta al lotului (US-009): cerut > default cont > ancora globala.
|
# Rezolva mediul RAR tinta al lotului: cerut > default cont > ancora globala.
|
||||||
try:
|
try:
|
||||||
env = rezolva_rar_env(conn, account_id, req.rar_env)
|
env = rezolva_rar_env(conn, account_id, req.rar_env)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -1043,8 +1076,8 @@ def commit_import(
|
|||||||
"disponibile": e.disponibile,
|
"disponibile": e.disponibile,
|
||||||
})
|
})
|
||||||
|
|
||||||
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
|
# Enforce volum plan — INAINTE de enqueue (invariant idempotenta).
|
||||||
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
|
# Respingere TOTALA a lotului (nu enqueue partial tacut).
|
||||||
from ...config import get_settings as _get_settings
|
from ...config import get_settings as _get_settings
|
||||||
from ...plans import PLANS, effective_tier, monthly_usage
|
from ...plans import PLANS, effective_tier, monthly_usage
|
||||||
from ...observ import log_event as _log_event_plan
|
from ...observ import log_event as _log_event_plan
|
||||||
@@ -1112,9 +1145,10 @@ def commit_import(
|
|||||||
# Incarca maparea de operatii
|
# Incarca maparea de operatii
|
||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
# Validare nomenclator + reguli text + excluderi incarcate O DATA, inainte de bucla.
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
excluded_ops = load_excluded_ops(conn, acct)
|
||||||
|
|
||||||
# Construieste payload-urile submissions
|
# Construieste payload-urile submissions
|
||||||
enqueued: list[dict] = []
|
enqueued: list[dict] = []
|
||||||
@@ -1125,6 +1159,9 @@ def commit_import(
|
|||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
purge_after_sql = "datetime('now', '+90 days')"
|
purge_after_sql = "datetime('now', '+90 days')"
|
||||||
|
# Snapshot `held` din comutatorul contului (chokepoint unic
|
||||||
|
# held_for_account) — 0 daca Auto ON, 1 daca Auto OFF. Per-cont -> o data.
|
||||||
|
held_val = held_for_account(conn, acct)
|
||||||
|
|
||||||
for ok_row in ok_rows:
|
for ok_row in ok_rows:
|
||||||
row_dict = ok_row["data"]
|
row_dict = ok_row["data"]
|
||||||
@@ -1158,49 +1195,53 @@ def commit_import(
|
|||||||
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||||
|
|
||||||
# obs derive-on-empty (D7/E3 PRD 5.15): copiere denumire in obs daca obs e gol.
|
# obs derive-on-empty: copiere denumire in obs daca obs e gol.
|
||||||
# Identic cu logica din _resolve_row_for_preview (override aplicat tot ultimul).
|
# Identic cu logica din _resolve_row_for_preview (override aplicat tot ultimul).
|
||||||
obs_curent = str(mapped.get("obs") or "").strip()
|
obs_curent = str(mapped.get("obs") or "").strip()
|
||||||
if not obs_curent and operatie_val:
|
if not obs_curent and operatie_val:
|
||||||
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||||
mapped["obs"] = obs_denumire
|
mapped["obs"] = obs_denumire
|
||||||
|
|
||||||
|
# Override editat in preview — aplicat INAINTE de resolve_prestatii,
|
||||||
|
# ca in _resolve_row_for_preview: prestatiile din override (chips
|
||||||
|
# editate in modal) trec prin ACELASI resolve, altfel raman raw si
|
||||||
|
# hash-ul/telemetria ar folosi prestatiile pre-editare.
|
||||||
|
override = ok_row.get("override") or {}
|
||||||
|
if override:
|
||||||
|
mapped.update(override)
|
||||||
|
|
||||||
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
|
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
|
||||||
prestatii = mapped.get("prestatii") or []
|
prestatii = mapped.get("prestatii") or []
|
||||||
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
|
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
|
||||||
|
# Prestatiile excluse de la declarare NU pleaca la RAR si nu intra in
|
||||||
|
# cheia de idempotenta, dar RAMAN in payload_json persistat (round-trip
|
||||||
|
# in editor, ca in preview) — ingustarea la declarabile se face doar
|
||||||
|
# local, pentru canon/cheie, si la worker, la momentul trimiterii.
|
||||||
|
declarabile, _excluse = split_prestatii_excluse(resolved)
|
||||||
|
# rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip.
|
||||||
|
if not declarabile:
|
||||||
|
continue
|
||||||
mapped["prestatii"] = resolved
|
mapped["prestatii"] = resolved
|
||||||
|
|
||||||
# Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie)
|
# Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie;
|
||||||
canon = canonicalize_row(mapped)
|
# cheia IDENTICA cu preview: _build_idempotency_key = canonicalize_row + build_key).
|
||||||
|
canon = canonicalize_row({**mapped, "prestatii": declarabile})
|
||||||
mapped.update({
|
mapped.update({
|
||||||
"vin": canon["vin"],
|
"vin": canon["vin"],
|
||||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||||
"odometru_final": canon["odometru_final"],
|
"odometru_final": canon["odometru_final"],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
|
|
||||||
override = ok_row.get("override") or {}
|
|
||||||
if override:
|
|
||||||
mapped.update(override)
|
|
||||||
# Re-canonicalizeaza pentru a obtine cheia IDENTICA cu cea din preview
|
|
||||||
# (_build_idempotency_key = canonicalize_row + build_key peste mapped).
|
|
||||||
canon = canonicalize_row(mapped)
|
|
||||||
mapped.update({
|
|
||||||
"vin": canon["vin"],
|
|
||||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
|
||||||
"odometru_final": canon["odometru_final"],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env)
|
# Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env)
|
||||||
key = build_key(account_id, canon, env)
|
key = build_key(account_id, canon, env)
|
||||||
|
|
||||||
# Hash row pentru atestare (valori rezolvate)
|
# Hash row pentru atestare (valori rezolvate, doar partea declarabila)
|
||||||
rows_for_hash.append(json.dumps({
|
rows_for_hash.append(json.dumps({
|
||||||
"row_index": row_index,
|
"row_index": row_index,
|
||||||
"vin": mapped.get("vin"),
|
"vin": mapped.get("vin"),
|
||||||
"data_prestatie": mapped.get("data_prestatie"),
|
"data_prestatie": mapped.get("data_prestatie"),
|
||||||
"odometru_final": mapped.get("odometru_final"),
|
"odometru_final": mapped.get("odometru_final"),
|
||||||
"prestatii": [str(p.get("cod_prestatie") or p.get("cod_op_service") or "") for p in resolved],
|
"prestatii": [str(p.get("cod_prestatie") or p.get("cod_op_service") or "") for p in declarabile],
|
||||||
}, sort_keys=True, ensure_ascii=False))
|
}, sort_keys=True, ensure_ascii=False))
|
||||||
|
|
||||||
payload_json = json.dumps(mapped, ensure_ascii=False)
|
payload_json = json.dumps(mapped, ensure_ascii=False)
|
||||||
@@ -1208,9 +1249,9 @@ def commit_import(
|
|||||||
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT OR IGNORE INTO submissions "
|
"INSERT OR IGNORE INTO submissions "
|
||||||
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) "
|
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env, held) "
|
||||||
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?)",
|
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?, ?)",
|
||||||
(key, acct, payload_json, import_id, row_index, env),
|
(key, acct, payload_json, import_id, row_index, env, held_val),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
@@ -1268,7 +1309,7 @@ def commit_import(
|
|||||||
class RandEditIn(BaseModel):
|
class RandEditIn(BaseModel):
|
||||||
"""Campuri de continut editabile in preview. None = ne-trimis (neschimbat);
|
"""Campuri de continut editabile in preview. None = ne-trimis (neschimbat);
|
||||||
"" = sterge override-ul (revine la valoarea din fisier).
|
"" = sterge override-ul (revine la valoarea din fisier).
|
||||||
obs = text liber fara validare de continut (US-005 PRD 5.15)."""
|
obs = text liber fara validare de continut."""
|
||||||
vin: str | None = None
|
vin: str | None = None
|
||||||
nr_inmatriculare: str | None = None
|
nr_inmatriculare: str | None = None
|
||||||
data_prestatie: str | None = None
|
data_prestatie: str | None = None
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from ...auth import _extract_key, account_for_key, resolve_account_id
|
|||||||
from ...config import get_settings
|
from ...config import get_settings
|
||||||
from ...db import get_connection
|
from ...db import get_connection
|
||||||
from ...mapping import account_or_default
|
from ...mapping import account_or_default
|
||||||
|
from ...rar_env import rar_env_efectiv
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1", tags=["integrare"])
|
router = APIRouter(prefix="/v1", tags=["integrare"])
|
||||||
|
|
||||||
@@ -35,7 +36,8 @@ def ping(
|
|||||||
|
|
||||||
Intoarce:
|
Intoarce:
|
||||||
account_id — contul rezolvat din cheie (sau 1 in dev fara cheie)
|
account_id — contul rezolvat din cheie (sau 1 in dev fara cheie)
|
||||||
mediu — "test" / "prod" (settings.rar_env)
|
mediu — mediul RAR efectiv al contului ("test"/"prod"),
|
||||||
|
nu ancora globala settings.rar_env
|
||||||
autentificat_cu_cheie — True daca cererea a venit cu o cheie API reala valida
|
autentificat_cu_cheie — True daca cererea a venit cu o cheie API reala valida
|
||||||
are_creds_rar — True daca contul are creds RAR stocate pe cel putin un mediu (test sau prod)
|
are_creds_rar — True daca contul are creds RAR stocate pe cel putin un mediu (test sau prod)
|
||||||
are_creds_test — True daca contul are creds RAR pentru mediul Testare
|
are_creds_test — True daca contul are creds RAR pentru mediul Testare
|
||||||
@@ -57,12 +59,13 @@ def ping(
|
|||||||
conn.close()
|
conn.close()
|
||||||
autentificat_cu_cheie = acct is not None
|
autentificat_cu_cheie = acct is not None
|
||||||
|
|
||||||
# Verificam daca contul are creds RAR stocate (per-env, US-013).
|
# Verificam daca contul are creds RAR stocate (per mediu).
|
||||||
aid = account_or_default(account_id)
|
aid = account_or_default(account_id)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=?", (aid,)
|
"SELECT rar_test_enabled, rar_prod_enabled, rar_creds_test_enc, "
|
||||||
|
"rar_creds_prod_enc, rar_env_default FROM accounts WHERE id=?", (aid,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -71,9 +74,15 @@ def ping(
|
|||||||
are_creds_prod = bool(row and row["rar_creds_prod_enc"])
|
are_creds_prod = bool(row and row["rar_creds_prod_enc"])
|
||||||
are_creds_rar = are_creds_test or are_creds_prod
|
are_creds_rar = are_creds_test or are_creds_prod
|
||||||
|
|
||||||
|
# mediu = mediul RAR EFECTIV al contului (REQ-DEFAULT), nu ancora globala
|
||||||
|
# settings.rar_env: un cont cu doar creds de test trebuie sa raporteze "test"
|
||||||
|
# chiar daca serverul ruleaza global pe "prod". Fallback pe ancora globala doar
|
||||||
|
# cand contul nu are niciun mediu disponibil (rar_env_efectiv -> None).
|
||||||
|
mediu_efectiv = (rar_env_efectiv(row) if row else None) or settings.rar_env
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"account_id": aid,
|
"account_id": aid,
|
||||||
"mediu": settings.rar_env,
|
"mediu": mediu_efectiv,
|
||||||
"autentificat_cu_cheie": autentificat_cu_cheie,
|
"autentificat_cu_cheie": autentificat_cu_cheie,
|
||||||
"are_creds_rar": are_creds_rar,
|
"are_creds_rar": are_creds_rar,
|
||||||
"are_creds_test": are_creds_test,
|
"are_creds_test": are_creds_test,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ...accounts import held_for_account
|
||||||
from ...auth import require_api_access, resolve_account_id
|
from ...auth import require_api_access, resolve_account_id
|
||||||
from ...crypto import encrypt_creds
|
from ...crypto import encrypt_creds
|
||||||
from ...db import get_connection
|
from ...db import get_connection
|
||||||
@@ -30,23 +31,27 @@ from ...mapping import (
|
|||||||
account_or_default,
|
account_or_default,
|
||||||
account_scope_clause,
|
account_scope_clause,
|
||||||
classify_prezentare,
|
classify_prezentare,
|
||||||
|
load_excluded_ops,
|
||||||
load_mapping_meta,
|
load_mapping_meta,
|
||||||
|
load_nomenclator,
|
||||||
load_nomenclator_codes,
|
load_nomenclator_codes,
|
||||||
load_text_rules,
|
load_text_rules,
|
||||||
pending_unmapped,
|
pending_unmapped,
|
||||||
reresolve_account,
|
reresolve_account,
|
||||||
save_mapping,
|
save_mapping,
|
||||||
|
sugestie_pentru_op,
|
||||||
)
|
)
|
||||||
from ...models import (
|
from ...models import (
|
||||||
PrezentareRequest,
|
PrezentareRequest,
|
||||||
PrezentariResponse,
|
PrezentariResponse,
|
||||||
SubmissionResult,
|
SubmissionResult,
|
||||||
|
SubmissionResultMinim,
|
||||||
ValidarePrezentariRequest,
|
ValidarePrezentariRequest,
|
||||||
ValidareResponse,
|
ValidareResponse,
|
||||||
ValidareResult,
|
ValidareResult,
|
||||||
)
|
)
|
||||||
from ...observ import log_event
|
from ...observ import log_event
|
||||||
from ...payload_view import prezentare_din_payload
|
from ...payload_view import iso_date_prefix, prezentare_din_payload
|
||||||
from ...submissions_admin import (
|
from ...submissions_admin import (
|
||||||
SubmissionNotFound,
|
SubmissionNotFound,
|
||||||
SubmissionStateConflict,
|
SubmissionStateConflict,
|
||||||
@@ -68,13 +73,13 @@ def _effective_on_unmapped_error(conn, acct: int, req_value: bool | None) -> boo
|
|||||||
return bool(row["on_unmapped_error_default"]) if row else False
|
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:
|
def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules=None, excluded_ops=None) -> dict:
|
||||||
"""classify_prezentare + aplicarea modului on_unmapped_error.
|
"""classify_prezentare + aplicarea modului on_unmapped_error.
|
||||||
|
|
||||||
Cand exista coduri nemapate si error_mode=True, marcheaza outcome-ul ca respingere
|
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.
|
(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 = classify_prezentare(content, mapping, mapping_meta, valid_codes, text_rules, excluded_ops)
|
||||||
cl["blocked_error"] = bool(cl["unmapped"]) and error_mode
|
cl["blocked_error"] = bool(cl["unmapped"]) and error_mode
|
||||||
return cl
|
return cl
|
||||||
|
|
||||||
@@ -87,13 +92,36 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ataseaza_sugestii(conn, cl: dict, nomenclator: list[dict]) -> None:
|
||||||
|
"""Adauga `sugestie` ({cod_prestatie, nume_prestatie, sursa} | None) pe fiecare
|
||||||
|
cod nemapat din clasificare. Suggestion-only: informeaza raspunsul API, nu
|
||||||
|
schimba statusul si nu se aplica automat."""
|
||||||
|
for u in cl.get("unmapped") or []:
|
||||||
|
u["sugestie"] = sugestie_pentru_op(
|
||||||
|
conn, u.get("denumire") or u.get("cod_op_service"), nomenclator
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Mesaj uman pentru un rand tinut manual (queued AND held=1).
|
||||||
|
# Reutilizeaza vocabularul existent AUTO_SEND_OPRIT (errors.py) — nu inventam al treilea
|
||||||
|
# vocabular "auto_send". Onestitate: raspunsul NU trebuie sa arate un "queued" curat fals.
|
||||||
|
MOTIV_HELD = (
|
||||||
|
"In asteptare — tinut pentru verificare; NU trimis la RAR (Auto OFF). "
|
||||||
|
"Elibereaza manual (trimite-acum) sau activeaza Trimiterea automata."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _motiv_held(status: str, held: bool) -> str | None:
|
||||||
|
"""Motiv uman pentru un rand tinut manual; None cand nu e cazul (status!=queued sau held=0)."""
|
||||||
|
return MOTIV_HELD if (held and status == "queued") else None
|
||||||
|
|
||||||
|
|
||||||
def _motiv_clasificare(cl: dict) -> str | None:
|
def _motiv_clasificare(cl: dict) -> str | None:
|
||||||
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
|
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
|
||||||
|
|
||||||
None cand status='queued'. Acopera ramurile de blocaj: erori de continut
|
None cand status='queued'. Acopera ramurile de blocaj: erori de continut
|
||||||
(needs_data) si coduri fara mapare RAR (needs_mapping).
|
(needs_data) si coduri fara mapare RAR (needs_mapping).
|
||||||
Dupa US-001: needs_mapping apare EXCLUSIV cand unmapped e non-gol
|
needs_mapping apare EXCLUSIV cand unmapped e non-gol.
|
||||||
(ramura auto_send_oprit era inaccesibila si a fost eliminata).
|
|
||||||
"""
|
"""
|
||||||
if cl["status"] == "queued":
|
if cl["status"] == "queued":
|
||||||
return None
|
return None
|
||||||
@@ -102,23 +130,35 @@ def _motiv_clasificare(cl: dict) -> str | None:
|
|||||||
(e.get("problema") or e.get("message") or "") for e in cl["errors"]
|
(e.get("problema") or e.get("message") or "") for e in cl["errors"]
|
||||||
).strip("; ") or "Date incomplete (respinse de RAR)."
|
).strip("; ") or "Date incomplete (respinse de RAR)."
|
||||||
if cl["unmapped"]:
|
if cl["unmapped"]:
|
||||||
coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"])
|
parti = []
|
||||||
return f"Coduri fara mapare RAR: {coduri}"
|
for u in cl["unmapped"]:
|
||||||
|
op = u.get("cod_op_service") or ""
|
||||||
|
s = u.get("sugestie")
|
||||||
|
if s and s.get("cod_prestatie"):
|
||||||
|
nume = f" {s['nume_prestatie']}" if s.get("nume_prestatie") else ""
|
||||||
|
parti.append(f"{op} (sugestie: {s['cod_prestatie']}{nume})")
|
||||||
|
else:
|
||||||
|
parti.append(op)
|
||||||
|
return f"Coduri fara mapare RAR: {', '.join(parti)}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult:
|
def _rezultat_enqueue(submission_id: int | None, cl: dict, held: int = 0, **extra) -> SubmissionResult:
|
||||||
"""SubmissionResult onest dintr-un rezultat de clasificare.
|
"""SubmissionResult onest dintr-un rezultat de clasificare.
|
||||||
|
|
||||||
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
|
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
|
||||||
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None.
|
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None, EXCEPTAND
|
||||||
|
randurile tinute manual (`held=1` -> held=true + motiv non-null).
|
||||||
"""
|
"""
|
||||||
|
tinut = bool(held)
|
||||||
|
motiv = _motiv_clasificare(cl) or _motiv_held(cl["status"], tinut)
|
||||||
return SubmissionResult(
|
return SubmissionResult(
|
||||||
submission_id=submission_id,
|
submission_id=submission_id,
|
||||||
status=cl["status"],
|
status=cl["status"],
|
||||||
erori=list(cl["errors"]),
|
erori=list(cl["errors"]),
|
||||||
nemapate=_erori_nemapate(cl["unmapped"]),
|
nemapate=_erori_nemapate(cl["unmapped"]),
|
||||||
motiv=_motiv_clasificare(cl),
|
motiv=motiv,
|
||||||
|
held=tinut,
|
||||||
**extra,
|
**extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -160,22 +200,25 @@ def create_prezentari(
|
|||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
results: list[SubmissionResult] = []
|
results: list[SubmissionResult] = []
|
||||||
try:
|
try:
|
||||||
# load_mapping_meta incarca maparea op->cod RAR; dupa US-001, auto_send nu mai tine randuri.
|
# load_mapping_meta incarca maparea op->cod RAR; auto_send nu mai tine randuri.
|
||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
|
# 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 gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
|
# Nomenclator complet (cod+nume) pentru sugestiile atasate codurilor nemapate.
|
||||||
|
nomenclator = load_nomenclator(conn)
|
||||||
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
|
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
excluded_ops = load_excluded_ops(conn, acct)
|
||||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||||
|
|
||||||
# US-005: rezolva mediul RAR tinta (cerut > default cont > ancora globala).
|
# Rezolva mediul RAR tinta (cerut > default cont > ancora globala).
|
||||||
# MediuIndisponibil -> 422 inainte de orice enqueue (respinge tot lotul).
|
# MediuIndisponibil -> 422 inainte de orice enqueue (respinge tot lotul).
|
||||||
try:
|
try:
|
||||||
env = rezolva_rar_env(conn, acct, req.rar_env)
|
env = rezolva_rar_env(conn, acct, req.rar_env)
|
||||||
except MediuIndisponibil as e:
|
except MediuIndisponibil as e:
|
||||||
# US-012: audit blocare mediu indisponibil (tip='rar_env_blocat').
|
# Audit blocare mediu indisponibil (tip='rar_env_blocat').
|
||||||
log_event(
|
log_event(
|
||||||
"rar_env_blocat",
|
"rar_env_blocat",
|
||||||
nivel="WARNING",
|
nivel="WARNING",
|
||||||
@@ -201,8 +244,8 @@ def create_prezentari(
|
|||||||
detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"),
|
detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
|
# Enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
|
||||||
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
|
# Respingere TOTALA a lotului (nu enqueue partial tacut).
|
||||||
from ...config import get_settings as _get_settings
|
from ...config import get_settings as _get_settings
|
||||||
from ...plans import PLANS, effective_tier, monthly_usage
|
from ...plans import PLANS, effective_tier, monthly_usage
|
||||||
_settings = _get_settings()
|
_settings = _get_settings()
|
||||||
@@ -240,6 +283,11 @@ def create_prezentari(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Snapshot `held` din comutatorul contului (chokepoint unic held_for_account).
|
||||||
|
# 0 daca Auto ON, 1 daca Auto OFF (tine randul). Depinde de
|
||||||
|
# cont, nu de rand -> calculat o data. NU intra in payload/idempotenta.
|
||||||
|
held_val = held_for_account(conn, acct)
|
||||||
|
|
||||||
for prez in req.prezentari:
|
for prez in req.prezentari:
|
||||||
content = prez.model_dump()
|
content = prez.model_dump()
|
||||||
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
||||||
@@ -254,7 +302,7 @@ def create_prezentari(
|
|||||||
"odometru_final": canon["odometru_final"],
|
"odometru_final": canon["odometru_final"],
|
||||||
})
|
})
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id, status, id_prezentare FROM submissions WHERE idempotency_key=?",
|
"SELECT id, status, id_prezentare, held FROM submissions WHERE idempotency_key=?",
|
||||||
(key,),
|
(key,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -262,22 +310,25 @@ def create_prezentari(
|
|||||||
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
|
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
|
||||||
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
|
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
|
||||||
if existing["status"] == "error":
|
if existing["status"] == "error":
|
||||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
||||||
|
_ataseaza_sugestii(conn, cl, nomenclator)
|
||||||
if cl["blocked_error"]:
|
if cl["blocked_error"]:
|
||||||
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
|
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
|
||||||
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
|
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
|
||||||
continue
|
continue
|
||||||
|
# La reactivare re-snapshot-am `held` din comutatorul contului. Fara
|
||||||
|
# asta randul pastra held VECHI si se auto-trimitea desi contul e Auto OFF.
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
|
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
|
||||||
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
|
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
|
||||||
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, "
|
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, "
|
||||||
"rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'",
|
"held=?, rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'",
|
||||||
(cl["status"], json.dumps(cl["content"], ensure_ascii=False),
|
(cl["status"], json.dumps(cl["content"], ensure_ascii=False),
|
||||||
cl["rar_error"], creds_enc, env, existing["id"]),
|
cl["rar_error"], creds_enc, held_val, env, existing["id"]),
|
||||||
)
|
)
|
||||||
if cur.rowcount == 1:
|
if cur.rowcount == 1:
|
||||||
# Creds noi se propaga si in slotul durabil per-env al contului
|
# Creds noi se propaga si in slotul durabil per-env al contului
|
||||||
# — ambele canale converg pe parola corectata (US-013, env-aware).
|
# — ambele canale converg pe parola corectata (env-aware).
|
||||||
if req.rar_credentials is not None:
|
if req.rar_credentials is not None:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"UPDATE accounts SET rar_creds_{env}_enc=?, rar_{env}_enabled=1 WHERE id=?",
|
f"UPDATE accounts SET rar_creds_{env}_enc=?, rar_{env}_enabled=1 WHERE id=?",
|
||||||
@@ -286,41 +337,57 @@ def create_prezentari(
|
|||||||
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
|
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
|
||||||
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
|
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
|
||||||
# needs_data/needs_mapping, expune motivul (nu doar status).
|
# needs_data/needs_mapping, expune motivul (nu doar status).
|
||||||
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True, rar_env=env))
|
results.append(_rezultat_enqueue(existing["id"], cl, held=held_val, reactivated=True, rar_env=env))
|
||||||
continue
|
continue
|
||||||
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
||||||
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
|
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id, status, id_prezentare FROM submissions WHERE id=?",
|
"SELECT id, status, id_prezentare, held FROM submissions WHERE id=?",
|
||||||
(existing["id"],),
|
(existing["id"],),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
# Echo onest pe dedup: propaga `held` al randului
|
||||||
|
# existent — un rand tinut NU trebuie sa apara ca un "queued" curat fals.
|
||||||
|
dedup_held = bool(existing["held"])
|
||||||
results.append(
|
results.append(
|
||||||
SubmissionResult(
|
SubmissionResult(
|
||||||
submission_id=existing["id"],
|
submission_id=existing["id"],
|
||||||
status=existing["status"],
|
status=existing["status"],
|
||||||
id_prezentare=existing["id_prezentare"],
|
id_prezentare=existing["id_prezentare"],
|
||||||
deduped=True,
|
deduped=True,
|
||||||
|
held=dedup_held,
|
||||||
|
motiv=_motiv_held(existing["status"], dedup_held),
|
||||||
rar_env=env,
|
rar_env=env,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
||||||
# (canonicalize + mapare op->cod + validare; auto_send gate eliminat dupa US-001).
|
# (canonicalize + mapare op->cod + validare; fara gate auto_send).
|
||||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
||||||
|
_ataseaza_sugestii(conn, cl, nomenclator)
|
||||||
if cl["blocked_error"]:
|
if cl["blocked_error"]:
|
||||||
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
||||||
results.append(_rezultat_respins(None, cl, rar_env=env))
|
results.append(_rezultat_respins(None, cl, rar_env=env))
|
||||||
continue
|
continue
|
||||||
|
# `held` = snapshot comutator cont (chokepoint held_for_account).
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env) "
|
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env, held) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env),
|
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env, held_val),
|
||||||
)
|
)
|
||||||
sub_id = int(cur.lastrowid)
|
sub_id = int(cur.lastrowid)
|
||||||
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
||||||
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv;
|
||||||
results.append(_rezultat_enqueue(sub_id, cl, rar_env=env))
|
# pe queued tinut manual (held=1) expune held=true + motiv.
|
||||||
|
results.append(_rezultat_enqueue(sub_id, cl, held=held_val, rar_env=env))
|
||||||
|
|
||||||
|
# Corelare raspuns<->cerere: fiecare prezentare produce EXACT un rezultat,
|
||||||
|
# in ordine, deci pozitia din lista e si indexul cererii. Ecoul vehiculului
|
||||||
|
# foloseste valorile normalizate de Pydantic (upper/strip).
|
||||||
|
for i, (rez, prez) in enumerate(zip(results, req.prezentari)):
|
||||||
|
rez.index = i
|
||||||
|
rez.vin = prez.vin
|
||||||
|
rez.nr_inmatriculare = prez.nr_inmatriculare
|
||||||
|
|
||||||
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
||||||
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
||||||
@@ -342,7 +409,21 @@ def create_prezentari(
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return PrezentariResponse(results=results)
|
# Forma implicita = minim (6 chei fixe per rezultat); formatul complet
|
||||||
|
# (erori/nemapate/deduped/held/...) doar la cerere explicita.
|
||||||
|
if req.raspuns == "complet":
|
||||||
|
return PrezentariResponse(results=list(results))
|
||||||
|
return PrezentariResponse(results=[
|
||||||
|
SubmissionResultMinim(
|
||||||
|
index=r.index if r.index is not None else 0,
|
||||||
|
submission_id=r.submission_id,
|
||||||
|
vehicul=r.nr_inmatriculare or "",
|
||||||
|
status=r.status,
|
||||||
|
motiv=r.motiv,
|
||||||
|
id_prezentare=r.id_prezentare,
|
||||||
|
)
|
||||||
|
for r in results
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/prezentari/valideaza", response_model=ValidareResponse)
|
@router.post("/prezentari/valideaza", response_model=ValidareResponse)
|
||||||
@@ -363,15 +444,17 @@ def valideaza_prezentari(
|
|||||||
mapping_meta = load_mapping_meta(conn, acct)
|
mapping_meta = load_mapping_meta(conn, acct)
|
||||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
|
nomenclator = load_nomenclator(conn)
|
||||||
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
|
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
excluded_ops = load_excluded_ops(conn, acct)
|
||||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||||
|
|
||||||
# US-005 (DX F5): rezolva env identic ca trimiterea reala si ecou-ieste in raspuns.
|
# Rezolva env identic ca trimiterea reala si ecou-ieste in raspuns.
|
||||||
try:
|
try:
|
||||||
env = rezolva_rar_env(conn, acct, req.rar_env)
|
env = rezolva_rar_env(conn, acct, req.rar_env)
|
||||||
except MediuIndisponibil as e:
|
except MediuIndisponibil as e:
|
||||||
# US-012: audit blocare mediu indisponibil pe dry-run (tip='rar_env_blocat').
|
# Audit blocare mediu indisponibil pe dry-run (tip='rar_env_blocat').
|
||||||
log_event(
|
log_event(
|
||||||
"rar_env_blocat",
|
"rar_env_blocat",
|
||||||
nivel="WARNING",
|
nivel="WARNING",
|
||||||
@@ -397,7 +480,8 @@ def valideaza_prezentari(
|
|||||||
|
|
||||||
for i, prez in enumerate(req.prezentari):
|
for i, prez in enumerate(req.prezentari):
|
||||||
content = prez.model_dump()
|
content = prez.model_dump()
|
||||||
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
||||||
|
_ataseaza_sugestii(conn, res, nomenclator)
|
||||||
if res["blocked_error"]:
|
if res["blocked_error"]:
|
||||||
res = {**res, "status": "error"}
|
res = {**res, "status": "error"}
|
||||||
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
||||||
@@ -419,40 +503,88 @@ def valideaza_prezentari(
|
|||||||
return ValidareResponse(results=results)
|
return ValidareResponse(results=results)
|
||||||
|
|
||||||
|
|
||||||
|
def _valida_data_filtru(nume_param: str, valoare: str | None) -> str | None:
|
||||||
|
"""Valideaza formatul YYYY-MM-DD al unui parametru de filtrare pe perioada.
|
||||||
|
|
||||||
|
None/gol -> None (filtru inactiv). Format invalid -> 422, consecvent cu restul
|
||||||
|
rutelor API (vezi celelalte `status_code=422` din acest fisier).
|
||||||
|
"""
|
||||||
|
v = (valoare or "").strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
datetime.strptime(v, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"{nume_param} trebuie sa fie in format YYYY-MM-DD",
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
@router.get("/prezentari")
|
@router.get("/prezentari")
|
||||||
def list_prezentari(
|
def list_prezentari(
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
data_de: str | None = None,
|
||||||
|
data_pana: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
account_id: int = Depends(resolve_account_id),
|
account_id: int = Depends(resolve_account_id),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
data_de = _valida_data_filtru("data_de", data_de)
|
||||||
|
data_pana = _valida_data_filtru("data_pana", data_pana)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
scope_sql, scope_params = account_scope_clause(account_id)
|
scope_sql, scope_params = account_scope_clause(account_id)
|
||||||
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
|
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
|
||||||
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
|
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
|
||||||
# rar_env inclus (US-005): badge mediu in lista.
|
# rar_env inclus: badge mediu in lista.
|
||||||
cols = (
|
cols = (
|
||||||
"id, status, id_prezentare, rar_status_code, retry_count, "
|
"id, status, id_prezentare, rar_status_code, retry_count, "
|
||||||
"created_at, updated_at, payload_json, rar_env"
|
"created_at, updated_at, payload_json, rar_env, held"
|
||||||
)
|
)
|
||||||
|
where = [scope_sql]
|
||||||
|
params: list = list(scope_params)
|
||||||
if status:
|
if status:
|
||||||
|
where.append("status=?")
|
||||||
|
params.append(status)
|
||||||
|
where_sql = " AND ".join(where)
|
||||||
|
|
||||||
|
# data_prestatie traieste in payload_json, nu e coloana SQL (vezi
|
||||||
|
# payload_view.prezentare_din_payload) — nu poate intra in clauza WHERE.
|
||||||
|
# Cu filtru de perioada activ: fetch scope+status FARA LIMIT, filtreaza in
|
||||||
|
# Python dupa derivarea prezentarii, apoi LIMIT-eaza DUPA filtrare (altfel
|
||||||
|
# LIMIT SQL ar trunchia gresit inainte de filtru). Acelasi pattern ca
|
||||||
|
# /_fragments/submissions (dashboard web, app/web/routes.py).
|
||||||
|
filtru_perioada = bool(data_de or data_pana)
|
||||||
|
if filtru_perioada:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? "
|
f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC",
|
||||||
f"ORDER BY id DESC LIMIT ?",
|
params,
|
||||||
scope_params + [status, limit],
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
|
f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC LIMIT ?",
|
||||||
scope_params + [limit],
|
params + [limit],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
# Campuri afisabile derivate din payload (acelasi helper ca dashboardul web);
|
# Campuri afisabile derivate din payload (acelasi helper ca dashboardul web);
|
||||||
# payload_json brut nu se intoarce in raspuns.
|
# payload_json brut nu se intoarce in raspuns.
|
||||||
d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None))
|
prezentare = prezentare_din_payload(d.pop("payload_json", None))
|
||||||
|
if filtru_perioada:
|
||||||
|
d_prefix = iso_date_prefix(prezentare["data_prestatie"])
|
||||||
|
if d_prefix is None:
|
||||||
|
continue
|
||||||
|
if data_de and d_prefix < data_de:
|
||||||
|
continue
|
||||||
|
if data_pana and d_prefix > data_pana:
|
||||||
|
continue
|
||||||
|
d["prezentare"] = prezentare
|
||||||
out.append(d)
|
out.append(d)
|
||||||
|
if filtru_perioada and len(out) >= limit:
|
||||||
|
break
|
||||||
return {"submissions": out}
|
return {"submissions": out}
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -468,8 +600,10 @@ _PREZENTARE_FIELDS = frozenset({
|
|||||||
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
|
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
|
||||||
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
|
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
|
||||||
"rar_error",
|
"rar_error",
|
||||||
# US-005: mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API.
|
# Mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API.
|
||||||
"rar_env",
|
"rar_env",
|
||||||
|
# Flag `held` (tinut manual) — onestitate GET (dev vede ca NU a plecat).
|
||||||
|
"held",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -548,6 +682,38 @@ def repune_prezentare(
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/prezentari/{submission_id}/trimite-acum")
|
||||||
|
def trimite_acum_prezentare(
|
||||||
|
submission_id: int,
|
||||||
|
account_id: int = Depends(resolve_account_id),
|
||||||
|
) -> dict:
|
||||||
|
"""Elibereaza manual un rand tinut (paritate API cu /repune).
|
||||||
|
|
||||||
|
Scoped strict pe contul cheii API (account_id din sesiune/cheie, NICIODATA din body).
|
||||||
|
404-before-leak pe id strain/inexistent. `held: 1 -> 0` DOAR daca randul e
|
||||||
|
`queued AND held=1` (no-op sigur altfel: un rand deja `sending`/`sent` ramane neatins).
|
||||||
|
Worker-ul preia randul la urmatorul poll (trimitere asincrona).
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
scope_sql, scope_params = account_scope_clause(account_id)
|
||||||
|
row = conn.execute(
|
||||||
|
f"SELECT id FROM submissions WHERE id=? AND {scope_sql}",
|
||||||
|
[submission_id] + scope_params,
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="submission inexistent")
|
||||||
|
cur = conn.execute(
|
||||||
|
f"UPDATE submissions SET held=0, updated_at=datetime('now') "
|
||||||
|
f"WHERE id=? AND {scope_sql} AND status='queued' AND held=1",
|
||||||
|
[submission_id] + scope_params,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "eliberat": cur.rowcount == 1}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nomenclator")
|
@router.get("/nomenclator")
|
||||||
def get_nomenclator() -> dict:
|
def get_nomenclator() -> dict:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -571,7 +737,7 @@ AUDIT_COLUMNS = [
|
|||||||
"odometru_final",
|
"odometru_final",
|
||||||
"prestatii",
|
"prestatii",
|
||||||
"rar_status_code",
|
"rar_status_code",
|
||||||
# US-010 (PRD 5.20): mediul RAR tinta per trimitere.
|
# Mediul RAR tinta per trimitere.
|
||||||
"rar_env",
|
"rar_env",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
@@ -626,7 +792,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
|
|||||||
"odometru_final": p.get("odometru_final") or "",
|
"odometru_final": p.get("odometru_final") or "",
|
||||||
"prestatii": codes,
|
"prestatii": codes,
|
||||||
"rar_status_code": r["rar_status_code"] or "",
|
"rar_status_code": r["rar_status_code"] or "",
|
||||||
# US-010 (PRD 5.20): mediul RAR tinta — coloana audit.
|
# Mediul RAR tinta — coloana audit.
|
||||||
"rar_env": r["rar_env"] or "",
|
"rar_env": r["rar_env"] or "",
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
"updated_at": r["updated_at"],
|
"updated_at": r["updated_at"],
|
||||||
@@ -758,7 +924,7 @@ def set_rar_creds(
|
|||||||
req: RarCredsIn,
|
req: RarCredsIn,
|
||||||
account_id: int = Depends(resolve_account_id),
|
account_id: int = Depends(resolve_account_id),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Seteaza creds RAR durabile per-cont, in slotul per-mediu (US-013, env-aware).
|
"""Seteaza creds RAR durabile per-cont, in slotul per-mediu (env-aware).
|
||||||
|
|
||||||
Slotul tinta: `req.rar_target` ('test'/'prod') sau ancora globala (AUTOPASS_RAR_ENV).
|
Slotul tinta: `req.rar_target` ('test'/'prod') sau ancora globala (AUTOPASS_RAR_ENV).
|
||||||
Activeaza mediul selectat (`rar_{env}_enabled=1`). Worker-ul le foloseste ca
|
Activeaza mediul selectat (`rar_{env}_enabled=1`). Worker-ul le foloseste ca
|
||||||
@@ -787,7 +953,7 @@ def delete_rar_creds(
|
|||||||
account_id: int = Depends(resolve_account_id),
|
account_id: int = Depends(resolve_account_id),
|
||||||
env: str | None = None,
|
env: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Sterge creds RAR durabile per-cont (revenire la modelul efemer Treapta 1).
|
"""Sterge creds RAR durabile per-cont (revenire la modelul efemer per submission).
|
||||||
|
|
||||||
`env` (query param): 'test' | 'prod' -> sterge DOAR slotul acelui mediu + dezactiveaza-l.
|
`env` (query param): 'test' | 'prod' -> sterge DOAR slotul acelui mediu + dezactiveaza-l.
|
||||||
Absent -> sterge AMBELE sloturi (revenire completa). Schimbare aditiva (back-compat).
|
Absent -> sterge AMBELE sloturi (revenire completa). Schimbare aditiva (back-compat).
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ def resolve_account_id(
|
|||||||
def require_api_access(
|
def require_api_access(
|
||||||
account_id: int = Depends(resolve_account_id),
|
account_id: int = Depends(resolve_account_id),
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Dependency FastAPI (T4, PRD 5.17): verifica ca tier-ul efectiv permite accesul la API.
|
"""Dependency FastAPI: verifica ca tier-ul efectiv permite accesul la API.
|
||||||
|
|
||||||
Reguli:
|
Reguli:
|
||||||
- enforce_plans=False (kill-switch): sare verificarea.
|
- enforce_plans=False (kill-switch): sare verificarea.
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ class Settings(BaseSettings):
|
|||||||
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
|
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
|
||||||
# ale `sent` — un blocat n-are valoare de audit.
|
# ale `sent` — un blocat n-are valoare de audit.
|
||||||
blocked_retention_days: int = 30
|
blocked_retention_days: int = 30
|
||||||
|
# Prag zile: randurile tinute manual (`queued AND held=1`) mai vechi de
|
||||||
|
# atat declanseaza bannerul de avertizare L.142 (declararea are termen legal).
|
||||||
|
held_warn_days: int = 7
|
||||||
|
# Retentie randuri tinute manual: un `queued AND held=1` mai vechi de atat
|
||||||
|
# expira la `error` (`TINUT_EXPIRAT`) + purge_after direct. Aliniat retentiei `sent` (90z).
|
||||||
|
held_retention_days: int = 90
|
||||||
|
|
||||||
# --- Securitate ---
|
# --- Securitate ---
|
||||||
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->
|
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->
|
||||||
@@ -61,7 +67,7 @@ class Settings(BaseSettings):
|
|||||||
# False (dev): cookie fara Secure, functioneaza pe HTTP.
|
# False (dev): cookie fara Secure, functioneaza pe HTTP.
|
||||||
session_https_only: bool = False
|
session_https_only: bool = False
|
||||||
|
|
||||||
# --- Contact suport (US-001, PRD 5.12) ---
|
# --- Contact suport ---
|
||||||
# Email/canal de contact afisat in mesaje catre utilizatori (ex. CUI duplicat la signup).
|
# Email/canal de contact afisat in mesaje catre utilizatori (ex. CUI duplicat la signup).
|
||||||
# Nesetat -> fallback la formularea generica fara canal concret.
|
# Nesetat -> fallback la formularea generica fara canal concret.
|
||||||
support_email: str | None = None
|
support_email: str | None = None
|
||||||
@@ -109,14 +115,13 @@ class Settings(BaseSettings):
|
|||||||
worker_retry_max_s: int = 300
|
worker_retry_max_s: int = 300
|
||||||
worker_max_retries: int = 8 # peste atat -> error + banner
|
worker_max_retries: int = 8 # peste atat -> error + banner
|
||||||
|
|
||||||
# --- Planuri de cont (PRD 5.17) ---
|
# --- Planuri de cont ---
|
||||||
# Enforcement DUR al limitelor de plan (volum + acces API). True (implicit) = activ.
|
# 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
|
# 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
|
# sau rollback rapid fara revert de cod). Nu exista conturi legacy, produs in teste.
|
||||||
# (decizie user 2026-06-28, decizia #22 autoplan): nu exista conturi legacy, produs in TESTE.
|
|
||||||
enforce_plans: bool = True
|
enforce_plans: bool = True
|
||||||
|
|
||||||
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
# --- Embeddings (sugestie mapare) ---
|
||||||
# ACTIVAT implicit: editorul de mapari ofera sugestii semantice (model fastembed/ONNX).
|
# 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
|
# 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
|
# cerere -> prima cerere /mapari poate dura 30-120s pana modelul intra in memorie; cererile
|
||||||
@@ -125,7 +130,7 @@ class Settings(BaseSettings):
|
|||||||
# /mapari instant la prima cerere sau suita de teste rapida (cade pe GOLD/SILVER+fuzzy).
|
# /mapari instant la prima cerere sau suita de teste rapida (cade pe GOLD/SILVER+fuzzy).
|
||||||
embeddings_enabled: bool = True
|
embeddings_enabled: bool = True
|
||||||
|
|
||||||
# --- Seed corpus operatii etichetate (SILVER, PRD 5.18 US-004) ---
|
# --- Seed corpus operatii etichetate (SILVER) ---
|
||||||
# ACTIVAT implicit: la init_db, populeaza mapping_suggestions din artefactul comis
|
# 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
|
# `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.
|
# productie -> sugestii exact-match + corpus k-NN reale. SUGGESTION-ONLY.
|
||||||
@@ -133,6 +138,12 @@ class Settings(BaseSettings):
|
|||||||
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
|
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
|
||||||
seed_operatii_enabled: bool = True
|
seed_operatii_enabled: bool = True
|
||||||
|
|
||||||
|
# --- Limita globala corp cerere (P1-4/P1-5, hardening 2026-07-03) ---
|
||||||
|
# Plafon aplicat de un middleware ASGI pur (app/web/body_cap.py) pe TOATE rutele,
|
||||||
|
# INAINTE ca FastAPI sa parseze body-ul (multipart e spooled pe disc altfel).
|
||||||
|
# Verificarea per-endpoint existenta (import_parse.MAX_BYTES, 5MB) ramane strat 2.
|
||||||
|
max_request_bytes: int = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rar_base_url(self) -> str:
|
def rar_base_url(self) -> str:
|
||||||
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test
|
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test
|
||||||
@@ -143,6 +154,32 @@ def get_settings() -> Settings:
|
|||||||
return Settings()
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_prod_invariants(settings: Settings) -> None:
|
||||||
|
"""Fail-fast la startup: refuza boot-ul in productie cand lipsesc invariantele
|
||||||
|
de securitate obligatorii, in loc sa lase aplicatia sa porneasca intr-o
|
||||||
|
configuratie nesigura descoperita abia dupa deploy (mirror `crypto.validate_creds_key`).
|
||||||
|
|
||||||
|
Conditionat STRICT pe `rar_env=="prod"` — altfel ar opri suita de teste, care
|
||||||
|
ruleaza implicit cu `rar_env="test"` si fara `session_secret`/`require_api_key`.
|
||||||
|
"""
|
||||||
|
if settings.rar_env != "prod":
|
||||||
|
return
|
||||||
|
if not settings.require_api_key:
|
||||||
|
raise RuntimeError(
|
||||||
|
"AUTOPASS_RAR_ENV=prod dar AUTOPASS_REQUIRE_API_KEY nu e activat: in "
|
||||||
|
"productie cheia API e obligatorie pe /v1/*, altfel orice cerere "
|
||||||
|
"neautentificata ajunge pe contul implicit id=1. Seteaza "
|
||||||
|
"AUTOPASS_REQUIRE_API_KEY=true."
|
||||||
|
)
|
||||||
|
if not settings.session_secret:
|
||||||
|
raise RuntimeError(
|
||||||
|
"AUTOPASS_RAR_ENV=prod dar AUTOPASS_SESSION_SECRET nu este setat: fara el, "
|
||||||
|
"secretul de sesiune e efemer per restart si toti userii sunt delogati la "
|
||||||
|
"fiecare redeploy. Genereaza unul persistent cu:\n"
|
||||||
|
" python3 -c \"import secrets; print(secrets.token_hex(32))\""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_test_credentials(settings_xml: Path | None = None) -> dict | None:
|
def load_test_credentials(settings_xml: Path | None = None) -> dict | None:
|
||||||
"""Citeste credentialele <test> din settings.xml (dev local / probe test).
|
"""Citeste credentialele <test> din settings.xml (dev local / probe test).
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
116
app/db.py
116
app/db.py
@@ -37,7 +37,7 @@ def init_db() -> None:
|
|||||||
from .mapping import seed_nomenclator_if_empty
|
from .mapping import seed_nomenclator_if_empty
|
||||||
|
|
||||||
seed_nomenclator_if_empty(conn)
|
seed_nomenclator_if_empty(conn)
|
||||||
# Seed corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
|
# Seed corpus operatii etichetate -> mapping_suggestions (SILVER).
|
||||||
# Gated: OFF in teste (conftest), ON in productie. INSERT OR IGNORE -> idempotent.
|
# 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
|
# 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
|
# FIECARE boot (API + worker concurent) tinea write-lock-ul indelung -> al doilea
|
||||||
@@ -72,9 +72,9 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
if "row_index" not in sub_cols:
|
if "row_index" not in sub_cols:
|
||||||
conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER")
|
conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER")
|
||||||
if "rar_env" not in sub_cols:
|
if "rar_env" not in sub_cols:
|
||||||
# PRD 5.20 US-001. Mediul RAR tinta pe submission. Pe DB existent NU lasam
|
# Mediul RAR tinta pe submission. Pe DB existent NU lasam
|
||||||
# randurile pe DEFAULT 'test': un rand prod pre-migrare etichetat 'test' ar fi
|
# 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
|
# reconciliat de worker contra endpoint TEST -> no-match -> re-send prod
|
||||||
# = DUPLICAT REAL IREVERSIBIL. Backfill din AUTOPASS_RAR_ENV global (ancora de
|
# = DUPLICAT REAL IREVERSIBIL. Backfill din AUTOPASS_RAR_ENV global (ancora de
|
||||||
# migrare) + recompute idempotency_key env-aware. Ruleaza O SINGURA DATA (in
|
# 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).
|
# blocul de adaugare a coloanei); pe DB fresh coloana vine din schema.sql (fara rows).
|
||||||
@@ -83,14 +83,21 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
"CHECK (rar_env IN ('test', 'prod'))"
|
"CHECK (rar_env IN ('test', 'prod'))"
|
||||||
)
|
)
|
||||||
_backfill_submissions_rar_env(conn)
|
_backfill_submissions_rar_env(conn)
|
||||||
|
if "held" not in sub_cols:
|
||||||
|
# Flag rand tinut manual. Default 0 (randurile pre-migrare nu erau
|
||||||
|
# tinute — pastreaza comportamentul; comutatorul de cont e OFF/held la ingestie NOUA).
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE submissions ADD COLUMN held INTEGER NOT NULL DEFAULT 0 "
|
||||||
|
"CHECK (held IN (0, 1))"
|
||||||
|
)
|
||||||
|
|
||||||
# Coloane accounts
|
# Coloane accounts
|
||||||
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||||
# AUTO-FIX 6a ELIMINAT (US-013): NU mai adaugam accounts.rar_creds_enc — coloana e dropata.
|
# NU mai adaugam accounts.rar_creds_enc — coloana e dropata.
|
||||||
# _migrate_accounts_medii gestioneaza absenta coloana (guard la ~219: if "rar_creds_enc" not in acc_cols: return).
|
# _migrate_accounts_medii gestioneaza absenta coloana (guard: if "rar_creds_enc" not in acc_cols: return).
|
||||||
# Medii RAR per cont (PRD 5.20 US-001): activare + slot creds + default, per mediu.
|
# Medii RAR per cont: activare + slot creds + default, per mediu.
|
||||||
_migrate_accounts_medii(conn, acc_cols)
|
_migrate_accounts_medii(conn, acc_cols)
|
||||||
# US-013: DROP coloana legacy accounts.rar_creds_enc dupa backfill complet.
|
# DROP coloana legacy accounts.rar_creds_enc dupa backfill complet.
|
||||||
_drop_legacy_accounts_rar_creds(conn, acc_cols)
|
_drop_legacy_accounts_rar_creds(conn, acc_cols)
|
||||||
if "active" not in acc_cols:
|
if "active" not in acc_cols:
|
||||||
# Conturi existente raman active (default 1).
|
# Conturi existente raman active (default 1).
|
||||||
@@ -113,11 +120,19 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
"ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 "
|
"ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 "
|
||||||
"CHECK (on_unmapped_error_default IN (0, 1))"
|
"CHECK (on_unmapped_error_default IN (0, 1))"
|
||||||
)
|
)
|
||||||
|
if "auto_send_enabled" not in acc_cols:
|
||||||
|
# Comutator Auto per cont. Default 0 (Auto OFF) — conturile existente
|
||||||
|
# trec pe „tine randurile" la ingestie noua pana cand operatorul porneste Auto explicit.
|
||||||
|
# Contul id=1 ramane pe default (0), fara tratament special.
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE accounts ADD COLUMN auto_send_enabled INTEGER NOT NULL DEFAULT 0 "
|
||||||
|
"CHECK (auto_send_enabled IN (0, 1))"
|
||||||
|
)
|
||||||
if "email" not in acc_cols:
|
if "email" not in acc_cols:
|
||||||
# Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy.
|
# Email canonic de contact al firmei. Nullable pt. conturi legacy.
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT")
|
conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT")
|
||||||
if "tier" not in acc_cols:
|
if "tier" not in acc_cols:
|
||||||
# Plan de cont (US-001, PRD 5.17). Legacy -> 'free' fara trial (enforcement DUR la deploy).
|
# Plan de cont. Legacy -> 'free' fara trial (enforcement DUR la deploy).
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT 'free' "
|
"ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT 'free' "
|
||||||
"CHECK (tier IN ('free','standard','pro','premium'))"
|
"CHECK (tier IN ('free','standard','pro','premium'))"
|
||||||
@@ -158,11 +173,31 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
if "override_json" not in irows_cols:
|
if "override_json" not in irows_cols:
|
||||||
conn.execute("ALTER TABLE import_rows ADD COLUMN override_json TEXT")
|
conn.execute("ALTER TABLE import_rows ADD COLUMN override_json TEXT")
|
||||||
if "reviewed" not in irows_cols:
|
if "reviewed" not in irows_cols:
|
||||||
# Marcaj confirmare umana (US-007, PRD 5.12). NU intra in payload/idempotenta.
|
# Marcaj confirmare umana. NU intra in payload/idempotenta.
|
||||||
# NOT NULL DEFAULT 0: valoare clara (0=neconfirmat), fara ambiguitate NULL vs 0.
|
# NOT NULL DEFAULT 0: valoare clara (0=neconfirmat), fara ambiguitate NULL vs 0.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE import_rows ADD COLUMN reviewed INTEGER NOT NULL DEFAULT 0"
|
"ALTER TABLE import_rows ADD COLUMN reviewed INTEGER NOT NULL DEFAULT 0"
|
||||||
)
|
)
|
||||||
|
# CHECK-ul resolved_status trebuie sa accepte 'excluded' (operatii excluse de la
|
||||||
|
# declarare). SQLite nu poate altera CHECK -> rebuild o singura data (detectat
|
||||||
|
# pe textul CREATE din sqlite_master).
|
||||||
|
create_sql = str(conn.execute(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='import_rows'"
|
||||||
|
).fetchone()["sql"] or "")
|
||||||
|
if "'excluded'" not in create_sql:
|
||||||
|
_rebuild_import_rows_cu_excluded(conn)
|
||||||
|
|
||||||
|
# Coloana operations_mapping.exclus: regula "nu se declara la RAR" per operatie.
|
||||||
|
om_tbl = conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='operations_mapping'"
|
||||||
|
).fetchone()
|
||||||
|
if om_tbl:
|
||||||
|
om_cols = {r["name"] for r in conn.execute("PRAGMA table_info(operations_mapping)").fetchall()}
|
||||||
|
if "exclus" not in om_cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE operations_mapping ADD COLUMN exclus INTEGER NOT NULL DEFAULT 0 "
|
||||||
|
"CHECK (exclus IN (0, 1))"
|
||||||
|
)
|
||||||
|
|
||||||
# Index batch_id pe submissions (poate lipsi pe DB veche)
|
# Index batch_id pe submissions (poate lipsi pe DB veche)
|
||||||
existing_idx = {r["name"] for r in conn.execute(
|
existing_idx = {r["name"] for r in conn.execute(
|
||||||
@@ -178,10 +213,55 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_submissions_account_status "
|
"CREATE INDEX IF NOT EXISTS idx_submissions_account_status "
|
||||||
"ON submissions(account_id, status)"
|
"ON submissions(account_id, status)"
|
||||||
)
|
)
|
||||||
|
if "idx_submissions_held" not in existing_idx:
|
||||||
|
# CREATE TABLE IF NOT EXISTS nu se declanseaza pe DB
|
||||||
|
# existent -> indexul partial trebuie creat si aici, nu doar in schema.sql.
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_submissions_held ON submissions(held) WHERE held=1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_import_rows_cu_excluded(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Rebuild import_rows cu 'excluded' in CHECK-ul resolved_status.
|
||||||
|
|
||||||
|
Ruleaza O SINGURA DATA pe DB-uri create inainte de starea 'excluded' (gardat de
|
||||||
|
apelant pe textul CREATE). Copiaza toate randurile 1:1 si recreeaza indexul.
|
||||||
|
"""
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE import_rows_new ("
|
||||||
|
" 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,"
|
||||||
|
" override_json TEXT,"
|
||||||
|
" reviewed INTEGER NOT NULL DEFAULT 0,"
|
||||||
|
" resolved_status TEXT NOT NULL DEFAULT 'pending'"
|
||||||
|
" CHECK (resolved_status IN ("
|
||||||
|
" 'pending','ok','needs_mapping','needs_data',"
|
||||||
|
" 'needs_review','already_sent','duplicate_in_file','excluded'"
|
||||||
|
" )),"
|
||||||
|
" error TEXT"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO import_rows_new "
|
||||||
|
" (id, batch_id, row_index, raw_json, override_json, reviewed, resolved_status, error) "
|
||||||
|
"SELECT id, batch_id, row_index, raw_json, override_json, reviewed, resolved_status, error "
|
||||||
|
"FROM import_rows"
|
||||||
|
)
|
||||||
|
conn.execute("DROP TABLE import_rows")
|
||||||
|
conn.execute("ALTER TABLE import_rows_new RENAME TO import_rows")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_import_rows_batch ON import_rows(batch_id)")
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
|
def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
|
||||||
"""PRD 5.20 US-001: coloane medii RAR per cont + backfill din ancora globala.
|
"""Coloane medii RAR per cont + backfill din ancora globala.
|
||||||
|
|
||||||
Adauga (idempotent): rar_test_enabled/rar_prod_enabled (bife activare),
|
Adauga (idempotent): rar_test_enabled/rar_prod_enabled (bife activare),
|
||||||
rar_creds_test_enc/rar_creds_prod_enc (sloturi creds), rar_env_default.
|
rar_creds_test_enc/rar_creds_prod_enc (sloturi creds), rar_env_default.
|
||||||
@@ -191,7 +271,7 @@ def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> Non
|
|||||||
momentul migrarii (ancora) — il copiem in slotul acelui mediu, activam DOAR acel
|
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
|
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
|
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.
|
bazeaza pe ancora globala.
|
||||||
"""
|
"""
|
||||||
newly_added = "rar_env_default" not in acc_cols
|
newly_added = "rar_env_default" not in acc_cols
|
||||||
if "rar_test_enabled" not in acc_cols:
|
if "rar_test_enabled" not in acc_cols:
|
||||||
@@ -231,12 +311,12 @@ def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> Non
|
|||||||
|
|
||||||
|
|
||||||
def _drop_legacy_accounts_rar_creds(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
|
def _drop_legacy_accounts_rar_creds(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
|
||||||
"""PRD 5.20 US-013: DROP coloana legacy `accounts.rar_creds_enc` dupa backfill complet.
|
"""DROP coloana legacy `accounts.rar_creds_enc` dupa backfill complet.
|
||||||
|
|
||||||
Idempotent si sigur la fiecare boot (garda one-way: coloana absenta = nimic de facut).
|
Idempotent si sigur la fiecare boot (garda one-way: coloana absenta = nimic de facut).
|
||||||
La eroare LOGHEAZA si lasa coloana pe loc (fail-safe — nu crapa boot-ul ambelor procese).
|
La eroare LOGHEAZA si lasa coloana pe loc (fail-safe — nu crapa boot-ul ambelor procese).
|
||||||
Structura separata pentru testabilitate: `_garda_si_drop(conn)` expune pasul de
|
Structura separata pentru testabilitate: `_garda_si_drop(conn)` expune pasul de
|
||||||
assert+backup+DROP izolat (fara re-backfill 6c), apelabil direct din teste.
|
assert+backup+DROP izolat (fara re-backfill), apelabil direct din teste.
|
||||||
"""
|
"""
|
||||||
if "rar_creds_enc" not in acc_cols:
|
if "rar_creds_enc" not in acc_cols:
|
||||||
return # garda one-way: coloana deja dropata sau DB fresh
|
return # garda one-way: coloana deja dropata sau DB fresh
|
||||||
@@ -251,10 +331,10 @@ def _drop_legacy_accounts_rar_creds(conn: sqlite3.Connection, acc_cols: set[str]
|
|||||||
|
|
||||||
|
|
||||||
def _drop_legacy_rar_creds_impl(conn: sqlite3.Connection) -> None:
|
def _drop_legacy_rar_creds_impl(conn: sqlite3.Connection) -> None:
|
||||||
"""Re-backfill (AUTO-FIX 6c) + delegate la _garda_si_drop.
|
"""Re-backfill + delegate la _garda_si_drop.
|
||||||
|
|
||||||
Re-backfill-ul 6c acopera creds setate via POST /v1/conturi/rar-creds intre US-001
|
Re-backfill-ul acopera creds setate via POST /v1/conturi/rar-creds intre adaugarea
|
||||||
si US-013 (pot fi DOAR in coloana veche). Ancora globala: AUTOPASS_RAR_ENV.
|
coloanei noi si drop-ul celei vechi (pot fi DOAR in coloana veche). Ancora globala: AUTOPASS_RAR_ENV.
|
||||||
"""
|
"""
|
||||||
if sqlite3.sqlite_version_info < (3, 35, 0):
|
if sqlite3.sqlite_version_info < (3, 35, 0):
|
||||||
print(
|
print(
|
||||||
@@ -323,7 +403,7 @@ def _garda_si_drop(conn: sqlite3.Connection) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _backfill_submissions_rar_env(conn: sqlite3.Connection) -> None:
|
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.
|
"""Backfill rar_env + recompute idempotency_key.
|
||||||
|
|
||||||
Ruleaza O SINGURA DATA, imediat dupa ce coloana `submissions.rar_env` a fost adaugata
|
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
|
pe un DB existent. Toate randurile pre-migrare au fost trimise (sau urmeaza) catre
|
||||||
|
|||||||
10
app/email.py
10
app/email.py
@@ -11,6 +11,7 @@ import textwrap
|
|||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
|
from .observ import log_event
|
||||||
|
|
||||||
|
|
||||||
def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
|
def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
|
||||||
@@ -23,10 +24,11 @@ def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
if not settings.smtp_host or not admin_emails:
|
if not settings.smtp_host or not admin_emails:
|
||||||
print(
|
log_event(
|
||||||
f"SIGNUP-NOTIFY degradat (fara SMTP) cont={account_id} "
|
"signup_notify_degradat",
|
||||||
f"email={email} admins={len(admin_emails)}",
|
account_id=account_id,
|
||||||
flush=True,
|
mesaj="fara SMTP configurat",
|
||||||
|
context={"admins": len(admin_emails)},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
210
app/embedding_cache.py
Normal file
210
app/embedding_cache.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""Cache persistent de vectori embeddings in SQLite (tabela `embedding_cache`).
|
||||||
|
|
||||||
|
Design:
|
||||||
|
- Cheie (model, text_hash): schimbarea modelului nu foloseste vectori vechi.
|
||||||
|
- Vectorii raman `array('f')` (float32) end-to-end -- fara conversie la list[float].
|
||||||
|
- Scrieri/stergeri in tranzactii scurte, chunk-uite (conexiunile sunt autocommit,
|
||||||
|
vezi app/db.py -- BEGIN/COMMIT explicit per chunk, altfel fiecare INSERT/DELETE
|
||||||
|
e propria tranzactie).
|
||||||
|
- Degradare gratioasa: orice eroare SQLite -> log.warning, fara exceptie propagata
|
||||||
|
din load/save/purge (caller-ul, ensure_embeddings_corpus, ramane neschimbat).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from array import array
|
||||||
|
from typing import Callable, Iterable, Sequence
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EMB_DIM = 384 # paraphrase-multilingual-MiniLM-L12-v2 (app/embeddings.py::FASTEMBED_MODEL)
|
||||||
|
_CHUNK_SIZE = 500 # A2/A3: tranzactii scurte vs worker BEGIN IMMEDIATE pe submissions
|
||||||
|
|
||||||
|
|
||||||
|
def text_hash(text: str) -> str:
|
||||||
|
"""SHA-256 hex al textului dat (apelantul normalizeaza inainte de hash)."""
|
||||||
|
return hashlib.sha256(str(text).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def vector_to_blob(vector: Sequence[float]) -> bytes:
|
||||||
|
"""Serializeaza un vector la float32 little-endian (independent de platforma)."""
|
||||||
|
arr = array("f", vector)
|
||||||
|
if sys.byteorder != "little":
|
||||||
|
arr = array("f", arr)
|
||||||
|
arr.byteswap()
|
||||||
|
return arr.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def blob_to_vector(blob: bytes) -> array:
|
||||||
|
"""Deserializeaza un blob float32 little-endian la `array('f')`."""
|
||||||
|
arr = array("f")
|
||||||
|
arr.frombytes(blob)
|
||||||
|
if sys.byteorder != "little":
|
||||||
|
arr.byteswap()
|
||||||
|
return arr
|
||||||
|
|
||||||
|
|
||||||
|
def _chunks(seq: Sequence, size: int = _CHUNK_SIZE) -> Iterable[Sequence]:
|
||||||
|
for i in range(0, len(seq), size):
|
||||||
|
yield seq[i : i + size]
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_vectors(conn: sqlite3.Connection, model: str, hashes: Sequence[str]) -> dict[str, array]:
|
||||||
|
"""Citeste vectorii existenti pentru `model` + `hashes`. Blob corupt (lungime
|
||||||
|
gresita) = tratat ca miss (nu apare in rezultat), cu log.warning.
|
||||||
|
|
||||||
|
Degradare gratioasa: orice eroare SQLite -> dict gol/partial, fara exceptie.
|
||||||
|
"""
|
||||||
|
out: dict[str, array] = {}
|
||||||
|
if not hashes:
|
||||||
|
return out
|
||||||
|
expected_bytes = EMB_DIM * 4
|
||||||
|
unique_hashes = list(dict.fromkeys(hashes))
|
||||||
|
try:
|
||||||
|
for chunk in _chunks(unique_hashes):
|
||||||
|
placeholders = ",".join("?" for _ in chunk)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT text_hash, vector FROM embedding_cache "
|
||||||
|
f"WHERE model = ? AND text_hash IN ({placeholders})",
|
||||||
|
(model, *chunk),
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
blob = row["vector"]
|
||||||
|
if len(blob) != expected_bytes:
|
||||||
|
log.warning(
|
||||||
|
"embedding_cache: blob lungime gresita pentru hash=%s (asteptat %d, primit %d) -- tratat ca miss",
|
||||||
|
row["text_hash"], expected_bytes, len(blob),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
out[row["text_hash"]] = blob_to_vector(blob)
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
log.warning("embedding_cache: load_cached_vectors esuat (%s) -- fallback embed complet", exc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def save_vectors(conn: sqlite3.Connection, model: str, items: Sequence[tuple[str, Sequence[float]]]) -> None:
|
||||||
|
"""INSERT OR REPLACE in chunk-uri de 500 randuri, BEGIN/COMMIT explicit per chunk
|
||||||
|
(conexiunile sunt autocommit -- conn.commit() singur e no-op).
|
||||||
|
|
||||||
|
Valideaza `len(vector) == EMB_DIM` la scriere: vector gresit = respins (log.warning),
|
||||||
|
NU scris. Degradare gratioasa: eroare SQLite pe un chunk -> log.warning, chunk-urile
|
||||||
|
ramase continua (cache partial e idempotent, se completeaza la urmatorul warmup).
|
||||||
|
|
||||||
|
Invarianta: `conn` trebuie sa fie in autocommit (fara tranzactie deschisa de
|
||||||
|
apelant) -- ROLLBACK-ul din except ar anula altfel tranzactia apelantului.
|
||||||
|
"""
|
||||||
|
valid = []
|
||||||
|
for h, vec in items:
|
||||||
|
if len(vec) != EMB_DIM:
|
||||||
|
log.warning(
|
||||||
|
"embedding_cache: vector dimensiune gresita pentru hash=%s (asteptat %d, primit %d) -- respins",
|
||||||
|
h, EMB_DIM, len(vec),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
valid.append((h, model, vector_to_blob(vec)))
|
||||||
|
|
||||||
|
for chunk in _chunks(valid):
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR REPLACE INTO embedding_cache (text_hash, model, vector) VALUES (?, ?, ?)",
|
||||||
|
chunk,
|
||||||
|
)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
try:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
log.warning("embedding_cache: save_vectors esuat pe un chunk (%s) -- cache ramane partial", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def purge_stale(conn: sqlite3.Connection, model: str, corpus_hashes: Iterable[str]) -> None:
|
||||||
|
"""Sterge intrarile care nu mai apartin corpusului curent: orfane ale
|
||||||
|
modelului curent (text_hash absent din `corpus_hashes`) SI toate intrarile
|
||||||
|
modelelor VECHI (model != curent). Diff calculat in Python, DELETE chunk-uit
|
||||||
|
pe PK (model, text_hash) -- tranzactii scurte.
|
||||||
|
|
||||||
|
Degradare gratioasa: eroare SQLite -> log.warning, orfanele raman pana la
|
||||||
|
urmatoarea trecere.
|
||||||
|
|
||||||
|
Invarianta: `conn` trebuie sa fie in autocommit (fara tranzactie deschisa de
|
||||||
|
apelant) -- ROLLBACK-ul din except ar anula altfel tranzactia apelantului.
|
||||||
|
Apelantul trebuie sa cheme aceasta functie DOAR dupa o indexare reusita
|
||||||
|
(vezi `ensure_embeddings_corpus`) -- un esec de indexare nu trebuie sa goleasca
|
||||||
|
cache-ul de randuri inca valide.
|
||||||
|
"""
|
||||||
|
keep = set(corpus_hashes)
|
||||||
|
try:
|
||||||
|
rows = conn.execute("SELECT model, text_hash FROM embedding_cache").fetchall()
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
log.warning("embedding_cache: purge_stale citire esuata (%s)", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
to_delete = [
|
||||||
|
(r["model"], r["text_hash"])
|
||||||
|
for r in rows
|
||||||
|
if r["model"] != model or r["text_hash"] not in keep
|
||||||
|
]
|
||||||
|
if not to_delete:
|
||||||
|
return
|
||||||
|
|
||||||
|
for chunk in _chunks(to_delete):
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM embedding_cache WHERE model = ? AND text_hash = ?",
|
||||||
|
chunk,
|
||||||
|
)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
try:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
log.warning("embedding_cache: purge_stale esuat pe un chunk (%s) -- orfanele raman", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_corpus_vectors(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
model: str,
|
||||||
|
texts: Sequence[str],
|
||||||
|
embed_fn: Callable[[list[str]], Sequence[Sequence[float]]],
|
||||||
|
) -> list[array]:
|
||||||
|
"""Orchestreaza hash -> load -> embed(doar miss-uri) -> save -> vectori aliniati.
|
||||||
|
|
||||||
|
Returneaza o lista de `array('f')` aliniata pozitional cu `texts`.
|
||||||
|
|
||||||
|
`embed_fn` primeste lista textelor lipsa din cache si intoarce vectorii lor
|
||||||
|
(aceeasi ordine). Daca `embed_fn` esueaza (arunca), exceptia se propaga;
|
||||||
|
apelantul (ensure_embeddings_corpus) are deja degradare gratioasa (except -> pass).
|
||||||
|
|
||||||
|
Un save partial esuat (lock SQLite) NU opreste intoarcerea vectorilor din RAM
|
||||||
|
(A13c): vectorii noi raman in `cached` indiferent de rezultatul persistarii.
|
||||||
|
|
||||||
|
NU purjeaza orfanele: purjarea e responsabilitatea apelantului, DUPA ce corpusul
|
||||||
|
a fost indexat cu succes (`index_corpus`) -- un esec de indexare nu trebuie sa
|
||||||
|
goleasca din greseala cache-ul de randuri inca valide.
|
||||||
|
"""
|
||||||
|
hashes = [text_hash(t) for t in texts]
|
||||||
|
cached = load_cached_vectors(conn, model, hashes)
|
||||||
|
|
||||||
|
missing_positions = [i for i, h in enumerate(hashes) if h not in cached]
|
||||||
|
if missing_positions:
|
||||||
|
new_texts = [texts[i] for i in missing_positions]
|
||||||
|
new_vecs = embed_fn(new_texts)
|
||||||
|
if len(new_vecs) != len(new_texts):
|
||||||
|
raise ValueError(
|
||||||
|
f"embed_fn a intors {len(new_vecs)} vectori pentru {len(new_texts)} texte"
|
||||||
|
)
|
||||||
|
to_save = []
|
||||||
|
for pos, vec in zip(missing_positions, new_vecs):
|
||||||
|
arr = array("f", vec)
|
||||||
|
cached[hashes[pos]] = arr
|
||||||
|
to_save.append((hashes[pos], arr))
|
||||||
|
save_vectors(conn, model, to_save)
|
||||||
|
|
||||||
|
return [cached[h] for h in hashes]
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
"""Modul embedding in-proces pentru sugestie cod RAR -- L14-S4.
|
"""Modul embedding in-proces pentru sugestie cod RAR.
|
||||||
|
|
||||||
Design (PRD 5.14, Decision #16/#16b):
|
Design:
|
||||||
- Model multilingv via fastembed/ONNX (~230MB pe disc, quantizat, fara torch)
|
- Model multilingv via fastembed/ONNX (~230MB pe disc, quantizat, fara torch)
|
||||||
- Lazy load la prima folosire, NU la import si NU pe /healthz
|
- Lazy load la prima folosire, NU la import si NU pe /healthz
|
||||||
- Worker NU incarca modelul (API-only)
|
- Worker NU incarca modelul (API-only)
|
||||||
- Degradare gratioasa: daca modelul nu se incarca -> is_available()=False,
|
- Degradare gratioasa: daca modelul nu se incarca -> is_available()=False,
|
||||||
suggest_nearest() -> [] fara exceptie, ingestia NU e blocata
|
suggest_nearest() -> [] fara exceptie, ingestia NU e blocata
|
||||||
- Embeddings = DOAR sugestie (nu intra in lantul de enqueue/resolve_prestatii)
|
- 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)
|
- NU apelat din resolve_prestatii/load_mapping
|
||||||
|
|
||||||
API public (nivel modul):
|
API public (nivel modul):
|
||||||
index_corpus(items) -> None
|
index_corpus(items, signature, vectors) -> None
|
||||||
suggest_nearest(text, top_k) -> [{cod, is_nul, similaritate}]
|
suggest_nearest(text, top_k) -> [{cod, is_nul, similaritate}]
|
||||||
is_available() -> bool
|
is_available() -> bool
|
||||||
|
|
||||||
Clase (pentru teste / injectare backend):
|
Clase (pentru teste / injectare backend):
|
||||||
EmbeddingEngine(backend) -- motor testabil cu backend injectabil
|
EmbeddingEngine(backend) -- motor testabil cu backend injectabil
|
||||||
@@ -22,13 +22,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import threading
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as _np # garantat de fastembed/onnxruntime; fallback pur-Python daca lipseste
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
_np = None
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Modelul ales: paraphrase-multilingual-MiniLM-L12-v2
|
# Modelul ales: paraphrase-multilingual-MiniLM-L12-v2
|
||||||
# ~230MB pe disc (ONNX quantizat), 384 dim, multilingv (ro/en/etc.), suportat de
|
# ~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, fara torch.
|
||||||
FASTEMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
FASTEMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +99,10 @@ class EmbeddingEngine:
|
|||||||
self._corpus_vecs: list[list[float]] = []
|
self._corpus_vecs: list[list[float]] = []
|
||||||
self._corpus_items: list[dict] = []
|
self._corpus_items: list[dict] = []
|
||||||
self._corpus_sig: str | None = None
|
self._corpus_sig: str | None = None
|
||||||
|
# Matrice numpy + norme precalculate: cautarea NN devine un matvec
|
||||||
|
# (~ms) in loc de cosine pur-Python per item (~0.5s la 17k vectori).
|
||||||
|
self._corpus_matrix = None
|
||||||
|
self._corpus_norms = None
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
def is_available(self) -> bool:
|
||||||
"""True daca backend-ul e disponibil si gata de folosire."""
|
"""True daca backend-ul e disponibil si gata de folosire."""
|
||||||
@@ -107,8 +117,19 @@ class EmbeddingEngine:
|
|||||||
doar cand semnatura nomenclatorului s-a schimbat (evita re-embed inutil)."""
|
doar cand semnatura nomenclatorului s-a schimbat (evita re-embed inutil)."""
|
||||||
return self._corpus_sig
|
return self._corpus_sig
|
||||||
|
|
||||||
def index_corpus(self, items: list[dict], signature: str | None = None) -> None:
|
def index_corpus(
|
||||||
"""Vectorizeaza corpus [{denumire, cod}] si il pastreaza in memorie.
|
self,
|
||||||
|
items: list[dict],
|
||||||
|
signature: str | None = None,
|
||||||
|
vectors: list | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Indexeaza corpus [{denumire, cod}] si il pastreaza in memorie.
|
||||||
|
|
||||||
|
`vectors`: vectori precalculati, aliniati POZITIONAL cu `items` (ex. din
|
||||||
|
embedding_cache). `None` (default) = comportamentul existent, embed complet
|
||||||
|
prin backend. Daca `vectors` e furnizat dar lungimea nu corespunde cu `items`
|
||||||
|
sau contine `None`, se ignora (log.warning) si se cade pe embed complet (A8) --
|
||||||
|
o dezaliniere silentioasa ar produce coduri sugerate GRESITE.
|
||||||
|
|
||||||
Ignora silentios daca backend-ul lipseste, corpus-ul e gol sau apare
|
Ignora silentios daca backend-ul lipseste, corpus-ul e gol sau apare
|
||||||
orice exceptie la vectorizare (degradare gratioasa).
|
orice exceptie la vectorizare (degradare gratioasa).
|
||||||
@@ -116,20 +137,63 @@ class EmbeddingEngine:
|
|||||||
self._corpus_vecs = []
|
self._corpus_vecs = []
|
||||||
self._corpus_items = []
|
self._corpus_items = []
|
||||||
self._corpus_sig = None
|
self._corpus_sig = None
|
||||||
|
self._corpus_matrix = None
|
||||||
|
self._corpus_norms = None
|
||||||
|
|
||||||
if not items or not self.is_available():
|
if not items or not self.is_available():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if vectors is not None and (len(vectors) != len(items) or any(v is None for v in vectors)):
|
||||||
|
log.warning(
|
||||||
|
"embeddings: index_corpus vectors (%d) nealiniat cu items (%d) sau contine None -- fallback embed complet",
|
||||||
|
len(vectors), len(items),
|
||||||
|
)
|
||||||
|
vectors = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
texts = [str(item["denumire"]) for item in items]
|
if vectors is not None:
|
||||||
vecs = self._backend.embed(texts)
|
self._corpus_vecs = list(vectors)
|
||||||
self._corpus_vecs = vecs
|
else:
|
||||||
|
texts = [str(item["denumire"]) for item in items]
|
||||||
|
self._corpus_vecs = self._backend.embed(texts)
|
||||||
self._corpus_items = list(items)
|
self._corpus_items = list(items)
|
||||||
self._corpus_sig = signature
|
self._corpus_sig = signature
|
||||||
|
self._build_matrix()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("embeddings: index_corpus esuat: %s", exc)
|
log.warning("embeddings: index_corpus esuat: %s", exc)
|
||||||
# corpus ramane gol -- suggest_nearest va returna []
|
# corpus ramane gol -- suggest_nearest va returna []
|
||||||
|
|
||||||
|
def _build_matrix(self) -> None:
|
||||||
|
"""Precalculeaza matricea numpy + normele corpusului pentru cautarea NN.
|
||||||
|
|
||||||
|
Esecul (numpy lipsa, vectori neregulati) lasa matricea None --
|
||||||
|
suggest_nearest cade pe scanarea pur-Python, corpusul ramane valid.
|
||||||
|
"""
|
||||||
|
if _np is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
matrix = _np.asarray(self._corpus_vecs, dtype=_np.float32)
|
||||||
|
if matrix.ndim != 2:
|
||||||
|
raise ValueError(f"vectori corpus neregulati (ndim={matrix.ndim})")
|
||||||
|
norms = _np.linalg.norm(matrix, axis=1)
|
||||||
|
norms[norms == 0.0] = 1.0 # vector nul -> similaritate 0, fara div/0
|
||||||
|
self._corpus_matrix = matrix
|
||||||
|
self._corpus_norms = norms
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("embeddings: matrice numpy esuata, fallback pur-Python: %s", exc)
|
||||||
|
self._corpus_matrix = None
|
||||||
|
self._corpus_norms = None
|
||||||
|
|
||||||
|
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
"""Vectorizeaza texte brute prin backend (folosit la miss-uri de cache).
|
||||||
|
|
||||||
|
Arunca daca backend-ul lipseste sau embed() esueaza -- apelantul
|
||||||
|
(sync_corpus_vectors) propaga eroarea, fara degradare gratioasa aici.
|
||||||
|
"""
|
||||||
|
if not self.is_available():
|
||||||
|
raise RuntimeError("embeddings: backend indisponibil")
|
||||||
|
return self._backend.embed(texts)
|
||||||
|
|
||||||
def suggest_nearest(
|
def suggest_nearest(
|
||||||
self,
|
self,
|
||||||
denumire: str,
|
denumire: str,
|
||||||
@@ -137,7 +201,7 @@ class EmbeddingEngine:
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Returneaza top_k vecini cosine [{cod, is_nul, similaritate}].
|
"""Returneaza top_k vecini cosine [{cod, is_nul, similaritate}].
|
||||||
|
|
||||||
`is_nul` (PRD 5.18 US-005): cand corpusul include exemple NUL (non-operatii),
|
`is_nul`: cand corpusul include exemple NUL (non-operatii),
|
||||||
un vecin NUL = semnal de SUPRESIE, nu cod. Default False pe corpusuri vechi
|
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
|
fara `is_nul` in itemi. Returneaza [] daca backend-ul lipseste, corpus-ul e gol
|
||||||
sau apare orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
|
sau apare orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
|
||||||
@@ -148,6 +212,26 @@ class EmbeddingEngine:
|
|||||||
try:
|
try:
|
||||||
query_vecs = self._backend.embed([str(denumire)])
|
query_vecs = self._backend.embed([str(denumire)])
|
||||||
query_vec = query_vecs[0]
|
query_vec = query_vecs[0]
|
||||||
|
if self._corpus_matrix is not None:
|
||||||
|
q = _np.asarray(query_vec, dtype=_np.float32)
|
||||||
|
qn = float(_np.linalg.norm(q)) or 1.0
|
||||||
|
sims = (self._corpus_matrix @ q) / (self._corpus_norms * qn)
|
||||||
|
k = min(top_k, sims.shape[0])
|
||||||
|
if k <= 0:
|
||||||
|
return []
|
||||||
|
# argpartition + sort doar pe top_k: O(n) in loc de O(n log n)
|
||||||
|
idx = _np.argpartition(-sims, k - 1)[:k]
|
||||||
|
idx = idx[_np.argsort(-sims[idx])]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"cod": self._corpus_items[i]["cod"],
|
||||||
|
"is_nul": bool(self._corpus_items[i].get("is_nul", False)),
|
||||||
|
# clamp la [-1, 1]: eroarea de rotunjire float32 poate da
|
||||||
|
# 1.0000001 pe text identic (ar afisa >100% in UI).
|
||||||
|
"similaritate": min(1.0, max(-1.0, float(sims[i]))),
|
||||||
|
}
|
||||||
|
for i in idx
|
||||||
|
]
|
||||||
scored = [
|
scored = [
|
||||||
{
|
{
|
||||||
"cod": item["cod"],
|
"cod": item["cod"],
|
||||||
@@ -168,6 +252,7 @@ class EmbeddingEngine:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
_engine: EmbeddingEngine | None = None
|
_engine: EmbeddingEngine | None = None
|
||||||
|
_engine_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _load_engine() -> EmbeddingEngine:
|
def _load_engine() -> EmbeddingEngine:
|
||||||
@@ -194,13 +279,24 @@ def _load_engine() -> EmbeddingEngine:
|
|||||||
|
|
||||||
|
|
||||||
def _get_engine() -> EmbeddingEngine:
|
def _get_engine() -> EmbeddingEngine:
|
||||||
"""Returneaza engine-ul global (lazy-init)."""
|
"""Returneaza engine-ul global (lazy-init, thread-safe).
|
||||||
|
|
||||||
|
Lock-ul previne incarcarea dubla a modelului cand warmup-ul de la startup
|
||||||
|
si un request concurent ajung aici simultan.
|
||||||
|
"""
|
||||||
global _engine
|
global _engine
|
||||||
if _engine is None:
|
if _engine is None:
|
||||||
_engine = _load_engine()
|
with _engine_lock:
|
||||||
|
if _engine is None:
|
||||||
|
_engine = _load_engine()
|
||||||
return _engine
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def is_loaded() -> bool:
|
||||||
|
"""True daca engine-ul global a fost deja construit. NU forteaza incarcarea."""
|
||||||
|
return _engine is not None
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# API public la nivel de modul (wiring L14-S6) #
|
# API public la nivel de modul (wiring L14-S6) #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -233,12 +329,22 @@ def corpus_signature() -> str | None:
|
|||||||
return _engine.corpus_signature()
|
return _engine.corpus_signature()
|
||||||
|
|
||||||
|
|
||||||
def index_corpus(items: list[dict], signature: str | None = None) -> None:
|
def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||||
|
"""Vectorizeaza texte brute prin motorul global (folosit la miss-uri de cache).
|
||||||
|
|
||||||
|
Arunca daca engine-ul e indisponibil -- apelantul (embedding_cache.sync_corpus_vectors)
|
||||||
|
propaga eroarea catre ensure_embeddings_corpus (degradare gratioasa acolo).
|
||||||
|
"""
|
||||||
|
return _get_engine().embed(texts)
|
||||||
|
|
||||||
|
|
||||||
|
def index_corpus(items: list[dict], signature: str | None = None, vectors: list | None = None) -> None:
|
||||||
"""Vectorizeaza corpus [{denumire, cod}] in motorul global.
|
"""Vectorizeaza corpus [{denumire, cod}] in motorul global.
|
||||||
|
|
||||||
|
`vectors`: vezi EmbeddingEngine.index_corpus (precalculati, aliniati cu `items`).
|
||||||
Silentios pe eroare (degradare gratioasa).
|
Silentios pe eroare (degradare gratioasa).
|
||||||
"""
|
"""
|
||||||
_get_engine().index_corpus(items, signature=signature)
|
_get_engine().index_corpus(items, signature=signature, vectors=vectors)
|
||||||
|
|
||||||
|
|
||||||
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]:
|
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]:
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ CATALOG: dict[str, dict[str, str]] = {
|
|||||||
"RAR_CREDS_INVALIDE": {
|
"RAR_CREDS_INVALIDE": {
|
||||||
"problema": "Credentiale RAR invalide",
|
"problema": "Credentiale RAR invalide",
|
||||||
"fix": (
|
"fix": (
|
||||||
"Verifica email-ul si parola contului RAR in tab-ul Cont;"
|
"Verifica email-ul si parola contului RAR in Meniu cont › Cont;"
|
||||||
" trimiterea nu se reincearca automat la credentiale gresite."
|
" trimiterea nu se reincearca automat la credentiale gresite."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -124,6 +124,13 @@ CATALOG: dict[str, dict[str, str]] = {
|
|||||||
"Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand."
|
"Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"CERERE_PREA_MARE": {
|
||||||
|
"problema": "Corpul cererii depaseste limita permisa",
|
||||||
|
"fix": (
|
||||||
|
"Trimite o cerere mai mica (imparte fisierul/lista in bucati mai mici"
|
||||||
|
" si retrimite)."
|
||||||
|
),
|
||||||
|
},
|
||||||
"IMPORT_ANTET_NECLAR": {
|
"IMPORT_ANTET_NECLAR": {
|
||||||
"problema": "Antet de coloane neclar",
|
"problema": "Antet de coloane neclar",
|
||||||
"fix": (
|
"fix": (
|
||||||
@@ -178,7 +185,7 @@ CATALOG: dict[str, dict[str, str]] = {
|
|||||||
" cererii (request_id) afisat."
|
" cererii (request_id) afisat."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
# Coduri de plan (PRD 5.17)
|
# Coduri de plan
|
||||||
"PLAN_LIMITA_LUNARA": {
|
"PLAN_LIMITA_LUNARA": {
|
||||||
"problema": "Ai atins limita planului Gratuit (60 prestatii/luna)",
|
"problema": "Ai atins limita planului Gratuit (60 prestatii/luna)",
|
||||||
"fix": (
|
"fix": (
|
||||||
@@ -197,7 +204,7 @@ CATALOG: dict[str, dict[str, str]] = {
|
|||||||
"RAR_MEDIU_INDISPONIBIL": {
|
"RAR_MEDIU_INDISPONIBIL": {
|
||||||
"problema": "Mediul RAR cerut nu e disponibil",
|
"problema": "Mediul RAR cerut nu e disponibil",
|
||||||
"fix": (
|
"fix": (
|
||||||
"Activeaza mediul si introdu credentialele RAR in tab-ul Cont."
|
"Activeaza mediul si introdu credentialele RAR in Meniu cont › Cont."
|
||||||
" Mediile disponibile acum sunt in campul cauza."
|
" Mediile disponibile acum sunt in campul cauza."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,9 +19,25 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
NRINM_STRIP_RE = re.compile(r"[\s\-.]+")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_nr_inmatriculare(value: object) -> str:
|
||||||
|
"""Normalizare canonica nr. inmatriculare: strip, upper, elimina spatii/cratime/puncte.
|
||||||
|
|
||||||
|
Formate reale ("B 99 XYZ", "B-99-XYZ") nu treceau NRINM_RE (app.validation) fara
|
||||||
|
aceasta curatare (spike SQL: doar 13.8% din numerele istorice treceau strict
|
||||||
|
^[A-Z0-9]{1,10}$). Folosita consecvent in models.PrezentareIn, validation._norm
|
||||||
|
si canonicalize_row de mai jos ca sa nu diverga validarea de cheia de dedup.
|
||||||
|
"""
|
||||||
|
s = str(value or "").strip().upper()
|
||||||
|
return NRINM_STRIP_RE.sub("", s)
|
||||||
|
|
||||||
|
|
||||||
def _op_identity(p: Any) -> str:
|
def _op_identity(p: Any) -> str:
|
||||||
"""Cod RAR (normalizat) daca exista, altfel codul intern ROAAUTO."""
|
"""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))
|
get = p.get if isinstance(p, dict) else (lambda k, d=None: getattr(p, k, d))
|
||||||
@@ -41,7 +57,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
|||||||
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
|
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
|
||||||
"""
|
"""
|
||||||
vin = (raw.get("vin") or "").strip().upper()
|
vin = (raw.get("vin") or "").strip().upper()
|
||||||
nr = (raw.get("nr_inmatriculare") or "").strip().upper()
|
nr = normalize_nr_inmatriculare(raw.get("nr_inmatriculare"))
|
||||||
|
|
||||||
# Odometru: strip ".0" Excel float coercion
|
# Odometru: strip ".0" Excel float coercion
|
||||||
odo_raw = raw.get("odometru_final")
|
odo_raw = raw.get("odometru_final")
|
||||||
@@ -71,7 +87,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def build_key(account_id: int | None, canon: dict[str, Any], rar_env: str = "test") -> str:
|
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).
|
"""SHA-256 partajat canal-API + canal-import, env-aware.
|
||||||
|
|
||||||
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
||||||
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2).
|
"""Parser fisiere xlsx/csv pentru import prezentari.
|
||||||
|
|
||||||
Arhitectura 2-treceri:
|
Arhitectura 2-treceri:
|
||||||
Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet.
|
Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet.
|
||||||
|
|||||||
48
app/main.py
48
app/main.py
@@ -7,6 +7,7 @@ un worker mort nu trebuie sa lase containerul "sanatos".
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -25,12 +26,13 @@ from . import errors
|
|||||||
from .api.v1.import_router import router as import_v1_router
|
from .api.v1.import_router import router as import_v1_router
|
||||||
from .api.v1.integrare_router import router as integrare_v1_router
|
from .api.v1.integrare_router import router as integrare_v1_router
|
||||||
from .api.v1.router import router as api_v1_router
|
from .api.v1.router import router as api_v1_router
|
||||||
from .config import get_settings
|
from .config import get_settings, validate_prod_invariants
|
||||||
from .crypto import validate_creds_key
|
from .crypto import validate_creds_key
|
||||||
from .db import get_connection, init_db, queue_depth, read_heartbeat
|
from .db import get_connection, init_db, queue_depth, read_heartbeat
|
||||||
from .observ import log_event, request_id_var
|
from .observ import log_event, request_id_var
|
||||||
from .security import install_log_redaction, scrub_text
|
from .security import install_log_redaction, scrub_text
|
||||||
from .web.middleware import RequestIDMiddleware
|
from .web.body_cap import BodyCapMiddleware
|
||||||
|
from .web.middleware import RequestIDMiddleware, SecurityHeadersMiddleware
|
||||||
from .web.routes import router as web_router
|
from .web.routes import router as web_router
|
||||||
from .web.auth_routes import router as auth_router
|
from .web.auth_routes import router as auth_router
|
||||||
from .web.admin_routes import router as admin_router
|
from .web.admin_routes import router as admin_router
|
||||||
@@ -38,13 +40,37 @@ from .web.csrf import CsrfError
|
|||||||
from .web.session import AdminRequired, LoginRequired
|
from .web.session import AdminRequired, LoginRequired
|
||||||
|
|
||||||
|
|
||||||
|
def _warmup_embeddings() -> None:
|
||||||
|
"""Incarca modelul de embeddings si indexeaza corpusul SILVER, in fundal.
|
||||||
|
|
||||||
|
Ruleaza intr-un thread daemon la startup: incarcarea modelului (~230MB) plus
|
||||||
|
vectorizarea corpusului dureaza zeci de secunde si NU are voie sa blocheze
|
||||||
|
primul request pe /mapari. Pana termina, sugestiile embeddings lipsesc
|
||||||
|
(degradare gratioasa); GOLD/SILVER/fuzzy functioneaza normal.
|
||||||
|
"""
|
||||||
|
from .mapping import ensure_embeddings_corpus
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
ensure_embeddings_corpus(conn, block=True)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass # best-effort: esecul warmup-ului nu opreste API-ul
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
install_log_redaction()
|
install_log_redaction()
|
||||||
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
|
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
|
||||||
# in loc de 500 brut la primul POST /v1/prezentari.
|
# in loc de 500 brut la primul POST /v1/prezentari.
|
||||||
validate_creds_key()
|
validate_creds_key()
|
||||||
|
# Fail-fast (prod-only): elimina clasa "am uitat env var in Dokploy" pentru
|
||||||
|
# cheia API si secretul de sesiune, in loc de o instanta descoperita post-deploy.
|
||||||
|
validate_prod_invariants(get_settings())
|
||||||
init_db()
|
init_db()
|
||||||
|
if get_settings().embeddings_enabled:
|
||||||
|
threading.Thread(target=_warmup_embeddings, name="emb-warmup", daemon=True).start()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +89,12 @@ app.add_middleware(
|
|||||||
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
|
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
|
||||||
# inclusiv 401/404/422/500 produse mai in interior.
|
# inclusiv 401/404/422/500 produse mai in interior.
|
||||||
app.add_middleware(RequestIDMiddleware)
|
app.add_middleware(RequestIDMiddleware)
|
||||||
|
# Headere de securitate (P1-4) pe TOATE raspunsurile.
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
# Body-cap (P1-5): ADAUGAT ULTIMUL -> ruleaza CEL MAI OUTERMOST (add_middleware
|
||||||
|
# prepend), deci intercepteaza `receive` inaintea oricarui parser FastAPI/Starlette
|
||||||
|
# (multipart, JSON) — vezi docstring body_cap.py pentru motivul ASGI-pur.
|
||||||
|
app.add_middleware(BodyCapMiddleware)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(LoginRequired)
|
@app.exception_handler(LoginRequired)
|
||||||
@@ -177,9 +209,21 @@ def metrics() -> str:
|
|||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
|
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
|
||||||
|
# Coada tinuta: observabilitate ops pt. default Auto OFF.
|
||||||
|
# Gauge-uri DERIVATE (zero stare noua): total randuri tinute + varsta celui
|
||||||
|
# mai vechi (din created_at), scoped global. Semnaleaza esecul silentios
|
||||||
|
# cand prezentari raman nedeclarate tacit (risc L.142).
|
||||||
|
held = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS n, "
|
||||||
|
"COALESCE(MAX(strftime('%s','now') - strftime('%s', created_at)), 0) AS oldest "
|
||||||
|
"FROM submissions WHERE status='queued' AND held=1"
|
||||||
|
).fetchone()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
lines = ["# submissions pe status"]
|
lines = ["# submissions pe status"]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}')
|
lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}')
|
||||||
|
# randuri tinute (queued AND held=1)
|
||||||
|
lines.append(f'autopass_held_submissions {held["n"]}')
|
||||||
|
lines.append(f'autopass_held_oldest_age_seconds {held["oldest"]}')
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|||||||
391
app/mapping.py
391
app/mapping.py
@@ -16,16 +16,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from rapidfuzz import fuzz, process
|
from rapidfuzz import fuzz, process
|
||||||
|
|
||||||
from . import errors as err_mod
|
from . import errors as err_mod
|
||||||
|
from .accounts import held_for_account
|
||||||
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
||||||
from .validation import validate_prezentare
|
from .validation import validate_prezentare
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Cont implicit cat timp auth API-key (CORE) nu e implementat: ingestiile vin cu
|
# 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).
|
# account_id NULL si le atribuim contului seed-at in schema (id=1).
|
||||||
DEFAULT_ACCOUNT_ID = 1
|
DEFAULT_ACCOUNT_ID = 1
|
||||||
@@ -33,6 +39,10 @@ DEFAULT_ACCOUNT_ID = 1
|
|||||||
# Sub acest scor (0..100) nu preselectam nicio sugestie — userul alege manual.
|
# Sub acest scor (0..100) nu preselectam nicio sugestie — userul alege manual.
|
||||||
SUGGEST_MIN_SCORE = 60
|
SUGGEST_MIN_SCORE = 60
|
||||||
|
|
||||||
|
# Sentinel pentru optiunea "nu se declara la RAR" din select-urile web de mapare.
|
||||||
|
# NU e cod RAR (codurile au max 5 caractere) — nu poate coliziona cu nomenclatorul.
|
||||||
|
EXCLUDE_SENTINEL = "__NEDECLARAT__"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Pur: normalizare + fuzzy + rezolvare #
|
# Pur: normalizare + fuzzy + rezolvare #
|
||||||
@@ -51,7 +61,7 @@ def normalize_for_match(value: object) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Pre-filtru determinist non-operatii (NUL) — US-001 PRD 5.18 #
|
# Pre-filtru determinist non-operatii (NUL) #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
#
|
#
|
||||||
# Masuratoarea k-NN (memorie test-precizie-knn-embeddings) arata recall NUL doar
|
# Masuratoarea k-NN (memorie test-precizie-knn-embeddings) arata recall NUL doar
|
||||||
@@ -87,7 +97,7 @@ _NUL_CTX_PIESA = re.compile(
|
|||||||
def prefiltru_nul(denumire: object) -> bool:
|
def prefiltru_nul(denumire: object) -> bool:
|
||||||
"""True daca operatia e gunoi evident (non-operatie de service) -> NUL determinist.
|
"""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.
|
Ruleaza INAINTE de k-NN/embeddings in `enrich_suggestions`. Pur, fara DB.
|
||||||
Zero fals-pozitiv pe operatii reale (vezi comentariul de mai sus + tests).
|
Zero fals-pozitiv pe operatii reale (vezi comentariul de mai sus + tests).
|
||||||
"""
|
"""
|
||||||
text = normalize_for_match(denumire)
|
text = normalize_for_match(denumire)
|
||||||
@@ -237,11 +247,15 @@ def resolve_prestatii(
|
|||||||
mapping: dict[str, str],
|
mapping: dict[str, str],
|
||||||
valid_codes: set[str] | None = None,
|
valid_codes: set[str] | None = None,
|
||||||
text_rules: list[dict] | None = None,
|
text_rules: list[dict] | None = None,
|
||||||
|
excluded_ops: set[str] | None = None,
|
||||||
) -> tuple[list[dict], list[dict]]:
|
) -> tuple[list[dict], list[dict]]:
|
||||||
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
|
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
|
||||||
|
|
||||||
Reguli (hibrid):
|
Reguli (hibrid):
|
||||||
- item cu `cod_prestatie` valid (in nomenclator) -> pastrat ca atare.
|
- item cu `cod_prestatie` valid (in nomenclator) -> pastrat ca atare.
|
||||||
|
- item fara cod, cu `cod_op_service` in `excluded_ops` -> adnotat `exclus=True`
|
||||||
|
(NU e nemapat; nu se declara la RAR — apelantii il scot din payload cu
|
||||||
|
`split_prestatii_excluse` inainte de enqueue).
|
||||||
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
|
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
|
||||||
- item fara cod, nemapat exact, dar al carui text da match pe o regula text
|
- item fara cod, nemapat exact, dar al carui text da match pe o regula text
|
||||||
(substring) -> umplem cod_prestatie din prima regula care potriveste.
|
(substring) -> umplem cod_prestatie din prima regula care potriveste.
|
||||||
@@ -252,9 +266,16 @@ def resolve_prestatii(
|
|||||||
COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
|
COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
|
||||||
la RAR (terminal) -> nu-l trimitem niciodata raw.
|
la RAR (terminal) -> nu-l trimitem niciodata raw.
|
||||||
|
|
||||||
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service`
|
Precedenta (stricta): item-exclus EXPLICIT (`exclus=1` venit pe item, ex. din
|
||||||
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista
|
editorul de chips) > `cod_prestatie` direct valid > exclus de la declarare
|
||||||
cod valid SI op nu e in `mapping`.
|
(regula de cont) > mapare exacta `cod_op_service` in `mapping` > reguli text >
|
||||||
|
nemapat. Un item marcat explicit `exclus` de utilizator (editor) domina TOT —
|
||||||
|
inclusiv un `cod_prestatie` deja valid pe rand — pentru ca alegerea explicita
|
||||||
|
"Nu se declara la RAR" e intentia cea mai recenta si trebuie sa supravietuiasca
|
||||||
|
re-rezolvarii (altfel un item exclus ar reveni needs_mapping sau, mai rau, ar fi
|
||||||
|
declarat la RAR). Restul precedentei (cod valid > exclus de cont > mapare >
|
||||||
|
reguli text) e NESCHIMBAT. 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
|
`valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e
|
||||||
None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece
|
None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece
|
||||||
@@ -279,10 +300,21 @@ def resolve_prestatii(
|
|||||||
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
|
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
|
||||||
it.pop("cod_sursa", None)
|
it.pop("cod_sursa", None)
|
||||||
it.pop("regula_fara_autosend", None)
|
it.pop("regula_fara_autosend", None)
|
||||||
|
exclus_explicit = bool(it.pop("exclus", None))
|
||||||
cod = (it.get("cod_prestatie") or "").strip().upper()
|
cod = (it.get("cod_prestatie") or "").strip().upper()
|
||||||
op = (it.get("cod_op_service") or "").strip()
|
op = (it.get("cod_op_service") or "").strip()
|
||||||
cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes)
|
cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes)
|
||||||
if cod_valid:
|
if exclus_explicit:
|
||||||
|
# Treapta noua (precedenta maxima): alegerea explicita "Nu se declara la
|
||||||
|
# RAR" (editor de chips) supravietuieste re-rezolvarii, chiar daca ramane
|
||||||
|
# (sau a ramas) un cod_prestatie valid pe rand.
|
||||||
|
if cod and not op:
|
||||||
|
op = cod
|
||||||
|
if op and not it.get("cod_op_service"):
|
||||||
|
it["cod_op_service"] = op
|
||||||
|
it["cod_prestatie"] = None
|
||||||
|
it["exclus"] = True
|
||||||
|
elif cod_valid:
|
||||||
it["cod_prestatie"] = cod
|
it["cod_prestatie"] = cod
|
||||||
else:
|
else:
|
||||||
# cod lipsa SAU necunoscut in nomenclator -> ruta de mapare.
|
# cod lipsa SAU necunoscut in nomenclator -> ruta de mapare.
|
||||||
@@ -293,7 +325,11 @@ def resolve_prestatii(
|
|||||||
it["cod_op_service"] = op
|
it["cod_op_service"] = op
|
||||||
if not it.get("denumire"):
|
if not it.get("denumire"):
|
||||||
it["denumire"] = cod
|
it["denumire"] = cod
|
||||||
if op and op in mapping:
|
if op and excluded_ops and op in excluded_ops:
|
||||||
|
# Exclus de la declarare: nu e nemapat, nu se trimite la RAR.
|
||||||
|
it["cod_prestatie"] = None
|
||||||
|
it["exclus"] = True
|
||||||
|
elif op and op in mapping:
|
||||||
it["cod_prestatie"] = mapping[op]
|
it["cod_prestatie"] = mapping[op]
|
||||||
elif op:
|
elif op:
|
||||||
# Mapare exacta absenta -> incearca regulile text (substring).
|
# Mapare exacta absenta -> incearca regulile text (substring).
|
||||||
@@ -305,7 +341,7 @@ def resolve_prestatii(
|
|||||||
# Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul
|
# Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul
|
||||||
# sursa. Payload-harmless (RAR citeste doar cod_prestatie).
|
# sursa. Payload-harmless (RAR citeste doar cod_prestatie).
|
||||||
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
|
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
|
||||||
# US-001 (PRD 5.11): regula_fara_autosend nu se mai seteaza;
|
# regula_fara_autosend nu se mai seteaza;
|
||||||
# auto_send nu mai tine randul (has_no_auto_send neutralizat).
|
# auto_send nu mai tine randul (has_no_auto_send neutralizat).
|
||||||
else:
|
else:
|
||||||
it["cod_prestatie"] = None
|
it["cod_prestatie"] = None
|
||||||
@@ -316,6 +352,17 @@ def resolve_prestatii(
|
|||||||
return resolved, unmapped
|
return resolved, unmapped
|
||||||
|
|
||||||
|
|
||||||
|
def split_prestatii_excluse(prestatii: list[dict] | None) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Separa prestatiile declarabile de cele adnotate `exclus` de resolve_prestatii.
|
||||||
|
|
||||||
|
Payload-ul trimis la RAR (si cheia de idempotenta) se construieste NUMAI din
|
||||||
|
declarabile; cele excluse nu parasesc niciodata sistemul.
|
||||||
|
"""
|
||||||
|
declarabile = [p for p in (prestatii or []) if not p.get("exclus")]
|
||||||
|
excluse = [p for p in (prestatii or []) if p.get("exclus")]
|
||||||
|
return declarabile, excluse
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Persistenta (conn) #
|
# Persistenta (conn) #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -398,10 +445,11 @@ def load_nomenclator_codes(conn) -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
def load_mapping(conn, account_id: int | None) -> dict[str, str]:
|
def load_mapping(conn, account_id: int | None) -> dict[str, str]:
|
||||||
"""{cod_op_service -> cod_prestatie} pentru un cont."""
|
"""{cod_op_service -> cod_prestatie} pentru un cont. Fara regulile de excludere."""
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT cod_op_service, cod_prestatie FROM operations_mapping WHERE account_id=?",
|
"SELECT cod_op_service, cod_prestatie FROM operations_mapping "
|
||||||
|
"WHERE account_id=? AND exclus=0",
|
||||||
(acct,),
|
(acct,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {r["cod_op_service"]: r["cod_prestatie"] for r in rows}
|
return {r["cod_op_service"]: r["cod_prestatie"] for r in rows}
|
||||||
@@ -411,10 +459,13 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
|
|||||||
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
|
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
|
||||||
|
|
||||||
Varianta extinsa care include si flagul auto_send per operatie.
|
Varianta extinsa care include si flagul auto_send per operatie.
|
||||||
|
Fara regulile de excludere (cod_prestatie gol) — consumatorii construiesc din ea
|
||||||
|
dict-uri de mapare op->cod; excluderile se incarca separat (load_excluded_ops).
|
||||||
"""
|
"""
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT cod_op_service, cod_prestatie, auto_send FROM operations_mapping WHERE account_id=?",
|
"SELECT cod_op_service, cod_prestatie, auto_send FROM operations_mapping "
|
||||||
|
"WHERE account_id=? AND exclus=0",
|
||||||
(acct,),
|
(acct,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {
|
return {
|
||||||
@@ -423,12 +474,23 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_excluded_ops(conn, account_id: int | None) -> set[str]:
|
||||||
|
"""Setul de operatii excluse de la declarare pentru un cont."""
|
||||||
|
acct = account_or_default(account_id)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT cod_op_service FROM operations_mapping WHERE account_id=? AND exclus=1",
|
||||||
|
(acct,),
|
||||||
|
).fetchall()
|
||||||
|
return {r["cod_op_service"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
def classify_prezentare(
|
def classify_prezentare(
|
||||||
content: dict,
|
content: dict,
|
||||||
mapping: dict[str, str],
|
mapping: dict[str, str],
|
||||||
mapping_meta: dict[str, dict],
|
mapping_meta: dict[str, dict],
|
||||||
valid_codes: set[str] | None = None,
|
valid_codes: set[str] | None = None,
|
||||||
text_rules: list[dict] | None = None,
|
text_rules: list[dict] | None = None,
|
||||||
|
excluded_ops: set[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
|
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
|
||||||
|
|
||||||
@@ -448,7 +510,7 @@ def classify_prezentare(
|
|||||||
"odometru_final": canon["odometru_final"],
|
"odometru_final": canon["odometru_final"],
|
||||||
})
|
})
|
||||||
|
|
||||||
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules)
|
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
|
||||||
c["prestatii"] = resolved
|
c["prestatii"] = resolved
|
||||||
|
|
||||||
if unmapped:
|
if unmapped:
|
||||||
@@ -460,12 +522,25 @@ def classify_prezentare(
|
|||||||
)
|
)
|
||||||
errors: list[dict] = []
|
errors: list[dict] = []
|
||||||
else:
|
else:
|
||||||
errors = validate_prezentare(c)
|
# Prestatiile excluse de la declarare NU intra in payload-ul trimis (nici in
|
||||||
|
# cheia de idempotenta). Toate excluse -> needs_data cu motiv explicit;
|
||||||
|
# payload-ul pastreaza itemii adnotati `exclus` ca detaliul sa arate operatiile.
|
||||||
|
declarabile, excluse = split_prestatii_excluse(resolved)
|
||||||
|
if not declarabile and excluse:
|
||||||
|
ops_excluse = ", ".join((p.get("cod_op_service") or "") for p in excluse)
|
||||||
|
errors = [{
|
||||||
|
"field": "prestatii",
|
||||||
|
"message": f"Toate operatiile sunt excluse de la declarare ({ops_excluse}) — randul nu se trimite la RAR.",
|
||||||
|
}]
|
||||||
|
else:
|
||||||
|
if excluse:
|
||||||
|
c["prestatii"] = declarabile
|
||||||
|
errors = validate_prezentare(c)
|
||||||
if errors:
|
if errors:
|
||||||
status = "needs_data"
|
status = "needs_data"
|
||||||
rar_error = json.dumps(errors, ensure_ascii=False)
|
rar_error = json.dumps(errors, ensure_ascii=False)
|
||||||
else:
|
else:
|
||||||
# US-001 (PRD 5.11): ramura AUTO_SEND_OPRIT eliminata.
|
# Ramura AUTO_SEND_OPRIT eliminata.
|
||||||
# Un cod rezolvat (mapare exacta sau regula text) -> queued direct.
|
# Un cod rezolvat (mapare exacta sau regula text) -> queued direct.
|
||||||
status = "queued"
|
status = "queued"
|
||||||
rar_error = None
|
rar_error = None
|
||||||
@@ -481,7 +556,7 @@ def classify_prezentare(
|
|||||||
|
|
||||||
|
|
||||||
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
|
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.
|
"""Neutralizat: auto_send nu mai tine randuri in needs_mapping.
|
||||||
|
|
||||||
Simbolul este PASTRAT (importat in routes.py si import_router.py); stergerea
|
Simbolul este PASTRAT (importat in routes.py si import_router.py); stergerea
|
||||||
ar produce ImportError la boot. Functia intoarce mereu False — codul rezolvat
|
ar produce ImportError la boot. Functia intoarce mereu False — codul rezolvat
|
||||||
@@ -516,6 +591,7 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
agg: dict[tuple[int, str], dict[str, Any]] = {}
|
agg: dict[tuple[int, str], dict[str, Any]] = {}
|
||||||
|
excluded_by_acct: dict[int, set[str]] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
acct = r["account_id"] if r["account_id"] is not None else DEFAULT_ACCOUNT_ID
|
acct = r["account_id"] if r["account_id"] is not None else DEFAULT_ACCOUNT_ID
|
||||||
try:
|
try:
|
||||||
@@ -530,6 +606,11 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
|
|||||||
op = (item.get("cod_op_service") or "").strip()
|
op = (item.get("cod_op_service") or "").strip()
|
||||||
if not op:
|
if not op:
|
||||||
continue
|
continue
|
||||||
|
# Operatiile excluse de la declarare nu sunt "de mapat" — nu apar in editor.
|
||||||
|
if acct not in excluded_by_acct:
|
||||||
|
excluded_by_acct[acct] = load_excluded_ops(conn, acct)
|
||||||
|
if op in excluded_by_acct[acct]:
|
||||||
|
continue
|
||||||
key = (acct, op)
|
key = (acct, op)
|
||||||
entry = agg.setdefault(
|
entry = agg.setdefault(
|
||||||
key,
|
key,
|
||||||
@@ -557,21 +638,44 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestatie: str, auto_send: bool) -> None:
|
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)."""
|
"""Upsert o mapare op->cod (UNIQUE pe account_id+cod_op_service).
|
||||||
|
|
||||||
|
Reseteaza `excluded=0`: maparea unei operatii excluse anterior o readuce
|
||||||
|
in fluxul de declarare (aceeasi cheie UNIQUE, o operatie = o regula).
|
||||||
|
"""
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
op = (cod_op_service or "").strip()
|
op = (cod_op_service or "").strip()
|
||||||
cod = (cod_prestatie or "").strip().upper()
|
cod = (cod_prestatie or "").strip().upper()
|
||||||
if not op or not cod:
|
if not op or not cod:
|
||||||
raise ValueError("cod_op_service si cod_prestatie sunt obligatorii")
|
raise ValueError("cod_op_service si cod_prestatie sunt obligatorii")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send, exclus) "
|
||||||
"VALUES (?, ?, ?, ?) "
|
"VALUES (?, ?, ?, ?, 0) "
|
||||||
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
|
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
|
||||||
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send",
|
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send, exclus=0",
|
||||||
(acct, op, cod, 1 if auto_send else 0),
|
(acct, op, cod, 1 if auto_send else 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_exclusion(conn, account_id: int | None, cod_op_service: str) -> None:
|
||||||
|
"""Upsert o regula 'nu se declara' pentru o operatie (UNIQUE pe account_id+op).
|
||||||
|
|
||||||
|
cod_prestatie ramane gol — regula nu mapeaza, doar exclude de la declarare.
|
||||||
|
Suprascrie o mapare existenta pe aceeasi operatie (o operatie = o regula).
|
||||||
|
"""
|
||||||
|
acct = account_or_default(account_id)
|
||||||
|
op = (cod_op_service or "").strip()
|
||||||
|
if not op:
|
||||||
|
raise ValueError("cod_op_service este obligatoriu")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send, exclus) "
|
||||||
|
"VALUES (?, ?, '', 0, 1) "
|
||||||
|
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
|
||||||
|
"cod_prestatie='', auto_send=0, exclus=1",
|
||||||
|
(acct, op),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_text_rules(conn, account_id: int | None) -> list[dict]:
|
def load_text_rules(conn, account_id: int | None) -> list[dict]:
|
||||||
"""Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC.
|
"""Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC.
|
||||||
|
|
||||||
@@ -625,10 +729,31 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Prag minim de similaritate cosine pentru sugestia din embeddings NN.
|
# Prag minim de similaritate cosine pentru un vecin k-NN luat in calcul la vot.
|
||||||
# Sub acest scor, sugestia NN e prea incerta si nu o afisam (previne recomandari
|
# Calibrat prin leave-one-out pe corpusul SILVER (tools/mapare-llm/knn_calibrate.py):
|
||||||
# irelevante cand corpus-ul e mic sau neindexat corect).
|
# modelul e anizotrop (perechi nelegate scoreaza 0.79-0.87), deci sub ~0.88 vecinul
|
||||||
EMB_MIN_SIMILARITATE = 0.5
|
# e zgomot. La 0.88: coverage 83%, precizie 93%; la 0.5 (vechiul prag): coverage
|
||||||
|
# 100% dar 7.4% cod gresit preselectat.
|
||||||
|
EMB_MIN_SIMILARITATE = 0.88
|
||||||
|
|
||||||
|
# Cati vecini intra in votul ponderat pe rang. Vot > top-1: corpusul SILVER
|
||||||
|
# are etichete contradictorii pe denumiri aproape identice; votul e imun la ele
|
||||||
|
# (+~1pp precizie la acelasi coverage, masurat LOO).
|
||||||
|
EMB_VOTE_TOP_K = 5
|
||||||
|
|
||||||
|
# Ponderea unui vecin = EMB_VOTE_DECAY^rang (1-indexat). Ponderarea cu similaritatea
|
||||||
|
# bruta e pacalita de anizotropia modelului: vecini fara legatura scoreaza aproape
|
||||||
|
# cat cei buni (ex. INLOCUIT BECURII la 0.934 de INLOCUIRE ANVELOPE), deci coada
|
||||||
|
# de zgomot poate invinge 2 vecini corecti de rang 1-2. Ordinea rangurilor ramane
|
||||||
|
# informativa; decay 0.7 = cel mai mic cod-gresit la precizie egala (LOO).
|
||||||
|
EMB_VOTE_DECAY = 0.7
|
||||||
|
|
||||||
|
# Protejeaza secventa hash->load->embed->save->purge->index (embedding_cache) de
|
||||||
|
# executie concurenta intre warmup-ul de fundal (block=True) si calea de request
|
||||||
|
# (block=False, dupa ce modelul e deja incarcat) -- altfel purjarea uneia ar sterge
|
||||||
|
# randuri tocmai scrise de cealalta. Calea de request obtine lock-ul neblocant
|
||||||
|
# (nu asteapta warmup-ul in curs); doar warmup-ul asteapta normal.
|
||||||
|
_embeddings_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _corpus_signature_silver(rows: list) -> str:
|
def _corpus_signature_silver(rows: list) -> str:
|
||||||
@@ -645,8 +770,8 @@ def _corpus_signature_silver(rows: list) -> str:
|
|||||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
|
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None, *, block: bool = False) -> None:
|
||||||
"""Construieste/actualizeaza corpusul embeddings din corpusul ETICHETAT (PRD 5.18 US-005).
|
"""Construieste/actualizeaza corpusul embeddings din corpusul ETICHETAT.
|
||||||
|
|
||||||
Sursa corpusului = `mapping_suggestions` (SILVER): exemple reale etichetate
|
Sursa corpusului = `mapping_suggestions` (SILVER): exemple reale etichetate
|
||||||
{denumire_normalizata -> cod, is_nul}, NU cele 18 categorii generice din
|
{denumire_normalizata -> cod, is_nul}, NU cele 18 categorii generice din
|
||||||
@@ -657,36 +782,82 @@ def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> Non
|
|||||||
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default ON; OFF in teste): cand e
|
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default ON; OFF in teste): cand e
|
||||||
dezactivat, e un no-op total -> /mapari instant + suita de teste rapida.
|
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
|
Cand e activat: indexeaza corpusul o singura data, re-indexeaza doar cand
|
||||||
prima chemare), re-indexeaza doar cand semnatura corpusului SILVER s-a schimbat.
|
semnatura corpusului SILVER s-a schimbat. Itemii NUL (is_nul=1, cod NULL) raman
|
||||||
Itemii NUL (is_nul=1, cod NULL) raman in corpus: un vecin NUL e semnal de supresie
|
in corpus: un vecin NUL e semnal de supresie.
|
||||||
(US-006). Degradare gratioasa: orice eroare lasa corpusul gol -> enrich cade pe restul.
|
|
||||||
|
`block=False` (default, calea de request): daca modelul NU e inca incarcat,
|
||||||
|
return imediat — incarcarea modelului (~230MB, zeci de secunde) NU are voie sa
|
||||||
|
blocheze un request HTTP; o face warmup-ul de la startup (block=True, in thread).
|
||||||
|
Odata modelul incarcat insa, warmup-ul mai poate fi INCA in curs de vectorizare
|
||||||
|
a corpusului (~1-2 min): calea de request NU asteapta dupa lock in acest caz —
|
||||||
|
incearca sa il obtina neblocant, iar daca e ocupat, iese imediat (degradare
|
||||||
|
gratioasa, sugestii lipsa pana termina warmup-ul). block=True (warmup) asteapta
|
||||||
|
normal dupa lock.
|
||||||
|
|
||||||
|
Cache persistent (embedding_cache): hash-ul se calculeaza pe lista FILTRATA de
|
||||||
|
`denumire` (EXACT ce intra in `index_corpus`), citeste vectorii existenti pentru
|
||||||
|
modelul curent, vectorizeaza doar miss-urile si salveaza-i. Purjarea orfanelor
|
||||||
|
ruleaza DUPA indexare, doar daca indexarea a reusit efectiv (semnatura noua
|
||||||
|
confirmata) — un esec de indexare nu trebuie sa goleasca cache-ul.
|
||||||
|
Degradare gratioasa: orice eroare lasa corpusul gol -> enrich cade pe restul.
|
||||||
"""
|
"""
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
if not get_settings().embeddings_enabled:
|
if not get_settings().embeddings_enabled:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from . import embeddings as _emb
|
from . import embeddings as _emb
|
||||||
rows = conn.execute(
|
if not block and not _emb.is_loaded():
|
||||||
"SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions"
|
return # warmup-ul din fundal nu a terminat inca; nu bloca request-ul
|
||||||
).fetchall()
|
|
||||||
if not rows:
|
if not _embeddings_lock.acquire(blocking=block):
|
||||||
return
|
return # warmup in curs; calea de request nu asteapta (nu bloca request-ul)
|
||||||
sig = _corpus_signature_silver(rows)
|
try:
|
||||||
if _emb.corpus_signature() == sig and _emb.has_corpus():
|
rows = conn.execute(
|
||||||
return # deja indexat pe acelasi corpus SILVER -> nimic de facut
|
"SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions"
|
||||||
items = [
|
).fetchall()
|
||||||
{
|
if not rows:
|
||||||
"denumire": str(r["denumire_normalizata"]),
|
return
|
||||||
"cod": (str(r["cod_prestatie"]) if r["cod_prestatie"] is not None else None),
|
sig = _corpus_signature_silver(rows)
|
||||||
"is_nul": bool(r["is_nul"]),
|
if _emb.corpus_signature() == sig and _emb.has_corpus():
|
||||||
}
|
return # deja indexat pe acelasi corpus SILVER -> nimic de facut
|
||||||
for r in rows
|
items = [
|
||||||
if r["denumire_normalizata"]
|
{
|
||||||
]
|
"denumire": str(r["denumire_normalizata"]),
|
||||||
_emb.index_corpus(items, signature=sig)
|
"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"]
|
||||||
|
]
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
from . import embedding_cache as _cache
|
||||||
|
|
||||||
|
texts = [item["denumire"] for item in items]
|
||||||
|
hashes = [_cache.text_hash(t) for t in texts]
|
||||||
|
miss_count = 0
|
||||||
|
|
||||||
|
def _embed_fn(missing_texts: list[str]) -> list:
|
||||||
|
nonlocal miss_count
|
||||||
|
miss_count += len(missing_texts)
|
||||||
|
return _emb.embed_texts(missing_texts)
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
|
vectors = _cache.sync_corpus_vectors(conn, _emb.FASTEMBED_MODEL, texts, _embed_fn)
|
||||||
|
_emb.index_corpus(items, signature=sig, vectors=vectors)
|
||||||
|
if _emb.corpus_signature() == sig and _emb.has_corpus():
|
||||||
|
_cache.purge_stale(conn, _emb.FASTEMBED_MODEL, set(hashes))
|
||||||
|
eticheta = "warmup ok" if block else "corpus reindexat"
|
||||||
|
log.info(
|
||||||
|
"embeddings: %s cache=%d embed=%d in %.1fs",
|
||||||
|
eticheta, len(texts) - miss_count, miss_count, time.monotonic() - t0,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_embeddings_lock.release()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # degradare gratioasa (#16b): esecul indexarii nu blocheaza editorul
|
pass # degradare gratioasa: esecul indexarii nu blocheaza editorul
|
||||||
|
|
||||||
|
|
||||||
def enrich_suggestions(
|
def enrich_suggestions(
|
||||||
@@ -697,39 +868,43 @@ def enrich_suggestions(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Imbogateste sugestiile cu GOLD partajat, SILVER LLM si embeddings NN.
|
"""Imbogateste sugestiile cu GOLD partajat, SILVER LLM si embeddings NN.
|
||||||
|
|
||||||
Precedenta Eng-F2 (pentru sugestie-only, nu auto-send):
|
Precedenta (pentru sugestie-only, nu auto-send):
|
||||||
shared GOLD > SILVER > embeddings
|
shared GOLD > SILVER > embeddings
|
||||||
|
|
||||||
(Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping;
|
(Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping;
|
||||||
nu apare in needs_mapping, deci nu e in precedenta de sugestie.)
|
nu apare in needs_mapping, deci nu e in precedenta de sugestie.)
|
||||||
|
|
||||||
Ordine completa (PRD 5.18 US-006):
|
Ordine completa:
|
||||||
pre-filtru NUL determinist -> (daca NUL: fara cod, `surse['nul']=True`)
|
pre-filtru NUL determinist -> (daca NUL: fara cod, `surse['nul']=True`)
|
||||||
altfel GOLD partajat > exact (SILVER) > k-NN embeddings.
|
altfel GOLD partajat > exact (SILVER) > k-NN embeddings.
|
||||||
|
|
||||||
Returneaza:
|
Returneaza:
|
||||||
{
|
{
|
||||||
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
|
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
|
||||||
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None, 'nul': bool}
|
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None,
|
||||||
|
'embedding_similaritate': float|None, 'nul': bool}
|
||||||
}
|
}
|
||||||
|
|
||||||
INVARIANTE:
|
INVARIANTE:
|
||||||
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13).
|
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping.
|
||||||
- Pre-filtru NUL (US-001) ruleaza PRIMUL: gunoiul evident (ITP/plata/discount...) e
|
- Pre-filtru NUL ruleaza PRIMUL: gunoiul evident (ITP/plata/discount...) e
|
||||||
marcat non-operatie INAINTE de k-NN, fara sugestie de cod.
|
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.
|
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie; vecin k-NN NUL idem.
|
||||||
- Degradare gratioasa pe embeddings (#16b): daca motorul nu e disponibil sau arunca,
|
- Degradare gratioasa pe embeddings: daca motorul nu e disponibil sau arunca,
|
||||||
returneaza sugestia disponibila din celelalte surse, fara exceptie.
|
returneaza sugestia disponibila din celelalte surse, fara exceptie.
|
||||||
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store
|
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store
|
||||||
importa normalize_for_match din mapping).
|
importa normalize_for_match din mapping).
|
||||||
"""
|
"""
|
||||||
sugestie_principala: dict | None = None
|
sugestie_principala: dict | None = None
|
||||||
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
|
surse: dict = {
|
||||||
|
"gold_partajat": None, "silver": None,
|
||||||
|
"embedding": None, "embedding_similaritate": None, "nul": False,
|
||||||
|
}
|
||||||
|
|
||||||
if not denumire:
|
if not denumire:
|
||||||
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
||||||
|
|
||||||
# 0. Pre-filtru NUL determinist (US-001) INAINTE de orice k-NN/lookup: non-operatie
|
# 0. Pre-filtru NUL determinist INAINTE de orice k-NN/lookup: non-operatie
|
||||||
# evidenta -> fara cod, scurtcircuit (nu interogheaza embeddings/SILVER pe gunoi).
|
# evidenta -> fara cod, scurtcircuit (nu interogheaza embeddings/SILVER pe gunoi).
|
||||||
if prefiltru_nul(denumire):
|
if prefiltru_nul(denumire):
|
||||||
surse["nul"] = True
|
surse["nul"] = True
|
||||||
@@ -757,7 +932,7 @@ def enrich_suggestions(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # degradare gratioasa
|
pass # degradare gratioasa
|
||||||
|
|
||||||
# 3. Embeddings NN (similaritate semantica, degradare gratioasa #16b)
|
# 3. Embeddings NN (similaritate semantica, degradare gratioasa)
|
||||||
if include_embeddings:
|
if include_embeddings:
|
||||||
try:
|
try:
|
||||||
from . import embeddings as _emb
|
from . import embeddings as _emb
|
||||||
@@ -767,18 +942,31 @@ def enrich_suggestions(
|
|||||||
# ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand
|
# ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand
|
||||||
# flagul e off, has_corpus() ramane False si calea e un no-op real.
|
# flagul e off, has_corpus() ramane False si calea e un no-op real.
|
||||||
if _emb.has_corpus():
|
if _emb.has_corpus():
|
||||||
# F1 (US-005): corpusul k-NN e text NORMALIZAT (denumire_normalizata),
|
# Corpusul k-NN e text NORMALIZAT (denumire_normalizata),
|
||||||
# deci query-ul TREBUIE normalizat la fel — altfel cosine degradeaza si
|
# deci query-ul TREBUIE normalizat la fel — altfel cosine degradeaza si
|
||||||
# nu mai e configul sub care s-a masurat 94.3%.
|
# nu mai e configul sub care s-a masurat 94.3%.
|
||||||
nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=1)
|
nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=EMB_VOTE_TOP_K)
|
||||||
# Prag minim: similaritate prea mica = sugestie inutila.
|
# Vot ponderat pe rang (decay^rang) pe vecinii peste prag; NUL e eticheta
|
||||||
# Evita recomandari irelevante cand corpus-ul e mic/partial.
|
# proprie (castiga -> supresie, nu cod). Vecinii sub prag nu voteaza.
|
||||||
if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
|
scoruri: dict[str, float] = {}
|
||||||
if nn[0].get("is_nul"):
|
sim_max: dict[str, float] = {}
|
||||||
# Vecin NUL (non-operatie) = semnal de SUPRESIE, nu cod (US-006).
|
for rang, v in enumerate(nn, 1):
|
||||||
|
sim = float(v.get("similaritate", 0))
|
||||||
|
if sim < EMB_MIN_SIMILARITATE:
|
||||||
|
continue
|
||||||
|
lab = "NUL" if v.get("is_nul") else (str(v["cod"]) if v.get("cod") else None)
|
||||||
|
if lab is None:
|
||||||
|
continue
|
||||||
|
scoruri[lab] = scoruri.get(lab, 0.0) + EMB_VOTE_DECAY ** rang
|
||||||
|
sim_max[lab] = max(sim_max.get(lab, 0.0), sim)
|
||||||
|
if scoruri:
|
||||||
|
castigator = max(scoruri, key=lambda k: scoruri[k])
|
||||||
|
if castigator == "NUL":
|
||||||
|
# Vecinatate NUL (non-operatie) = semnal de SUPRESIE, nu cod.
|
||||||
surse["nul"] = True
|
surse["nul"] = True
|
||||||
elif nn[0].get("cod"):
|
else:
|
||||||
surse["embedding"] = str(nn[0]["cod"])
|
surse["embedding"] = castigator
|
||||||
|
surse["embedding_similaritate"] = sim_max[castigator]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # degradare gratioasa (#16b): motorul absent nu blocheaza
|
pass # degradare gratioasa (#16b): motorul absent nu blocheaza
|
||||||
|
|
||||||
@@ -793,6 +981,40 @@ def enrich_suggestions(
|
|||||||
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
||||||
|
|
||||||
|
|
||||||
|
# Prag fuzzy sub care nu propunem nimic (evita sugestii de zgomot in raspunsul API).
|
||||||
|
FUZZY_SUGESTIE_MIN_SCORE = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def sugestie_pentru_op(conn, denumire: str | None, nomenclator: list[dict]) -> dict | None:
|
||||||
|
"""Sugestia principala de cod RAR pentru o operatie nemapata.
|
||||||
|
|
||||||
|
Aceeasi precedenta ca editorul de mapari (enrich_suggestions: GOLD partajat >
|
||||||
|
SILVER > embeddings), cu fallback fuzzy pe nomenclator peste prag. SUGGESTION-ONLY:
|
||||||
|
nu se aplica automat, doar se afiseaza (raspuns API / editor).
|
||||||
|
|
||||||
|
Returneaza {cod_prestatie, nume_prestatie, sursa} sau None (inclusiv pe
|
||||||
|
pre-filtrul NUL: non-operatiile nu primesc sugestie de cod).
|
||||||
|
"""
|
||||||
|
if not denumire:
|
||||||
|
return None
|
||||||
|
nume = {r.get("cod_prestatie"): r.get("nume_prestatie") for r in nomenclator}
|
||||||
|
enriched = enrich_suggestions(conn, denumire)
|
||||||
|
sp = enriched.get("sugestie_principala")
|
||||||
|
if sp and sp.get("cod_prestatie"):
|
||||||
|
cod = str(sp["cod_prestatie"])
|
||||||
|
return {"cod_prestatie": cod, "nume_prestatie": nume.get(cod), "sursa": sp.get("sursa")}
|
||||||
|
if enriched.get("surse", {}).get("nul"):
|
||||||
|
return None
|
||||||
|
fuzzy = suggest_codes(denumire, nomenclator, limit=1)
|
||||||
|
if fuzzy and float(fuzzy[0].get("score") or 0) >= FUZZY_SUGESTIE_MIN_SCORE:
|
||||||
|
return {
|
||||||
|
"cod_prestatie": fuzzy[0]["cod_prestatie"],
|
||||||
|
"nume_prestatie": fuzzy[0].get("nume_prestatie"),
|
||||||
|
"sursa": "fuzzy",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
|
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.
|
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
|
||||||
|
|
||||||
@@ -839,6 +1061,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
|||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
# Incarca regulile text O DATA, inainte de bucla pe randuri.
|
# Incarca regulile text O DATA, inainte de bucla pe randuri.
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
excluded_ops = load_excluded_ops(conn, acct)
|
||||||
|
|
||||||
if batch_id is not None:
|
if batch_id is not None:
|
||||||
# Scope la batch-ul specificat (import commit explicit).
|
# Scope la batch-ul specificat (import commit explicit).
|
||||||
@@ -864,7 +1087,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
|||||||
content = json.loads(r["payload_json"])
|
content = json.loads(r["payload_json"])
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
|
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
|
||||||
content["prestatii"] = resolved
|
content["prestatii"] = resolved
|
||||||
payload_json = json.dumps(content, ensure_ascii=False)
|
payload_json = json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
@@ -879,7 +1102,29 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
|||||||
stats["still_blocked"] += 1
|
stats["still_blocked"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# US-001 (PRD 5.11): ramura auto_send eliminata din reresolve.
|
# Prestatiile excluse ies din payload-ul trimis; toate excluse -> needs_data
|
||||||
|
# cu motiv explicit (randul nu se mai trimite; operatorul il poate sterge).
|
||||||
|
# Payload-ul pastreaza itemii adnotati `exclus` DOAR in ramura blocata, ca
|
||||||
|
# detaliul sa arate operatiile; la queued pleaca numai declarabilele.
|
||||||
|
declarabile, excluse = split_prestatii_excluse(resolved)
|
||||||
|
if not declarabile and excluse:
|
||||||
|
ops_excluse = ", ".join((p.get("cod_op_service") or "") for p in excluse)
|
||||||
|
motiv = [{
|
||||||
|
"field": "prestatii",
|
||||||
|
"message": f"Toate operatiile sunt excluse de la declarare ({ops_excluse}) — randul nu se trimite la RAR.",
|
||||||
|
}]
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
|
||||||
|
"updated_at=datetime('now') WHERE id=?",
|
||||||
|
(payload_json, json.dumps(motiv, ensure_ascii=False), r["id"]),
|
||||||
|
)
|
||||||
|
stats["needs_data"] += 1
|
||||||
|
continue
|
||||||
|
if excluse:
|
||||||
|
content["prestatii"] = declarabile
|
||||||
|
payload_json = json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Ramura auto_send eliminata din reresolve.
|
||||||
# Un cod rezolvat -> queued direct (review_manual ramane 0).
|
# Un cod rezolvat -> queued direct (review_manual ramane 0).
|
||||||
|
|
||||||
errors = validate_prezentare(content)
|
errors = validate_prezentare(content)
|
||||||
@@ -891,10 +1136,14 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
|||||||
)
|
)
|
||||||
stats["needs_data"] += 1
|
stats["needs_data"] += 1
|
||||||
else:
|
else:
|
||||||
|
# Re-snapshot `held` din comutatorul contului la trecerea
|
||||||
|
# needs_mapping -> queued (chokepoint held_for_account, `acct` deja normalizat
|
||||||
|
# prin account_or_default la intrarea in reresolve_account). Altfel un rand
|
||||||
|
# deblocat din needs_mapping ar pleca automat desi contul e Auto OFF.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE submissions SET status='queued', payload_json=?, rar_error=NULL, "
|
"UPDATE submissions SET status='queued', payload_json=?, rar_error=NULL, "
|
||||||
"retry_count=0, next_attempt_at=NULL, updated_at=datetime('now') WHERE id=?",
|
"retry_count=0, next_attempt_at=NULL, held=?, updated_at=datetime('now') WHERE id=?",
|
||||||
(payload_json, r["id"]),
|
(payload_json, held_for_account(conn, acct), r["id"]),
|
||||||
)
|
)
|
||||||
stats["requeued"] += 1
|
stats["requeued"] += 1
|
||||||
return stats
|
return stats
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from typing import Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from app.idempotency import normalize_nr_inmatriculare
|
||||||
|
|
||||||
|
|
||||||
class RarCredentials(BaseModel):
|
class RarCredentials(BaseModel):
|
||||||
"""Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza."""
|
"""Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza."""
|
||||||
@@ -69,11 +71,16 @@ class PrezentareIn(BaseModel):
|
|||||||
obs: str | None = None
|
obs: str | None = None
|
||||||
b64_image: str | None = None
|
b64_image: str | None = None
|
||||||
|
|
||||||
@field_validator("vin", "nr_inmatriculare")
|
@field_validator("vin")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _norm_upper(cls, v: str) -> str:
|
def _norm_upper(cls, v: str) -> str:
|
||||||
return v.strip().upper()
|
return v.strip().upper()
|
||||||
|
|
||||||
|
@field_validator("nr_inmatriculare")
|
||||||
|
@classmethod
|
||||||
|
def _norm_nrinm(cls, v: str) -> str:
|
||||||
|
return normalize_nr_inmatriculare(v)
|
||||||
|
|
||||||
@field_validator("data_prestatie", "odometru_final")
|
@field_validator("data_prestatie", "odometru_final")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _norm_strip(cls, v: str) -> str:
|
def _norm_strip(cls, v: str) -> str:
|
||||||
@@ -97,11 +104,37 @@ class PrezentareRequest(BaseModel):
|
|||||||
on_unmapped_error: bool | None = None
|
on_unmapped_error: bool | None = None
|
||||||
# Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT).
|
# Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT).
|
||||||
rar_env: Literal["test", "prod"] | None = None
|
rar_env: Literal["test", "prod"] | None = None
|
||||||
|
# Forma raspunsului: 'minim' (implicit) = 6 campuri fixe per rezultat
|
||||||
|
# (index, submission_id, vehicul, status, motiv, id_prezentare);
|
||||||
|
# 'complet' = SubmissionResult intreg (erori/nemapate/deduped/held/...).
|
||||||
|
raspuns: Literal["minim", "complet"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionResultMinim(BaseModel):
|
||||||
|
"""Rezultat compact per prezentare — forma implicita a raspunsului.
|
||||||
|
|
||||||
|
Chei FIXE (mereu prezente), usor de parsat de clienti simpli (ex. VFP):
|
||||||
|
`motiv` e null cand randul e curat in coada; `id_prezentare` e non-null
|
||||||
|
doar pe dedup al unui rand deja trimis la RAR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
submission_id: int | None = None
|
||||||
|
vehicul: str
|
||||||
|
status: str
|
||||||
|
motiv: str | None = None
|
||||||
|
id_prezentare: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class SubmissionResult(BaseModel):
|
class SubmissionResult(BaseModel):
|
||||||
# submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True).
|
# submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True).
|
||||||
submission_id: int | None = None
|
submission_id: int | None = None
|
||||||
|
# Corelare cu cererea (campuri aditive): pozitia 0-based in `prezentari`
|
||||||
|
# + ecoul vehiculului trimis, ca integratorul sa lege fiecare rezultat
|
||||||
|
# de randul lui fara sa se bazeze doar pe ordine.
|
||||||
|
index: int | None = None
|
||||||
|
vin: str | None = None
|
||||||
|
nr_inmatriculare: str | None = None
|
||||||
status: str
|
status: str
|
||||||
id_prezentare: int | None = None
|
id_prezentare: int | None = None
|
||||||
deduped: bool = False # True daca idempotency a intors un submission existent
|
deduped: bool = False # True daca idempotency a intors un submission existent
|
||||||
@@ -109,6 +142,10 @@ class SubmissionResult(BaseModel):
|
|||||||
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
|
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
|
||||||
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
||||||
reactivated: bool = False
|
reactivated: bool = False
|
||||||
|
# Ecou PUR de coada: True cand randul e `queued AND held=1`
|
||||||
|
# (tinut manual, cont Auto OFF) -> NU a plecat la RAR, asteapta eliberarea umana.
|
||||||
|
# NU influenteaza payload/idempotenta; onestitate fata de integratorul API (invariant 5.7).
|
||||||
|
held: bool = False
|
||||||
# Mediul RAR tinta efectiv (ecou din DB / rezolvat la ingestie).
|
# Mediul RAR tinta efectiv (ecou din DB / rezolvat la ingestie).
|
||||||
rar_env: str = "test"
|
rar_env: str = "test"
|
||||||
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
|
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
|
||||||
@@ -123,7 +160,9 @@ class SubmissionResult(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PrezentariResponse(BaseModel):
|
class PrezentariResponse(BaseModel):
|
||||||
results: list[SubmissionResult]
|
# Uniune per element: instantele sunt deja modelul potrivit (smart union
|
||||||
|
# pydantic v2 pastreaza tipul exact), deci serializarea nu amesteca formele.
|
||||||
|
results: list[SubmissionResult | SubmissionResultMinim]
|
||||||
|
|
||||||
|
|
||||||
class ValidarePrezentariRequest(BaseModel):
|
class ValidarePrezentariRequest(BaseModel):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Seeder corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
|
"""Seeder corpus operatii etichetate -> mapping_suggestions (SILVER).
|
||||||
|
|
||||||
Artefactul `app/data/operatii-etichetate.json` e produs offline de
|
Artefactul `app/data/operatii-etichetate.json` e produs offline de
|
||||||
`tools/mapare-llm/genereaza_seed.py` (etichetare LM Studio, o singura data) si comis
|
`tools/mapare-llm/genereaza_seed.py` (etichetare LM Studio, o singura data) si comis
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Constructor payload postPrezentare (T4).
|
"""Constructor payload postPrezentare.
|
||||||
|
|
||||||
Reguli din contract (docs/api-rar-contract.md), confirmate live in T1:
|
Reguli din contract (docs/api-rar-contract.md), confirmate live:
|
||||||
- status mereu "FINALIZATA".
|
- status mereu "FINALIZATA".
|
||||||
- tipPrestatie NU se trimite (server-generated GENERIC).
|
- tipPrestatie NU se trimite (server-generated GENERIC).
|
||||||
- odometruFinal ca string; odometruInitial ca string cand e prezent, altfel null.
|
- odometruFinal ca string; odometruInitial ca string cand e prezent, altfel null.
|
||||||
@@ -33,6 +33,16 @@ def _cod(p: object) -> str | None:
|
|||||||
return str(cod).strip().upper() if cod else None
|
return str(cod).strip().upper() if cod else None
|
||||||
|
|
||||||
|
|
||||||
|
def _declarabil(p: object) -> bool:
|
||||||
|
"""Un item pleaca la RAR doar daca are cod si nu e adnotat exclus.
|
||||||
|
|
||||||
|
Filtru no-op pentru payload deja ingustat la declarabile; sarit ca ultima
|
||||||
|
plasa de siguranta — un item fara cod nu produce niciodata {codPrestatie: null}.
|
||||||
|
"""
|
||||||
|
exclus = p.get("exclus") if isinstance(p, dict) else getattr(p, "exclus", None)
|
||||||
|
return not exclus and _cod(p) is not None
|
||||||
|
|
||||||
|
|
||||||
def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
|
def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare."""
|
"""Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare."""
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
@@ -46,6 +56,7 @@ def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"prestatii": [
|
"prestatii": [
|
||||||
{"codPrestatie": _cod(p), "idPrezentare": None}
|
{"codPrestatie": _cod(p), "idPrezentare": None}
|
||||||
for p in (prezentare.get("prestatii") or [])
|
for p in (prezentare.get("prestatii") or [])
|
||||||
|
if _declarabil(p)
|
||||||
],
|
],
|
||||||
"sistemReparat": prezentare.get("sistem_reparat") or "null",
|
"sistemReparat": prezentare.get("sistem_reparat") or "null",
|
||||||
"status": "FINALIZATA",
|
"status": "FINALIZATA",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Extragere payload submission -> campuri afisabile.
|
"""Extragere payload submission -> campuri afisabile.
|
||||||
|
|
||||||
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
|
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
|
||||||
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
|
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua.
|
||||||
eng review, DRY). Fara DB, fara request — primeste `payload_json` (text JSON
|
Fara DB, fara request — primeste `payload_json` (text JSON
|
||||||
plaintext, vezi `submissions.payload_json`) sau un dict deja parsat.
|
plaintext, vezi `submissions.payload_json`) sau un dict deja parsat.
|
||||||
|
|
||||||
Defensiv prin constructie: nu arunca niciodata pe payload malformat — degradeaza
|
Defensiv prin constructie: nu arunca niciodata pe payload malformat — degradeaza
|
||||||
@@ -13,11 +13,34 @@ la em-dash. Citeste cheile tolerant (canalele API si import pot diferi usor:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
EMPTY = "—"
|
EMPTY = "—"
|
||||||
|
|
||||||
|
|
||||||
|
def iso_date_prefix(value: Any) -> str | None:
|
||||||
|
"""Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel None.
|
||||||
|
|
||||||
|
Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda
|
||||||
|
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
|
||||||
|
fara a exclude timestamp-urile. Valori care nu incep cu o data ISO valida
|
||||||
|
(ex. '05.12.2024') intorc None si sunt excluse din filtru.
|
||||||
|
|
||||||
|
Partajat intre canalul web (dashboard Trimiteri, filtrul data_de/data_pana) si
|
||||||
|
canalul API (`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua.
|
||||||
|
"""
|
||||||
|
s = str(value or "").strip()
|
||||||
|
if len(s) < 10:
|
||||||
|
return None
|
||||||
|
prefix = s[:10]
|
||||||
|
try:
|
||||||
|
datetime.strptime(prefix, "%Y-%m-%d")
|
||||||
|
return prefix
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _clean_str(value: Any) -> str:
|
def _clean_str(value: Any) -> str:
|
||||||
"""str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe)."""
|
"""str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe)."""
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -59,6 +82,19 @@ def _vin_scurt(vin: str) -> str:
|
|||||||
return "…" + vin[-6:]
|
return "…" + vin[-6:]
|
||||||
|
|
||||||
|
|
||||||
|
def _coduri_rar(prestatii: Any) -> list[str]:
|
||||||
|
"""Codurile RAR (cod_prestatie) din toate prestatiile: ordonate, dedup."""
|
||||||
|
coduri: list[str] = []
|
||||||
|
if isinstance(prestatii, list):
|
||||||
|
for item in prestatii:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
cod = _clean_cod_rar(item.get("cod_prestatie"))
|
||||||
|
if cod and cod not in coduri:
|
||||||
|
coduri.append(cod)
|
||||||
|
return coduri
|
||||||
|
|
||||||
|
|
||||||
def _prima_prestatie(prestatii: Any) -> dict[str, Any]:
|
def _prima_prestatie(prestatii: Any) -> dict[str, Any]:
|
||||||
"""Primul item de prestatie ca dict, defensiv (lista goala/non-dict -> {})."""
|
"""Primul item de prestatie ca dict, defensiv (lista goala/non-dict -> {})."""
|
||||||
if isinstance(prestatii, list):
|
if isinstance(prestatii, list):
|
||||||
@@ -83,10 +119,11 @@ def _payload_dict(payload: str | dict | None) -> dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
def prezentare_din_payload(payload: str | dict | None) -> dict[str, Any]:
|
||||||
"""Campuri afisabile dintr-un payload de submission.
|
"""Campuri afisabile dintr-un payload de submission.
|
||||||
|
|
||||||
Intoarce un dict cu chei stabile (toate string-uri, EMPTY cand lipsesc):
|
Intoarce un dict cu chei stabile (string-uri, EMPTY cand lipsesc; exceptie
|
||||||
|
`coduri_rar` = list[str]):
|
||||||
vehicul_nr, vin, vin_scurt, operatie, data_prestatie, odometru, cod.
|
vehicul_nr, vin, vin_scurt, operatie, data_prestatie, odometru, cod.
|
||||||
|
|
||||||
`operatie` = denumire prestatiei daca exista, altfel codul; `cod` = codul RAR
|
`operatie` = denumire prestatiei daca exista, altfel codul; `cod` = codul RAR
|
||||||
@@ -112,8 +149,11 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
|||||||
denumire = _clean_str(item.get("denumire"))
|
denumire = _clean_str(item.get("denumire"))
|
||||||
operatie = denumire or cod
|
operatie = denumire or cod
|
||||||
|
|
||||||
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
|
# cod_rar: primul cod_prestatie din TOATE prestatiile (NU doar primul item si NU
|
||||||
cod_rar = _clean_cod_rar(item.get("cod_prestatie"))
|
# fallback la cod_op_service). Primul item poate fi operatia originala exclusa
|
||||||
|
# (cod_prestatie null), cu codurile declarate pe itemii urmatori.
|
||||||
|
coduri_rar = _coduri_rar(data.get("prestatii"))
|
||||||
|
cod_rar = coduri_rar[0] if coduri_rar else ""
|
||||||
|
|
||||||
# Operatia de service originala (codul intern + denumire venita prin API/import),
|
# Operatia de service originala (codul intern + denumire venita prin API/import),
|
||||||
# distincta de operatia RAR mapata (cod_rar).
|
# distincta de operatia RAR mapata (cod_rar).
|
||||||
@@ -126,7 +166,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
|||||||
op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else ""
|
op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else ""
|
||||||
|
|
||||||
# obs: text liber observatii (camp RAR, optional). Conventie goala "" (nu EMPTY).
|
# obs: text liber observatii (camp RAR, optional). Conventie goala "" (nu EMPTY).
|
||||||
# US-005 PRD 5.15: obs traieste in payload_json (nu coloana separata).
|
# Traieste in payload_json, nu in coloana separata.
|
||||||
obs = _clean_str(data.get("obs"))
|
obs = _clean_str(data.get("obs"))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -138,6 +178,9 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
|||||||
"odometru": odo or EMPTY,
|
"odometru": odo or EMPTY,
|
||||||
"cod": cod or EMPTY,
|
"cod": cod or EMPTY,
|
||||||
"cod_rar": cod_rar or EMPTY,
|
"cod_rar": cod_rar or EMPTY,
|
||||||
|
# Lista ordonata, fara duplicate, a codurilor RAR din TOATE prestatiile
|
||||||
|
# (goala cand nimic nu e mapat). Randul compact afiseaza primul + "+N".
|
||||||
|
"coduri_rar": coduri_rar,
|
||||||
# Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
|
# Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
|
||||||
"op_service_cod": op_service_cod,
|
"op_service_cod": op_service_cod,
|
||||||
"op_service_denumire": op_service_denumire,
|
"op_service_denumire": op_service_denumire,
|
||||||
|
|||||||
17
app/plans.py
17
app/plans.py
@@ -3,11 +3,11 @@
|
|||||||
Pattern ca app/errors.py: catalog + helperi. Consumat de rutele de ingestie si dashboard.
|
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.
|
Nu importa DB, HTTP, sau orice alt modul intern cu efecte secundare.
|
||||||
|
|
||||||
Decizii implementare (PRD 5.17 / autoplan 2026-06-28):
|
Note implementare:
|
||||||
- FREE_MONTHLY_LIMIT: constanta unica (T-CEO-2), tunabila fara arqueologie de cod.
|
- FREE_MONTHLY_LIMIT: constanta unica, tunabila fara arheologie de cod.
|
||||||
- CONSUMED_STATUSES: decizie #20 — prestatie consumata = acceptata in coada.
|
- CONSUMED_STATUSES: prestatie consumata = acceptata in coada.
|
||||||
- effective_tier: `now` injectabil (decizie #2) pentru teste deterministe.
|
- effective_tier: `now` injectabil pentru teste deterministe.
|
||||||
- monthly_usage: pattern E7/5.15 (strftime localtime), `now` injectabil.
|
- monthly_usage: strftime localtime, `now` injectabil.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,11 +16,10 @@ import sqlite3
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
# Limita lunara pentru planul Gratuit.
|
# Limita lunara pentru planul Gratuit.
|
||||||
# Decizie user T-CEO-2 (2026-06-28): o singura constanta, referita din PLANS.
|
# O singura constanta, referita din PLANS. Tunabila fara a modifica logica de enforcement.
|
||||||
# Tunabila fara a modifica logica de enforcement.
|
|
||||||
FREE_MONTHLY_LIMIT: int = 60
|
FREE_MONTHLY_LIMIT: int = 60
|
||||||
|
|
||||||
# Statusurile care consuma din cota lunara (decizie #20, 2026-06-28).
|
# Statusurile care consuma din cota lunara.
|
||||||
# Prestatie consumata = acceptata in coada (queued/sending/sent), nu cele respinse/blocate.
|
# 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.
|
# Rationale: limita e pe ce trimitem la RAR, nu pe incercari esuate sau blocate.
|
||||||
CONSUMED_STATUSES: tuple[str, ...] = ("queued", "sending", "sent")
|
CONSUMED_STATUSES: tuple[str, ...] = ("queued", "sending", "sent")
|
||||||
@@ -30,7 +29,7 @@ CONSUMED_STATUSES: tuple[str, ...] = ("queued", "sending", "sent")
|
|||||||
# monthly_limit -- None = nelimitat; int = limita prestatii/luna
|
# monthly_limit -- None = nelimitat; int = limita prestatii/luna
|
||||||
# api_access -- True = acces import prin API (/v1/*); False = doar web dashboard
|
# api_access -- True = acces import prin API (/v1/*); False = doar web dashboard
|
||||||
#
|
#
|
||||||
# Aliniat landing-ului comercial (PRD 5.17 US-001):
|
# Aliniat landing-ului comercial:
|
||||||
# Gratuit: 60/luna, fara API
|
# Gratuit: 60/luna, fara API
|
||||||
# Standard: nelimitat, fara API
|
# Standard: nelimitat, fara API
|
||||||
# Pro: nelimitat, cu API
|
# Pro: nelimitat, cu API
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Medii RAR per cont (PRD 5.20): disponibilitate + default efectiv.
|
"""Medii RAR per cont: disponibilitate + default efectiv.
|
||||||
|
|
||||||
Sursa UNICA de adevar pentru REQ-DISP / REQ-DEFAULT: vizibilitatea selector/toggle
|
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
|
in UI, validarea tintei in API si decizia worker-ului citesc TOATE de aici, ca sa
|
||||||
@@ -92,7 +92,7 @@ def rar_env_efectiv_cont(conn: sqlite3.Connection, account_id: int) -> str | Non
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Exceptie si rezolvator de mediu tinta (US-004, dependent de US-002) #
|
# Exceptie si rezolvator de mediu tinta #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
class MediuIndisponibil(Exception):
|
class MediuIndisponibil(Exception):
|
||||||
@@ -126,7 +126,7 @@ def rezolva_rar_env(
|
|||||||
4. `cerut` None -> incearca rar_env_efectiv_cont (default-ul contului).
|
4. `cerut` None -> incearca rar_env_efectiv_cont (default-ul contului).
|
||||||
5. Daca contul nu are niciun mediu disponibil (rar_env_efectiv_cont == None)
|
5. Daca contul nu are niciun mediu disponibil (rar_env_efectiv_cont == None)
|
||||||
-> cade pe ancora globala get_settings().rar_env, normalizata la VALID_ENVS.
|
-> cade pe ancora globala get_settings().rar_env, normalizata la VALID_ENVS.
|
||||||
Acest fallback e intentionat (PRD 5.20 §2 Non-Goals): AUTOPASS_RAR_ENV ramane
|
Acest fallback e intentionat: AUTOPASS_RAR_ENV ramane
|
||||||
ancora de migrare si fallback pentru actiuni fara cont (keepalive, canal API cu
|
ancora de migrare si fallback pentru actiuni fara cont (keepalive, canal API cu
|
||||||
creds efemere pe conturi nou-create fara medii configurate).
|
creds efemere pe conturi nou-create fara medii configurate).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
-- Schema SQLite (WAL) pentru gateway RAR AUTOPASS.
|
-- Schema SQLite (WAL) pentru gateway RAR AUTOPASS.
|
||||||
-- Vezi plan.md sect. 5 + plan-treapta2.md sect. 4.
|
-- Conturi cu creds RAR durabile, tabele import, atestari.
|
||||||
-- Treapta 2: adauga conturi cu creds RAR durabile, tabele import, atestari.
|
|
||||||
|
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
@@ -10,7 +9,7 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
cui TEXT,
|
cui TEXT,
|
||||||
email TEXT, -- email canonic de contact al firmei (US-001, PRD 5.12); nullable pt. conturi legacy
|
email TEXT, -- email canonic de contact al firmei; nullable pt. conturi legacy
|
||||||
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
|
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
|
||||||
-- Stare de ciclu de viata explicita (5.5). Superset al lui `active`: mentinem invariantul
|
-- 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.
|
-- active=1 <=> status='active' (vezi accounts.set_status / set_active). Worker gate-uieste pe status.
|
||||||
@@ -19,8 +18,8 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
-- vezi accounts.delete_account — randul ramane doar pentru audit).
|
-- vezi accounts.delete_account — randul ramane doar pentru audit).
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
CHECK (status IN ('pending','active','blocked','archived','deleted')),
|
CHECK (status IN ('pending','active','blocked','archived','deleted')),
|
||||||
-- Medii RAR per cont (PRD 5.20 US-001). Fiecare mediu = bifa de activare + slot creds.
|
-- Medii RAR per cont. Fiecare mediu = bifa de activare + slot creds.
|
||||||
-- accounts.rar_creds_enc (legacy env-less) a fost dropata in US-013 (SQLite DROP COLUMN).
|
-- accounts.rar_creds_enc (legacy env-less) a fost dropata (SQLite DROP COLUMN).
|
||||||
-- medii_disponibile = enabled AND creds prezente (app/rar_env.py). Cont client nou =
|
-- 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.
|
-- 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_test_enabled INTEGER NOT NULL DEFAULT 0 CHECK (rar_test_enabled IN (0, 1)),
|
||||||
@@ -33,6 +32,14 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
|
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
|
||||||
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
|
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
|
||||||
CHECK (on_unmapped_error_default IN (0, 1)),
|
CHECK (on_unmapped_error_default IN (0, 1)),
|
||||||
|
-- Comutator Auto per cont. AL TREILEA `auto_send` din schema:
|
||||||
|
-- DISTINCT de operations_mapping.auto_send si operation_text_rules.auto_send, care sunt
|
||||||
|
-- PER-OPERATIE. Acesta e PER-CONT si guverneaza DOAR: (a) valoarea implicita a lui
|
||||||
|
-- submissions.held la ingestie (held = 0 daca 1, altfel 1 — vezi accounts.held_for_account);
|
||||||
|
-- (b) eliberarea in bloc a randurilor tinute la comutarea OFF -> ON. NU e per-operatie si
|
||||||
|
-- NU intra in payload/idempotenta/RAR. Default 0 = Auto OFF (randuri noi tinute manual).
|
||||||
|
auto_send_enabled INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK (auto_send_enabled IN (0, 1)),
|
||||||
-- Plan de cont (5.17). Tier de baza al contului (admin aloca manual via CLI set-tier).
|
-- 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).
|
-- 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.
|
-- Cont nou primeste tier='free' + trial_until=now+30z via create_account.
|
||||||
@@ -75,8 +82,9 @@ CREATE TABLE IF NOT EXISTS operations_mapping (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
cod_op_service TEXT NOT NULL,
|
cod_op_service TEXT NOT NULL,
|
||||||
cod_prestatie TEXT NOT NULL,
|
cod_prestatie TEXT NOT NULL, -- gol ('') cand exclus=1 (regula nu mapeaza)
|
||||||
auto_send INTEGER NOT NULL DEFAULT 1,
|
auto_send INTEGER NOT NULL DEFAULT 1,
|
||||||
|
exclus INTEGER NOT NULL DEFAULT 0, -- 1 = operatia NU se declara la RAR
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
UNIQUE (account_id, cod_op_service)
|
UNIQUE (account_id, cod_op_service)
|
||||||
);
|
);
|
||||||
@@ -96,7 +104,7 @@ CREATE TABLE IF NOT EXISTS submissions (
|
|||||||
status TEXT NOT NULL DEFAULT 'queued'
|
status TEXT NOT NULL DEFAULT 'queued'
|
||||||
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
|
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
|
||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
-- Mediul RAR tinta al acestei trimiteri (PRD 5.20 US-001). DEFAULT 'test' e doar plasa
|
-- Mediul RAR tinta al acestei trimiteri. DEFAULT 'test' e doar plasa
|
||||||
-- pentru randuri net-noi care nu seteaza explicit; fiecare INSERT (API/import/web) seteaza
|
-- 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 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_env TEXT NOT NULL DEFAULT 'test' CHECK (rar_env IN ('test', 'prod')),
|
||||||
@@ -105,18 +113,28 @@ CREATE TABLE IF NOT EXISTS submissions (
|
|||||||
rar_error TEXT,
|
rar_error TEXT,
|
||||||
id_prezentare INTEGER, -- data.id intors de RAR la succes
|
id_prezentare INTEGER, -- data.id intors de RAR la succes
|
||||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
next_attempt_at TEXT, -- backoff: randul nu se ia inainte de acest moment (T2)
|
next_attempt_at TEXT, -- backoff: randul nu se ia inainte de acest moment
|
||||||
sending_since TEXT, -- pentru lease/timeout pe randuri 'sending' orfane (T2)
|
sending_since TEXT, -- pentru lease/timeout pe randuri 'sending' orfane
|
||||||
purge_after TEXT, -- sent + 90z (T16)
|
purge_after TEXT, -- sent + 90z
|
||||||
batch_id INTEGER, -- import batch (T7; NULL = canal API)
|
batch_id INTEGER, -- import batch (NULL = canal API)
|
||||||
row_index INTEGER, -- rand in batch (T7; NULL = canal API)
|
row_index INTEGER, -- rand in batch (NULL = canal API)
|
||||||
|
-- Randuri tinute manual. rand `queued AND held=1` = "In asteptare
|
||||||
|
-- (manual)": tinut pana la eliberare umana (trimitere manuala per rand/bulk: held 1 -> 0).
|
||||||
|
-- Worker-ul (claim_one) ia doar `queued AND held=0`. held NU intra in payload/idempotenta
|
||||||
|
-- si NU pleaca la RAR — e pur control de coada (ca import_rows.reviewed). Snapshot la
|
||||||
|
-- ingestie din accounts.auto_send_enabled (vezi accounts.held_for_account).
|
||||||
|
held INTEGER NOT NULL DEFAULT 0 CHECK (held IN (0, 1)),
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_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_status ON submissions(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_submissions_account_status ON submissions(account_id, 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).
|
-- Nota: idx_submissions_batch si idx_submissions_held se creeaza in _migrate, NU aici. `init_db`
|
||||||
|
-- ruleaza executescript(schema.sql) INAINTE de _migrate; pe un DB existent pre-`held`/`batch_id`
|
||||||
|
-- coloana inca nu exista in momentul executarii schemei, deci un CREATE INDEX pe ea ar esua
|
||||||
|
-- (no such column). Indexul partial `idx_submissions_held ON submissions(held) WHERE held=1`
|
||||||
|
-- traieste doar in _migrate, ca `idx_submissions_batch`.
|
||||||
|
|
||||||
-- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane).
|
-- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane).
|
||||||
CREATE TABLE IF NOT EXISTS column_mappings (
|
CREATE TABLE IF NOT EXISTS column_mappings (
|
||||||
@@ -144,7 +162,7 @@ CREATE TABLE IF NOT EXISTS import_batches (
|
|||||||
already_sent INTEGER NOT NULL DEFAULT 0,
|
already_sent INTEGER NOT NULL DEFAULT 0,
|
||||||
duplicate_in_file INTEGER NOT NULL DEFAULT 0,
|
duplicate_in_file INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
purge_after TEXT -- created_at + 90z (T16)
|
purge_after TEXT -- created_at + 90z
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Randuri din lot de import (PII criptat cu Fernet).
|
-- Randuri din lot de import (PII criptat cu Fernet).
|
||||||
@@ -153,12 +171,13 @@ CREATE TABLE IF NOT EXISTS import_rows (
|
|||||||
batch_id INTEGER NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
|
batch_id INTEGER NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
|
||||||
row_index INTEGER NOT NULL,
|
row_index INTEGER NOT NULL,
|
||||||
raw_json TEXT NOT NULL, -- PII criptat (Fernet, ca submissions)
|
raw_json TEXT NOT NULL, -- PII criptat (Fernet, ca submissions)
|
||||||
override_json TEXT, -- patch CANONIC editat in preview, criptat Fernet (3.6, Approach B); NULL = fara editare
|
override_json TEXT, -- patch CANONIC editat in preview, criptat Fernet; 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
|
reviewed INTEGER NOT NULL DEFAULT 0, -- 0=neconfirmat, 1=confirmat de operator; NU intra in payload/idempotenta
|
||||||
resolved_status TEXT NOT NULL DEFAULT 'pending'
|
resolved_status TEXT NOT NULL DEFAULT 'pending'
|
||||||
CHECK (resolved_status IN (
|
CHECK (resolved_status IN (
|
||||||
'pending','ok','needs_mapping','needs_data',
|
'pending','ok','needs_mapping','needs_data',
|
||||||
'needs_review','already_sent','duplicate_in_file'
|
'needs_review','already_sent','duplicate_in_file',
|
||||||
|
'excluded'
|
||||||
)),
|
)),
|
||||||
error TEXT
|
error TEXT
|
||||||
);
|
);
|
||||||
@@ -189,15 +208,15 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Jurnal de aplicatie la nivel de eveniment (PRD 5.6). Dublu canal: aici (vizibil
|
-- Jurnal de aplicatie la nivel de eveniment. Dublu canal: aici (vizibil
|
||||||
-- in tab "Jurnal") + log text rotativ (depanare). `tip` e text liber documentat
|
-- in tab "Jurnal") + log text rotativ (depanare). `tip` e text liber documentat
|
||||||
-- (lista extensibila, decizie §5) — adaugam tipuri fara migrare. Toate valorile
|
-- (lista extensibila) — adaugam tipuri fara migrare. Toate valorile
|
||||||
-- sunt REDACTATE la scriere (app/observ.py via app/security.py): parole/token ->
|
-- sunt REDACTATE la scriere (app/observ.py via app/security.py): parole/token ->
|
||||||
-- ***REDACTED***, VIN/nr partial. `context_json` = metadate (NU payload PII integral).
|
-- ***REDACTED***, VIN/nr partial. `context_json` = metadate (NU payload PII integral).
|
||||||
CREATE TABLE IF NOT EXISTS app_events (
|
CREATE TABLE IF NOT EXISTS app_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
request_id TEXT, -- corelare cu raspunsul clientului (US-002)
|
request_id TEXT, -- corelare cu raspunsul clientului
|
||||||
account_id INTEGER, -- NULL = eveniment de sistem (fara cont)
|
account_id INTEGER, -- NULL = eveniment de sistem (fara cont)
|
||||||
sursa TEXT NOT NULL DEFAULT 'api'
|
sursa TEXT NOT NULL DEFAULT 'api'
|
||||||
CHECK (sursa IN ('api','worker')),
|
CHECK (sursa IN ('api','worker')),
|
||||||
@@ -206,14 +225,14 @@ CREATE TABLE IF NOT EXISTS app_events (
|
|||||||
cod TEXT, -- cod din catalogul de erori (app/errors.py) daca aplica
|
cod TEXT, -- cod din catalogul de erori (app/errors.py) daca aplica
|
||||||
mesaj TEXT, -- mesaj scurt redactat
|
mesaj TEXT, -- mesaj scurt redactat
|
||||||
context_json TEXT, -- JSON metadate redactate (submission_id, count, status...)
|
context_json TEXT, -- JSON metadate redactate (submission_id, count, status...)
|
||||||
purge_after TEXT -- ts + log_retention_days (US-008)
|
purge_after TEXT -- ts + log_retention_days
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_app_events_ts ON app_events(ts);
|
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_account ON app_events(account_id, ts);
|
||||||
CREATE INDEX IF NOT EXISTS idx_app_events_tip ON app_events(tip);
|
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.
|
-- Reguli automate de mapare pe text (substring, per cont).
|
||||||
-- auto_send DEFAULT 0 (decizie CEO 2026-06-24): substring are blast-radius mai mare
|
-- auto_send DEFAULT 0: substring are blast-radius mai mare
|
||||||
-- decat maparea exacta; o regula noua rezolva codul dar tine randul pentru verificare
|
-- decat maparea exacta; o regula noua rezolva codul dar tine randul pentru verificare
|
||||||
-- umana pana cand operatorul activeaza explicit "In coada".
|
-- umana pana cand operatorul activeaza explicit "In coada".
|
||||||
CREATE TABLE IF NOT EXISTS operation_text_rules (
|
CREATE TABLE IF NOT EXISTS operation_text_rules (
|
||||||
@@ -228,7 +247,7 @@ CREATE TABLE IF NOT EXISTS operation_text_rules (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_text_rules_account ON operation_text_rules(account_id);
|
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).
|
-- Sugestii de mapare (strat SILVER).
|
||||||
-- Etichete LLM/embedding — bootstrap; citita DOAR de suggest_codes/pending_unmapped,
|
-- Etichete LLM/embedding — bootstrap; citita DOAR de suggest_codes/pending_unmapped,
|
||||||
-- NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
|
-- NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
|
||||||
-- Cheia = denumire normalizata (fara diacritice, uppercase, spatii colapsate).
|
-- Cheia = denumire normalizata (fara diacritice, uppercase, spatii colapsate).
|
||||||
@@ -247,7 +266,7 @@ CREATE TABLE IF NOT EXISTS mapping_suggestions (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_mapping_suggestions_cod
|
CREATE INDEX IF NOT EXISTS idx_mapping_suggestions_cod
|
||||||
ON mapping_suggestions(cod_prestatie) WHERE cod_prestatie IS NOT NULL;
|
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).
|
-- Mapari validate de oameni (strat GOLD partajat cross-account).
|
||||||
-- Confirmarile umane din ORICE cont contribuie la acest store (#8).
|
-- Confirmarile umane din ORICE cont contribuie la acest store (#8).
|
||||||
-- cross-account = suggestion-only (pre-completeaza editorul, F-A/#11), NU auto-send.
|
-- 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).
|
-- Auto-send DOAR din operations_mapping (GOLD propriu per-cont, Eng-F2).
|
||||||
@@ -264,6 +283,17 @@ CREATE TABLE IF NOT EXISTS shared_mappings (
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Cache persistent vectori embeddings corpus (mapping_suggestions). Cheia (model,
|
||||||
|
-- text_hash) leaga cache-ul de numele modelului: schimbare model = randuri vechi inerte,
|
||||||
|
-- fara sa fie folosite (curatate ulterior de purge_stale).
|
||||||
|
CREATE TABLE IF NOT EXISTS embedding_cache (
|
||||||
|
text_hash TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
vector BLOB NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (model, text_hash)
|
||||||
|
);
|
||||||
|
|
||||||
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
|
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
|
||||||
CREATE TABLE IF NOT EXISTS worker_heartbeat (
|
CREATE TABLE IF NOT EXISTS worker_heartbeat (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
"""Store partajat pentru sugestii (SILVER) si mapari validate de oameni (GOLD cross-account).
|
"""Store partajat pentru sugestii (SILVER) si mapari validate de oameni (GOLD cross-account).
|
||||||
|
|
||||||
Straturi (L14-S3, PRD 5.14):
|
Straturi:
|
||||||
- mapping_suggestions (SILVER): sugestii LLM/embedding, citite DOAR de suggest/pending_unmapped,
|
- mapping_suggestions (SILVER): sugestii LLM/embedding, citite DOAR de suggest/pending_unmapped,
|
||||||
NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
|
NICIODATA de load_mapping/resolve_prestatii (separare structurala).
|
||||||
- shared_mappings (GOLD partajat): mapari validate de om din orice cont; pot pre-completa
|
- 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
|
editorul (suggestion-only cross-account); auto-send DOAR GOLD propriu
|
||||||
(operations_mapping per-cont, Eng-F2).
|
(operations_mapping per-cont).
|
||||||
|
|
||||||
Invariante:
|
Invariante:
|
||||||
- INSERT OR IGNORE la seed: nu suprascrie randuri existente (#2).
|
- INSERT OR IGNORE la seed: nu suprascrie randuri existente.
|
||||||
- NUL = is_nul=1, cod_prestatie NULL; NU se promoveaza la cod RAR (#4).
|
- NUL = is_nul=1, cod_prestatie NULL; NU se promoveaza la cod RAR.
|
||||||
- source/confidence pe fiecare rand (provenienta + rollback batch model prost, #5).
|
- source/confidence pe fiecare rand (provenienta + rollback batch model prost).
|
||||||
- Wiring in resolve_prestatii/load_mapping vine in L14-S6; modulul de fata e API pur.
|
- Wiring in resolve_prestatii/load_mapping nu e cablat inca; modulul de fata e API pur.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from .accounts import held_for_account
|
||||||
from .mapping import (
|
from .mapping import (
|
||||||
account_or_default,
|
account_or_default,
|
||||||
account_scope_clause,
|
account_scope_clause,
|
||||||
classify_prezentare,
|
classify_prezentare,
|
||||||
|
load_excluded_ops,
|
||||||
load_mapping_meta,
|
load_mapping_meta,
|
||||||
load_nomenclator_codes,
|
load_nomenclator_codes,
|
||||||
)
|
)
|
||||||
@@ -101,13 +103,18 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict:
|
|||||||
mapping_meta = load_mapping_meta(conn, account_id)
|
mapping_meta = load_mapping_meta(conn, account_id)
|
||||||
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes)
|
excluded_ops = load_excluded_ops(conn, account_id)
|
||||||
|
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, excluded_ops=excluded_ops)
|
||||||
|
|
||||||
|
# Re-snapshot `held` din comutatorul contului la re-punere in coada
|
||||||
|
# (paritate cu create_prezentari/reresolve_account). Fara asta un rand repus pastra
|
||||||
|
# `held` VECHI si s-ar auto-trimite desi contul e Auto OFF (sau ar ramane blocat cand e ON).
|
||||||
|
held_val = held_for_account(conn, account_or_default(account_id))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, retry_count=0, "
|
"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') "
|
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, held=?, updated_at=datetime('now') "
|
||||||
"WHERE id=?",
|
"WHERE id=?",
|
||||||
(cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], sid),
|
(cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], held_val, sid),
|
||||||
)
|
)
|
||||||
log_event(
|
log_event(
|
||||||
"submission_repus",
|
"submission_repus",
|
||||||
|
|||||||
47
app/users.py
47
app/users.py
@@ -157,6 +157,53 @@ def verify_password(conn: sqlite3.Connection, email: str, password: str) -> int
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def change_password(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
user_id: int,
|
||||||
|
current_password: str,
|
||||||
|
new_password: str,
|
||||||
|
) -> None:
|
||||||
|
"""Schimba parola de acces a userului identificat prin user_id.
|
||||||
|
|
||||||
|
Verifica parola actuala (scrypt, comparatie constant-time), valideaza parola
|
||||||
|
noua (10..128, diferita de cea actuala) si rescrie password_hash + salt nou cu
|
||||||
|
parametrii scrypt curenti. Parola NICIODATA stocata in clar.
|
||||||
|
|
||||||
|
Ridica ValueError la: user inexistent, parametri parola corupti, parola actuala
|
||||||
|
gresita, parola noua invalida. Nu invalideaza sesiunea (apelantul decide).
|
||||||
|
"""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT password_hash, salt, scrypt_params FROM users WHERE id=?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("utilizator inexistent")
|
||||||
|
|
||||||
|
salt = bytes.fromhex(row["salt"])
|
||||||
|
expected = bytes.fromhex(row["password_hash"])
|
||||||
|
params = _parse_scrypt_params(row["scrypt_params"] or "")
|
||||||
|
if params is None:
|
||||||
|
raise ValueError("parametri parola corupti")
|
||||||
|
n, r, p = params
|
||||||
|
actual = _scrypt_hash(current_password, salt, n=n, r=r, p=p)
|
||||||
|
if not hmac.compare_digest(actual, expected):
|
||||||
|
raise ValueError("Parola actuala este incorecta.")
|
||||||
|
|
||||||
|
if len(new_password) < _PASSWORD_MIN:
|
||||||
|
raise ValueError(f"Parola noua e prea scurta (minim {_PASSWORD_MIN} caractere).")
|
||||||
|
if len(new_password) > _PASSWORD_MAX:
|
||||||
|
raise ValueError(f"Parola noua e prea lunga (maxim {_PASSWORD_MAX} caractere).")
|
||||||
|
if new_password == current_password:
|
||||||
|
raise ValueError("Parola noua trebuie sa difere de cea actuala.")
|
||||||
|
|
||||||
|
new_salt = secrets.token_bytes(16)
|
||||||
|
new_hash = _scrypt_hash(new_password, new_salt)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET password_hash=?, salt=?, scrypt_params=? WHERE id=?",
|
||||||
|
(new_hash.hex(), new_salt.hex(), SCRYPT_PARAMS, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_email(conn: sqlite3.Connection, email: str) -> dict | None:
|
def get_user_by_email(conn: sqlite3.Connection, email: str) -> dict | None:
|
||||||
"""Metadate user dupa email (FARA password_hash si salt)."""
|
"""Metadate user dupa email (FARA password_hash si salt)."""
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Validare de domeniu pentru prezentari (T3).
|
"""Validare de domeniu pentru prezentari.
|
||||||
|
|
||||||
Replica regulile RAR (docs/api-rar-contract.md sect. "Reguli de validare") ÎNAINTE
|
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
|
de enqueue, ca sa nu primim 4xx de la RAR. Spre deosebire de validarea de SHAPE
|
||||||
@@ -18,6 +18,7 @@ from datetime import date
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from app.errors import eroare as _eroare
|
from app.errors import eroare as _eroare
|
||||||
|
from app.idempotency import normalize_nr_inmatriculare
|
||||||
|
|
||||||
# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
|
# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
|
||||||
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
|
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
|
||||||
@@ -73,7 +74,7 @@ def validate_prezentare(content: dict) -> list[dict]:
|
|||||||
))
|
))
|
||||||
|
|
||||||
# --- nrInmatriculare ---
|
# --- nrInmatriculare ---
|
||||||
nrinm = _norm(content.get("nr_inmatriculare"))
|
nrinm = normalize_nr_inmatriculare(content.get("nr_inmatriculare"))
|
||||||
if not NRINM_RE.match(nrinm):
|
if not NRINM_RE.match(nrinm):
|
||||||
errors.append(_eroare(
|
errors.append(_eroare(
|
||||||
"NR_INMATRICULARE_FORMAT",
|
"NR_INMATRICULARE_FORMAT",
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
|
|||||||
for aid in ids:
|
for aid in ids:
|
||||||
try:
|
try:
|
||||||
if action == "activate":
|
if action == "activate":
|
||||||
# Gate US-002: nu activam conturi fara identitate completa (companie+email+CUI)
|
# Nu activam conturi fara identitate completa (companie+email+CUI)
|
||||||
acct_row = conn.execute(
|
acct_row = conn.execute(
|
||||||
"SELECT id, name, cui, email FROM accounts WHERE id=?", (aid,)
|
"SELECT id, name, cui, email FROM accounts WHERE id=?", (aid,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ..auth import create_api_key
|
|||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db import get_connection
|
from ..db import get_connection
|
||||||
from ..email import notify_signup
|
from ..email import notify_signup
|
||||||
|
from ..observ import log_event
|
||||||
from ..users import count_admins, create_user, list_admin_emails, verify_password
|
from ..users import count_admins, create_user, list_admin_emails, verify_password
|
||||||
from ..web.csrf import get_csrf_token, verify_csrf
|
from ..web.csrf import get_csrf_token, verify_csrf
|
||||||
from ..web.ratelimit import check_rate_limit
|
from ..web.ratelimit import check_rate_limit
|
||||||
@@ -78,7 +79,6 @@ async def signup_post(
|
|||||||
name=name, cui=cui, email=email, plan=requested_plan,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=422)
|
), status_code=422)
|
||||||
|
|
||||||
# CUI obligatoriu la signup (US-001, PRD 5.12)
|
|
||||||
cui_norm = cui.strip().upper() if cui else ""
|
cui_norm = cui.strip().upper() if cui else ""
|
||||||
if not cui_norm:
|
if not cui_norm:
|
||||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||||
@@ -157,7 +157,7 @@ async def signup_post(
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
set_session(request, account_id, user_id)
|
set_session(request, account_id, user_id)
|
||||||
print(f"SIGNUP cont={account_id} email={email}", flush=True)
|
log_event("signup", account_id=account_id, mesaj="cont nou creat")
|
||||||
|
|
||||||
# Notificare email admin (best-effort, nu blocheaza signup-ul)
|
# Notificare email admin (best-effort, nu blocheaza signup-ul)
|
||||||
try:
|
try:
|
||||||
|
|||||||
93
app/web/body_cap.py
Normal file
93
app/web/body_cap.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Middleware ASGI PUR: plafon global pe dimensiunea corpului cererii (P1-5).
|
||||||
|
|
||||||
|
`BaseHTTPMiddleware` nu poate refuza devreme: pentru a construi un `Request`
|
||||||
|
reconstituie intregul flux `receive()`, iar FastAPI parseaza deja COMPLET
|
||||||
|
multipart-ul (spooled pe disc peste prag) INAINTE sa ruleze endpointul — o
|
||||||
|
verificare per-endpoint (chiar si citire pe chunk-uri) nu repara cauza, doar
|
||||||
|
simptomul. Un middleware ASGI pur intercepteaza `receive` inaintea oricarui
|
||||||
|
parser: Content-Length peste limita -> 413 fara sa citeasca nimic din body;
|
||||||
|
altfel numara cumulativ octetii mesajelor `http.request` si intrerupe la
|
||||||
|
depasire. Cererile fara body (GET etc.) trec netaxate.
|
||||||
|
|
||||||
|
Verificarea per-endpoint existenta (`import_router.py`, `import_parse.MAX_BYTES`)
|
||||||
|
ramane NEATINSA ca strat 2 — acopera orice cale care ar ocoli acest middleware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from starlette.datastructures import Headers
|
||||||
|
|
||||||
|
from .. import errors
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class _BodyTooLarge(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _error_body() -> bytes:
|
||||||
|
payload = errors.eroare(
|
||||||
|
"CERERE_PREA_MARE",
|
||||||
|
cauza="Corpul cererii depaseste limita permisa.",
|
||||||
|
)
|
||||||
|
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class BodyCapMiddleware:
|
||||||
|
"""Limiteaza corpul cererii la `settings.max_request_bytes`, pe TOATE rutele.
|
||||||
|
|
||||||
|
Limita se citeste din `get_settings()` LA FIECARE CERERE (nu o data la
|
||||||
|
construirea middleware-ului): `app.add_middleware` ruleaza o singura data per
|
||||||
|
proces (modulul `app.main` e importat o singura data si in teste), deci un
|
||||||
|
`max_bytes` fixat in `__init__` ar ramane inghetat la valoarea de la primul
|
||||||
|
import si ar ignora `AUTOPASS_MAX_REQUEST_BYTES` schimbat ulterior (teste).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
max_bytes = get_settings().max_request_bytes
|
||||||
|
|
||||||
|
content_length = Headers(scope=scope).get("content-length")
|
||||||
|
if content_length is not None:
|
||||||
|
try:
|
||||||
|
declared = int(content_length)
|
||||||
|
except ValueError:
|
||||||
|
declared = None
|
||||||
|
if declared is not None and declared > max_bytes:
|
||||||
|
await self._send_413(send)
|
||||||
|
return
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
async def capped_receive():
|
||||||
|
nonlocal total
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.request":
|
||||||
|
total += len(message.get("body") or b"")
|
||||||
|
if total > max_bytes:
|
||||||
|
raise _BodyTooLarge()
|
||||||
|
return message
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.app(scope, capped_receive, send)
|
||||||
|
except _BodyTooLarge:
|
||||||
|
await self._send_413(send)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _send_413(send) -> None:
|
||||||
|
await send(
|
||||||
|
{
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 413,
|
||||||
|
"headers": [(b"content-type", b"application/json")],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await send({"type": "http.response.body", "body": _error_body()})
|
||||||
@@ -29,7 +29,7 @@ def _payload_prezentari_dict(account_id: int) -> dict:
|
|||||||
omise pentru concizie — nu sunt obligatorii.
|
omise pentru concizie — nu sunt obligatorii.
|
||||||
|
|
||||||
`rar_credentials` NU e inclus: cererea trimite doar cheia API + datele prezentarii,
|
`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
|
iar worker-ul foloseste credentialele RAR salvate pe cont (Meniu cont › Cont). Trimiterea
|
||||||
lor in payload e optionala (suprascrie creds-urile contului pe acea cerere).
|
lor in payload e optionala (suprascrie creds-urile contului pe acea cerere).
|
||||||
"""
|
"""
|
||||||
# Construim un dict cu toate campurile obligatorii
|
# Construim un dict cu toate campurile obligatorii
|
||||||
@@ -69,6 +69,50 @@ def _payload_json_compact(account_id: int) -> str:
|
|||||||
return json.dumps(_payload_prezentari_dict(account_id), separators=(",", ":"), ensure_ascii=False)
|
return json.dumps(_payload_prezentari_dict(account_id), separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _exemplu_cerere_dict() -> dict:
|
||||||
|
"""Cerere exemplu completa: ambele forme de prestatie + campuri optionale uzuale."""
|
||||||
|
return {
|
||||||
|
"prezentari": [
|
||||||
|
{
|
||||||
|
"vin": "WVWZZZ1JZXW000001",
|
||||||
|
"nr_inmatriculare": "B123ABC",
|
||||||
|
"data_prestatie": "2026-06-22",
|
||||||
|
"odometru_final": "150000",
|
||||||
|
"odometru_initial": "149500",
|
||||||
|
"prestatii": [
|
||||||
|
{"cod_prestatie": "OE-1"},
|
||||||
|
{"cod_op_service": "SF01", "denumire": "Schimb filtru ulei"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rar_env": "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _exemplu_raspuns_dict() -> dict:
|
||||||
|
"""Raspuns exemplu in forma implicita `minim`: 6 chei fixe per rezultat."""
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"submission_id": 123,
|
||||||
|
"vehicul": "B123ABC",
|
||||||
|
"status": "queued",
|
||||||
|
"motiv": None,
|
||||||
|
"id_prezentare": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def exemplu_json() -> dict:
|
||||||
|
"""Perechea cerere/raspuns JSON formatata pentru cardul din pagina Integrare."""
|
||||||
|
return {
|
||||||
|
"cerere": json.dumps(_exemplu_cerere_dict(), indent=2, ensure_ascii=False),
|
||||||
|
"raspuns": json.dumps(_exemplu_raspuns_dict(), indent=2, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _snippet_curl_prezentari(base_url: str, account_id: int) -> str:
|
def _snippet_curl_prezentari(base_url: str, account_id: int) -> str:
|
||||||
payload = _payload_json_str(account_id)
|
payload = _payload_json_str(account_id)
|
||||||
return f"""curl -X POST "{base_url}/v1/prezentari" \\
|
return f"""curl -X POST "{base_url}/v1/prezentari" \\
|
||||||
|
|||||||
@@ -71,13 +71,39 @@ ETICHETE_SCURTE: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def eticheta_scurta(status: str) -> str:
|
# ---------------------------------------------------------------------------
|
||||||
|
# Randuri tinute manual (held).
|
||||||
|
#
|
||||||
|
# `held` NU e o stare noua in masina de stari: un rand cu `status='queued' AND
|
||||||
|
# held=1` este in coada, dar tinut pana la eliberarea umana. Il afisam distinct
|
||||||
|
# ("In asteptare (manual)") cu culoare de AVERTIZARE (--warn / amber) — asteptare
|
||||||
|
# benigna, NU eroare. Reutilizam clasa `s-needs_review` (definita in base.html ca
|
||||||
|
# `color:var(--warn)`); NU folosim clasele needs_* rosii (s-needs_data /
|
||||||
|
# s-needs_mapping = --err). CHECK-ul din schema.sql ramane neatins.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ETICHETA_HELD: Eticheta = (
|
||||||
|
"In asteptare (manual)",
|
||||||
|
"Tinuta pentru confirmarea ta; nu pleaca spre RAR pana nu o eliberezi.",
|
||||||
|
"s-needs_review",
|
||||||
|
)
|
||||||
|
|
||||||
|
ETICHETA_HELD_SCURTA = "Manual"
|
||||||
|
|
||||||
|
|
||||||
|
def eticheta_scurta(status: str, held: bool = False) -> str:
|
||||||
"""
|
"""
|
||||||
Returneaza eticheta compacta (pill) pentru o stare de submission.
|
Returneaza eticheta compacta (pill) pentru o stare de submission.
|
||||||
|
|
||||||
|
Cand `status='queued'` si `held` este adevarat, randul e tinut manual si
|
||||||
|
intoarce "Manual" (distinct de "In coada"). Pentru orice alta stare `held`
|
||||||
|
este ignorat.
|
||||||
|
|
||||||
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
|
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
|
||||||
stari noi adaugate in schema fara mapare corespunzatoare.
|
stari noi adaugate in schema fara mapare corespunzatoare.
|
||||||
"""
|
"""
|
||||||
|
if held and status == "queued":
|
||||||
|
return ETICHETA_HELD_SCURTA
|
||||||
try:
|
try:
|
||||||
return ETICHETE_SCURTE[status]
|
return ETICHETE_SCURTE[status]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -87,13 +113,19 @@ def eticheta_scurta(status: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def eticheta_stare(status: str) -> Eticheta:
|
def eticheta_stare(status: str, held: bool = False) -> Eticheta:
|
||||||
"""
|
"""
|
||||||
Returneaza (text, subtext, css_class) pentru o stare de submission.
|
Returneaza (text, subtext, css_class) pentru o stare de submission.
|
||||||
|
|
||||||
|
Cand `status='queued'` si `held` este adevarat, randul e tinut manual si
|
||||||
|
intoarce eticheta "In asteptare (manual)" cu clasa de avertizare (amber /
|
||||||
|
--warn). Pentru orice alta stare `held` este ignorat.
|
||||||
|
|
||||||
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
|
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
|
||||||
stari noi adaugate in schema fara mapare corespunzatoare.
|
stari noi adaugate in schema fara mapare corespunzatoare.
|
||||||
"""
|
"""
|
||||||
|
if held and status == "queued":
|
||||||
|
return ETICHETA_HELD
|
||||||
try:
|
try:
|
||||||
return STARI_SUBMISSION[status]
|
return STARI_SUBMISSION[status]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -327,6 +359,31 @@ def parse_erori(rar_error: object) -> list[dict]:
|
|||||||
return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}]
|
return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# eroare_bruta — textul compact al erorii pentru sub-linia randului de tabel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Stari cu sub-linie de eroare pe rand. needs_mapping e EXCLUS intentionat:
|
||||||
|
# "nemapat" e deja vizibil in coloana Cod RAR, sub-linia ar fi redundanta.
|
||||||
|
_STARI_CU_EROARE_BRUTA = ("error", "needs_data")
|
||||||
|
|
||||||
|
|
||||||
|
def eroare_bruta(status: str, rar_error: object) -> str:
|
||||||
|
"""Textul brut, compact, al primei erori — pentru sub-linia randului.
|
||||||
|
|
||||||
|
Prefera cauza tehnica (mesajul serverului RAR, ex. ORA-12899) si cade pe
|
||||||
|
problema. Sir gol in afara starilor error/needs_data. Nu arunca niciodata
|
||||||
|
(reuse parse_erori, degradare gratioasa).
|
||||||
|
"""
|
||||||
|
if status not in _STARI_CU_EROARE_BRUTA:
|
||||||
|
return ""
|
||||||
|
erori = parse_erori(rar_error)
|
||||||
|
if not erori:
|
||||||
|
return ""
|
||||||
|
e = erori[0]
|
||||||
|
return (str(e.get("cauza") or "").strip() or str(e.get("problema") or "").strip())[:200]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Etichete stari preview import (vocabular DIFERIT de starile de submission)
|
# Etichete stari preview import (vocabular DIFERIT de starile de submission)
|
||||||
#
|
#
|
||||||
@@ -343,6 +400,7 @@ STARI_PREVIEW: dict[str, tuple[str, str]] = {
|
|||||||
"needs_data": ("Date incomplete", "s-needs_data"),
|
"needs_data": ("Date incomplete", "s-needs_data"),
|
||||||
"already_sent": ("Deja trimis", "s-already_sent"),
|
"already_sent": ("Deja trimis", "s-already_sent"),
|
||||||
"duplicate_in_file": ("Duplicat in fisier", "s-duplicate_in_file"),
|
"duplicate_in_file": ("Duplicat in fisier", "s-duplicate_in_file"),
|
||||||
|
"excluded": ("Nedeclarat", "s-excluded"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -351,7 +409,7 @@ def nota_umana_preview(status: str, errors: list, flags: list) -> str:
|
|||||||
|
|
||||||
Primeste ``errors`` ca lista Python (nu JSON string) — NU pasa la motiv_uman
|
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
|
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).
|
prin fallback ``raw[:160]``.
|
||||||
|
|
||||||
Logica de prioritate:
|
Logica de prioritate:
|
||||||
- already_sent / duplicate_in_file -> "" (template le afiseaza separat)
|
- already_sent / duplicate_in_file -> "" (template le afiseaza separat)
|
||||||
@@ -365,6 +423,8 @@ def nota_umana_preview(status: str, errors: list, flags: list) -> str:
|
|||||||
"""
|
"""
|
||||||
if status in ("already_sent", "duplicate_in_file"):
|
if status in ("already_sent", "duplicate_in_file"):
|
||||||
return ""
|
return ""
|
||||||
|
if status == "excluded":
|
||||||
|
return "Operatie exclusa de la declarare — randul nu se trimite la RAR."
|
||||||
# needs_mapping: codul RAR lipseste — prioritizeaza 'unmapped' inaintea flags,
|
# needs_mapping: codul RAR lipseste — prioritizeaza 'unmapped' inaintea flags,
|
||||||
# altfel un rand cu si un flag (ex. VIN numeric) ar afisa textul flag-ului
|
# altfel un rand cu si un flag (ex. VIN numeric) ar afisa textul flag-ului
|
||||||
# si ascunde motivul real (cod lipsa).
|
# si ascunde motivul real (cod lipsa).
|
||||||
@@ -402,7 +462,7 @@ def nota_umana_preview(status: str, errors: list, flags: list) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Etichete mediu RAR (Test / Productie) — US-010 PRD 5.20
|
# Etichete mediu RAR (Test / Productie)
|
||||||
#
|
#
|
||||||
# Conventia de culori (semantica risc L.142):
|
# Conventia de culori (semantica risc L.142):
|
||||||
# prod = env-badge-prod (fill atentie, text alb) — declaratie REALA, ireversibila.
|
# prod = env-badge-prod (fill atentie, text alb) — declaratie REALA, ireversibila.
|
||||||
|
|||||||
@@ -31,3 +31,23 @@ class RequestIDMiddleware(BaseHTTPMiddleware):
|
|||||||
request_id_var.reset(token)
|
request_id_var.reset(token)
|
||||||
response.headers["X-Request-ID"] = request_id
|
response.headers["X-Request-ID"] = request_id
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Headere de securitate pe TOATE raspunsurile (P1-4, hardening 2026-07-03).
|
||||||
|
|
||||||
|
HSTS se pune DOAR pe HTTPS: pe HTTP browserul l-ar ignora oricum, dar l-am
|
||||||
|
omis explicit ca sa nu sugereze o garantie falsa in dev/smoke pe HTTP simplu.
|
||||||
|
Nu suprascrie un header deja setat explicit de raspuns (setdefault).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
if request.url.scheme == "https":
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Strict-Transport-Security", "max-age=31536000; includeSubDomains"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (conf
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
# ip/key -> lista de timestamps (time.monotonic) ale cererilor din fereastra activa
|
# ip/key -> lista de timestamps (time.monotonic) ale cererilor din fereastra activa
|
||||||
_hits: dict[str, list[float]] = defaultdict(list)
|
_hits: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
|
||||||
def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
|
def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
|
||||||
@@ -19,13 +18,21 @@ def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
|
|||||||
Curata timestamp-urile expirate la fiecare apel (O(n) per cheie, acceptabil
|
Curata timestamp-urile expirate la fiecare apel (O(n) per cheie, acceptabil
|
||||||
pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple;
|
pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple;
|
||||||
suficient pentru un singur proces uvicorn.
|
suficient pentru un singur proces uvicorn.
|
||||||
|
|
||||||
|
Cheile fara timestamp-uri valide sunt sterse din `_hits` (nu doar golite), altfel
|
||||||
|
dictionarul creste monoton pe viata procesului odata ce cheia devine IP-uri reale
|
||||||
|
de internet (F5). Foloseste `.get` in loc de acces direct pe dict pentru a nu
|
||||||
|
reintroduce o cheie doar prin citire.
|
||||||
"""
|
"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
cutoff = now - window_s
|
cutoff = now - window_s
|
||||||
timestamps = _hits[key]
|
filtered = [t for t in _hits.get(key, []) if t > cutoff]
|
||||||
# Sterge intrari expirate
|
if len(filtered) >= max_hits:
|
||||||
_hits[key] = [t for t in timestamps if t > cutoff]
|
if filtered:
|
||||||
if len(_hits[key]) >= max_hits:
|
_hits[key] = filtered
|
||||||
|
else:
|
||||||
|
_hits.pop(key, None)
|
||||||
return False
|
return False
|
||||||
_hits[key].append(now)
|
filtered.append(now)
|
||||||
|
_hits[key] = filtered
|
||||||
return True
|
return True
|
||||||
|
|||||||
1520
app/web/routes.py
1520
app/web/routes.py
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
<div id="acasa-section">
|
<div id="acasa-section">
|
||||||
|
|
||||||
{# === Banner ne-blocant: cont incomplet (US-002) ===
|
{# === Banner ne-blocant: cont incomplet ===
|
||||||
Apare cand accounts.name / email / CUI sunt necompletate (conturi legacy sau create din CLI).
|
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.
|
NU blocheaza importul sau uploadul — doar orienteaza operatorul sa completeze datele.
|
||||||
Dispare automat dupa ce contul devine complet (re-render la urmatoarea navigare/reload).
|
Dispare automat dupa ce contul devine complet (re-render la urmatoarea navigare/reload).
|
||||||
@@ -13,43 +13,31 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === Container colapsabil: stepper + upload intr-un singur element <details> (US-006).
|
{# === Primii pasi: DEASUPRA importului, ca ghidajul sa fie primul lucru vizibil === #}
|
||||||
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 %}
|
{% set toti_esentiali = are_creds and are_trimiteri %}
|
||||||
{% if not toti_esentiali %}
|
{% if not toti_esentiali %}
|
||||||
<div class="card" style="margin-top:14px; padding:12px 16px;">
|
<div class="card" style="margin-bottom:14px; padding:12px 16px;">
|
||||||
<div style="display:flex; gap:20px; flex-wrap:wrap; align-items:center; font-size:13px;">
|
<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>
|
<span class="muted" style="font-weight:600;">Primii pasi:</span>
|
||||||
|
|
||||||
{# Pas 1: Cont RAR (esential) #}
|
{# Pas 1: Cont RAR (esential) — direct pe sub-tabul Mediu RAR din Setari #}
|
||||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||||
{% if are_creds %}
|
{% if are_creds %}
|
||||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="muted" aria-hidden="true">○</span>
|
<span class="muted" aria-hidden="true">○</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/?tab=cont">Cont RAR</a>
|
<a href="/?tab=cont&subtab=rar">Cont RAR</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{# Pas 2: Cheie API (optional) #}
|
{# Pas 2: Cheie API (optional) — direct pe sub-tabul Cheie API din Setari #}
|
||||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||||
{% if are_cheie_folosita %}
|
{% if are_cheie_folosita %}
|
||||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="muted" aria-hidden="true">○</span>
|
<span class="muted" aria-hidden="true">○</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/?tab=cont">Cheie API</a>
|
<a href="/?tab=cont&subtab=api">Cheie API</a>
|
||||||
<span class="muted">(optional)</span>
|
<span class="muted">(optional)</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -60,12 +48,24 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="s-queued" aria-hidden="true" style="font-weight:bold;">●</span>
|
<span class="s-queued" aria-hidden="true" style="font-weight:bold;">●</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<strong>Import</strong> <span class="muted">(incarca fisierul sus)</span>
|
<strong>Import</strong> <span class="muted">(incarca fisierul jos)</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# === Container colapsabil: stepper + upload intr-un singur element <details>.
|
||||||
|
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>
|
||||||
|
|
||||||
{# Sectiunea Trimiteri, permanenta sub upload.
|
{# Sectiunea Trimiteri, permanenta sub upload.
|
||||||
La first-run (zero trimiteri), randam un placeholder <section> gol/ascuns — necesar
|
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
|
ca OOB swap-ul de la confirma sa gaseasca tinta valida in DOM si sa injecteze
|
||||||
|
|||||||
54
app/web/templates/_auto_send_toggle.html
Normal file
54
app/web/templates/_auto_send_toggle.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{#
|
||||||
|
_auto_send_toggle.html — comutatorul "Trimite automat la RAR".
|
||||||
|
|
||||||
|
Plasat in clusterul de header (base.html, langa .rar-chip). NU face parte din
|
||||||
|
swap-ul periodic al #status-bar (every 15s) -> fara flicker / pierdere focus.
|
||||||
|
|
||||||
|
Non-optimist: la esec POST bifa revine + toast eroare (hx-on::response-error).
|
||||||
|
account_id vine din SESIUNE server-side (ruta /auto-send), niciodata din formular.
|
||||||
|
|
||||||
|
Raspunsul POST re-randeaza acest fragment (outerHTML pe #auto-send-toggle-wrap):
|
||||||
|
- confirmare_release=True -> injecteaza modalul de confirmare OOB in #modal-confirma-slot;
|
||||||
|
- curata_modal=True -> goleste slotul de modal (dupa activare reusita).
|
||||||
|
#}
|
||||||
|
<span id="auto-send-toggle-wrap" class="auto-send-wrap"
|
||||||
|
style="display:inline-flex; align-items:center; gap:6px;">
|
||||||
|
<input type="hidden" id="auto-send-csrf" name="csrf_token" value="{{ csrf_token | default('') }}">
|
||||||
|
<label class="auto-send-label"
|
||||||
|
title="Trimite automat la RAR. Debifat: prezentarile asteapta confirmarea ta."
|
||||||
|
style="display:inline-flex; align-items:center; gap:6px; cursor:pointer;
|
||||||
|
font-size:var(--fs-sm); white-space:nowrap; user-select:none;">
|
||||||
|
<input type="checkbox" name="enabled" value="1"
|
||||||
|
{% if auto_send_enabled|default(false) %}checked{% endif %}
|
||||||
|
hx-post="/auto-send"
|
||||||
|
hx-include="#auto-send-csrf"
|
||||||
|
hx-target="#auto-send-toggle-wrap"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-on::response-error="this.checked = !this.checked; if(window.arataToast) window.arataToast('Nu am putut salva setarea Auto-send', 's-error');"
|
||||||
|
aria-label="Trimite automat la RAR"
|
||||||
|
style="width:16px; height:16px; cursor:pointer;">
|
||||||
|
<span class="auto-send-text">Trimite automat la RAR</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% if confirmare_release|default(false) %}
|
||||||
|
{# OOB: modal de confirmare tipata pentru auto-release OFF->ON (garda F4/D4). #}
|
||||||
|
<span hx-swap-oob="innerHTML:#modal-confirma-slot">
|
||||||
|
{% set cf_id = 'modal-auto-release' %}
|
||||||
|
{% set cf_titlu = 'Activezi trimiterea automata?' %}
|
||||||
|
{% set cf_count = release_count %}
|
||||||
|
{% set cf_env = release_env %}
|
||||||
|
{% set cf_url = '/auto-send' %}
|
||||||
|
{% set cf_vals = '{"enabled":"1","confirma":"1"}' %}
|
||||||
|
{% set cf_target = '#auto-send-toggle-wrap' %}
|
||||||
|
{% set cf_swap = 'outerHTML' %}
|
||||||
|
{% set cf_confirm_label = 'Activez si trimit ' ~ release_count %}
|
||||||
|
{% set cf_deschis = true %}
|
||||||
|
{% include '_modal_confirma_trimitere.html' %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if curata_modal|default(false) %}
|
||||||
|
{# Curata slotul de modal dupa o comutare reusita (fara modal rezidual). #}
|
||||||
|
<span hx-swap-oob="innerHTML:#modal-confirma-slot"></span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
42
app/web/templates/_bulk_held_banner.html
Normal file
42
app/web/templates/_bulk_held_banner.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{#
|
||||||
|
_bulk_held_banner.html — banner bulk "Trimite toate (N)".
|
||||||
|
|
||||||
|
N = randuri tinute ale contului (queued AND held=1). Randat:
|
||||||
|
- initial in _coada.html (banner_oob nesetat -> fara OOB);
|
||||||
|
- RE-RANDAT prin OOB (banner_oob=true) din _submissions.html la FIECARE reincarcare a
|
||||||
|
listei, ca sa nu ramana stale dupa eliberari.
|
||||||
|
|
||||||
|
Wrapper cu id STABIL #bulk-held-banner-wrap: OOB outerHTML il inlocuieste mereu (chiar si
|
||||||
|
gol la N=0), ca id-ul sa ramana tinta pentru urmatorul OOB. Confirmare tipata prin modal
|
||||||
|
(count + mediu RAR), NU hx-confirm.
|
||||||
|
#}
|
||||||
|
<div id="bulk-held-banner-wrap"{% if banner_oob %} hx-swap-oob="outerHTML"{% endif %}>
|
||||||
|
{% if held_manual_count|default(0) %}
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 10px;
|
||||||
|
padding:8px 10px; border-left:3px solid var(--warn);
|
||||||
|
background:color-mix(in srgb, var(--warn) 10%, var(--card)); border-radius:6px;">
|
||||||
|
<span style="font-size:var(--fs-sm);">
|
||||||
|
<strong>{{ held_manual_count }}</strong>
|
||||||
|
{{ 'prezentare tinuta' if held_manual_count == 1 else 'prezentari tinute' }} manual.
|
||||||
|
</span>
|
||||||
|
<button type="button"
|
||||||
|
onclick="var m=document.getElementById('modal-trimite-toate'); if(m){m.hidden=false;}"
|
||||||
|
style="margin-left:auto; padding:4px 12px; font-size:13px; border-radius:6px;
|
||||||
|
border:1px solid var(--accent); background:var(--accent); color:#fff;
|
||||||
|
font-weight:600; cursor:pointer;">
|
||||||
|
Trimite toate ({{ held_manual_count }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% set cf_id = 'modal-trimite-toate' %}
|
||||||
|
{% set cf_titlu = 'Trimiti toate prezentarile tinute?' %}
|
||||||
|
{% set cf_count = held_manual_count %}
|
||||||
|
{% set cf_env = env_bulk | default('prod') %}
|
||||||
|
{% set cf_url = '/trimite-toate' %}
|
||||||
|
{% set cf_vals = '{}' %}
|
||||||
|
{% set cf_target = '#submissions-wrap' %}
|
||||||
|
{% set cf_swap = 'innerHTML' %}
|
||||||
|
{% set cf_confirm_label = 'Trimite toate (' ~ held_manual_count ~ ')' %}
|
||||||
|
{% set cf_deschis = false %}
|
||||||
|
{% include '_modal_confirma_trimitere.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -1,234 +1,219 @@
|
|||||||
{#
|
{#
|
||||||
_chips_prestatii.html — sectiunea de prestatii chips (E4, server-driven via /form-chips).
|
_chips_prestatii.html — control unificat chips prestatii.
|
||||||
|
|
||||||
Re-randata de endpoint-ul /form-chips la fiecare add/remove de chip.
|
UN singur container `.chips`: chips cod (legate de operatie sau libere), chips
|
||||||
Inclusa si din _form_editare.html pentru randarea initiala.
|
warning pentru operatii nemapate, chip `Nedeclarat` pentru itemi exclusi, si
|
||||||
|
selectul unic `chips_pick` la final. Fara `.op-row`, fara butoane Adauga/+ —
|
||||||
|
selectul adauga instant la `change` (POST /form-chips, chips_action=add).
|
||||||
|
|
||||||
Starea chip-urilor traieste in input-uri hidden din form (NU in DB mid-edit).
|
Diagrama starii per item (chips_state.items[i]):
|
||||||
Fiecare operatie are un picker propriu cand e nemapata (E4 binding op<->cod).
|
cod="" & exclus=0 -> chip warning (warn OP-X . fara cod), click = tinta
|
||||||
Reveal odometru initial semnalat prin data-has-r-odo="true" si chip-warn pe R-ODO/I-ODO.
|
cod="" & exclus=1 -> chip Nedeclarat (line-through), x -> readuce warning
|
||||||
|
cod!="" (op="" sau op!="") -> chip cod (eticheta COD, tooltip = operatia sursa
|
||||||
|
sau "cod adaugat manual")
|
||||||
|
|
||||||
|
Stare persistata intr-UN SINGUR hidden `chips_state` (JSON versionat)
|
||||||
|
+ hidden `chips_target_index` (tinta curenta; comutata CLIENT-SIDE la click pe
|
||||||
|
un chip warning, fara round-trip — indexul pleaca la server abia la urmatorul
|
||||||
|
add) + hidden `chips_submission_id` (ecouat ca sa "salveaza ca regula" ramana
|
||||||
|
disponibil dupa re-randari via /form-chips).
|
||||||
|
|
||||||
Context vars (toate cu defaults):
|
Context vars (toate cu defaults):
|
||||||
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire}
|
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire, exclus}
|
||||||
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru picker
|
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru select
|
||||||
has_r_odo — True daca orice chip e R-ODO sau I-ODO (server-computed)
|
has_r_odo — True daca vreun chip e R-ODO/I-ODO (server-computed)
|
||||||
form_chips_url — URL pentru HTMX; default '/form-chips'
|
chips_added_index — index-ul chip-ului tocmai adaugat (emfaza chip-nou)
|
||||||
chips_section_id — ID div (default 'chips-section')
|
chips_target_index — index-ul operatiei tinta curente (sau none)
|
||||||
csrf_token — CSRF (trecut prin hx-include din form parinte)
|
chips_submission_id — id submission cand chips sunt in modalul de detaliu
|
||||||
|
(activeaza butonul "salveaza ca regula")
|
||||||
|
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 _chips_url = form_chips_url or '/form-chips' %}
|
||||||
{% set _sec_id = chips_section_id or 'chips-section' %}
|
{% set _sec_id = chips_section_id or 'chips-section' %}
|
||||||
{% set _chips = prestatii_chips or [] %}
|
{% set _chips = prestatii_chips or [] %}
|
||||||
{% set _has_ops = _chips | selectattr('cod_op_service') | list | length > 0 %}
|
{% set _sub_id = chips_submission_id if (chips_submission_id is defined and chips_submission_id) else '' %}
|
||||||
{# US-009: chips_submission_id e setat din _detaliu_ctx cand chips sunt randate in modalul de detaliu.
|
{% set _target_idx = chips_target_index if (chips_target_index is defined and chips_target_index is not none) else none %}
|
||||||
Lipseste cand _chips_prestatii.html e rerandat via /form-chips (stateless, fara submission). #}
|
{% set _target_op = (_chips[_target_idx].cod_op_service or '') if (_target_idx is not none and _target_idx < _chips | length) else '' %}
|
||||||
{% 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' }}"
|
<div id="{{ _sec_id }}" class="chips-editor" data-has-r-odo="{{ 'true' if has_r_odo else 'false' }}"
|
||||||
aria-live="polite" aria-label="Prestatii cod RAR">
|
aria-live="polite" aria-label="Prestatii cod RAR">
|
||||||
|
|
||||||
{# ===== Input-uri hidden pentru starea curenta a chip-urilor =====
|
{# ===== Stare: UN SINGUR hidden JSON versionat + tinta + submission id ===== #}
|
||||||
TOATE itemele emit 3 hidden inputs (cod poate fi "" pentru unmapped).
|
<input type="hidden" name="chips_state" value='{{ chips_state_json(_chips) }}'>
|
||||||
Paralele index-by-index: cod_prestatie[i], chip_op_service[i], chip_denumire[i].
|
<input type="hidden" name="chips_target_index" id="chips-target-index"
|
||||||
Filtrate la submit de post_corectie_trimitere (coduri goale = neschimbate). #}
|
value="{{ _target_idx if _target_idx is not none else '' }}">
|
||||||
{% for chip in _chips %}
|
<input type="hidden" name="chips_submission_id" value="{{ _sub_id }}">
|
||||||
<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;">
|
<div class="camp-slim" style="margin-bottom:8px;">
|
||||||
<label>Prestatii — cod RAR pe fiecare operatie</label>
|
<label>Prestatii — cod RAR</label>
|
||||||
|
|
||||||
{% if _has_ops %}
|
<div class="chips" role="group" aria-label="Prestatii cod RAR">
|
||||||
{# ===== Mod operatii: UN picker PE operatie (E4 binding) ===== #}
|
{% for chip in _chips %}
|
||||||
{% for chip in _chips %}
|
{% set _idx = loop.index0 %}
|
||||||
{% if chip.cod_op_service %}
|
{% set _is_target = _target_idx is not none and _idx == _target_idx %}
|
||||||
{% 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 ===== #}
|
{% if chip.cod_op_service and not chip.cod_prestatie and not chip.exclus %}
|
||||||
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat).
|
{# ===== Chip warning: operatie nemapata — click comuta tinta client-side ===== #}
|
||||||
T-7 (5.16): containerul .chips se randeaza DOAR cand exista chips extra — altfel ramanea
|
{% set _label = chip.cod_op_service %}
|
||||||
un chenar gol nefinisat sub randurile de operatie. #}
|
{% set _label_scurt = (_label[:18] ~ '…') if _label | length > 18 else _label %}
|
||||||
{% set _extra_chips = _chips | rejectattr('cod_op_service') | selectattr('cod_prestatie') | list %}
|
{% set _title = chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '') %}
|
||||||
{% if _extra_chips %}
|
<button type="button"
|
||||||
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;">
|
class="chip chip-op-warn {% if _is_target %}is-target{% endif %}"
|
||||||
{% for chip in _extra_chips %}
|
data-chip-idx="{{ _idx }}"
|
||||||
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
data-op-nume="{{ chip.cod_op_service }}"
|
||||||
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %}"
|
title="{{ _title }}"
|
||||||
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}">
|
onclick="window.chipsSetTarget && window.chipsSetTarget(this, {{ _idx }})"
|
||||||
{{ chip.cod_prestatie }}
|
aria-pressed="{{ 'true' if _is_target else 'false' }}"
|
||||||
|
aria-label="Operatia {{ chip.cod_op_service }} nu are cod RAR{% if _is_target %} (tinta curenta){% endif %}">
|
||||||
|
⚠ {{ _label_scurt }} · fara cod
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% elif chip.exclus %}
|
||||||
|
{# ===== Chip Nedeclarat: vizibil (line-through), x readuce warning-ul ===== #}
|
||||||
|
<span class="chip chip-nedeclarat"
|
||||||
|
title="{{ chip.cod_op_service or 'Cod liber' }} — nu se declara la RAR"
|
||||||
|
aria-label="{{ chip.cod_op_service or 'Operatia' }} nu se declara la RAR">
|
||||||
|
Nedeclarat
|
||||||
<button type="button" class="chip-del"
|
<button type="button" class="chip-del"
|
||||||
hx-post="{{ _chips_url }}"
|
hx-post="{{ _chips_url }}"
|
||||||
|
hx-disabled-elt="this"
|
||||||
|
hx-indicator="#{{ _sec_id }}"
|
||||||
hx-include="closest form"
|
hx-include="closest form"
|
||||||
hx-target="#{{ _sec_id }}"
|
hx-target="#{{ _sec_id }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
|
hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}'
|
||||||
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">×</button>
|
aria-label="Anuleaza Nedeclarat pentru {{ chip.cod_op_service or 'cod liber' }} (readuce avertismentul)">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{% elif chip.cod_prestatie %}
|
||||||
|
{# ===== Chip cod: legat de operatie sau liber ===== #}
|
||||||
|
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
||||||
|
{% set _e_nou = (chips_added_index is defined) and chips_added_index is not none and _idx == chips_added_index %}
|
||||||
|
{% set _tooltip = (chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '')) if chip.cod_op_service else 'cod adaugat manual' %}
|
||||||
|
<span class="chip {% if _is_warn %}chip-warn{% endif %} {% if _e_nou %}chip-nou{% endif %}"
|
||||||
|
title="{{ _tooltip }}"
|
||||||
|
aria-label="Prestatie {{ chip.cod_prestatie }}{% if chip.cod_op_service %} pentru {{ chip.cod_op_service }}{% endif %}">
|
||||||
|
<span class="chip-cod-label">COD</span> {{ chip.cod_prestatie }}
|
||||||
|
<button type="button" class="chip-del"
|
||||||
|
hx-post="{{ _chips_url }}"
|
||||||
|
hx-disabled-elt="this"
|
||||||
|
hx-indicator="#{{ _sec_id }}"
|
||||||
|
hx-include="closest form"
|
||||||
|
hx-target="#{{ _sec_id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}'
|
||||||
|
aria-label="Sterge codul {{ chip.cod_prestatie }}{% if chip.cod_op_service %} pentru {{ chip.cod_op_service }}{% endif %}">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
|
||||||
|
(modalul de detaliu, nu la re-randarea stateless via /form-chips fara ecou). #}
|
||||||
|
{% if _sub_id and chip.cod_op_service and chip.cod_prestatie %}
|
||||||
|
<span id="save-rule-slot-{{ _idx }}" class="save-rule-slot">
|
||||||
|
<button type="button"
|
||||||
|
style="font-size:10px;color:var(--muted);background:none;border:none;cursor:pointer;text-decoration:underline;padding:0;margin-left:-2px;line-height:1;"
|
||||||
|
hx-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
|
||||||
|
hx-disabled-elt="this"
|
||||||
|
hx-indicator="#{{ _sec_id }}"
|
||||||
|
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>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
{% endif %}
|
{# ===== Selectul unic: adauga instant la schimbare, placeholder numeste tinta ===== #}
|
||||||
{% if nomenclator_rar %}
|
{% if nomenclator_rar %}
|
||||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;">
|
<select name="chips_pick"
|
||||||
<select name="chips_add_cod_flat"
|
id="chips-picker"
|
||||||
aria-label="Adauga cod RAR suplimentar"
|
class="chips-picker"
|
||||||
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);">
|
hx-trigger="change"
|
||||||
<option value="">+ Adauga alta operatie / cod RAR</option>
|
hx-post="{{ _chips_url }}"
|
||||||
{% for n in nomenclator_rar %}
|
hx-target="#{{ _sec_id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-include="closest form"
|
||||||
|
hx-disabled-elt="this"
|
||||||
|
hx-indicator="#{{ _sec_id }}"
|
||||||
|
hx-vals='{"chips_action":"add"}'
|
||||||
|
aria-label="{% if _target_op %}Alege cod RAR pentru {{ _target_op }}{% else %}Adauga cod RAR{% endif %}">
|
||||||
|
<option value="">{% if _target_op %}Cod pentru {{ _target_op }} — alege{% else %}— adauga cod RAR —{% endif %}</option>
|
||||||
|
{% if _target_idx is not none %}
|
||||||
|
{% set _sugestii = sugestii_tinta if (sugestii_tinta is defined and sugestii_tinta) else [] %}
|
||||||
|
{% if _sugestii %}
|
||||||
|
<optgroup label="Sugestii">
|
||||||
|
{% for s in _sugestii %}
|
||||||
|
<option value="{{ s.cod_prestatie }}">{{ s.cod_prestatie }} — {{ s.nume_prestatie }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
<option value="{{ EXCLUDE_SENTINEL }}">Nu se declara la RAR (exclude operatia)</option>
|
||||||
|
{% endif %}
|
||||||
|
{% for n in (nomenclator_rar or []) %}
|
||||||
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
|
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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 %}
|
{% else %}
|
||||||
{# T-D1/T-E5 (5.16): empty state in mod plat cand nomenclatorul lipseste #}
|
{# T-D1/T-E5 (5.16): empty state cand nomenclatorul lipseste #}
|
||||||
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;">
|
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;">
|
||||||
Nomenclator indisponibil — nu se pot adauga coduri RAR momentan.
|
Nomenclator indisponibil — adaugarea de coduri RAR nu e posibila.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Hint discret fara chips (debut) #}
|
{# Rezultatul ultimei adaugari: refuz (rol=alert) sau confirmare.
|
||||||
{% if not _chips %}
|
Clasa chips-extra-error pastrata pe eroare (selector stabil pentru teste/QA). #}
|
||||||
<div style="font-size:10px;color:var(--muted);padding:4px 0;">
|
{% if chips_error is defined and chips_error %}
|
||||||
Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare.
|
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
|
||||||
|
{{ chips_error }}
|
||||||
|
</div>
|
||||||
|
{% elif chips_ok is defined and chips_ok %}
|
||||||
|
<div class="chips-adaugat-ok" style="font-size:11px;color:var(--ok);padding:2px 0;" role="status">
|
||||||
|
{{ chips_ok }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (window.__chipsUnifiedBound) { return; }
|
||||||
|
window.__chipsUnifiedBound = true;
|
||||||
|
|
||||||
|
// Click pe chip warning -> comuta tinta CLIENT-SIDE (fara round-trip). Click
|
||||||
|
// pe tinta activa = no-op (nu se poate "deselecta" tinta cat exista warnings).
|
||||||
|
window.chipsSetTarget = function (btn, idx) {
|
||||||
|
var hidden = document.getElementById('chips-target-index');
|
||||||
|
if (!hidden) { return; }
|
||||||
|
if (String(idx) === hidden.value) { return; }
|
||||||
|
hidden.value = String(idx);
|
||||||
|
document.querySelectorAll('.chip-op-warn.is-target').forEach(function (el) {
|
||||||
|
el.classList.remove('is-target');
|
||||||
|
el.setAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
btn.classList.add('is-target');
|
||||||
|
btn.setAttribute('aria-pressed', 'true');
|
||||||
|
var picker = document.getElementById('chips-picker');
|
||||||
|
if (picker && picker.options.length) {
|
||||||
|
picker.options[0].textContent = 'Cod pentru ' + (btn.dataset.opNume || '') + ' — alege';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Focus restaurat pe select dupa fiecare swap outerHTML al sectiunii.
|
||||||
|
document.body.addEventListener('htmx:afterSettle', function (evt) {
|
||||||
|
var t = evt.target;
|
||||||
|
if (t && t.classList && t.classList.contains('chips-editor')) {
|
||||||
|
var el = document.getElementById('chips-picker');
|
||||||
|
if (el) { el.focus(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -6,10 +6,15 @@
|
|||||||
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"
|
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"
|
||||||
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
|
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{# US-002 (5.16): titlul de sectiune vizibil ("Trimiterile tale") a fost eliminat —
|
{# Titlul de sectiune vizibil ("Trimiterile tale") a fost eliminat — lista incepe
|
||||||
lista incepe direct sub filtre. Heading pastrat sr-only pentru a11y (section
|
direct sub filtre. Heading pastrat sr-only pentru a11y (section
|
||||||
aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
|
aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
|
||||||
<h2 id="trimiteri-heading" class="sr-only">Trimiterile tale</h2>
|
<h2 id="trimiteri-heading" class="sr-only">Trimiterile tale</h2>
|
||||||
|
|
||||||
|
{# Bulk "Trimite toate (N)". Randat initial aici; re-randat prin OOB
|
||||||
|
din _submissions.html la fiecare reincarcare a listei (evita staleness). #}
|
||||||
|
{% include '_bulk_held_banner.html' %}
|
||||||
|
|
||||||
{% if blocate_total %}
|
{% if blocate_total %}
|
||||||
<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin:0 0 10px;">
|
<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"
|
<span class="tab-badge" title="{{ blocate_total }} necesita atentie"
|
||||||
@@ -39,6 +44,9 @@
|
|||||||
<input type="hidden" id="f-status" name="status" value="{{ status_filtru | default('', true) }}">
|
<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. #}
|
{# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #}
|
||||||
<input type="hidden" id="f-page" name="page" value="1">
|
<input type="hidden" id="f-page" name="page" value="1">
|
||||||
|
{# Filtrul "doar tinute" — persistat prin OOB din _submissions.html, ca sa
|
||||||
|
nu se piarda la reincarcare (trimiteriChanged) sau paginare. #}
|
||||||
|
<input type="hidden" id="f-held" name="held" value="{{ f_held | default('') }}">
|
||||||
|
|
||||||
{# === STANGA: Quick-pills de data (preset interval) + buton Custom ===
|
{# === STANGA: Quick-pills de data (preset interval) + buton Custom ===
|
||||||
Azi / 7 zile / 30 zile → seteaza interval preset si submitr automat.
|
Azi / 7 zile / 30 zile → seteaza interval preset si submitr automat.
|
||||||
|
|||||||
@@ -1,226 +1,241 @@
|
|||||||
<div class="card" id="card-cont">
|
{# Pagina Setari: sub-taburi interne — Cont (date firma + plan + parola) /
|
||||||
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
|
Credentiale RAR / Cheie API. `activ_subtab` (server) alege panoul activ dupa fiecare
|
||||||
|
swap HTMX, ca re-randarea sa nu resete la primul tab. Comutarea intre taburi
|
||||||
|
fara server = JS delegat in base.html (#card-cont). #}
|
||||||
|
{% set _firma_ok = account_meta.name and account_meta.email and account_meta.cui %}
|
||||||
|
{% set _rar_ok = medii_disponibile and medii_disponibile|length > 0 %}
|
||||||
|
{% set _subtab = activ_subtab|default('cont') %}
|
||||||
|
{% if _subtab == 'firma' %}{% set _subtab = 'cont' %}{% endif %}
|
||||||
|
<div class="card" id="card-cont" data-activ-subtab="{{ _subtab }}">
|
||||||
|
<h2 class="setari-title">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.6 4.6l2.1 2.1M17.3 17.3l2.1 2.1M4.6 19.4l2.1-2.1M17.3 6.7l2.1-2.1"/></svg>
|
||||||
|
Setari
|
||||||
|
</h2>
|
||||||
|
|
||||||
<!-- Sectiunea: Plan curent (US-006 PRD 5.17) -->
|
<div class="setari-subtabs" role="tablist" aria-label="Sectiuni setari">
|
||||||
{% if plan_linie is defined %}
|
<button type="button" class="setari-subtab{{ ' is-active' if _subtab == 'cont' else '' }}" role="tab" data-subtab="cont"
|
||||||
<div id="sectiune-plan" style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
|
aria-selected="{{ 'true' if _subtab == 'cont' else 'false' }}">
|
||||||
<h3 style="font-size:var(--fs-sm); color:var(--muted); font-weight:500; margin:0 0 10px;
|
<span class="st-dot {{ 'ok' if _firma_ok else 'warn' }}" aria-hidden="true"></span>Cont</button>
|
||||||
text-transform:uppercase; letter-spacing:.04em;">Plan curent</h3>
|
<button type="button" class="setari-subtab{{ ' is-active' if _subtab == 'rar' else '' }}" role="tab" data-subtab="rar"
|
||||||
|
aria-selected="{{ 'true' if _subtab == 'rar' else 'false' }}">
|
||||||
|
<span class="st-dot {{ 'ok' if _rar_ok else 'warn' }}" aria-hidden="true"></span>Credentiale RAR</button>
|
||||||
|
<button type="button" class="setari-subtab{{ ' is-active' if _subtab == 'api' else '' }}" role="tab" data-subtab="api"
|
||||||
|
aria-selected="{{ 'true' if _subtab == 'api' else 'false' }}">
|
||||||
|
<span class="st-dot {{ 'ok' if are_cheie else 'warn' }}" aria-hidden="true"></span>Cheie API</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="font-size:var(--fs-md); font-weight:600; margin-bottom:6px;
|
{# ================= Sub-tab: Cont (date firma + plan + parola) ================ #}
|
||||||
color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--ink){% endif %};">
|
<div class="setari-pane" data-subpane="cont" role="tabpanel"
|
||||||
{{ plan_linie }}
|
{% if _subtab != 'cont' %}hidden{% endif %}>
|
||||||
|
|
||||||
|
{# --- Date firma --- #}
|
||||||
|
<div class="setari-zone-title">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 21V4a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v17"/><path d="M15 9h4a1 1 0 0 1 1 1v11"/><path d="M2 21h20"/><path d="M8 7h3M8 11h3M8 15h3"/></svg>
|
||||||
|
Date firma
|
||||||
|
<span class="setari-badge {{ 'sb-ok' if _firma_ok else 'sb-warn' }}">{{ 'complet' if _firma_ok else 'incomplet' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if monthly_limit_val is defined and monthly_limit_val is not none and effective_tier_name|default('') == 'free' %}
|
{% if date_firma_mesaj %}<div class="flash" style="margin-bottom:12px;">{{ date_firma_mesaj }}</div>{% endif %}
|
||||||
<div style="font-size:var(--fs-sm); color:var(--muted); margin-bottom:8px;">
|
{% if date_firma_eroare %}<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ date_firma_eroare }}</div>{% endif %}
|
||||||
Planul Gratuit include {{ monthly_limit_val }} prestatii/luna prin dashboard-ul web.
|
|
||||||
{% if plan_limita_atinsa|default(false) %}
|
<form hx-post="/cont/date-firma" hx-target="#card-cont" hx-swap="outerHTML">
|
||||||
Limita lunara a fost atinsa — trimiterile noi sunt blocate pana la inceputul lunii urmatoare.
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
{% elif plan_warn|default(false) %}
|
<div class="setari-fgrid">
|
||||||
Te apropii de limita lunara.
|
<div>
|
||||||
|
<label for="df-companie">Companie</label>
|
||||||
|
<input id="df-companie" type="text" name="companie" required value="{{ account_meta.name or '' }}"
|
||||||
|
placeholder="Numele firmei (ex. Service Auto SRL)">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="df-cui">CUI</label>
|
||||||
|
<input id="df-cui" type="text" name="cui" required value="{{ account_meta.cui or '' }}"
|
||||||
|
placeholder="RO12345678">
|
||||||
|
</div>
|
||||||
|
<div class="fg-full">
|
||||||
|
<label for="df-email">Email contact</label>
|
||||||
|
<input id="df-email" type="email" name="email" required value="{{ account_meta.email or '' }}"
|
||||||
|
placeholder="contact@firma.ro">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setari-btn-row">
|
||||||
|
<button type="submit">Salveaza</button>
|
||||||
|
<span class="setari-micro">Apar pe declaratiile RAR si pe facturi.</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# --- Plan --- #}
|
||||||
|
{% if plan_linie is defined %}
|
||||||
|
<hr class="setari-subzone-sep">
|
||||||
|
<div id="sectiune-plan" class="setari-zone-title">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 3 8l9 5 9-5z"/><path d="M3 13l9 5 9-5"/></svg>
|
||||||
|
Plan
|
||||||
|
</div>
|
||||||
|
{# plan_linie vine ca "Plan: <tier> · <detaliu>"; aici tier-ul apare O data, bold. #}
|
||||||
|
{% set _plan_text = plan_linie|replace('Plan: ', '', 1) %}
|
||||||
|
{% set _plan_parts = _plan_text.split(' · ', 1) %}
|
||||||
|
<div class="plan-row">
|
||||||
|
<span class="plan-tier">{{ _plan_parts[0] }}</span>
|
||||||
|
{% if _plan_parts|length > 1 %}
|
||||||
|
<span class="plan-usage" style="{% if plan_limita_atinsa|default(false) %}color:var(--err); font-weight:600;{% elif plan_warn|default(false) %}color:var(--warn); font-weight:600;{% endif %}">{{ _plan_parts[1] }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% if monthly_limit_val|default(none) and usage_lunar is defined %}
|
||||||
|
{% set _pct = (usage_lunar / monthly_limit_val * 100)|round|int %}
|
||||||
<div style="font-size:var(--fs-sm); color:var(--muted); padding:8px 10px;
|
<div class="plan-meter" role="progressbar" aria-valuenow="{{ usage_lunar }}" aria-valuemin="0"
|
||||||
border:1px solid var(--line); border-radius:6px; margin-top:4px;">
|
aria-valuemax="{{ monthly_limit_val }}" aria-label="Consum plan luna curenta">
|
||||||
Vrei sa treci pe Standard, Pro sau Premium?
|
<span style="width:{{ [_pct, 100]|min }}%;{% if plan_limita_atinsa|default(false) %} background:var(--err);{% elif plan_warn|default(false) %} background:var(--warn);{% endif %}"></span>
|
||||||
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>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endif %}
|
{% if plan_limita_atinsa|default(false) %}
|
||||||
|
<p class="setari-micro" style="color:var(--err); margin:0 0 6px;">Limita lunara atinsa — trimiterile noi sunt blocate pana luna viitoare.</p>
|
||||||
<!-- Sectiunea: Date firma (US-002) -->
|
{% endif %}
|
||||||
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
|
<p class="setari-micro" style="margin:0;">Pentru mai mult volum sau acces API, <a href="mailto:contact@romfast.ro">contacteaza-ne</a>.</p>
|
||||||
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if date_firma_eroare %}
|
{# --- Parola de acces --- #}
|
||||||
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ date_firma_eroare }}</div>
|
<hr class="setari-subzone-sep">
|
||||||
{% endif %}
|
<div class="setari-zone-title">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="15.5" r="1.2"/></svg>
|
||||||
|
Parola de acces
|
||||||
|
</div>
|
||||||
|
<p class="setari-zone-desc">Parola dashboard-ului — separata de credentialele RAR. Minim 10 caractere.</p>
|
||||||
|
|
||||||
<form hx-post="/cont/date-firma"
|
{% if parola_mesaj %}<div class="flash" style="margin-bottom:12px;">{{ parola_mesaj }}</div>{% endif %}
|
||||||
hx-target="#card-cont"
|
{% if parola_eroare %}<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ parola_eroare }}</div>{% endif %}
|
||||||
hx-swap="outerHTML">
|
|
||||||
|
<form hx-post="/cont/schimba-parola" hx-target="#card-cont" hx-swap="outerHTML">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<p style="margin:0 0 8px;">
|
<div class="setari-fgrid">
|
||||||
<label style="font-size:13px; color:var(--muted);">Companie</label><br>
|
<div class="fg-full" style="max-width:273px;">
|
||||||
<input type="text" name="companie" required
|
<label for="pw-actuala">Parola actuala</label>
|
||||||
value="{{ account_meta.name or '' }}"
|
<input id="pw-actuala" type="password" name="parola_actuala" autocomplete="current-password">
|
||||||
style="width:100%; max-width:340px;"
|
</div>
|
||||||
placeholder="Numele firmei (ex. Service Auto SRL)">
|
<div>
|
||||||
</p>
|
<label for="pw-noua">Parola noua</label>
|
||||||
<p style="margin:0 0 8px;">
|
<input id="pw-noua" type="password" name="parola_noua" autocomplete="new-password">
|
||||||
<label style="font-size:13px; color:var(--muted);">Email contact</label><br>
|
</div>
|
||||||
<input type="email" name="email" required
|
<div>
|
||||||
value="{{ account_meta.email or '' }}"
|
<label for="pw-confirma">Confirma parola noua</label>
|
||||||
style="width:100%; max-width:340px;"
|
<input id="pw-confirma" type="password" name="parola_confirma" autocomplete="new-password">
|
||||||
placeholder="contact@firma.ro">
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
<button type="submit">Schimba parola</button>
|
||||||
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sectiunea: Credentiale RAR -->
|
{# ========================= Sub-tab: Credentiale RAR ========================= #}
|
||||||
<div>
|
<div class="setari-pane" data-subpane="rar" role="tabpanel"
|
||||||
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 12px; text-transform:uppercase; letter-spacing:.04em;">Credentiale RAR (portal AUTOPASS)</h3>
|
{% if _subtab != 'rar' %}hidden{% endif %}>
|
||||||
|
<div class="setari-zone-title">
|
||||||
<p class="muted" style="font-size:12px; margin:0 0 12px; line-height:1.5;">
|
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 11l1.5-4A2 2 0 0 1 8.4 5.6h7.2A2 2 0 0 1 17.5 7L19 11"/><path d="M4 11h16v6H4z"/><path d="M6 17v2M18 17v2"/><circle cx="7.5" cy="14" r="1"/><circle cx="16.5" cy="14" r="1"/></svg>
|
||||||
Aceasta instanta ruleaza pe mediul global <strong>{{ mediu_instanta() }}</strong>.
|
Credentiale RAR
|
||||||
Poti configura mai jos ambele medii — Testare si Productie — fiecare validat separat
|
<span class="setari-badge {{ 'sb-ok' if _rar_ok else 'sb-warn' }}">{{ 'configurat' if _rar_ok else 'neconfigurat' }}</span>
|
||||||
la salvare pe sistemul RAR corespunzator. Cand un cont nu are niciun mediu activ,
|
</div>
|
||||||
trimiterile cad pe mediul global al instantei ({{ mediu_instanta() }}).
|
<p class="setari-zone-desc">
|
||||||
|
Fiecare mediu are propriile credentiale, verificate la salvare. Trimiterile merg doar pe mediile active.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if creds_mesaj %}
|
{% if creds_mesaj %}<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>{% endif %}
|
||||||
<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>
|
{% if creds_eroare %}<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ creds_eroare }}</div>{% endif %}
|
||||||
{% endif %}
|
|
||||||
{% if creds_eroare %}
|
|
||||||
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ creds_eroare }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form hx-post="/cont/rar-medii"
|
<form hx-post="/cont/rar-medii" hx-target="#card-cont" hx-swap="outerHTML">
|
||||||
hx-target="#card-cont"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
<!-- Subsectiunea: Testare -->
|
<div class="env-cards">
|
||||||
<div style="margin-bottom:16px; padding-bottom:16px; border-bottom:1px solid var(--line);">
|
<!-- Testare -->
|
||||||
<p style="margin:0 0 6px;">
|
<div class="env-card{% if test_disponibil %} active-test{% endif %}">
|
||||||
<label style="font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px;">
|
<h4>Testare
|
||||||
|
{% if test_disponibil %}<span class="setari-badge sb-ok">configurat</span>{% else %}<span class="setari-badge sb-info">inactiv</span>{% endif %}
|
||||||
|
</h4>
|
||||||
|
<label class="env-sw">
|
||||||
<input type="checkbox" name="test_enabled" value="1" {% if test_enabled %}checked{% endif %}>
|
<input type="checkbox" name="test_enabled" value="1" {% if test_enabled %}checked{% endif %}>
|
||||||
Activare Testare
|
Activare Testare
|
||||||
</label>
|
</label>
|
||||||
{% if test_disponibil %}
|
<label class="env-lbl" for="rar-test-email">Email RAR Testare</label>
|
||||||
<span style="font-size:12px; color:var(--ok);">configurat</span>
|
<input id="rar-test-email" type="email" name="test_email" placeholder="email@service.ro">
|
||||||
{% endif %}
|
<label class="env-lbl" for="rar-test-parola">Parola RAR Testare</label>
|
||||||
</p>
|
<input id="rar-test-parola" type="password" name="test_parola" autocomplete="new-password"
|
||||||
<p style="margin:0 0 8px;">
|
{% if test_disponibil %}placeholder="•••••••• (setata)"{% endif %}>
|
||||||
<label style="font-size:13px; color:var(--muted);">Email RAR Testare</label><br>
|
{% if creds_test_mesaj %}<div class="flash" style="margin-top:6px;">{{ creds_test_mesaj }}</div>{% endif %}
|
||||||
<input type="email" name="test_email" style="width:100%; max-width:340px;"
|
{% if creds_test_eroare %}<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_test_eroare }}</div>{% endif %}
|
||||||
placeholder="email@service.ro">
|
</div>
|
||||||
</p>
|
|
||||||
<p style="margin:0 0 8px;">
|
|
||||||
<label style="font-size:13px; color:var(--muted);">Parola RAR Testare</label><br>
|
|
||||||
<input type="password" name="test_parola" style="width:100%; max-width:340px;"
|
|
||||||
autocomplete="new-password">
|
|
||||||
</p>
|
|
||||||
{% if creds_test_mesaj %}
|
|
||||||
<div class="flash" style="margin-top:6px;">{{ creds_test_mesaj }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if creds_test_eroare %}
|
|
||||||
<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_test_eroare }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subsectiunea: Productie -->
|
<!-- Productie -->
|
||||||
<div style="margin-bottom:16px; padding-bottom:16px; border-bottom:1px solid var(--line);">
|
<div class="env-card{% if not prod_disponibil %} inactive{% endif %}">
|
||||||
<p style="margin:0 0 6px;">
|
<h4>Productie
|
||||||
<label style="font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px;">
|
{% if prod_disponibil %}<span class="setari-badge sb-ok">configurat</span>{% else %}<span class="setari-badge sb-warn">inactiv</span>{% endif %}
|
||||||
|
</h4>
|
||||||
|
<label class="env-sw">
|
||||||
<input type="checkbox" name="prod_enabled" value="1" {% if prod_enabled %}checked{% endif %}>
|
<input type="checkbox" name="prod_enabled" value="1" {% if prod_enabled %}checked{% endif %}>
|
||||||
Activare Productie
|
Activare Productie
|
||||||
</label>
|
</label>
|
||||||
{% if prod_disponibil %}
|
<label class="env-lbl" for="rar-prod-email">Email RAR Productie</label>
|
||||||
<span style="font-size:12px; color:var(--ok);">configurat</span>
|
<input id="rar-prod-email" type="email" name="prod_email" placeholder="email@service.ro">
|
||||||
{% endif %}
|
<label class="env-lbl" for="rar-prod-parola">Parola RAR Productie</label>
|
||||||
</p>
|
<input id="rar-prod-parola" type="password" name="prod_parola" autocomplete="new-password"
|
||||||
<p style="margin:0 0 8px;">
|
{% if prod_disponibil %}placeholder="•••••••• (setata)"{% endif %}>
|
||||||
<label style="font-size:13px; color:var(--muted);">Email RAR Productie</label><br>
|
<label class="env-confirm">
|
||||||
<input type="email" name="prod_email" style="width:100%; max-width:340px;"
|
<input type="checkbox" name="prod_confirmare" value="1">
|
||||||
placeholder="email@service.ro">
|
<span>Confirm: trimiterile pe Productie sunt declaratii reale (L.142), finale si fara anulare.</span>
|
||||||
</p>
|
|
||||||
<p style="margin:0 0 8px;">
|
|
||||||
<label style="font-size:13px; color:var(--muted);">Parola RAR Productie</label><br>
|
|
||||||
<input type="password" name="prod_parola" style="width:100%; max-width:340px;"
|
|
||||||
autocomplete="new-password">
|
|
||||||
</p>
|
|
||||||
<p style="margin:0 0 8px;">
|
|
||||||
<label style="font-size:13px; color:var(--muted); display:flex; align-items:flex-start; gap:6px;">
|
|
||||||
<input type="checkbox" name="prod_confirmare" value="1" style="margin-top:2px; flex-shrink:0;">
|
|
||||||
Inteleg ca trimiterile pe Productie sunt declaratii reale (L.142), finale si fara anulare.
|
|
||||||
</label>
|
</label>
|
||||||
</p>
|
{% if creds_prod_mesaj %}<div class="flash" style="margin-top:6px;">{{ creds_prod_mesaj }}</div>{% endif %}
|
||||||
{% if creds_prod_mesaj %}
|
{% if creds_prod_eroare %}<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_prod_eroare }}</div>{% endif %}
|
||||||
<div class="flash" style="margin-top:6px;">{{ creds_prod_mesaj }}</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% if creds_prod_eroare %}
|
|
||||||
<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_prod_eroare }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selector mediu implicit -->
|
<div class="default-env">
|
||||||
<div style="margin-bottom:16px;">
|
<label for="rar-env-default" style="font-size:13px; color:var(--muted);">Mediu implicit pentru trimiteri</label>
|
||||||
<label style="font-size:13px; color:var(--muted);">Mediu implicit pentru trimiteri</label><br>
|
|
||||||
{% if medii_disponibile %}
|
{% if medii_disponibile %}
|
||||||
<select name="rar_env_default" style="width:100%; max-width:340px; margin-top:4px;">
|
<select id="rar-env-default" name="rar_env_default">
|
||||||
{% for env in medii_disponibile %}
|
{% for env in medii_disponibile %}
|
||||||
<option value="{{ env }}"{% if env == rar_env_default %} selected{% endif %}>{{ "Testare" if env == "test" else "Productie" }}</option>
|
<option value="{{ env }}"{% if env == rar_env_default %} selected{% endif %}>{{ "Testare" if env == "test" else "Productie" }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="font-size:13px; color:var(--muted); margin:4px 0 0;">Activeaza si valideaza un mediu intai.</p>
|
<span style="font-size:13px; color:var(--muted);">Activeaza si valideaza un mediu intai.</span>
|
||||||
{% endif %}
|
|
||||||
{% if creds_default_mesaj %}
|
|
||||||
<div class="flash" style="margin-top:6px;">{{ creds_default_mesaj }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if creds_default_eroare %}
|
|
||||||
<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_default_eroare }}</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if creds_default_mesaj %}<div class="flash" style="margin-top:6px; flex-basis:100%;">{{ creds_default_mesaj }}</div>{% endif %}
|
||||||
|
{% if creds_default_eroare %}<div class="banner" style="margin-top:6px; padding:8px 12px; flex-basis:100%;">{{ creds_default_eroare }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">Salveaza mediile RAR</button>
|
<div class="setari-btn-row">
|
||||||
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Parolele stocate criptat, niciodata in clar.</span>
|
<button type="submit">Salveaza</button>
|
||||||
|
<span class="setari-micro">Parolele se stocheaza criptat.</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ============================ Sub-tab: Cheie API ============================ #}
|
||||||
|
<div class="setari-pane" data-subpane="api" role="tabpanel"
|
||||||
|
{% if _subtab != 'api' %}hidden{% endif %}>
|
||||||
|
<div class="setari-zone-title">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9"/><path d="M17 6l2.5 2.5M14 9l2.5 2.5"/></svg>
|
||||||
|
Cheie API
|
||||||
|
<span class="setari-badge {{ 'sb-ok' if are_cheie else 'sb-warn' }}">{{ 'activa' if are_cheie else 'lipsa' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="setari-zone-desc">
|
||||||
|
Pentru trimiteri din softul propriu / ROAAUTO. Se afiseaza integral o singura data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% 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="keybox">{{ api_key }}</div>
|
||||||
|
<button type="button" class="btn-secondary" style="margin-top:8px;"
|
||||||
|
data-key="{{ api_key }}"
|
||||||
|
onclick="navigator.clipboard.writeText(this.dataset.key).then(()=>this.textContent='Copiat!')">
|
||||||
|
Copiaza cheia
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if rot_eroare %}<div class="banner" style="margin:12px 0; 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 %}14px{% else %}0{% endif %};">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="setari-btn-row">
|
||||||
|
<button type="submit" class="btn-warn">{{ 'Roteste cheia API' if are_cheie else 'Genereaza cheia API' }}</button>
|
||||||
|
<span class="setari-micro">Rotirea revoca imediat cheia veche.</span>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{# _editare_preview_modal.html — fragment de editare rand preview in modalul global.
|
{# _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.
|
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
|
Butonul 'Confirma valorile' apare DOAR pe randurile needs_review si trimite CSRF
|
||||||
(T2): trimite CSRF POST la /confirma-review, inchide modalul via HX-Trigger-After-Settle.
|
POST la /confirma-review, inchide modalul via HX-Trigger-After-Settle.
|
||||||
|
|
||||||
Necesita din context:
|
Necesita din context:
|
||||||
import_id — id batch import
|
import_id — id batch import
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if is_needs_review %}
|
{% if is_needs_review %}
|
||||||
{# T2 (US-007): Butonul 'Confirma valorile' apare DOAR pe randurile needs_review.
|
{# Butonul 'Confirma valorile' apare DOAR pe randurile needs_review.
|
||||||
POST separat (form propriu) la /confirma-review cu CSRF. Raspunsul inchide
|
POST separat (form propriu) la /confirma-review cu CSRF. Raspunsul inchide
|
||||||
modalul via HX-Trigger-After-Settle: inchideModal + swap OOB randul si countorii. #}
|
modalul via HX-Trigger-After-Settle: inchideModal + swap OOB randul si countorii. #}
|
||||||
<form hx-post="/_import/{{ import_id }}/rand/{{ row_index }}/confirma-review"
|
<form hx-post="/_import/{{ import_id }}/rand/{{ row_index }}/confirma-review"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# _form_editare.html — partial partajat slim: campurile vehicul/data/odo + obs + chips prestatii.
|
{# _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),
|
Redesign slim cu VIN unic, Observatii textarea, chips prestatii, si reveal dinamic
|
||||||
si reveal dinamic odometru initial cand chips contin R-ODO/I-ODO (D10c, E6 server-driven).
|
odometru initial cand chips contin R-ODO/I-ODO (server-driven).
|
||||||
|
|
||||||
Inclus cu {% include "_form_editare.html" %} INSIDE un <form> element al
|
Inclus cu {% include "_form_editare.html" %} INSIDE un <form> element al
|
||||||
template-ului parinte. Acel parinte pune form-ul, CSRF-ul si orice campuri
|
template-ului parinte. Acel parinte pune form-ul, CSRF-ul si orice campuri
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# === 3. Observatii (obs) — textarea liber, US-005 === #}
|
{# === 3. Observatii (obs) — textarea liber === #}
|
||||||
<div class="camp-slim">
|
<div class="camp-slim">
|
||||||
<label for="c-obs">Observatii (operatiile efectuate)</label>
|
<label for="c-obs">Observatii (operatiile efectuate)</label>
|
||||||
<textarea id="c-obs" name="obs" rows="2"
|
<textarea id="c-obs" name="obs" rows="2"
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
placeholder="ex: Revizie; schimbare placute frana">{{ obs_val or '' }}</textarea>
|
placeholder="ex: Revizie; schimbare placute frana">{{ obs_val or '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# === 4. Prestatii chips (E4 server-driven, US-007) === #}
|
{# === 4. Prestatii chips (server-driven) === #}
|
||||||
{% set form_chips_url = form_chips_url or '/form-chips' %}
|
{% set form_chips_url = form_chips_url or '/form-chips' %}
|
||||||
{% set chips_section_id = 'chips-section' %}
|
{% set chips_section_id = 'chips-section' %}
|
||||||
{% include "_chips_prestatii.html" %}
|
{% include "_chips_prestatii.html" %}
|
||||||
|
|||||||
29
app/web/templates/_header_env_badge.html
Normal file
29
app/web/templates/_header_env_badge.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{# Badge compact "Mediu RAR" din header, langa chip-ul RAR.
|
||||||
|
0 medii: wrapper gol (id-ul ramane in DOM pentru viitoare OOB swap).
|
||||||
|
1 mediu: eticheta statica. 2 medii: buton de comutare HTMX (ciclic).
|
||||||
|
Clasele .env-badge-prod/.env-badge-test sunt cele din listele de trimiteri (consecventa). #}
|
||||||
|
{% set _medii = medii_disponibile | default([]) %}
|
||||||
|
<div id="header-env-badge"{% if oob %} hx-swap-oob="outerHTML"{% endif %}>
|
||||||
|
{% if _medii | length >= 1 %}
|
||||||
|
{% set _env = env_default | default('prod') %}
|
||||||
|
{% set _eb = eticheta_env(_env) %}
|
||||||
|
{% set _scurt = 'PROD' if _env == 'prod' else 'TEST' %}
|
||||||
|
{% if _medii | length >= 2 %}
|
||||||
|
<button type="button" class="{{ _eb[1] }}"
|
||||||
|
style="cursor:pointer;"
|
||||||
|
hx-post="/_fragments/status/toggle-env"
|
||||||
|
hx-target="#header-env-badge"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-vals='{"csrf_token": {{ csrf_token | tojson }}}'
|
||||||
|
title="Comuta la {{ 'Testare' if _env == 'prod' else 'PRODUCȚIE' }}">
|
||||||
|
<span class="env-tx-full">{{ _eb[0] }}</span>
|
||||||
|
<span class="env-tx-short">{{ _scurt }}</span>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="{{ _eb[1] }}">
|
||||||
|
<span class="env-tx-full">{{ _eb[0] }}</span>
|
||||||
|
<span class="env-tx-short">{{ _scurt }}</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
Lipseste o cheie API activa.
|
Lipseste o cheie API activa.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
Mergi la <a href="/?tab=cont">tab-ul Cont</a> pentru a le configura.
|
Mergi la <a href="/?tab=cont&subtab={{ 'rar' if not are_creds else 'api' }}">Meniu cont › Setari</a> pentru a le configura.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="muted" style="font-size:12px; margin:10px 0 0;">
|
<p class="muted" style="font-size:12px; margin:10px 0 0;">
|
||||||
Cererile trimit doar cheia API + datele prezentarii. Credentialele RAR se configureaza
|
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,
|
o data in <a href="/?tab=cont&subtab=rar">Setari</a> si sunt folosite automat la trimitere. Optional,
|
||||||
poti include <code>rar_credentials</code> in payload ca sa le suprascrii pe acea cerere.
|
poti include <code>rar_credentials</code> in payload ca sa le suprascrii pe acea cerere.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,6 +216,87 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Card: exemplu cerere + raspuns JSON pentru POST /v1/prezentari #}
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<h3 style="margin:0 0 6px; font-size:15px;">Cerere si raspuns JSON</h3>
|
||||||
|
<p class="muted" style="font-size:12px; margin:0 0 12px;">
|
||||||
|
<code>POST /v1/prezentari</code> — o prezentare cu doua operatii: una cu cod RAR direct
|
||||||
|
(<code>cod_prestatie</code>), una cu cod intern + denumire (mapata automat de gateway).
|
||||||
|
</p>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div style="flex:1 1 320px; min-width:0;">
|
||||||
|
<p style="font-size:12.5px; margin:0 0 4px;"><strong>Cerere</strong></p>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<button class="btn-copiaza" aria-label="Copiaza exemplu cerere JSON" 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);">{{ exemplu_json["cerere"] | 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 style="flex:1 1 320px; min-width:0;">
|
||||||
|
<p style="font-size:12.5px; margin:0 0 4px;"><strong>Raspuns 200</strong> <span class="muted">(forma implicita <code>minim</code>)</span></p>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<button class="btn-copiaza" aria-label="Copiaza exemplu raspuns JSON" 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);">{{ exemplu_json["raspuns"] | 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>
|
||||||
|
<ul class="muted" style="font-size:12px; margin:8px 0 0; padding-left:18px; line-height:1.6;">
|
||||||
|
<li>Obligatorii: <code>vin</code>, <code>nr_inmatriculare</code>, <code>data_prestatie</code> (YYYY-MM-DD), <code>odometru_final</code> (string), <code>prestatii</code> (min. 1).</li>
|
||||||
|
<li>Fiecare prestatie are <code>cod_prestatie</code> (cod RAR din <a href="/?tab=nomenclator">nomenclator</a>) SAU <code>cod_op_service</code> + <code>denumire</code> — gateway-ul le mapeaza la cod RAR.</li>
|
||||||
|
<li>Optionale: <code>odometru_initial</code>, <code>obs</code>; <code>rar_env</code> (<code>"test"</code> / <code>"prod"</code>) — absent = mediul implicit al contului.</li>
|
||||||
|
<li>Un rezultat per prezentare, in aceeasi ordine (<code>index</code> = pozitia din cerere). <code>status</code>: <code>queued</code> (in coada), <code>needs_mapping</code> / <code>needs_data</code> / <code>error</code> (blocat — vezi <code>motiv</code>).</li>
|
||||||
|
<li><code>id_prezentare</code> e completat doar cand randul era DEJA trimis la RAR (duplicat detectat); altfel apare ulterior, in dashboard sau la <code>GET /v1/prezentari/{id}</code>.</li>
|
||||||
|
<li>Vrei detalii complete (erori pe camp, coduri nemapate, <code>held</code>)? Trimite <code>"raspuns": "complet"</code> in body.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Card: De ce nu ajunge la RAR? (onestitate canal API) #}
|
||||||
|
{# Un POST /v1/prezentari poate intoarce 200 dar randul sa NU plece la RAR. #}
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<h3 style="margin:0 0 6px; font-size:15px;">De ce nu ajunge la RAR?</h3>
|
||||||
|
<p class="muted" style="font-size:12px; margin:0 0 12px;">
|
||||||
|
Un <code>POST /v1/prezentari</code> poate intoarce <strong>200</strong> cu
|
||||||
|
<code>status: "queued"</code>, dar randul sa NU plece automat la RAR. Cele trei motive:
|
||||||
|
</p>
|
||||||
|
<div class="banner warn" role="note" style="margin:0 0 12px; font-size:12.5px;">
|
||||||
|
<strong>Conturile noi pornesc cu Trimiterea automata OPRITA (Auto OFF).</strong>
|
||||||
|
Primul <code>POST</code> da 200/<code>queued</code>, dar randul e tinut
|
||||||
|
(<code>held=true</code>) si asteapta eliberare manuala. Verifica bifa
|
||||||
|
<em>„Trimite automat la RAR"</em> din dashboard.
|
||||||
|
</div>
|
||||||
|
<div class="tablewrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>Motiv</th>
|
||||||
|
<th>Ce inseamna</th>
|
||||||
|
<th>Cum deblochezi</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><span class="pill">held</span> <span class="muted">(Auto OFF)</span></td>
|
||||||
|
<td>Contul are Trimiterea automata OPRITA → randul e tinut
|
||||||
|
(<code>held=true</code>), NU pleaca la RAR.</td>
|
||||||
|
<td>Elibereaza manual din dashboard (butonul <em>Trimite</em> / <em>Trimite toate</em>)
|
||||||
|
sau activeaza Trimiterea automata. Verifici cu
|
||||||
|
<code>GET /v1/prezentari/{id}</code> (<code>held=true</code>).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="pill">needs_mapping</span></td>
|
||||||
|
<td>Cod operatie fara mapare la un cod RAR din nomenclator.</td>
|
||||||
|
<td>Intra in editorul de mapare din dashboard; la salvarea maparii randul
|
||||||
|
se re-rezolva automat.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="pill">needs_data</span></td>
|
||||||
|
<td>Validare de continut esuata (VIN / data prestatie / odometru).</td>
|
||||||
|
<td>Corectezi datele si repui prezentarea.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Card: Export & referinta #}
|
{# Card: Export & referinta #}
|
||||||
<div class="card" style="margin-bottom:16px;">
|
<div class="card" style="margin-bottom:16px;">
|
||||||
<h3 style="margin:0 0 12px; font-size:15px;">Export & referinta</h3>
|
<h3 style="margin:0 0 12px; font-size:15px;">Export & referinta</h3>
|
||||||
@@ -246,7 +327,7 @@
|
|||||||
<div id="integrare-test-rezultat" style="margin-top:8px;"></div>
|
<div id="integrare-test-rezultat" style="margin-top:8px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Formular test credentiale RAR (US-007, PRD 5.20) #}
|
{# Formular test credentiale RAR #}
|
||||||
{# Login de proba pe mediul ales — fara efecte secundare, nu salveaza nimic. #}
|
{# Login de proba pe mediul ales — fara efecte secundare, nu salveaza nimic. #}
|
||||||
{# Banner-ul de rezultat include eticheta mediului ("pe TESTARE" / "pe PRODUCTIE"). #}
|
{# Banner-ul de rezultat include eticheta mediului ("pe TESTARE" / "pe PRODUCTIE"). #}
|
||||||
<div class="card" style="margin-bottom:16px;">
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="muted" style="font-size:12px;">doar evenimentele contului tau</span>
|
<span class="muted" style="font-size:12px;">doar evenimentele contului tau</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Badge mediu RAR activ (US-010 PRD 5.20) — mediul implicit al contului #}
|
{# Badge mediu RAR activ — mediul implicit al contului #}
|
||||||
{% if env_default | default('') %}
|
{% if env_default | default('') %}
|
||||||
{% set _eb = eticheta_env(env_default) %}
|
{% set _eb = eticheta_env(env_default) %}
|
||||||
<span class="{{ _eb[1] }}" style="margin-left:auto;" title="Mediu RAR activ">{{ _eb[0] }}</span>
|
<span class="{{ _eb[1] }}" style="margin-left:auto;" title="Mediu RAR activ">{{ _eb[0] }}</span>
|
||||||
|
|||||||
8
app/web/templates/_lp_footer.html
Normal file
8
app/web/templates/_lp_footer.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{# Footer comun landing + pagini statice publice. #}
|
||||||
|
<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);flex-wrap:wrap;">
|
||||||
|
<a href="/termeni" style="color:inherit;">Termeni</a><a href="/confidentialitate" style="color:inherit;">Confidențialitate / GDPR</a><a href="/documentatie-api" style="color:inherit;">Documentație API</a><a href="https://romfast.ro/menu/contact.html" target="_blank" rel="noopener" style="color:inherit;">Contact</a>
|
||||||
|
</div>
|
||||||
|
<div style="font:400 13px var(--font-ui);color:var(--mut,#5c6473);">© 2026 ROMFAST</div>
|
||||||
|
</div>
|
||||||
27
app/web/templates/_lp_header.html
Normal file
27
app/web/templates/_lp_header.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{# Antet comun landing + pagini statice. `lp_root` = '' pe landing, '/' pe paginile statice
|
||||||
|
(ancorele si butoanele de auth duc inapoi pe landing). #}
|
||||||
|
{% set lp_root = lp_root | default('') %}
|
||||||
|
<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;">
|
||||||
|
<a href="/" style="display:flex;align-items:center;gap:14px;color:inherit;">
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
</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="{{ lp_root }}#cum-functioneaza" style="color:inherit;text-decoration:none;">Cum funcționează</a><a href="{{ lp_root }}#api" style="color:inherit;text-decoration:none;">API</a><a href="{{ lp_root }}#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>
|
||||||
51
app/web/templates/_lp_style.html
Normal file
51
app/web/templates/_lp_style.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{# Stiluri comune landing + pagini statice publice (termeni, confidentialitate, contact, documentatie API). #}
|
||||||
|
<style>
|
||||||
|
|
||||||
|
/* 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}
|
||||||
|
/* Teme aliniate cu aplicatia (base.html) — acelasi set de 8; 'auto' se rezolva la light/dark. */
|
||||||
|
body[data-theme="light"]{--bg:#f5f7fa;--card:#ffffff;--card2:#f5f7fa;--text:#1a1d24;--sub:#5c6473;--line:#e2e5ea;--line2:#eaedf2;--accent:#1F66C9;--hbg:rgba(245,247,250,.9);--okt:#15803d;--infot:#1F66C9;--errt:#dc2626;--mut:#8a92a0}
|
||||||
|
body[data-theme="dark"]{--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="petrol"]{--bg:#0e1416;--card:#161e20;--card2:#0e1416;--text:#e6e9ef;--sub:#8b93a7;--line:#232c2e;--line2:#1c2426;--accent:#0E7C7B;--hbg:rgba(14,20,22,.9);--okt:#2FBF8F;--infot:#3fb9b6;--errt:#E05D5D;--mut:#5c6473}
|
||||||
|
.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>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{# Macro-uri partajate intre template-urile de import si mapari. #}
|
{# 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).
|
{# autosend_toggle neutralizat — auto_send nu mai tine randuri.
|
||||||
Simbolul pastrat (apelat in _mapari.html, _preview_import.html, _trimitere_detaliu.html)
|
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). #}
|
dar intoarce string gol. Coloanele DB raman (default 1, ne-citite pentru hold). #}
|
||||||
{% macro autosend_toggle(form_id='', checked=True, label='') -%}{%- endmacro %}
|
{% macro autosend_toggle(form_id='', checked=True, label='') -%}{%- endmacro %}
|
||||||
|
|
||||||
{# US-005 (PRD 5.12): macro `camp` partajat — extras din _trimitere_detaliu.html si
|
{# macro `camp` partajat — extras din _trimitere_detaliu.html si
|
||||||
_preview_rand.html. Suporta tip='date' (calendar nativ, D#10/R3) si tip='text' (default).
|
_preview_rand.html. Suporta tip='date' (calendar nativ) si tip='text' (default).
|
||||||
|
|
||||||
Parametri:
|
Parametri:
|
||||||
nome — name="" al input-ului (si cheie in err_map/fix_map)
|
nome — name="" al input-ului (si cheie in err_map/fix_map)
|
||||||
@@ -19,14 +19,14 @@
|
|||||||
id_prefix — prefix pentru id="" al input-ului (default 'c'; preview poate folosi 'e-N')
|
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) %}
|
{% 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:
|
{# slim=False: randare clasica (neschimbata). slim=True: varianta compacta (.camp-slim):
|
||||||
label 11px muted deasupra, input ~30px, fundal --card2.
|
label 11px muted deasupra, input ~30px, fundal --card2.
|
||||||
mono=True (valid numai cu slim=True): adauga clasa 'camp-mono' pe input pentru campuri
|
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). #}
|
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 %}>
|
<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>
|
<label for="{{ id_prefix }}-{{ nome }}"{% if not slim %} class="muted" style="font-size:12px; display:block;"{% endif %}>{{ eticheta }}</label>
|
||||||
{% if tip == 'date' %}
|
{% if tip == 'date' %}
|
||||||
{# D#10/R3: degradare grijulie pentru valori ne-YYYY-MM-DD.
|
{# Degradare grijulie pentru valori ne-YYYY-MM-DD.
|
||||||
Daca valoarea nu e in formatul corect, inputul ramane gol + hint + hidden cu valoarea bruta
|
Daca valoarea nu e in formatul corect, inputul ramane gol + hint + hidden cu valoarea bruta
|
||||||
(ca sa nu se piarda tacut la submit). #}
|
(ca sa nu se piarda tacut la submit). #}
|
||||||
{%- set _dp_ok = (valoare and valoare|length == 10 and valoare[4:5] == '-' and valoare[7:8] == '-') -%}
|
{%- set _dp_ok = (valoare and valoare|length == 10 and valoare[4:5] == '-' and valoare[7:8] == '-') -%}
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{# PRD 5.13 — sistem butoane de actiune responsive.
|
{# Sistem butoane de actiune responsive.
|
||||||
CSS-ul aferent (.act, .act-tx, .act-ic, .act-save, .act-del, .act-group)
|
CSS-ul aferent (.act, .act-tx, .act-ic, .act-save, .act-del, .act-group)
|
||||||
este definit in base.html.
|
este definit in base.html.
|
||||||
Desktop: se afiseaza textul (.act-tx); mobil: se afiseaza iconita (.act-ic). #}
|
Desktop: se afiseaza textul (.act-tx); mobil: se afiseaza iconita (.act-ic). #}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
|
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="muted" style="font-size:12px;" data-eticheta="Sugestii">
|
<td class="muted" style="font-size:12px;" data-eticheta="Sugestii">
|
||||||
{# 5.18 US-007: badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
|
{# Badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
|
||||||
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
|
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
|
||||||
{% if e.sugestie_principala %}
|
{% if e.sugestie_principala %}
|
||||||
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
|
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
|
||||||
@@ -62,10 +62,17 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
|
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# Codul sugerat de sistem, explicit (nu doar preselectat in dropdown);
|
||||||
|
pentru k-NN si scorul de similaritate. #}
|
||||||
|
<strong>{{ e.sugestie_principala.cod_prestatie }}</strong>
|
||||||
|
{%- if e.sugestie_principala.sursa == 'embedding' and e.surse_sugestie.embedding_similaritate %}
|
||||||
|
({{ (e.surse_sugestie.embedding_similaritate * 100)|round|int }}%)
|
||||||
|
{%- endif %}
|
||||||
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
|
{% 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>
|
<span class="sugg-sursa sugg-sursa--nul" title="pare non-operatie (ITP/plata/discount...)">non-operatie</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if e.suggestions %}
|
{% if e.suggestions %}
|
||||||
|
{% if e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul) %}<span class="muted">· potrivire text:</span>{% endif %}
|
||||||
{% for s in e.suggestions[:3] %}
|
{% for s in e.suggestions[:3] %}
|
||||||
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
|
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -75,6 +82,7 @@
|
|||||||
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
|
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
|
||||||
aria-label="Cod RAR pentru {{ e.cod_op_service }}">
|
aria-label="Cod RAR pentru {{ e.cod_op_service }}">
|
||||||
<option value="">— alege cod RAR —</option>
|
<option value="">— alege cod RAR —</option>
|
||||||
|
<option value="__NEDECLARAT__">Nu se declara la RAR (exclude operatia)</option>
|
||||||
{% for n in nomenclator %}
|
{% for n in nomenclator %}
|
||||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
|
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
|
||||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||||
@@ -122,7 +130,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for m in saved_mappings %}
|
{% for m in saved_mappings %}
|
||||||
{# data-dt-row = haystack de cautare (randul contine un <select> cu tot nomenclatorul). #}
|
{# 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 '' }}">
|
<tr data-dt-row="{{ m.cod_op_service }} {{ m.cod_prestatie }} {{ m.nume_prestatie or '' }}{% if m.exclus %} nedeclarat exclus{% endif %}">
|
||||||
<td data-eticheta="Operatie">
|
<td data-eticheta="Operatie">
|
||||||
<form id="map-salv-{{ loop.index }}" hx-post="/mapari/salvate" hx-target="#mapari-section" hx-swap="outerHTML">
|
<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="csrf_token" value="{{ csrf_token or '' }}">
|
||||||
@@ -135,14 +143,16 @@
|
|||||||
</form>
|
</form>
|
||||||
<div><strong>{{ m.cod_op_service }}</strong></div>
|
<div><strong>{{ m.cod_op_service }}</strong></div>
|
||||||
<div class="muted map-acum" style="font-size:12px;">
|
<div class="muted map-acum" style="font-size:12px;">
|
||||||
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
|
{% if m.exclus %}acum: <span class="pill s-excluded" style="font-size:11px;">Nu se declara la RAR</span>
|
||||||
|
{% else %}acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-eticheta="Cod RAR">
|
<td data-eticheta="Cod RAR">
|
||||||
<select name="cod_prestatie" form="map-salv-{{ loop.index }}" required
|
<select name="cod_prestatie" form="map-salv-{{ loop.index }}" required
|
||||||
aria-label="Cod RAR pentru {{ m.cod_op_service }}">
|
aria-label="Cod RAR pentru {{ m.cod_op_service }}">
|
||||||
|
<option value="__NEDECLARAT__" {% if m.exclus %}selected{% endif %}>Nu se declara la RAR (exclude operatia)</option>
|
||||||
{% for n in nomenclator %}
|
{% for n in nomenclator %}
|
||||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
|
<option value="{{ n.cod_prestatie }}" {% if not m.exclus and n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
|
||||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -171,18 +181,19 @@
|
|||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2>
|
<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;">
|
{# Un exemplu concret in loc de descriere — arata direct formatul unei reguli. #}
|
||||||
O regula leaga orice operatie al carei text <strong>contine</strong> (nu egal, ci substring)
|
<div class="regula-exemplu"
|
||||||
un cuvant de un cod RAR. Util pentru operatii fara cod intern: ex. orice operatie care
|
style="margin:0 0 12px; font-size:13.5px; max-width:680px; padding:10px 12px;
|
||||||
<em>contine</em> „verificare" primeste codul ales. Match insensibil la majuscule/diacritice.
|
border:1px dashed var(--line); border-radius:8px; background:var(--card2);">
|
||||||
</p>
|
Daca operatia contine
|
||||||
|
<span style="font-family:var(--font-mono); font-size:12.5px; padding:1px 6px; border-radius:4px;
|
||||||
{% if not text_rules %}
|
background:color-mix(in srgb, var(--warn) 18%, transparent); color:var(--warn);">verificare</span>
|
||||||
<div class="empty" style="margin-bottom:12px;">
|
<span class="muted" aria-hidden="true">→</span>
|
||||||
Inca nu ai reguli. Ex: operatia contine «verificare» → OE-2.
|
Cod RAR =
|
||||||
Mapeaza automat operatii similare fara cod intern. Adauga prima regula mai jos.
|
<span style="font-family:var(--font-mono); font-size:12.5px; font-weight:700; padding:1px 6px; border-radius:4px;
|
||||||
|
background:color-mix(in srgb, var(--accent) 16%, transparent); color:var(--accent);">OE-2</span>
|
||||||
|
Intretinere si revizie
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="tablewrap tabel-card">
|
<div class="tablewrap tabel-card">
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -65,7 +65,8 @@
|
|||||||
|
|
||||||
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
|
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
|
||||||
hx-target="#import-section"
|
hx-target="#import-section"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator="#mapcol-spinner">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||||
|
|
||||||
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Tabel mapare: coloana din fisier | exemplu | camp RAR (mockup 5.16 / US-013) #}
|
{# Tabel mapare: coloana din fisier | exemplu | camp RAR #}
|
||||||
<div class="tablewrap" style="margin-bottom:16px;">
|
<div class="tablewrap" style="margin-bottom:16px;">
|
||||||
<table style="border-collapse:collapse; width:100%;">
|
<table style="border-collapse:collapse; width:100%;">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -162,6 +163,11 @@
|
|||||||
style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}">
|
style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}">
|
||||||
Salveaza si continua la preview
|
Salveaza si continua la preview
|
||||||
</button>
|
</button>
|
||||||
|
<span id="mapcol-spinner" class="htmx-indicator muted"
|
||||||
|
style="font-size:var(--fs-sm);" role="status">
|
||||||
|
<span class="spin-dot" aria-hidden="true"></span>
|
||||||
|
se verifica randurile si se calculeaza sugestiile...
|
||||||
|
</span>
|
||||||
{% if not prima_inreg %}
|
{% if not prima_inreg %}
|
||||||
<span style="font-size:var(--fs-xs); color:var(--err);">
|
<span style="font-size:var(--fs-xs); color:var(--err);">
|
||||||
Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare.
|
Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare.
|
||||||
|
|||||||
67
app/web/templates/_modal_confirma_trimitere.html
Normal file
67
app/web/templates/_modal_confirma_trimitere.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{#
|
||||||
|
_modal_confirma_trimitere.html — modal REAL de confirmare tipata.
|
||||||
|
|
||||||
|
`hx-confirm` nativ (doar OK/Cancel) NU e suficient pentru o actiune ireversibila care
|
||||||
|
trimite N prezentari catre RAR. Acest modal arata numarul + mediul tinta (Testare /
|
||||||
|
PRODUCTIE) si — pentru PRODUCTIE — cere o confirmare tipata ("TRIMIT") inainte de a
|
||||||
|
activa butonul. Reutilizat din:
|
||||||
|
- _auto_send_toggle.html (auto-release OFF->ON), injectat OOB in #modal-confirma-slot;
|
||||||
|
- _coada.html (bulk "Trimite toate (N)"), randat inline ascuns si aratat la click.
|
||||||
|
|
||||||
|
Parametri (context):
|
||||||
|
cf_id, cf_titlu, cf_count, cf_env ('prod'/'test'), cf_url, cf_vals (json string),
|
||||||
|
cf_target, cf_swap, cf_confirm_label, cf_deschis (bool), csrf_token.
|
||||||
|
#}
|
||||||
|
{% set _prod = (cf_env == 'prod') %}
|
||||||
|
{% set _eb = eticheta_env(cf_env) %}
|
||||||
|
<div id="{{ cf_id }}" class="modal-overlay" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="{{ cf_id }}-titlu"{% if not cf_deschis %} hidden{% endif %}>
|
||||||
|
<div class="modal-backdrop"
|
||||||
|
onclick="this.closest('.modal-overlay').hidden=true"></div>
|
||||||
|
<div class="modal-dialog" role="document" style="max-width:440px;">
|
||||||
|
<button type="button" class="modal-close" aria-label="Anuleaza"
|
||||||
|
onclick="this.closest('.modal-overlay').hidden=true">×</button>
|
||||||
|
<h2 id="{{ cf_id }}-titlu" style="margin:0 0 10px; font-size:var(--fs-lg);">{{ cf_titlu }}</h2>
|
||||||
|
<p style="margin:0 0 8px; font-size:var(--fs-sm);">
|
||||||
|
Se trimit imediat <strong>{{ cf_count }}</strong>
|
||||||
|
{{ 'prezentare' if cf_count == 1 else 'prezentari' }} catre
|
||||||
|
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 12px; font-size:var(--fs-sm); color:var(--warn); font-weight:600;">
|
||||||
|
Declararea la RAR este ireversibila (FINALIZATA nu se poate anula).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if _prod %}
|
||||||
|
{# Type-to-confirm DOAR pe PRODUCTIE: butonul ramane dezactivat pana se scrie TRIMIT. #}
|
||||||
|
<label for="{{ cf_id }}-typ" style="display:block; font-size:var(--fs-xs); color:var(--muted); margin-bottom:4px;">
|
||||||
|
Scrie <strong>TRIMIT</strong> pentru a confirma:
|
||||||
|
</label>
|
||||||
|
<input type="text" id="{{ cf_id }}-typ" autocomplete="off" autocapitalize="characters"
|
||||||
|
style="width:100%; margin-bottom:12px; font-size:14px; padding:5px 8px;
|
||||||
|
border:1px solid var(--line); border-radius:5px; background:var(--card2); color:var(--ink);"
|
||||||
|
oninput="document.getElementById('{{ cf_id }}-ok').disabled = (this.value.trim().toUpperCase() !== 'TRIMIT');">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap;">
|
||||||
|
<button type="button"
|
||||||
|
onclick="this.closest('.modal-overlay').hidden=true"
|
||||||
|
style="background:var(--card); color:var(--muted); border:1px solid var(--line);
|
||||||
|
padding:6px 14px; border-radius:6px; cursor:pointer; font-size:13px;">
|
||||||
|
Anuleaza
|
||||||
|
</button>
|
||||||
|
<button type="button" id="{{ cf_id }}-ok"{% if _prod %} disabled{% endif %}
|
||||||
|
hx-post="{{ cf_url }}"
|
||||||
|
hx-vals='{{ cf_vals }}'
|
||||||
|
hx-target="{{ cf_target }}"
|
||||||
|
hx-swap="{{ cf_swap }}"
|
||||||
|
hx-include="#{{ cf_id }}-csrf"
|
||||||
|
onclick="this.closest('.modal-overlay').hidden=true"
|
||||||
|
style="background:{% if _prod %}var(--err){% else %}var(--accent){% endif %};
|
||||||
|
color:#fff; border:none; padding:6px 14px; border-radius:6px;
|
||||||
|
cursor:pointer; font-size:13px; font-weight:600;">
|
||||||
|
{{ cf_confirm_label }}
|
||||||
|
</button>
|
||||||
|
<input type="hidden" id="{{ cf_id }}-csrf" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
Preview —
|
Preview —
|
||||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
{# Badge mediu RAR (US-009): vizibil intotdeauna in preview (claritate tinta) #}
|
{# Badge mediu RAR: vizibil intotdeauna in preview (claritate tinta) #}
|
||||||
{% if rar_env %}
|
{% if rar_env %}
|
||||||
<span class="pill" style="font-size:var(--fs-xs);
|
<span class="pill" style="font-size:var(--fs-xs);
|
||||||
{% if rar_env == 'prod' %}background:color-mix(in srgb,#B4452F 15%,var(--card)); border-color:#B4452F; color:#B4452F; font-weight:700;
|
{% if rar_env == 'prod' %}background:color-mix(in srgb,#B4452F 15%,var(--card)); border-color:#B4452F; color:#B4452F; font-weight:700;
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
('needs_data', 'Date incomplete'),
|
('needs_data', 'Date incomplete'),
|
||||||
('already_sent', 'Deja trimis'),
|
('already_sent', 'Deja trimis'),
|
||||||
('duplicate_in_file','Duplicat in fisier'),
|
('duplicate_in_file','Duplicat in fisier'),
|
||||||
|
('excluded', 'Nedeclarat'),
|
||||||
] %}
|
] %}
|
||||||
<div id="preview-rezumat" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
|
<div id="preview-rezumat" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
|
||||||
{% for status_key, label in status_labels %}
|
{% for status_key, label in status_labels %}
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload).
|
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload).
|
||||||
US-004: un singur <form> cu un select per operatie + un singur buton Salveaza. -->
|
Un singur <form> cu un select per operatie + un singur buton Salveaza. -->
|
||||||
{% if unmapped_ops %}
|
{% if unmapped_ops %}
|
||||||
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
|
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
|
||||||
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
|
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
|
||||||
@@ -81,7 +82,8 @@
|
|||||||
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
|
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
|
||||||
</p>
|
</p>
|
||||||
<form hx-post="/_import/{{ import_id }}/mapare-operatii"
|
<form hx-post="/_import/{{ import_id }}/mapare-operatii"
|
||||||
hx-target="#import-section" hx-swap="outerHTML">
|
hx-target="#import-section" hx-swap="outerHTML"
|
||||||
|
hx-indicator="#mapop-spinner">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||||
{% for e in unmapped_ops %}
|
{% for e in unmapped_ops %}
|
||||||
{%- set top = e.suggestions[0] if e.suggestions else None -%}
|
{%- set top = e.suggestions[0] if e.suggestions else None -%}
|
||||||
@@ -98,7 +100,7 @@
|
|||||||
{% if e.suggestions or e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul) %}
|
{% if e.suggestions or e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul) %}
|
||||||
<div class="muted" style="font-size:12px; margin-top:4px;">
|
<div class="muted" style="font-size:12px; margin-top:4px;">
|
||||||
sugestii:
|
sugestii:
|
||||||
{# 5.18 US-007: badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
|
{# Badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
|
||||||
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
|
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
|
||||||
{% if e.sugestie_principala %}
|
{% if e.sugestie_principala %}
|
||||||
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
|
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
|
||||||
@@ -106,9 +108,16 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
|
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# Codul sugerat de sistem, explicit (nu doar preselectat in dropdown);
|
||||||
|
pentru k-NN si scorul de similaritate. #}
|
||||||
|
<strong>{{ e.sugestie_principala.cod_prestatie }}</strong>
|
||||||
|
{%- if e.sugestie_principala.sursa == 'embedding' and e.surse_sugestie.embedding_similaritate %}
|
||||||
|
({{ (e.surse_sugestie.embedding_similaritate * 100)|round|int }}%)
|
||||||
|
{%- endif %}
|
||||||
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
|
{% 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>
|
<span class="sugg-sursa sugg-sursa--nul" title="pare non-operatie (ITP/plata/discount...)">non-operatie</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if e.suggestions and (e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul)) %}<span class="muted">· potrivire text:</span>{% endif %}
|
||||||
{% for s in e.suggestions[:3] %}
|
{% for s in e.suggestions[:3] %}
|
||||||
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
|
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -118,6 +127,7 @@
|
|||||||
<div class="mapcol">
|
<div class="mapcol">
|
||||||
<select name="cod_prestatie" aria-label="Cod RAR pentru {{ e.cod_op_service }}">
|
<select name="cod_prestatie" aria-label="Cod RAR pentru {{ e.cod_op_service }}">
|
||||||
<option value="">— alege cod RAR —</option>
|
<option value="">— alege cod RAR —</option>
|
||||||
|
<option value="__NEDECLARAT__">Nu se declara la RAR (exclude operatia)</option>
|
||||||
{% for n in nomenclator %}
|
{% for n in nomenclator %}
|
||||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
|
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
|
||||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||||
@@ -127,14 +137,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div style="margin-top:12px;">
|
<div style="margin-top:12px; display:flex; align-items:center; gap:12px;">
|
||||||
<button type="submit" style="min-height:44px;">Salveaza maparile</button>
|
<button type="submit" style="min-height:44px;">Salveaza maparile</button>
|
||||||
|
<span id="mapop-spinner" class="htmx-indicator muted"
|
||||||
|
style="font-size:var(--fs-sm);" role="status">
|
||||||
|
<span class="spin-dot" aria-hidden="true"></span>
|
||||||
|
se salveaza si se recalculeaza preview-ul...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Banner discoverability (T1, US-007): vizibil cand exista randuri needs_review.
|
<!-- Banner discoverability: vizibil cand exista randuri needs_review.
|
||||||
Explica operatorului ca randurile cu 'Verifica valori' nu pleaca la RAR
|
Explica operatorului ca randurile cu 'Verifica valori' nu pleaca la RAR
|
||||||
pana le deschide in modal si apasa 'Confirma valorile'. Dispare via OOB
|
pana le deschide in modal si apasa 'Confirma valorile'. Dispare via OOB
|
||||||
cand summary.needs_review == 0. -->
|
cand summary.needs_review == 0. -->
|
||||||
@@ -213,7 +228,7 @@
|
|||||||
hx-target="#import-section"
|
hx-target="#import-section"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||||
{# Mediu RAR ales la upload — propagar la commit (US-009) #}
|
{# Mediu RAR ales la upload — propagar la commit #}
|
||||||
<input type="hidden" name="rar_env" value="{{ rar_env or '' }}">
|
<input type="hidden" name="rar_env" value="{{ rar_env or '' }}">
|
||||||
<div class="sticky-bar">
|
<div class="sticky-bar">
|
||||||
<div style="flex:1; min-width:280px;">
|
<div style="flex:1; min-width:280px;">
|
||||||
@@ -285,7 +300,7 @@ din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Actualizeaza N dupa editare/confirmare rand (OOB).
|
/* Actualizeaza N dupa editare/confirmare rand (OOB).
|
||||||
US-007: reviewed_rows (checkboxe) eliminate; N = randurile ok din DB,
|
reviewed_rows (checkboxe) eliminate; N = randurile ok din DB,
|
||||||
actualizate via OOB (#preview-ok-count[data-ok]) dupa /confirma-review sau /editeaza. */
|
actualizate via OOB (#preview-ok-count[data-ok]) dupa /confirma-review sau /editeaza. */
|
||||||
function updateN() {
|
function updateN() {
|
||||||
var total = getOk();
|
var total = getOk();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{#
|
{#
|
||||||
_preview_rand.html — un singur rand de preview import.
|
_preview_rand.html — un singur rand de preview import.
|
||||||
US-006 (PRD 5.12): editarea inline (tr.preview-edit + mutual-exclusion script)
|
Editarea inline (tr.preview-edit + mutual-exclusion script) a fost eliminata.
|
||||||
a fost eliminata. Butonul Editeaza deschide MODALUL global (#detaliu-modal-body).
|
Butonul Editeaza deschide MODALUL global (#detaliu-modal-body).
|
||||||
|
|
||||||
Parametri:
|
Parametri:
|
||||||
editing — ELIMINAT (ignorat, pastrat pentru compatibilitate apeluri vechi)
|
editing — ELIMINAT (ignorat, pastrat pentru compatibilitate apeluri vechi)
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<span class="pill {{ row.stare_css }}" style="display:inline-flex; align-items:center; gap:5px;"
|
<span class="pill {{ row.stare_css }}" style="display:inline-flex; align-items:center; gap:5px;"
|
||||||
{% if _nota %}title="{{ _nota }}"{% endif %}>
|
{% 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>
|
<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>
|
||||||
{# Badge mediu RAR (US-010 PRD 5.20) — rar_env disponibil din contextul batch-ului. #}
|
{# Badge mediu RAR — rar_env disponibil din contextul batch-ului. #}
|
||||||
{% if rar_env | default('') %}
|
{% if rar_env | default('') %}
|
||||||
{% set _eb = eticheta_env(rar_env) %}
|
{% set _eb = eticheta_env(rar_env) %}
|
||||||
<div style="margin-top:3px;"><span class="{{ _eb[1] }}">{{ _eb[0] }}</span></div>
|
<div style="margin-top:3px;"><span class="{{ _eb[1] }}">{{ _eb[0] }}</span></div>
|
||||||
|
|||||||
@@ -14,10 +14,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# US-006 (5.17) — Banner one-time trial->Gratuit (T-DES-1): afisat la prima incarcare
|
{# Banner coada tinuta imbatranita — mitigare pt. default OFF.
|
||||||
|
Cand exista randuri `queued AND held=1` mai vechi de held_aged_days, semnalul de conformitate
|
||||||
|
L.142 (declarare obligatorie). Derivat (zero stare noua), varsta pe created_at.
|
||||||
|
Prioritate bannere (un-singur-banner "notice", ca sa nu impinga contoarele sub fold pe
|
||||||
|
mobil): cont inactiv (sus) + strip RAR jos raman separate; intre notice-uri, held-aged >
|
||||||
|
trial (risc legal > notita trial) -> trial e suprimat cand held-aged e activ. #}
|
||||||
|
{% set _held_aged_activ = (held_aged_count|default(0) > 0) and account_active %}
|
||||||
|
{% if _held_aged_activ %}
|
||||||
|
<div id="banner-held-aged" role="alert"
|
||||||
|
style="margin-bottom:10px; padding:8px 12px; border-left:3px solid var(--warn);
|
||||||
|
background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px;
|
||||||
|
font-size:var(--fs-sm); display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
|
||||||
|
<span><strong>{{ held_aged_count }}</strong>
|
||||||
|
{{ 'prezentare tinuta' if held_aged_count == 1 else 'prezentari tinute' }}
|
||||||
|
de peste {{ held_aged_days }} zile — declarare obligatorie (L.142).</span>
|
||||||
|
<button type="button"
|
||||||
|
hx-get="/_fragments/submissions?held=1"
|
||||||
|
hx-target="#submissions-wrap" hx-swap="innerHTML"
|
||||||
|
style="margin-left:auto; padding:2px 10px; font-size:var(--fs-xs); border-radius:99px;
|
||||||
|
border:1px solid var(--warn); background:transparent; color:var(--warn);
|
||||||
|
cursor:pointer; font-weight:600;">
|
||||||
|
Vezi tinute
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Banner one-time trial->Gratuit: afisat la prima incarcare
|
||||||
dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage.
|
dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage.
|
||||||
Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #}
|
Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #}
|
||||||
{% if trial_expirat_recent|default(false) %}
|
{% if trial_expirat_recent|default(false) and not _held_aged_activ %}
|
||||||
<div id="banner-trial-expirat"
|
<div id="banner-trial-expirat"
|
||||||
role="status"
|
role="status"
|
||||||
style="margin-bottom:10px; padding:7px 12px;
|
style="margin-bottom:10px; padding:7px 12px;
|
||||||
@@ -34,7 +60,7 @@
|
|||||||
<script>(function(){ if(sessionStorage.getItem('tfx')){ var el=document.getElementById('banner-trial-expirat'); if(el) el.style.display='none'; } })();</script>
|
<script>(function(){ if(sessionStorage.getItem('tfx')){ var el=document.getElementById('banner-trial-expirat'); if(el) el.style.display='none'; } })();</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === US-003 (PRD 5.16): Banda de stare RAR — NUMAI cand BLOCAT (rosu, lat de 100%).
|
{# === 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.
|
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,
|
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.
|
astfel "hidden" + fara continut eroare in sursa = nu pica testele de prezenta id-ului.
|
||||||
@@ -58,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === US-002 (PRD 5.16): 5 carduri-contor separate (desktop) + bara compacta (mobil <=560px).
|
{# === 5 carduri-contor separate (desktop) + bara compacta (mobil <=560px).
|
||||||
Total / Luna asta / Azi / In coada / De corectat.
|
Total / Luna asta / Azi / In coada / De corectat.
|
||||||
#}
|
#}
|
||||||
{# Desktop: 5 carduri side-by-side. display:flex + layout stau in CSS (.contoare-desktop in
|
{# Desktop: 5 carduri side-by-side. display:flex + layout stau in CSS (.contoare-desktop in
|
||||||
@@ -83,10 +109,17 @@
|
|||||||
<div class="contor-label">Azi</div>
|
<div class="contor-label">Azi</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# In coada #}
|
{# In coada — contorul "In asteptare (manual)" se pliaza aici: nu adaugam a 6-a
|
||||||
|
celula, ci o sub-linie amber cand exista randuri tinute (queued AND held=1). #}
|
||||||
<div class="contor-card" style="flex:1; min-width:80px;">
|
<div class="contor-card" style="flex:1; min-width:80px;">
|
||||||
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
|
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
|
||||||
<div class="contor-label">In coada</div>
|
<div class="contor-label">In coada</div>
|
||||||
|
{% if held_manual_count|default(0) %}
|
||||||
|
<div class="contor-sub" style="font-size:var(--fs-xs); color:var(--warn); font-weight:600; margin-top:2px;"
|
||||||
|
title="Prezentari tinute manual — asteapta eliberarea ta">
|
||||||
|
{{ held_manual_count }} manual
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
|
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
|
||||||
@@ -115,7 +148,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="compact-item">
|
<div class="compact-item">
|
||||||
<div class="compact-nr s-queued">{{ counts_queued }}</div>
|
<div class="compact-nr s-queued">{{ counts_queued }}</div>
|
||||||
<div class="compact-lbl">Coada</div>
|
<div class="compact-lbl">Coada{% if held_manual_count|default(0) %} <span style="color:var(--warn); font-weight:700;">·{{ held_manual_count }}m</span>{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="compact-item" href="/" style="text-decoration:none; color:inherit;">
|
<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-nr {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
|
||||||
@@ -123,45 +156,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# === US-011 (5.20): Indicator mediu RAR activ per cont.
|
|
||||||
La 0 medii: nu afisa (cont fara configuratie RAR).
|
|
||||||
La 1 mediu: eticheta statica (fara toggle).
|
|
||||||
La 2 medii: buton de comutare HTMX (toggle conditionat).
|
|
||||||
US-010: culori prin eticheta_env/clase .env-badge-* (consistenta cu badge-urile din liste).
|
|
||||||
#}
|
|
||||||
{% set _medii = medii_disponibile | default([]) %}
|
|
||||||
{% set _env = env_default | default('prod') %}
|
|
||||||
{% set _eb = eticheta_env(_env) %}
|
|
||||||
{% if _medii | length >= 1 %}
|
|
||||||
<div class="env-indicator"
|
|
||||||
style="display:flex; align-items:center; gap:8px; margin-bottom:10px;
|
|
||||||
padding:6px 10px; border-radius:6px; font-size:var(--fs-sm);
|
|
||||||
background:color-mix(in srgb, {% if _env == 'prod' %}var(--err){% else %}var(--line){% endif %} 10%, var(--card));
|
|
||||||
border:1px solid color-mix(in srgb, {% if _env == 'prod' %}var(--err){% else %}var(--line){% endif %} 30%, transparent);">
|
|
||||||
<span style="font-weight:600;">Mediu RAR:</span>
|
|
||||||
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>
|
|
||||||
{% if _medii | length >= 2 %}
|
|
||||||
{# Toggle: apare DOAR cand sunt cel putin 2 medii disponibile #}
|
|
||||||
<form method="post" action="/_fragments/status/toggle-env"
|
|
||||||
hx-post="/_fragments/status/toggle-env"
|
|
||||||
hx-target="#status-bar"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
style="margin:0; padding:0; display:inline;">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token | default('') }}">
|
|
||||||
<button type="submit"
|
|
||||||
style="padding:2px 10px; font-size:var(--fs-xs); border-radius:99px;
|
|
||||||
border:1px solid currentColor; background:transparent; cursor:pointer;
|
|
||||||
color:var(--muted); line-height:1.4;"
|
|
||||||
title="Comuta la {{ 'PRODUCȚIE' if _env == 'test' else 'Testare' }}">
|
|
||||||
Comuta
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping ===
|
{# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping ===
|
||||||
Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ.
|
Pastrata exact ca inainte: tab_activ determina marcajul activ.
|
||||||
#}
|
#}
|
||||||
{% set _tab = tab_activ | default('acasa') %}
|
{% set _tab = tab_activ | default('acasa') %}
|
||||||
<nav class="status-nav" aria-label="Navigatie rapida"
|
<nav class="status-nav" aria-label="Navigatie rapida"
|
||||||
@@ -174,7 +170,7 @@
|
|||||||
class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a>
|
class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{# US-006 (5.17) + T-6 (5.16): linia de plan in CORP apare DOAR in starea de avertizare
|
{# Linia de plan in CORP apare DOAR in starea de avertizare
|
||||||
(>=80% -> --warn; limita atinsa -> --err). Consumul normal (N/60) traieste in badge-ul
|
(>=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).
|
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). #}
|
Ierarhie: nu concureaza cu stripul de sanatate (zero-silent-failures pastrat). #}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{#
|
{#
|
||||||
_stepper.html — Antet wizard import COMPACT (PUR vizual). PRD 5.13.
|
_stepper.html — Antet wizard import COMPACT (PUR vizual).
|
||||||
Parametru: `pas` (integer 1-4). Clasele .stepper-* sunt definite in base.html.
|
Parametru: `pas` (integer 1-4). Clasele .stepper-* sunt definite in base.html.
|
||||||
>=1024px: bara slim orizontala (.stepper-track). <1024px: forma colapsata
|
>=1024px: bara slim orizontala (.stepper-track). <1024px: forma colapsata
|
||||||
"Pasul N din 4 - Titlu" + bara de progres (.stepper-collapsed).
|
"Pasul N din 4 - Titlu" + bara de progres (.stepper-collapsed).
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
#}
|
#}
|
||||||
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
|
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
|
||||||
|
|
||||||
|
{# OOB: sincronizeaza filtrul "doar tinute" (#f-held) cu starea randata acum, ca reincarcarile
|
||||||
|
(trimiteriChanged / paginare) sa-l pastreze in loc sa revina la lista completa. #}
|
||||||
|
<input type="hidden" id="f-held" name="held" value="{{ f_held | default('') }}" hx-swap-oob="true">
|
||||||
|
|
||||||
|
{# OOB: banner bulk "Trimite toate (N)" re-randat la fiecare reincarcare a listei (fix staleness). #}
|
||||||
|
{% with banner_oob = true %}{% include '_bulk_held_banner.html' %}{% endwith %}
|
||||||
|
|
||||||
{# OOB: re-randeaza pill-urile de stare (in bara de filtre, in afara #submissions-wrap) cu
|
{# 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. #}
|
contoarele si starea activa proaspete la fiecare reincarcare a tabelului. #}
|
||||||
<span hx-swap-oob="innerHTML:#pills-categorii">{% include '_pills.html' %}</span>
|
<span hx-swap-oob="innerHTML:#pills-categorii">{% include '_pills.html' %}</span>
|
||||||
@@ -13,7 +20,7 @@
|
|||||||
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
|
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
|
||||||
|
|
||||||
{% if bulk_message %}
|
{% if bulk_message %}
|
||||||
{# Sumar actiune bulk (US-010 PRD 5.15): afisat dupa bulk-fix, disparut la urmatoarea reincarcare. #}
|
{# Sumar actiune bulk: afisat dupa bulk-fix, disparut la urmatoarea reincarcare. #}
|
||||||
<div class="bulk-message" role="status" aria-live="polite"
|
<div class="bulk-message" role="status" aria-live="polite"
|
||||||
style="font-size:13px; color:var(--ink); background:var(--card2);
|
style="font-size:13px; color:var(--ink); background:var(--card2);
|
||||||
border:1px solid var(--line); border-radius:6px;
|
border:1px solid var(--line); border-radius:6px;
|
||||||
@@ -22,8 +29,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Chip filtru activ "doar tinute" cu buton de golire — filtrul held e sticky
|
||||||
|
prin OOB, deci ii dam un off-switch explicit. #}
|
||||||
|
{% if f_held %}
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:var(--fs-sm);">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:6px; padding:2px 10px; border-radius:99px;
|
||||||
|
border:1px solid var(--warn); color:var(--warn); font-weight:600;">
|
||||||
|
Doar tinute manual
|
||||||
|
</span>
|
||||||
|
<button type="button"
|
||||||
|
onclick="var h=document.getElementById('f-held'); if(h){h.value='';} var f=document.getElementById('filtre-trimiteri'); if(f&&window.htmx){htmx.trigger(f,'submit');}"
|
||||||
|
style="background:none; border:none; color:var(--muted); cursor:pointer;
|
||||||
|
text-decoration:underline; font-size:var(--fs-sm);">
|
||||||
|
Arata tot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
{# Form bulk cu DOUA actiuni: (1) aplica cod RAR la selectate (bulk-fix, US-010),
|
{# Form bulk cu DOUA actiuni: (1) aplica cod RAR la selectate (bulk-fix),
|
||||||
(2) sterge selectate (sterge-bulk). Selectia opereaza DOAR pe randuri blocate
|
(2) sterge selectate (sterge-bulk). Selectia opereaza DOAR pe randuri blocate
|
||||||
(gestionabil); sent/sending/queued nu au checkbox (read-only).
|
(gestionabil); sent/sending/queued nu au checkbox (read-only).
|
||||||
Butonul "Aplica cod" foloseste hx-post propriu (override form action).
|
Butonul "Aplica cod" foloseste hx-post propriu (override form action).
|
||||||
@@ -36,45 +60,71 @@
|
|||||||
hx-disinherit="hx-confirm"
|
hx-disinherit="hx-confirm"
|
||||||
style="margin:0;">
|
style="margin:0;">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<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;">
|
{# Bara contextuala bulk (5.22): randata inactiva (ascunsa prin CSS), afisata de
|
||||||
{# Bulk-fix: input cod + buton aplica (US-010 PRD 5.15) #}
|
JS (base.html) cand >= 1 checkbox e bifat. Contoarele .bulk-n / .bulk-n-txt
|
||||||
|
sunt actualizate client-side. Endpoint-urile si CSRF raman neschimbate;
|
||||||
|
"Sterge" pastreaza hx-confirm-ul form-ului, "Aplica" e exclus (hx-disinherit). #}
|
||||||
|
<div id="bulk-bar" class="bulk-bar" role="toolbar" aria-label="Actiuni pe randurile selectate">
|
||||||
|
<span style="font-weight:700;"><span class="bulk-n">0</span> selectate</span>
|
||||||
|
<span style="color:var(--muted);" aria-hidden="true">·</span>
|
||||||
<input type="text" name="cod_prestatie" id="bulk-fix-cod"
|
<input type="text" name="cod_prestatie" id="bulk-fix-cod"
|
||||||
placeholder="Cod RAR (ex: OE-1)"
|
placeholder="Cod RAR (ex: OE-1)"
|
||||||
autocomplete="off" autocapitalize="characters"
|
autocomplete="off" autocapitalize="characters"
|
||||||
style="width:120px; font-size:12px; padding:3px 7px;
|
style="width:120px; font-size:12px; padding:3px 7px;
|
||||||
border:1px solid var(--line); border-radius:5px;
|
border:1px solid var(--line); border-radius:5px;
|
||||||
background:var(--card2); color:var(--ink);"
|
background:var(--card2); color:var(--ink); font-family:var(--font-mono);"
|
||||||
aria-label="Cod RAR de aplicat la randurile selectate">
|
aria-label="Cod RAR de aplicat la randurile selectate">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
hx-post="/trimiteri/bulk-fix"
|
hx-post="/trimiteri/bulk-fix"
|
||||||
hx-target="#submissions-wrap"
|
hx-target="#submissions-wrap"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
style="background:var(--card); color:var(--accent); border-color:var(--accent);
|
style="background:var(--card); color:var(--accent); border:1px solid var(--accent);
|
||||||
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;"
|
font-size:12.5px; padding:4px 10px; border-radius:5px; cursor:pointer;"
|
||||||
aria-label="Aplica codul RAR la randurile blocate selectate">
|
aria-label="Aplica codul RAR la randurile blocate selectate">
|
||||||
Aplica cod
|
Aplica acest cod la <span class="bulk-n">0</span> <span class="bulk-n-txt">randuri</span>
|
||||||
</button>
|
</button>
|
||||||
{# Separator vizual #}
|
|
||||||
<span style="color:var(--muted); font-size:11px; padding:0 2px;" aria-hidden="true">|</span>
|
<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"
|
<button type="submit" id="bulk-sterge-btn"
|
||||||
style="background:var(--card); color:var(--err); border-color:var(--err);
|
style="background:var(--card); color:var(--err); border:1px solid var(--err);
|
||||||
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;">
|
font-size:12.5px; padding:4px 10px; border-radius:5px; cursor:pointer;">
|
||||||
Sterge selectate
|
Sterge <span class="bulk-n">0</span> <span class="bulk-n-txt">randuri</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" id="bulk-deselect"
|
||||||
|
style="margin-left:auto; background:none; border:none; color:var(--muted);
|
||||||
|
cursor:pointer; text-decoration:underline; font-size:12px;">
|
||||||
|
Deselecteaza
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bulk-hint">
|
||||||
|
Aplica codul RAR doar operatiilor fara cod din randurile selectate, apoi le repune in coada.
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Lista slim trimiteri (US-004, PRD 5.15).
|
{# Tabel compact trimiteri (grid CSS, PRD 5.22).
|
||||||
Inlocuieste tabelul cu randuri compacte: VIN mono + operatie·ora + pill.
|
Desktop: un rand per trimitere sub un header de coloane; status = bulina cu
|
||||||
Nr. inmatriculare, data prestatie si nr. prezentare RAR raman accesibile
|
tooltip, mediu = litera T/P, multi-cod = primul cod + badge +N.
|
||||||
pe linia meta discreta (linia 3) si in modalul de detaliu. #}
|
Mobil (max-width:767px): header ascuns, randul revine la 2 linii
|
||||||
|
(placuta / cod·operatie·data) cu litera + bulina in dreapta. #}
|
||||||
<ul class="lista-trimiteri-slim" role="list"
|
<ul class="lista-trimiteri-slim" role="list"
|
||||||
aria-label="Lista trimiteri">
|
aria-label="Lista trimiteri">
|
||||||
|
|
||||||
|
{# Header de coloane — doar desktop (CSS); numele starii/mediului stau aici,
|
||||||
|
celulele raman semnale compacte (bulina, litera). #}
|
||||||
|
<li class="trimiteri-head" aria-hidden="true">
|
||||||
|
<span></span>
|
||||||
|
<span>#</span>
|
||||||
|
<span>Vehicul</span>
|
||||||
|
<span>Cod RAR</span>
|
||||||
|
<span>Operatie</span>
|
||||||
|
<span>Data</span>
|
||||||
|
<span class="th-c">Status</span>
|
||||||
|
<span class="th-c">Mediu</span>
|
||||||
|
<span></span>
|
||||||
|
</li>
|
||||||
|
|
||||||
{% for r in rows %}
|
{% for r in rows %}
|
||||||
{# Randul slim: stanga = VIN mono scurt (L1) + operatie·ora muted (L2) + meta (L3);
|
{# Rand clickabil (role=button, Enter/Space in JS din base.html) — deschide
|
||||||
dreapta = pill de stare. Click deschide modalul global (#detaliu-modal-body).
|
modalul global de detaliu (#detaliu-modal-body). #}
|
||||||
Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
|
|
||||||
<li id="trimitere-row-{{ r.id }}"
|
<li id="trimitere-row-{{ r.id }}"
|
||||||
class="trimitere-slim"
|
class="trimitere-slim"
|
||||||
data-detaliu-id="{{ r.id }}"
|
data-detaliu-id="{{ r.id }}"
|
||||||
@@ -86,64 +136,89 @@
|
|||||||
style="cursor:pointer;"
|
style="cursor:pointer;"
|
||||||
title="Click pentru detaliul complet">
|
title="Click pentru detaliul complet">
|
||||||
|
|
||||||
{# Zona checkbox — nu declanseaza modalul (stopPropagation).
|
{# Checkbox — nu declanseaza modalul (stopPropagation). Vizibil DOAR pe
|
||||||
Vizibila DOAR pe randurile gestionabile (error/needs_data/needs_mapping).
|
randurile gestionabile; latimea fixa previne reflow. #}
|
||||||
Latimea fixa previne reflow la prezenta/absenta checkbox-ului. #}
|
<div class="c-check" onclick="event.stopPropagation();">
|
||||||
<div style="flex:0 0 22px; display:flex; align-items:center;" onclick="event.stopPropagation();">
|
|
||||||
{% if r.gestionabil %}
|
{% if r.gestionabil %}
|
||||||
<input type="checkbox" name="submission_id" value="{{ r.id }}"
|
<input type="checkbox" name="submission_id" value="{{ r.id }}"
|
||||||
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
|
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Bloc text principal — stanga, ocupa spatiul ramas. Rand de 2 linii (spec 5.16):
|
{# Numarul trimiterii (id din baza) — aceeasi valoare ca `submission_id`
|
||||||
L1 = placuta (identificator primar); L2 = cod RAR · operatie · data prestatie. #}
|
din raspunsul API si ca titlul modalului de detaliu/editare. #}
|
||||||
<div style="flex:1 1 auto; min-width:0;">
|
<span class="c-id">#{{ r.id }}</span>
|
||||||
|
|
||||||
{# Linia 1: nr. inmatriculare (placuta) — identificatorul primar pe care il
|
{# Vehicul: placuta (identificator primar); fallback VIN scurt / 'fara numar'
|
||||||
scaneaza operatorul. .slim-vin reumplut (acelasi nume de clasa, churn minim).
|
(niciodata em-dash izolat). #}
|
||||||
Fallback cand placuta lipseste ('—'): VIN scurt, apoi mesaj neutru
|
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
|
||||||
(nu randa em-dash izolat ca identificator). #}
|
<div class="slim-vin">{{ r.prez.vehicul_nr }}</div>
|
||||||
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
|
{% elif r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
||||||
<div class="slim-vin">{{ r.prez.vehicul_nr }}</div>
|
<div class="slim-vin muted">{{ r.prez.vin_scurt }}</div>
|
||||||
{% elif r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
{% else %}
|
||||||
<div class="slim-vin muted">{{ r.prez.vin_scurt }}</div>
|
<div class="slim-vin muted">fara numar</div>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<div class="slim-vin muted">fara numar</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Linia 2: cod RAR (sau 'nemapat') · operatie (ink, ellipsis) · data prestatie.
|
{# Pe desktop .c-meta e display:contents (copiii devin celule de grid);
|
||||||
Separatorul "·" e injectat prin CSS intre celule. Operatia primeste ellipsis
|
pe mobil e linia 2 (flex) cu separatoare "·" din CSS. #}
|
||||||
ca randul sa NU treaca pe a 3-a linie nici la 390px.
|
<div class="c-meta">
|
||||||
VIN integral, #id_prezentare si secundele traiesc in modalul de detaliu. #}
|
{% set _cods = r.prez.coduri_rar or [] %}
|
||||||
<div class="slim-meta slim-rand2">
|
<span class="c-cod">
|
||||||
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
|
{% if _cods %}
|
||||||
<span class="cod-rar-cod">{{ r.prez.cod_rar }}</span>
|
<span class="cod-rar-cod">{{ _cods[0] }}</span>
|
||||||
|
{%- if _cods | length > 1 -%}
|
||||||
|
<span class="cod-plus" title="{{ _cods | join(' · ') }}">+{{ _cods | length - 1 }}</span>
|
||||||
|
{%- endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="cod-rar-cod cod-rar-sub muted">nemapat</span>
|
<span class="cod-rar-cod cod-rar-sub muted">nemapat</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="slim-op">{{ r.prez.operatie }}</span>
|
</span>
|
||||||
{% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %}
|
<span class="c-op">{{ r.prez.operatie }}</span>
|
||||||
<span class="slim-data muted">{{ r.prez.data_prestatie }}</span>
|
<span class="c-data">{% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %}{{ r.prez.data_prestatie }}{% endif %}</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>
|
</div>
|
||||||
|
|
||||||
{# Zona dreapta: pill stare + badge mediu RAR (US-010 PRD 5.20) #}
|
{# Status: doar bulina colorata; numele complet in title + aria-label. #}
|
||||||
<div style="flex:0 0 auto; display:flex; flex-direction:column; align-items:flex-end; gap:3px; white-space:nowrap;">
|
<span class="c-status">
|
||||||
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
|
<span class="dot-stare {{ r.stare_css }}" title="{{ r.stare_text }}"
|
||||||
|
role="img" aria-label="{{ r.stare_text }}"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{# Mediu: litera compacta — P fill atentie (productie, risc L.142),
|
||||||
|
T contur discret (testare). Numele complet in title. #}
|
||||||
|
<span class="c-env">
|
||||||
{% if r.rar_env %}
|
{% if r.rar_env %}
|
||||||
{% set _eb = eticheta_env(r.rar_env) %}
|
{% set _eb = eticheta_env(r.rar_env) %}
|
||||||
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>
|
<span class="env-l {{ 'env-l-prod' if 'prod' in _eb[1] else 'env-l-test' }}"
|
||||||
|
title="{{ _eb[0] }}" role="img" aria-label="Mediu RAR: {{ _eb[0] }}">{{ _eb[0][0] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{# Actiuni: buton "Trimite" DOAR pe randurile tinute (queued AND held=1). #}
|
||||||
|
<div class="c-act">
|
||||||
|
{% if r.tinut %}
|
||||||
|
<form hx-post="/trimitere/{{ r.id }}/trimite-acum"
|
||||||
|
hx-target="#submissions-wrap" hx-swap="innerHTML"
|
||||||
|
hx-confirm="Trimit aceasta prezentare la RAR? Declararea este ireversibila (FINALIZATA)."
|
||||||
|
onclick="event.stopPropagation();"
|
||||||
|
onkeydown="event.stopPropagation();"
|
||||||
|
style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="btn-trimite-rand"
|
||||||
|
onclick="event.stopPropagation();"
|
||||||
|
style="padding:2px 10px; font-size:var(--fs-xs);
|
||||||
|
border:1px solid var(--accent); border-radius:99px; cursor:pointer;
|
||||||
|
background:var(--accent); color:#fff; font-weight:600;">
|
||||||
|
Trimite
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Sub-linie de eroare: textul BRUT (mesaj RAR/validare), compact, un rand,
|
||||||
|
doar pe error/needs_data (eroare_bruta e "" in rest). #}
|
||||||
|
{% if r.eroare_bruta %}
|
||||||
|
<div class="c-err" title="{{ r.eroare_bruta }}">{{ r.eroare_bruta }}</div>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -176,6 +251,7 @@
|
|||||||
{% if f_vehicul %}{% set pq = pq + "&vehicul=" + f_vehicul %}{% 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_de %}{% set pq = pq + "&data_de=" + f_data_de %}{% endif %}
|
||||||
{% if f_data_pana %}{% set pq = pq + "&data_pana=" + f_data_pana %}{% endif %}
|
{% if f_data_pana %}{% set pq = pq + "&data_pana=" + f_data_pana %}{% endif %}
|
||||||
|
{% if f_held %}{% set pq = pq + "&held=" + f_held %}{% endif %}
|
||||||
|
|
||||||
<nav aria-label="Paginare trimiteri"
|
<nav aria-label="Paginare trimiteri"
|
||||||
style="display:flex; justify-content:center; gap:4px; flex-wrap:wrap; margin-top:10px;">
|
style="display:flex; justify-content:center; gap:4px; flex-wrap:wrap; margin-top:10px;">
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
{% import '_macros.html' as ui %}
|
{% import '_macros.html' as ui %}
|
||||||
{# Detaliu editabil in-place. Fragmentul se swap-uieste in corpul modalului global
|
{# 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.
|
(#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`
|
Operatie + coduri RAR rezolvate apar IMPREUNA, read-only, folosind `prez.coduri_rar`
|
||||||
(fallback „nemapat"), fara eticheta separata „Cod RAR". #}
|
(toate codurile, fallback „nemapat"), fara eticheta separata „Cod RAR". #}
|
||||||
{% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
|
{% set cod_afis = prez.coduri_rar | join(', ') if prez.coduri_rar else 'nemapat' %}
|
||||||
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
|
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
|
||||||
|
|
||||||
{# === Header — #id + pill stare + badge mediu RAR (US-010 PRD 5.20) + motiv === #}
|
{# === Header — #id + pill stare + badge mediu RAR + motiv === #}
|
||||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 8px;">
|
<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>
|
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
|
||||||
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
|
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
|
||||||
@@ -110,14 +110,13 @@
|
|||||||
hx-disabled-elt="find button">
|
hx-disabled-elt="find button">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
{# Cleanup B (US-009 PRD 5.15): vechiul <select name="cod_prestatie"> eliminat.
|
{# Vechiul <select name="cod_prestatie"> eliminat. Chips din _form_editare.html
|
||||||
Chips din _form_editare.html (via _chips_prestatii.html) il inlocuiesc complet:
|
(via _chips_prestatii.html) il inlocuiesc complet: emit hidden inputs
|
||||||
emit hidden inputs name="cod_prestatie" + picker per-operatie (E4, US-007).
|
name="cod_prestatie" + picker per-operatie.
|
||||||
post_corectie_trimitere foloseste form.getlist("cod_prestatie") → compatibil. #}
|
post_corectie_trimitere foloseste form.getlist("cod_prestatie") → compatibil. #}
|
||||||
|
|
||||||
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
|
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
|
||||||
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent.
|
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. #}
|
||||||
RAMANE in _trimitere_detaliu.html (D#5). #}
|
|
||||||
{% if prez.op_service_cod %}
|
{% if prez.op_service_cod %}
|
||||||
<div style="margin:0 0 12px;">
|
<div style="margin:0 0 12px;">
|
||||||
<div class="muted" style="font-size:12px;">Operatie service</div>
|
<div class="muted" style="font-size:12px;">Operatie service</div>
|
||||||
@@ -125,7 +124,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === Campurile vehicul/data/odo + erori/fix + buton — partial DRY (US-005). === #}
|
{# === Campurile vehicul/data/odo + erori/fix + buton — partial DRY. === #}
|
||||||
{% include "_form_editare.html" %}
|
{% include "_form_editare.html" %}
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -150,6 +149,29 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# === Rand tinut manual (queued AND held=1) — afordanta de eliberare.
|
||||||
|
Buton dedicat „Trimite acum": elibereaza randul (held 1->0); worker-ul il preia la
|
||||||
|
urmatorul poll. Reincarca lista + inchide modalul. Confirmare 1-linie (ireversibil). === #}
|
||||||
|
{% if tinut %}
|
||||||
|
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
|
||||||
|
<p class="muted" style="margin:0 0 8px; font-size:12.5px;">
|
||||||
|
Prezentare tinuta manual — nu a plecat la RAR. Elibereaz-o pentru trimitere.
|
||||||
|
</p>
|
||||||
|
<form hx-post="/trimitere/{{ id }}/trimite-acum"
|
||||||
|
hx-target="#submissions-wrap" hx-swap="innerHTML"
|
||||||
|
hx-confirm="Trimit aceasta prezentare la RAR? Declararea este ireversibila (FINALIZATA)."
|
||||||
|
hx-on::after-request="document.getElementById('modal-detaliu').hidden=true"
|
||||||
|
hx-disabled-elt="find button" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit"
|
||||||
|
style="border:1px solid var(--accent); background:var(--accent); color:#fff;
|
||||||
|
font-weight:600; padding:6px 16px; border-radius:6px; cursor:pointer;">
|
||||||
|
Trimite acum la RAR
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# === Actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT === #}
|
{# === Actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT === #}
|
||||||
{% if status == 'error' or gestionabil %}
|
{% if status == 'error' or gestionabil %}
|
||||||
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
|
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
|
||||||
|
|||||||
@@ -32,17 +32,16 @@
|
|||||||
hx-indicator="#upload-spinner">
|
hx-indicator="#upload-spinner">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||||
|
|
||||||
{# Indicator mediu RAR (US-009, PRD 5.20): vizibil inainte de drop-zone #}
|
{# Indicator mediu RAR: vizibil inainte de drop-zone #}
|
||||||
{% set medii_rar = medii | default([]) %}
|
{% set medii_rar = medii | default([]) %}
|
||||||
{% if medii_rar | length == 0 %}
|
{% if medii_rar | length == 0 %}
|
||||||
{# Banner avertisment — non-blocant (upload continua; commit foloseste ancora globala) #}
|
{# Banner avertisment — non-blocant (upload continua; commit foloseste ancora globala) #}
|
||||||
<div style="margin-bottom:10px; padding:8px 14px; border-radius:6px;
|
<div style="margin-bottom:10px; padding:8px 14px; border-radius:6px;
|
||||||
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
|
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
|
||||||
border:1px solid var(--warn, #e6b34a); font-size:13px;" role="note">
|
border:1px solid var(--warn, #e6b34a); font-size:13px;" role="note">
|
||||||
<strong>Niciun mediu RAR configurat pentru acest cont.</strong>
|
<strong>Niciun mediu RAR activ.</strong>
|
||||||
Pana activezi un mediu, trimiterile din aceasta instanta folosesc mediul global
|
Activeaza mediul de Productie sau Testare in
|
||||||
<strong>{{ mediu_instanta() }}</strong>. Configureaza Testare si/sau Productie in
|
<a href="/?tab=cont&subtab=rar" style="color:var(--accent);">Meniu cont › Setari › Mediu RAR</a>.
|
||||||
<a href="?tab=cont" style="color:var(--accent);">tab-ul Cont</a>.
|
|
||||||
</div>
|
</div>
|
||||||
{% elif medii_rar | length == 1 %}
|
{% elif medii_rar | length == 1 %}
|
||||||
{# Eticheta statica (un singur mediu disponibil) #}
|
{# Eticheta statica (un singur mediu disponibil) #}
|
||||||
@@ -123,9 +122,12 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# FARA display in stilul inline: ar suprascrie .htmx-indicator{display:none}
|
||||||
|
si indicatorul ar ramane vizibil permanent (deci invizibil ca progres). #}
|
||||||
<span id="upload-spinner" class="htmx-indicator muted"
|
<span id="upload-spinner" class="htmx-indicator muted"
|
||||||
style="font-size:var(--fs-sm); margin-top:6px; display:inline;">
|
style="font-size:var(--fs-sm); margin-top:6px;" role="status">
|
||||||
se parseaza fisierul...
|
<span class="spin-dot" aria-hidden="true"></span>
|
||||||
|
se parseaza fisierul si se pregatesc sugestiile...
|
||||||
</span>
|
</span>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
/* US-001 PRD 5.16: stive de font standard web. Toate regulile font-face IBM Plex sterse.
|
/* Stive de font standard web. Toate regulile font-face IBM Plex sterse.
|
||||||
Motiv: decizie user (risc AI-Slop #11 acceptat constient), uniformitate cross-page.
|
Motiv: decizie user, uniformitate cross-page.
|
||||||
Fisierele woff2 raman pe disc (curatare = follow-up optional, non-blocant).
|
Fisierele woff2 raman pe disc (curatare = follow-up optional, non-blocant).
|
||||||
Referinte catre directorul de fonturi statice eliminate — font-ui si font-mono sunt stive sistem. */
|
Referinte catre directorul de fonturi statice eliminate — font-ui si font-mono sunt stive sistem. */
|
||||||
/* Paleta dark (default) — accent azur ROMFAST.
|
/* Paleta dark (default) — accent azur ROMFAST.
|
||||||
@@ -45,10 +45,10 @@
|
|||||||
--line2: separator subtire (intre --bg si --line). */
|
--line2: separator subtire (intre --bg si --line). */
|
||||||
:root { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
|
:root { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
|
||||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6;
|
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6;
|
||||||
/* US-001 (PRD 5.16): stive font standard web — sursa unica de adevar */
|
/* Stive font standard web — sursa unica de adevar */
|
||||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||||
/* US-002 (PRD 5.16): scala tipografica uniforma — sursa unica de adevar */
|
/* Scala tipografica uniforma — sursa unica de adevar */
|
||||||
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px;
|
--fs-xs:12px; --fs-sm:13.5px; --fs-base:15px; --fs-md:16px;
|
||||||
--fs-lg:18px; --fs-xl:20px; --fs-2xl:28px; --fs-3xl:34px;
|
--fs-lg:18px; --fs-xl:20px; --fs-2xl:28px; --fs-3xl:34px;
|
||||||
--lh-tight:1.25; --lh-body:1.55; }
|
--lh-tight:1.25; --lh-body:1.55; }
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --card2:#0e1416; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e; --line2:#1c2426;
|
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --card2:#0e1416; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e; --line2:#1c2426;
|
||||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
|
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
|
||||||
/* Paleta Grafit — similara cu dark, accent azur deschis (#6ea2ec = landing --infot).
|
/* Paleta Grafit — similara cu dark, accent azur deschis (#6ea2ec = landing --infot).
|
||||||
Distinta de dark la cererea userului (D2 PRD 5.15). */
|
Distinta de dark la cererea userului. */
|
||||||
[data-theme="grafit"] { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
|
[data-theme="grafit"] { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
|
||||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#6ea2ec; }
|
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#6ea2ec; }
|
||||||
/* Paleta Cobalt — fundal bleumarin adanc, accent albastru viu (#8aa0ff = landing --infot). */
|
/* Paleta Cobalt — fundal bleumarin adanc, accent albastru viu (#8aa0ff = landing --infot). */
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
[data-theme="cupru"] { --bg:#15110b; --card:#211a12; --card2:#15110b; --ink:#efe6d6; --muted:#a89a85; --line:#36291c; --line2:#281e14;
|
[data-theme="cupru"] { --bg:#15110b; --card:#211a12; --card2:#15110b; --ink:#efe6d6; --muted:#a89a85; --line:#36291c; --line2:#281e14;
|
||||||
--ok:#67b98c; --warn:#c97d2e; --err:#e2685a; --accent:#dfa45c; }
|
--ok:#67b98c; --warn:#c97d2e; --err:#e2685a; --accent:#dfa45c; }
|
||||||
/* Paleta Hartie — fundal crem cald, accent albastru clasic (#1F5FBF = landing --infot = --accent).
|
/* Paleta Hartie — fundal crem cald, accent albastru clasic (#1F5FBF = landing --infot = --accent).
|
||||||
Similara cu light, distinta la cererea userului (D2 PRD 5.15). */
|
Similara cu light, distinta la cererea userului. */
|
||||||
[data-theme="hartie"] { --bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052; --line:#e2dccc; --line2:#ece6d9;
|
[data-theme="hartie"] { --bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052; --line:#e2dccc; --line2:#ece6d9;
|
||||||
--ok:#1c7d5d; --warn:#b45309; --err:#bd463c; --accent:#1F5FBF; }
|
--ok:#1c7d5d; --warn:#b45309; --err:#bd463c; --accent:#1F5FBF; }
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
||||||
.empty { color:var(--muted); padding:24px; text-align:center; }
|
.empty { color:var(--muted); padding:24px; text-align:center; }
|
||||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
|
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
|
||||||
/* Badge sursa sugestie (5.18 US-007): de unde vine sugestia de cod in editorul de mapare.
|
/* Badge sursa sugestie: de unde vine sugestia de cod in editorul de mapare.
|
||||||
confirmat = GOLD validat de om (verde); similar = SILVER/embedding k-NN (azur);
|
confirmat = GOLD validat de om (verde); similar = SILVER/embedding k-NN (azur);
|
||||||
non-operatie = pre-filtru NUL / vecin NUL (gri-cald). Suggestion-only, doar indiciu vizual. */
|
non-operatie = pre-filtru NUL / vecin NUL (gri-cald). Suggestion-only, doar indiciu vizual. */
|
||||||
.sugg-sursa { display:inline-block; font-size:10px; font-weight:700; line-height:1; padding:2px 6px;
|
.sugg-sursa { display:inline-block; font-size:10px; font-weight:700; line-height:1; padding:2px 6px;
|
||||||
@@ -141,8 +141,8 @@
|
|||||||
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
|
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
|
||||||
.s-ok{color:var(--ok);}
|
.s-ok{color:var(--ok);}
|
||||||
.s-needs_review{color:var(--warn);}
|
.s-needs_review{color:var(--warn);}
|
||||||
.s-already_sent,.s-duplicate_in_file{color:var(--muted);}
|
.s-already_sent,.s-duplicate_in_file,.s-excluded{color:var(--muted);}
|
||||||
/* Badge mediu RAR (US-010 PRD 5.20) — semantica risc L.142.
|
/* Badge mediu RAR — semantica risc L.142.
|
||||||
Productie: fill atentie (ton --err), text alb — declaratie reala, ireversibila.
|
Productie: fill atentie (ton --err), text alb — declaratie reala, ireversibila.
|
||||||
Testare: outline discret, ton --muted — mediu de proba, low-stakes.
|
Testare: outline discret, ton --muted — mediu de proba, low-stakes.
|
||||||
NU se foloseste verde pentru Productie (ar semnala "sigur"). */
|
NU se foloseste verde pentru Productie (ar semnala "sigur"). */
|
||||||
@@ -150,6 +150,15 @@
|
|||||||
background:color-mix(in srgb, var(--err) 80%, var(--card)); color:#fff; border:1px solid transparent; }
|
background:color-mix(in srgb, var(--err) 80%, var(--card)); color:#fff; border:1px solid transparent; }
|
||||||
.env-badge-test { display:inline-block; padding:1px 7px; border-radius:99px; font-size:11px;
|
.env-badge-test { display:inline-block; padding:1px 7px; border-radius:99px; font-size:11px;
|
||||||
background:transparent; color:var(--muted); border:1px solid var(--line); }
|
background:transparent; color:var(--muted); border:1px solid var(--line); }
|
||||||
|
/* Badge mediu RAR din header: eticheta scurta PROD/TEST ramane mereu vizibila pe
|
||||||
|
mobil (semnal de risc L.142), eticheta lunga se ascunde sub 560px. Clasele
|
||||||
|
.env-badge-prod/.env-badge-test isi redefinesc deja border-ul complet, deci
|
||||||
|
suprascriu stilul global de buton fara reguli suplimentare. */
|
||||||
|
.env-tx-short { display:none; }
|
||||||
|
@media (max-width:560px) {
|
||||||
|
.env-tx-full { display:none; }
|
||||||
|
.env-tx-short { display:inline; }
|
||||||
|
}
|
||||||
.muted { color:var(--muted); }
|
.muted { color:var(--muted); }
|
||||||
/* Heading/eticheta accesibila doar pentru cititoare de ecran (vizual ascunsa). */
|
/* Heading/eticheta accesibila doar pentru cititoare de ecran (vizual ascunsa). */
|
||||||
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
|
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
|
||||||
@@ -167,7 +176,12 @@
|
|||||||
flex-wrap:wrap; z-index:10; }
|
flex-wrap:wrap; z-index:10; }
|
||||||
/* Indicator HTMX — ascuns pana la request */
|
/* Indicator HTMX — ascuns pana la request */
|
||||||
.htmx-indicator { display:none; }
|
.htmx-indicator { display:none; }
|
||||||
/* US-011 (PRD 5.16): selector tema stil pill — icon + eticheta temei curente.
|
/* Cerc rotitor pentru indicatorii de procesare (import: parsare + sugestii) */
|
||||||
|
.spin-dot { display:inline-block; width:13px; height:13px; border:2px solid var(--line);
|
||||||
|
border-top-color:var(--accent); border-radius:50%; vertical-align:-2px;
|
||||||
|
margin-right:6px; animation:spin-dot .7s linear infinite; }
|
||||||
|
@keyframes spin-dot { to { transform:rotate(360deg); } }
|
||||||
|
/* Selector tema stil pill — icon + eticheta temei curente.
|
||||||
Eticheta se ascunde pe <=560px (spatiu ingust), ramane iconita. */
|
Eticheta se ascunde pe <=560px (spatiu ingust), ramane iconita. */
|
||||||
.tema-btn { display:inline-flex; align-items:center; gap:6px; height:36px; padding:0 12px;
|
.tema-btn { display:inline-flex; align-items:center; gap:6px; height:36px; padding:0 12px;
|
||||||
border-radius:8px; background:transparent; border:1px solid var(--line);
|
border-radius:8px; background:transparent; border:1px solid var(--line);
|
||||||
@@ -176,7 +190,7 @@
|
|||||||
.tema-btn:hover { border-color:var(--accent); color:var(--ink); }
|
.tema-btn:hover { border-color:var(--accent); color:var(--ink); }
|
||||||
.tema-btn:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
.tema-btn:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||||
@media (max-width:560px) { #tema-label { display:none; } }
|
@media (max-width:560px) { #tema-label { display:none; } }
|
||||||
/* US-003 (PRD 5.16): dot RAR compact in antet.
|
/* Dot RAR compact in antet.
|
||||||
Stare OK: dot verde pulsant + "RAR online". Stare BLOCAT: dot rosu.
|
Stare OK: dot verde pulsant + "RAR online". Stare BLOCAT: dot rosu.
|
||||||
Stilat ca pill; sensul NU depinde de culoare (aria-label + title). */
|
Stilat ca pill; sensul NU depinde de culoare (aria-label + title). */
|
||||||
.rar-chip { display:inline-flex; align-items:center; gap:7px; height:36px; padding:0 12px;
|
.rar-chip { display:inline-flex; align-items:center; gap:7px; height:36px; padding:0 12px;
|
||||||
@@ -190,7 +204,7 @@
|
|||||||
.rar-dot.live { animation:rar-pulse 2s ease-in-out infinite; }
|
.rar-dot.live { animation:rar-pulse 2s ease-in-out infinite; }
|
||||||
@keyframes rar-pulse { 0%,100%{opacity:1;} 50%{opacity:.5;} }
|
@keyframes rar-pulse { 0%,100%{opacity:1;} 50%{opacity:.5;} }
|
||||||
@media (max-width:560px) { .rar-chip .rar-tx { display:none; } }
|
@media (max-width:560px) { .rar-chip .rar-tx { display:none; } }
|
||||||
/* US-010 (PRD 5.16): sub-titlu cu numele service-ului (cand autentificat). */
|
/* Sub-titlu cu numele service-ului (cand autentificat). */
|
||||||
.h-sub { font-size:var(--fs-xs); color:var(--muted); margin-top:2px; line-height:1.2; }
|
.h-sub { font-size:var(--fs-xs); color:var(--muted); margin-top:2px; line-height:1.2; }
|
||||||
.h-sub .svc { color:var(--ink); font-weight:600; }
|
.h-sub .svc { color:var(--ink); font-weight:600; }
|
||||||
/* Badge env (test/prod) si badge tier (plan cont) langa titlu. */
|
/* Badge env (test/prod) si badge tier (plan cont) langa titlu. */
|
||||||
@@ -367,7 +381,7 @@
|
|||||||
border-radius:6px; cursor:pointer; min-height:36px; white-space:nowrap; }
|
border-radius:6px; cursor:pointer; min-height:36px; white-space:nowrap; }
|
||||||
.kebab-menu button:hover, .kebab-menu a:hover { background:var(--line); }
|
.kebab-menu button:hover, .kebab-menu a:hover { background:var(--line); }
|
||||||
.kebab-menu button.danger { color:var(--err); }
|
.kebab-menu button.danger { color:var(--err); }
|
||||||
/* === Accordion import compact (US-006 — regiune CSS disjuncta) ===
|
/* === Accordion import compact (regiune CSS disjuncta) ===
|
||||||
<details id="import-details"> invelete stepper + upload pe Acasa.
|
<details id="import-details"> invelete stepper + upload pe Acasa.
|
||||||
Atribut `open` setat de server din `are_trimiteri`:
|
Atribut `open` setat de server din `are_trimiteri`:
|
||||||
False (first-run) → open; True (returning) → colapsat.
|
False (first-run) → open; True (returning) → colapsat.
|
||||||
@@ -394,7 +408,7 @@
|
|||||||
border-bottom:none; border-radius:0;
|
border-bottom:none; border-radius:0;
|
||||||
margin-bottom:0; }
|
margin-bottom:0; }
|
||||||
/* === Sfarsit regiune accordion import === */
|
/* === Sfarsit regiune accordion import === */
|
||||||
/* === Inceput regiune nav links status-bar (US-005) ===
|
/* === Inceput regiune nav links status-bar ===
|
||||||
Linkuri Trimiteri + Mapari sub contoare; marcaj activ cu aria-current/status-nav-activ. */
|
Linkuri Trimiteri + Mapari sub contoare; marcaj activ cu aria-current/status-nav-activ. */
|
||||||
.status-nav-link { color:var(--accent); text-decoration:none; padding:2px 6px; border-radius:4px;
|
.status-nav-link { color:var(--accent); text-decoration:none; padding:2px 6px; border-radius:4px;
|
||||||
transition:background .1s; }
|
transition:background .1s; }
|
||||||
@@ -504,7 +518,7 @@
|
|||||||
/* === Preview import: coloane extra fata de tabelul Trimiteri.
|
/* === Preview import: coloane extra fata de tabelul Trimiteri.
|
||||||
SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata —
|
SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata —
|
||||||
nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat).
|
nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat).
|
||||||
US-007: 8 coloane (coloana de verificare manuala eliminata).
|
8 coloane (coloana de verificare manuala eliminata).
|
||||||
Suma latimi fixe: col-id(48) + col-stare(104) + col-data(104) +
|
Suma latimi fixe: col-id(48) + col-stare(104) + col-data(104) +
|
||||||
col-km(76) + col-note(176) + col-actiuni(92) = 600px.
|
col-km(76) + col-note(176) + col-actiuni(92) = 600px.
|
||||||
Restul (~680px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
|
Restul (~680px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
|
||||||
@@ -561,7 +575,7 @@
|
|||||||
font-size:12px; margin-bottom:2px; }
|
font-size:12px; margin-bottom:2px; }
|
||||||
.tabel-trimiteri td.col-chk, .tabel-trimiteri td.col-id { display:none; }
|
.tabel-trimiteri td.col-chk, .tabel-trimiteri td.col-id { display:none; }
|
||||||
|
|
||||||
/* === Card COMPACT (PRD 5.13, corectie dogfood) ===
|
/* === Card COMPACT ===
|
||||||
Inlocuieste stiva generica eticheta+valoare (prea inalta) cu un card
|
Inlocuieste stiva generica eticheta+valoare (prea inalta) cu un card
|
||||||
scanabil la prima vedere: vehicul = titlu, stare = pill dreapta-sus,
|
scanabil la prima vedere: vehicul = titlu, stare = pill dreapta-sus,
|
||||||
operatie+cod pe rand, meta (data/km/rar) muted mic, nota mica. Fara
|
operatie+cod pe rand, meta (data/km/rar) muted mic, nota mica. Fara
|
||||||
@@ -701,10 +715,10 @@
|
|||||||
.sticky-bar { padding:10px 12px; gap:10px; }
|
.sticky-bar { padding:10px 12px; gap:10px; }
|
||||||
.sticky-bar button { width:100%; min-height:44px; }
|
.sticky-bar button { width:100%; min-height:44px; }
|
||||||
}
|
}
|
||||||
/* === SENTINEL-COMPONENTE-SLIM: inceput componente slim US-002 (PRD 5.15).
|
/* === SENTINEL-COMPONENTE-SLIM: inceput componente slim.
|
||||||
Testele ancoreaza pe acest marker. Nu muta/sterge. === */
|
Testele ancoreaza pe acest marker. Nu muta/sterge. === */
|
||||||
/* .contor-card — card cifra contor: fundal --card2, bordura --line, radius 8px.
|
/* .contor-card — card cifra contor: fundal --card2, bordura --line, radius 8px.
|
||||||
US-002 PRD 5.16: padding marit (18px), cifra pe --fs-2xl, label pe --fs-sm, sub pe --fs-xs.
|
Padding marit (18px), cifra pe --fs-2xl, label pe --fs-sm, sub pe --fs-xs.
|
||||||
Variante de culoare a cifrei prin clasele .s-* existente (verde/accent/rosu). */
|
Variante de culoare a cifrei prin clasele .s-* existente (verde/accent/rosu). */
|
||||||
.contor-card { background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:18px 18px; }
|
.contor-card { background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:18px 18px; }
|
||||||
.contor-cifra { font-size:var(--fs-2xl); font-weight:700; line-height:1; }
|
.contor-cifra { font-size:var(--fs-2xl); font-weight:700; line-height:1; }
|
||||||
@@ -727,35 +741,72 @@
|
|||||||
.compact-nr { font-size:var(--fs-xl); font-weight:700; line-height:1; }
|
.compact-nr { font-size:var(--fs-xl); font-weight:700; line-height:1; }
|
||||||
.compact-lbl { font-size:10px; color:var(--muted); margin-top:3px; white-space:nowrap; }
|
.compact-lbl { font-size:10px; color:var(--muted); margin-top:3px; white-space:nowrap; }
|
||||||
}
|
}
|
||||||
/* .lista-trimiteri-slim + .trimitere-slim — lista compacta cu separator --line2.
|
/* Bara contextuala bulk (5.22): ascunsa fara selectie; .activ (setat de JS
|
||||||
Randul e clickabil (rol button), tinta min-height:44px pe mobil. */
|
cand >=1 checkbox e bifat) o afiseaza impreuna cu hint-ul de sub ea. */
|
||||||
|
.bulk-bar { display:none; align-items:center; gap:8px; flex-wrap:wrap; font-size:13px;
|
||||||
|
background:color-mix(in srgb, var(--accent) 10%, var(--card));
|
||||||
|
border:1px solid color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
border-radius:8px; padding:8px 12px; margin-bottom:4px; }
|
||||||
|
.bulk-bar.activ { display:flex; }
|
||||||
|
.bulk-hint { display:none; font-size:12px; color:var(--muted); margin:0 0 8px; }
|
||||||
|
.bulk-bar.activ + .bulk-hint { display:block; }
|
||||||
|
|
||||||
|
/* Tabel compact trimiteri (grid CSS, 5.22).
|
||||||
|
Desktop: header de coloane + un rand per trimitere; sub-linia de eroare
|
||||||
|
(.c-err) ocupa coloanele 2..-1 pe un al doilea rand de grid.
|
||||||
|
Mobil (max-width:767px, mai jos): header ascuns, rand pe 2 linii. */
|
||||||
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
|
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
|
||||||
.trimitere-slim { display:flex; align-items:center; justify-content:space-between; gap:12px;
|
.trimiteri-head, .trimitere-slim {
|
||||||
padding:14px 16px; border-bottom:1px solid var(--line2); min-height:44px; cursor:pointer; }
|
display:grid; align-items:center; column-gap:10px; row-gap:2px;
|
||||||
|
grid-template-columns:22px 42px minmax(90px,150px) minmax(64px,auto) minmax(0,1fr) auto 46px 44px 84px; }
|
||||||
|
.trimiteri-head { padding:2px 16px 8px; border-bottom:1px solid var(--line);
|
||||||
|
font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase;
|
||||||
|
color:var(--muted); }
|
||||||
|
.trimiteri-head .th-c { text-align:center; }
|
||||||
|
.trimitere-slim { padding:9px 16px; border-bottom:1px solid var(--line2);
|
||||||
|
min-height:44px; cursor:pointer; }
|
||||||
.trimitere-slim:last-child { border-bottom:none; }
|
.trimitere-slim:last-child { border-bottom:none; }
|
||||||
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||||
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||||
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-md); font-weight:600; color:var(--ink); }
|
.trimitere-slim .c-check { display:flex; align-items:center; }
|
||||||
.slim-meta { font-size:var(--fs-sm); color:var(--muted); margin-top:3px; }
|
/* Numarul trimiterii: id-ul din baza, corelabil cu submission_id din API. */
|
||||||
/* Linia 2 a randului slim (5.16): cod RAR · operatie (ellipsis) · data, pe UN rand.
|
.trimitere-slim .c-id { font-family:var(--font-mono); font-size:var(--fs-xs);
|
||||||
Ellipsis-ul pe operatie garanteaza 2 linii MAX si la 390px. */
|
color:var(--muted); white-space:nowrap;
|
||||||
.slim-rand2 { display:flex; align-items:baseline; gap:6px; min-width:0; }
|
font-variant-numeric:tabular-nums; }
|
||||||
.slim-rand2 .cod-rar-cod { flex:0 0 auto; font-family:var(--font-mono); font-weight:600;
|
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-sm); font-weight:600; color:var(--ink);
|
||||||
color:var(--accent); }
|
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||||
.slim-rand2 .cod-rar-cod.muted { color:var(--muted); font-weight:500; }
|
/* Pe desktop copiii .c-meta devin celule de grid (cod / operatie / data). */
|
||||||
.slim-rand2 .slim-op { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden;
|
.trimitere-slim .c-meta { display:contents; }
|
||||||
text-overflow:ellipsis; color:var(--ink); }
|
.trimitere-slim .c-cod { white-space:nowrap; }
|
||||||
.slim-rand2 .slim-data { flex:0 0 auto; }
|
.trimitere-slim .cod-rar-cod { font-family:var(--font-mono); font-weight:600;
|
||||||
.slim-rand2 .slim-op::before, .slim-rand2 .slim-data::before {
|
font-size:var(--fs-xs); color:var(--accent); }
|
||||||
content:"·"; color:var(--muted); margin-right:6px; }
|
.trimitere-slim .cod-rar-cod.muted { color:var(--muted); font-weight:500; font-style:italic; }
|
||||||
.lista-trimiteri-slim .eticheta-problema { font-size:var(--fs-xs); line-height:1.3; margin-top:2px; }
|
/* Badge +N: trimiterea are mai multe coduri RAR; toate stau in title. */
|
||||||
/* Pill slim (5.16): fill-tint + dot 7px + text colorat per stare (currentColor din .s-*).
|
.trimitere-slim .cod-plus { display:inline-block; font-family:var(--font-mono);
|
||||||
Pastrat pe FIECARE rand inclusiv Finalizat (linistit dar prezent). */
|
font-size:10px; font-weight:700; color:var(--muted); border:1px solid var(--line);
|
||||||
.lista-trimiteri-slim .pill { display:inline-flex; align-items:center; gap:5px; font-weight:600;
|
border-radius:99px; padding:0 5px; margin-left:4px; vertical-align:1px; cursor:default; }
|
||||||
background:color-mix(in srgb, currentColor 14%, transparent);
|
.trimitere-slim .c-op { min-width:0; white-space:nowrap; overflow:hidden;
|
||||||
border-color:color-mix(in srgb, currentColor 35%, transparent); }
|
text-overflow:ellipsis; font-size:var(--fs-sm); color:var(--ink); }
|
||||||
.lista-trimiteri-slim .pill::before { content:""; width:7px; height:7px; border-radius:99px;
|
.trimitere-slim .c-data { font-size:var(--fs-xs); color:var(--muted); white-space:nowrap;
|
||||||
background:currentColor; flex-shrink:0; }
|
font-variant-numeric:tabular-nums; }
|
||||||
|
.trimitere-slim .c-status, .trimitere-slim .c-env { justify-self:center; line-height:1;
|
||||||
|
display:inline-flex; align-items:center; }
|
||||||
|
/* Bulina de status: doar cerc colorat (currentColor din .s-*); numele starii
|
||||||
|
traieste in headerul coloanei + title/aria-label. */
|
||||||
|
.dot-stare { display:inline-block; width:11px; height:11px; border-radius:99px;
|
||||||
|
background:currentColor; }
|
||||||
|
/* Litera de mediu: P = fill atentie (declaratie reala, ireversibila — L.142);
|
||||||
|
T = contur discret (mediu de proba). */
|
||||||
|
.env-l { display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
width:20px; height:20px; border-radius:99px; font-size:11px; font-weight:800;
|
||||||
|
cursor:default; }
|
||||||
|
.env-l-prod { background:color-mix(in srgb, var(--err) 80%, var(--card)); color:var(--card); }
|
||||||
|
.env-l-test { color:var(--muted); border:1px solid var(--line); }
|
||||||
|
.trimitere-slim .c-act { justify-self:end; }
|
||||||
|
/* Sub-linia de eroare: text brut, mono, un singur rand cu ellipsis (title = complet). */
|
||||||
|
.trimitere-slim .c-err { grid-column:3 / -1; min-width:0; font-family:var(--font-mono);
|
||||||
|
font-size:var(--fs-xs); color:var(--err); white-space:nowrap; overflow:hidden;
|
||||||
|
text-overflow:ellipsis; }
|
||||||
/* .camp-slim — varianta compacta camp formular: label --fs-sm muted deasupra, input --fs-md, fundal --card2.
|
/* .camp-slim — varianta compacta camp formular: label --fs-sm muted deasupra, input --fs-md, fundal --card2.
|
||||||
Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
|
Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
|
||||||
.camp-slim { margin-bottom:8px; }
|
.camp-slim { margin-bottom:8px; }
|
||||||
@@ -779,25 +830,52 @@
|
|||||||
.chip .chip-del:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
|
.chip .chip-del:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
|
||||||
/* Varianta chip warn (ex. R-ODO necesita odometruInitial) */
|
/* Varianta chip warn (ex. R-ODO necesita odometruInitial) */
|
||||||
.chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); }
|
.chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); }
|
||||||
/* .add-code — buton dashed pentru adaugare cod in chipbox */
|
/* Chip abia adaugat via /form-chips — confirmare vizibila a actiunii "+" */
|
||||||
.add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent;
|
.chip-nou { outline:2px solid color-mix(in srgb, var(--ok) 45%, transparent); outline-offset:1px; }
|
||||||
border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line));
|
/* Eticheta mono "COD" pe chip-ul de cod */
|
||||||
border-radius:5px; color:var(--accent); font:500 10px var(--font-ui); cursor:pointer; }
|
.chip-cod-label { font-size:9px; font-weight:800; opacity:.65; letter-spacing:.03em; }
|
||||||
.add-code:hover, .add-code:focus-visible { border-style:solid; }
|
/* Chip warning operatie nemapata: dashed implicit; tinta activa = solid + inel —
|
||||||
/* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */
|
singurul tratament "tare" din rand. */
|
||||||
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
|
.chip-op-warn { background:color-mix(in srgb, var(--warn) 16%, transparent); color:var(--warn);
|
||||||
padding:8px 10px; border:1px solid var(--line); border-radius:6px;
|
border:1px dashed var(--warn); cursor:pointer; font-weight:600; }
|
||||||
background:var(--card2); margin-bottom:8px; }
|
.chip-op-warn.is-target { border-style:solid; outline:2px solid color-mix(in srgb, var(--warn) 40%, transparent);
|
||||||
/* Nume operatie emfatic (T-9 5.16): proeminent (bold) ca in mockup — e ancora
|
outline-offset:1px; }
|
||||||
vizuala a randului de mapare op<->cod. */
|
.chip-op-warn:focus-visible { outline:2px solid var(--warn); outline-offset:1px; }
|
||||||
.op-row-name { font-size:var(--fs-sm); font-weight:700; color:var(--ink); }
|
/* Chip Nedeclarat: vizibil (line-through), NU mut/invizibil — cea mai riscanta
|
||||||
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); }
|
actiune legala (exclude de la declararea RAR) nu poate fi cea mai discreta. */
|
||||||
/* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */
|
.chip-nedeclarat { background:var(--card2); color:var(--muted); border:1px dashed var(--line);
|
||||||
|
text-decoration:line-through; }
|
||||||
|
/* Sectiunea chips inerta pe durata unui request /form-chips in zbor:
|
||||||
|
click pe x/warning mid-flight nu se pierde, doar se ignora. */
|
||||||
|
.chips-editor.htmx-request { pointer-events:none; opacity:.7; }
|
||||||
|
/* Mobil: fara header, randul revine la 2 linii (placuta / cod·operatie·data),
|
||||||
|
cu actiune + litera mediu + bulina status in dreapta. Grid-areas peste
|
||||||
|
aceleasi celule; .c-meta redevine linie flex cu separatoare "·". */
|
||||||
@media (max-width:767px) {
|
@media (max-width:767px) {
|
||||||
.trimitere-slim { padding:12px 14px; }
|
.trimiteri-head { display:none; }
|
||||||
|
.trimitere-slim { padding:12px 14px; column-gap:8px;
|
||||||
|
grid-template-columns:22px auto minmax(0,1fr) auto auto auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"check id veh act env status"
|
||||||
|
"check meta meta act env status"
|
||||||
|
"err err err err err err"; }
|
||||||
|
.trimitere-slim .c-check { grid-area:check; }
|
||||||
|
.trimitere-slim .c-id { grid-area:id; align-self:center; }
|
||||||
|
.trimitere-slim .slim-vin { grid-area:veh; font-size:var(--fs-md); }
|
||||||
|
.trimitere-slim .c-meta { grid-area:meta; display:flex; align-items:baseline;
|
||||||
|
gap:6px; min-width:0; margin-top:3px; }
|
||||||
|
.trimitere-slim .c-cod { flex:0 0 auto; }
|
||||||
|
.trimitere-slim .c-op { flex:1 1 auto; }
|
||||||
|
.trimitere-slim .c-data { flex:0 0 auto; }
|
||||||
|
.trimitere-slim .c-op::before, .trimitere-slim .c-data:not(:empty)::before {
|
||||||
|
content:"·"; color:var(--muted); margin-right:6px; }
|
||||||
|
.trimitere-slim .c-status { grid-area:status; }
|
||||||
|
.trimitere-slim .c-env { grid-area:env; }
|
||||||
|
.trimitere-slim .c-act { grid-area:act; }
|
||||||
|
.trimitere-slim .c-err { grid-area:err; }
|
||||||
}
|
}
|
||||||
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim US-002 === */
|
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim === */
|
||||||
/* === Fix mobil Mapari (bug live 2026-06-29) ===
|
/* === Fix mobil Mapari ===
|
||||||
Doua probleme raportate la 390px pe pagina Mapari:
|
Doua probleme raportate la 390px pe pagina Mapari:
|
||||||
(1) butoanele Salveaza/Sterge taiate: regula `.tabel-card td button {width:100%}`
|
(1) butoanele Salveaza/Sterge taiate: regula `.tabel-card td button {width:100%}`
|
||||||
(specificitate 0,1,2) batea `.act {width:44px}` (0,1,0) -> cele doua butoane act
|
(specificitate 0,1,2) batea `.act {width:44px}` (0,1,0) -> cele doua butoane act
|
||||||
@@ -823,10 +901,108 @@
|
|||||||
.tabel-card td .act .act-tx { display:inline; }
|
.tabel-card td .act .act-tx { display:inline; }
|
||||||
.tabel-card td .act .act-ic { display:inline-block; }
|
.tabel-card td .act .act-ic { display:inline-block; }
|
||||||
}
|
}
|
||||||
|
/* === Meniu hamburger grupat === */
|
||||||
|
/* Iconite SVG stroke unic, currentColor — mostenesc culoarea intrarii. */
|
||||||
|
.ic { width:18px; height:18px; stroke:currentColor; fill:none; stroke-width:1.7;
|
||||||
|
stroke-linecap:round; stroke-linejoin:round; flex-shrink:0; }
|
||||||
|
.cont-menu a, .cont-menu form button { display:flex; align-items:center; gap:10px; }
|
||||||
|
.cont-menu a .mi, .cont-menu form button .mi { display:inline-flex; opacity:.6; }
|
||||||
|
.cont-menu a:hover .mi, .cont-menu form button:hover .mi { opacity:.9; }
|
||||||
|
.cont-menu-group { font-size:10px; text-transform:uppercase; letter-spacing:.08em;
|
||||||
|
color:var(--muted); font-weight:700; padding:8px 12px 4px; }
|
||||||
|
/* Badge-ul de mapari se ancoreaza la dreapta chiar cand intrarea e flex. */
|
||||||
|
.cont-menu a .tab-badge { margin-left:auto; }
|
||||||
|
/* === Pagina Setari cu sub-taburi === */
|
||||||
|
.setari-head { margin-bottom:4px; }
|
||||||
|
.setari-title { font-size:var(--fs-lg); margin:0 0 2px; letter-spacing:-.01em;
|
||||||
|
display:flex; align-items:center; gap:9px; }
|
||||||
|
.setari-title .ic { width:20px; height:20px; opacity:.7; }
|
||||||
|
.setari-sub { color:var(--muted); font-size:var(--fs-sm); margin:0 0 14px; }
|
||||||
|
.setari-subtabs { display:flex; gap:2px; border-bottom:1px solid var(--line); margin-bottom:20px;
|
||||||
|
overflow-x:auto; -webkit-overflow-scrolling:touch; scrollbar-width:none; }
|
||||||
|
.setari-subtabs::-webkit-scrollbar { display:none; }
|
||||||
|
.setari-subtab { appearance:none; background:transparent; border:none; border-bottom:2px solid transparent;
|
||||||
|
color:var(--muted); font:inherit; font-size:var(--fs-sm); font-weight:500; cursor:pointer;
|
||||||
|
padding:10px 2px; margin-right:22px; white-space:nowrap; display:inline-flex;
|
||||||
|
align-items:center; gap:8px; margin-bottom:-1px; transition:color .12s, border-color .12s; }
|
||||||
|
.setari-subtab:hover { color:var(--ink); }
|
||||||
|
/* Stare activa prin clasa (nu selector pe atribut): aria-selected ramane pe buton
|
||||||
|
pentru accesibilitate, dar stilul nu depinde de el. */
|
||||||
|
.setari-subtab.is-active { color:var(--ink); border-bottom-color:var(--accent); font-weight:600; }
|
||||||
|
.setari-subtab:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||||
|
.st-dot { width:7px; height:7px; border-radius:99px; flex-shrink:0; }
|
||||||
|
.st-dot.ok { background:var(--ok); }
|
||||||
|
.st-dot.warn { background:var(--warn); }
|
||||||
|
.st-dot.info { background:var(--muted); }
|
||||||
|
.setari-pane[hidden] { display:none; }
|
||||||
|
/* Titlu de sectiune compact (uppercase mic) — ierarhie sub titlul paginii, nu concurenta cu el. */
|
||||||
|
.setari-zone-title { font-size:var(--fs-xs); font-weight:600; text-transform:uppercase;
|
||||||
|
letter-spacing:.07em; color:var(--muted); margin:0 0 10px; display:flex;
|
||||||
|
align-items:center; gap:8px; flex-wrap:wrap; }
|
||||||
|
.setari-zone-title .ic { opacity:.7; width:15px; height:15px; }
|
||||||
|
.setari-zone-title .setari-badge { text-transform:none; letter-spacing:0; }
|
||||||
|
.setari-zone-desc { color:var(--muted); font-size:var(--fs-sm); margin:0 0 12px; max-width:60ch; line-height:1.5; }
|
||||||
|
/* Formulare compacte pe 2 coloane in Setari. */
|
||||||
|
.setari-fgrid { display:grid; grid-template-columns:1fr 1fr; gap:10px 14px; max-width:560px; margin:0 0 12px; }
|
||||||
|
.setari-fgrid .fg-full { grid-column:1 / -1; }
|
||||||
|
.setari-fgrid label { display:block; font-size:var(--fs-xs); color:var(--muted); margin-bottom:3px; }
|
||||||
|
.setari-fgrid input { width:100%; box-sizing:border-box; font:inherit; font-size:14px;
|
||||||
|
background:var(--bg); color:var(--ink); border:1px solid var(--line);
|
||||||
|
border-radius:6px; padding:7px 10px; }
|
||||||
|
@media (max-width:560px) { .setari-fgrid { grid-template-columns:1fr; } }
|
||||||
|
.setari-btn-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||||
|
.setari-micro { font-size:var(--fs-xs); color:var(--muted); }
|
||||||
|
/* Plan: tier + consum pe un rand, bara de progres sub ele. */
|
||||||
|
.plan-row { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin:0 0 8px; }
|
||||||
|
.plan-tier { font-size:var(--fs-md); font-weight:600; color:var(--ink); }
|
||||||
|
.plan-usage { font-size:var(--fs-sm); color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||||
|
.plan-meter { height:6px; border-radius:99px; background:var(--line2); max-width:340px;
|
||||||
|
overflow:hidden; margin:0 0 8px; }
|
||||||
|
.plan-meter > span { display:block; height:100%; border-radius:99px; background:var(--accent); }
|
||||||
|
.setari-badge { font-size:11px; font-weight:700; padding:2px 9px; border-radius:99px;
|
||||||
|
text-transform:uppercase; letter-spacing:.03em; border:1px solid transparent; }
|
||||||
|
.setari-badge.sb-ok { color:var(--ok); background:color-mix(in srgb,var(--ok) 12%,transparent);
|
||||||
|
border-color:color-mix(in srgb,var(--ok) 40%,transparent); }
|
||||||
|
.setari-badge.sb-warn { color:var(--warn); background:color-mix(in srgb,var(--warn) 12%,transparent);
|
||||||
|
border-color:color-mix(in srgb,var(--warn) 40%,transparent); }
|
||||||
|
.setari-badge.sb-info { color:var(--muted); background:color-mix(in srgb,var(--muted) 12%,transparent);
|
||||||
|
border-color:color-mix(in srgb,var(--muted) 35%,transparent); }
|
||||||
|
.setari-subzone-sep { border:none; border-top:1px solid var(--line); margin:20px 0 18px; }
|
||||||
|
/* Cheie API afisata o singura data. */
|
||||||
|
.keybox { font-family:var(--font-mono); font-size:14px; background:var(--card2);
|
||||||
|
border:1px solid var(--line); border-radius:8px; padding:12px 14px; word-break:break-all;
|
||||||
|
max-width:480px; }
|
||||||
|
.btn-warn { background:transparent; color:var(--warn); border:1px solid var(--warn); border-radius:6px;
|
||||||
|
padding:8px 14px; font:inherit; font-weight:500; cursor:pointer; min-height:36px;
|
||||||
|
display:inline-flex; align-items:center; justify-content:center; gap:6px; }
|
||||||
|
.btn-warn:hover, .btn-warn:focus-visible { background:var(--warn); color:#fff; filter:none; }
|
||||||
|
/* Credentiale RAR: doua carduri de mediu paralele. */
|
||||||
|
.env-cards { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
|
||||||
|
.env-card { border:1px solid var(--line); border-radius:10px; padding:16px; background:var(--card2); }
|
||||||
|
.env-card.active-test { border-color:color-mix(in srgb,var(--ok) 45%,var(--line)); }
|
||||||
|
.env-card h4 { margin:0 0 12px; font-size:var(--fs-sm); display:flex; align-items:center;
|
||||||
|
justify-content:space-between; gap:8px; }
|
||||||
|
.env-card .env-sw { display:flex; align-items:center; gap:8px; font-size:var(--fs-sm);
|
||||||
|
color:var(--muted); margin-bottom:12px; }
|
||||||
|
.env-card .env-lbl { display:block; font-size:var(--fs-xs); color:var(--muted); margin-bottom:5px; }
|
||||||
|
.env-card input[type=email], .env-card input[type=password] {
|
||||||
|
width:100%; font:inherit; background:var(--bg); color:var(--ink); border:1px solid var(--line);
|
||||||
|
border-radius:6px; padding:8px 10px; margin-bottom:10px; font-size:var(--fs-sm); }
|
||||||
|
.env-card .env-confirm { display:flex; align-items:flex-start; gap:8px; font-size:var(--fs-xs);
|
||||||
|
color:var(--muted); line-height:1.4;
|
||||||
|
background:color-mix(in srgb,var(--err) 7%,transparent);
|
||||||
|
border:1px solid color-mix(in srgb,var(--err) 22%,transparent);
|
||||||
|
border-radius:7px; padding:9px 11px; margin-top:2px; }
|
||||||
|
.env-card .env-confirm input { margin-top:2px; flex-shrink:0; }
|
||||||
|
.default-env { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin:0 0 18px;
|
||||||
|
padding:14px; border:1px solid var(--line); border-radius:9px; background:var(--card2); }
|
||||||
|
.default-env select { font:inherit; background:var(--bg); color:var(--ink); border:1px solid var(--line);
|
||||||
|
border-radius:6px; padding:7px 10px; max-width:340px; }
|
||||||
|
@media (max-width:640px) { .env-cards { grid-template-columns:1fr; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{# US-010 (PRD 5.16): antet branduit ROA AUTOPASS.
|
{# Antet branduit ROA AUTOPASS.
|
||||||
Grila 3 coloane — stanga (logo) | centru (titlu+env+tier+account_name) | dreapta (RAR dot + tema + burger).
|
Grila 3 coloane — stanga (logo) | centru (titlu+env+tier+account_name) | dreapta (RAR dot + tema + burger).
|
||||||
Antet MINIMAL pe /login: nu afiseaza RAR dot, meniu burger sau account_name (nelogat). #}
|
Antet MINIMAL pe /login: nu afiseaza RAR dot, meniu burger sau account_name (nelogat). #}
|
||||||
<header>
|
<header>
|
||||||
@@ -850,7 +1026,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{# Celula dreapta: dot RAR (numai cand logat) + selector tema + versiune + meniu burger #}
|
{# Celula dreapta: dot RAR (numai cand logat) + selector tema + versiune + meniu burger #}
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
{# US-003 (PRD 5.16): dot RAR in antet — OK = chip verde pulsant, BLOCAT = chip rosu.
|
{# Dot RAR in antet — OK = chip verde pulsant, BLOCAT = chip rosu.
|
||||||
Banda plina apare DOAR in _status.html cand BLOCAT (nu mai e mereu vizibila). #}
|
Banda plina apare DOAR in _status.html cand BLOCAT (nu mai e mereu vizibila). #}
|
||||||
{% if is_authenticated|default(false) %}
|
{% if is_authenticated|default(false) %}
|
||||||
{% if sanatate_ok|default(true) %}
|
{% if sanatate_ok|default(true) %}
|
||||||
@@ -858,7 +1034,7 @@
|
|||||||
title="RAR online{% if last_login|default('') %} — Ultima autentificare: {{ last_login }}{% endif %}"
|
title="RAR online{% if last_login|default('') %} — Ultima autentificare: {{ last_login }}{% endif %}"
|
||||||
aria-label="RAR online">
|
aria-label="RAR online">
|
||||||
<span class="rar-dot live" aria-hidden="true"></span>
|
<span class="rar-dot live" aria-hidden="true"></span>
|
||||||
<span class="rar-tx">RAR online</span>
|
<span class="rar-tx">RAR</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="rar-chip rar-err" role="status"
|
<div class="rar-chip rar-err" role="status"
|
||||||
@@ -868,8 +1044,12 @@
|
|||||||
<span class="rar-tx">RAR blocat</span>
|
<span class="rar-tx">RAR blocat</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% include '_header_env_badge.html' %}
|
||||||
|
{# Comutatorul "Trimite automat la RAR" langa semnalul RAR.
|
||||||
|
In afara swap-ului periodic al #status-bar. #}
|
||||||
|
{% include '_auto_send_toggle.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# US-011 (PRD 5.16): selector tema = pill cu icon FIX (acelasi SVG ca landing) +
|
{# Selector tema = pill cu icon FIX (acelasi SVG ca landing) +
|
||||||
eticheta temei curente. Eticheta ascunsa pe <=560px via CSS. JS actualizeaza
|
eticheta temei curente. Eticheta ascunsa pe <=560px via CSS. JS actualizeaza
|
||||||
doar #tema-label (iconita nu se mai schimba per tema — consecventa cu landing). #}
|
doar #tema-label (iconita nu se mai schimba per tema — consecventa cu landing). #}
|
||||||
<button id="tema-toggle" class="tema-btn"
|
<button id="tema-toggle" class="tema-btn"
|
||||||
@@ -882,14 +1062,14 @@
|
|||||||
<span class="muted" style="font-size:var(--fs-xs);">v{{ version }}</span>
|
<span class="muted" style="font-size:var(--fs-xs);">v{{ version }}</span>
|
||||||
{% if is_authenticated|default(false) %}
|
{% if is_authenticated|default(false) %}
|
||||||
{# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout.
|
{# Meniu cont: Cont/Integrare/Nomenclator + (admin) + logout.
|
||||||
US-010: structura cu <hr> separatori + RAR status (prima intrare) + Plan tier.
|
Structura cu <hr> separatori + RAR status (prima intrare) + Plan tier.
|
||||||
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
|
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
|
||||||
<div class="cont-menu-wrap">
|
<div class="cont-menu-wrap">
|
||||||
<button id="cont-menu-toggle" class="icon-btn"
|
<button id="cont-menu-toggle" class="icon-btn"
|
||||||
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
|
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
|
||||||
aria-label="Meniu cont" title="Meniu cont">☰</button>
|
aria-label="Meniu cont" title="Meniu cont">☰</button>
|
||||||
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
|
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
|
||||||
{# Prima intrare: starea RAR (US-003) #}
|
{# Prima intrare: starea RAR #}
|
||||||
{% if sanatate_ok|default(true) %}
|
{% if sanatate_ok|default(true) %}
|
||||||
<div class="menu-rar-line ok" role="menuitem" aria-disabled="true">
|
<div class="menu-rar-line ok" role="menuitem" aria-disabled="true">
|
||||||
<span style="width:8px;height:8px;border-radius:99px;background:currentColor;display:inline-block;"></span>
|
<span style="width:8px;height:8px;border-radius:99px;background:currentColor;display:inline-block;"></span>
|
||||||
@@ -901,31 +1081,31 @@
|
|||||||
RAR indisponibil
|
RAR indisponibil
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Plan cont curent (US-006 PRD 5.17): linie detaliata cu trial/consum/warn.
|
{# Plan cont curent: linie detaliata cu trial/consum/warn.
|
||||||
Warn = culoare + text (accesibilitate, decizie #14). #}
|
Warn = culoare + text (accesibilitate). #}
|
||||||
<div class="menu-rar-line" role="menuitem" aria-disabled="true"
|
<div class="menu-rar-line" role="menuitem" aria-disabled="true"
|
||||||
style="color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--muted){% endif %};
|
style="color:{% if plan_limita_atinsa|default(false) %}var(--err){% elif plan_warn|default(false) %}var(--warn){% else %}var(--muted){% endif %};
|
||||||
{% if plan_warn|default(false) %}font-weight:600;{% endif %}">
|
{% if plan_warn|default(false) %}font-weight:600;{% endif %}">
|
||||||
{{ plan_linie|default('Plan: ' + (tier_label|default('Gratuit'))) }}
|
{{ plan_linie|default('Plan: ' + (tier_label|default('Gratuit'))) }}
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
{# Navigare principala: Trimiteri + Mapari #}
|
{# Grup LUCRU: fluxul zilnic — trimiteri, mapari, nomenclator. #}
|
||||||
<a role="menuitem" href="/">Trimiteri</a>
|
<div class="cont-menu-group">Lucru</div>
|
||||||
|
<a role="menuitem" href="/"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/></svg></span>Trimiteri</a>
|
||||||
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
|
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
|
||||||
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
|
<a role="menuitem" href="/?tab=mapari"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14 14 10"/><path d="M11.5 6.5 13 5a4 4 0 0 1 6 6l-1.5 1.5"/><path d="M12.5 17.5 11 19a4 4 0 0 1-6-6l1.5-1.5"/></svg></span>Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
|
||||||
|
<a role="menuitem" href="/?tab=nomenclator"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4a1 1 0 0 1 1-1h13v18H6a1 1 0 0 0-1 1z"/><path d="M5 18h14"/></svg></span>Nomenclator</a>
|
||||||
<hr>
|
<hr>
|
||||||
{# Nomenclator: coduri RAR — public, dar in meniu arata mai logic la logat #}
|
{# Grup CONT: configurare + audit + admin. "Cont" redenumit "Setari" (URL ramane ?tab=cont). #}
|
||||||
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
|
<div class="cont-menu-group">Cont</div>
|
||||||
<hr>
|
<a role="menuitem" href="/?tab=cont"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.6 4.6l2.1 2.1M17.3 17.3l2.1 2.1M4.6 19.4l2.1-2.1M17.3 6.7l2.1-2.1"/></svg></span>Setari</a>
|
||||||
{# Setari cont #}
|
<a role="menuitem" href="/?tab=integrare"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3v6M15 3v6"/><path d="M7 9h10v3a5 5 0 0 1-10 0z"/><path d="M12 17v4"/></svg></span>Integrare</a>
|
||||||
<a role="menuitem" href="/?tab=cont">Cont</a>
|
<a role="menuitem" href="/?tab=jurnal"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3h8l4 4v14H7z"/><path d="M15 3v4h4"/><path d="M10 13h6M10 17h6"/></svg></span>Jurnal</a>
|
||||||
<a role="menuitem" href="/?tab=integrare">Integrare</a>
|
{% if is_admin|default(false) %}<a role="menuitem" href="/admin"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><path d="M3.5 20a5.5 5.5 0 0 1 11 0"/><path d="M16 5.5a3 3 0 0 1 0 5"/><path d="M17.5 14.2A5.5 5.5 0 0 1 21 20"/></svg></span>Conturi clienti</a>{% endif %}
|
||||||
<a role="menuitem" href="/?tab=jurnal">Jurnal</a>
|
|
||||||
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %}
|
|
||||||
<hr>
|
<hr>
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token|default('') }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token|default('') }}">
|
||||||
<button role="menuitem" type="submit">Iesi din cont</button>
|
<button role="menuitem" type="submit"><span class="mi"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-3"/><path d="M9 12h12"/><path d="m18 9 3 3-3 3"/></svg></span>Iesi din cont</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -951,8 +1131,12 @@
|
|||||||
<div id="detaliu-modal-body"></div>
|
<div id="detaliu-modal-body"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{# Slot pentru modalul de confirmare tipata: auto-release OFF->ON injecteaza
|
||||||
|
aici modalul via OOB (#modal-confirma-slot). Bulk "Trimite toate" isi randeaza propriul
|
||||||
|
modal inline in _coada.html. #}
|
||||||
|
<div id="modal-confirma-slot"></div>
|
||||||
<script>
|
<script>
|
||||||
// Comutator tema ciclic (DRY E2 — PRD 5.15): config traieste intr-o singura structura
|
// Comutator tema ciclic: config traieste intr-o singura structura
|
||||||
// sursa-de-adevar THEMES din care se DERIVA CYCLE/VALID/LABELS/NEXT.
|
// sursa-de-adevar THEMES din care se DERIVA CYCLE/VALID/LABELS/NEXT.
|
||||||
// Adaugarea unei teme noi = O singura intrare in THEMES.
|
// Adaugarea unei teme noi = O singura intrare in THEMES.
|
||||||
// Ciclu: Light->Dark->Petrol->Grafit->Cobalt->Cupru->Hartie->Auto->(inapoi la Light).
|
// Ciclu: Light->Dark->Petrol->Grafit->Cobalt->Cupru->Hartie->Auto->(inapoi la Light).
|
||||||
@@ -990,7 +1174,7 @@
|
|||||||
}
|
}
|
||||||
function _syncButton(stored) {
|
function _syncButton(stored) {
|
||||||
var s = VALID[stored] ? stored : 'auto';
|
var s = VALID[stored] ? stored : 'auto';
|
||||||
// US-011: iconita e fixa (nu se schimba per tema); actualizam doar eticheta.
|
// Iconita e fixa (nu se schimba per tema); actualizam doar eticheta.
|
||||||
var label = document.getElementById('tema-label');
|
var label = document.getElementById('tema-label');
|
||||||
if (label) label.textContent = LABELS[s];
|
if (label) label.textContent = LABELS[s];
|
||||||
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
|
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
|
||||||
@@ -1102,6 +1286,56 @@
|
|||||||
if (saveBtn) saveBtn.classList.add('dirty');
|
if (saveBtn) saveBtn.classList.add('dirty');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// Bara contextuala bulk (trimiteri): vizibila DOAR cu selectie.
|
||||||
|
// Delegare pe document (supravietuieste swap-urilor HTMX); dupa un swap al
|
||||||
|
// listei checkbox-urile se reseteaza, deci sync() ascunde bara automat.
|
||||||
|
(function() {
|
||||||
|
function syncBulkBar() {
|
||||||
|
var form = document.getElementById('bulk-trimiteri');
|
||||||
|
var bar = document.getElementById('bulk-bar');
|
||||||
|
if (!form || !bar) return;
|
||||||
|
var n = form.querySelectorAll('input[name="submission_id"]:checked').length;
|
||||||
|
bar.classList.toggle('activ', n > 0);
|
||||||
|
bar.querySelectorAll('.bulk-n').forEach(function(el) { el.textContent = n; });
|
||||||
|
bar.querySelectorAll('.bulk-n-txt').forEach(function(el) {
|
||||||
|
el.textContent = (n === 1 ? 'rand' : 'randuri');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('change', function(e) {
|
||||||
|
if (e.target && e.target.name === 'submission_id') syncBulkBar();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!(e.target.closest && e.target.closest('#bulk-deselect'))) return;
|
||||||
|
document.querySelectorAll('#bulk-trimiteri input[name="submission_id"]:checked')
|
||||||
|
.forEach(function(cb) { cb.checked = false; });
|
||||||
|
syncBulkBar();
|
||||||
|
});
|
||||||
|
document.body.addEventListener('htmx:afterSwap', syncBulkBar);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// Sub-taburi pagina Setari: comutare pur client-side, fara server.
|
||||||
|
// Delegare pe document -> supravietuieste swap-urilor HTMX ale #card-cont (dupa
|
||||||
|
// fiecare salvare serverul re-randeaza cu sub-tabul corect via `aria-selected`/`hidden`,
|
||||||
|
// iar acest handler preia doar navigarea manuala intre taburi).
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var tab = e.target.closest('.setari-subtab');
|
||||||
|
if (!tab) return;
|
||||||
|
var card = tab.closest('#card-cont');
|
||||||
|
if (!card) return;
|
||||||
|
var name = tab.getAttribute('data-subtab');
|
||||||
|
card.querySelectorAll('.setari-subtab').forEach(function(t) {
|
||||||
|
var on = t === tab;
|
||||||
|
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||||
|
t.classList.toggle('is-active', on);
|
||||||
|
});
|
||||||
|
card.querySelectorAll('.setari-pane').forEach(function(p) {
|
||||||
|
if (p.getAttribute('data-subpane') === name) { p.removeAttribute('hidden'); }
|
||||||
|
else { p.setAttribute('hidden', ''); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Toast global: feedback tranzitoriu vizibil + accesibil (aria-live).
|
// Toast global: feedback tranzitoriu vizibil + accesibil (aria-live).
|
||||||
// window.arataToast(text, stareCss) — stareCss (ex. "s-error"/"s-needs_review")
|
// window.arataToast(text, stareCss) — stareCss (ex. "s-error"/"s-needs_review")
|
||||||
@@ -1267,7 +1501,7 @@
|
|||||||
// Deschidere la click pe rand (htmx:beforeRequest): arata modalul cu placeholder
|
// Deschidere la click pe rand (htmx:beforeRequest): arata modalul cu placeholder
|
||||||
// inainte ca raspunsul fragmentului sa fie swap-uit in corp.
|
// inainte ca raspunsul fragmentului sa fie swap-uit in corp.
|
||||||
// Trateaza atat .trimitere-row (Trimiteri) cat si .btn-editeaza (preview import)
|
// Trateaza atat .trimitere-row (Trimiteri) cat si .btn-editeaza (preview import)
|
||||||
// → open() instaleaza inert pe <main>, focus-trap si readuce focusul la inchidere (US-006).
|
// → open() instaleaza inert pe <main>, focus-trap si readuce focusul la inchidere.
|
||||||
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
||||||
var elt = evt.detail && evt.detail.elt;
|
var elt = evt.detail && evt.detail.elt;
|
||||||
if (!elt || !elt.classList) return;
|
if (!elt || !elt.classList) return;
|
||||||
@@ -1380,7 +1614,7 @@
|
|||||||
// Daca difera (schimbari externe — ex. worker a procesat trimiteri), reincarca automat
|
// Daca difera (schimbari externe — ex. worker a procesat trimiteri), reincarca automat
|
||||||
// pastrand filtrul curent. Fara nudge "Date noi" — auto-refresh e mai consistent.
|
// pastrand filtrul curent. Fara nudge "Date noi" — auto-refresh e mai consistent.
|
||||||
// Decizie: nudge eliminat; distinctia propriu vs extern e imposibila pe client
|
// Decizie: nudge eliminat; distinctia propriu vs extern e imposibila pe client
|
||||||
// fara sesiune dedicata — auto-refresh acoper ambele cazuri (US-008).
|
// fara sesiune dedicata — auto-refresh acoper ambele cazuri.
|
||||||
var INTERVAL = 20000;
|
var INTERVAL = 20000;
|
||||||
function versiuneCurenta() {
|
function versiuneCurenta() {
|
||||||
var e = document.getElementById('trimiteri-versiune');
|
var e = document.getElementById('trimiteri-versiune');
|
||||||
|
|||||||
69
app/web/templates/confidentialitate.html
Normal file
69
app/web/templates/confidentialitate.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "landing_static.html" %}
|
||||||
|
{% block titlu %}Confidențialitate / GDPR{% endblock %}
|
||||||
|
{% block descriere %}Politica de confidențialitate și protecția datelor (GDPR) pentru ROA AUTOPASS.{% endblock %}
|
||||||
|
{% block continut %}
|
||||||
|
<h1>Politica de confidențialitate / GDPR</h1>
|
||||||
|
<p class="lp-sub">Ultima actualizare: 9 iulie 2026</p>
|
||||||
|
|
||||||
|
<h2>1. Cine prelucrează datele</h2>
|
||||||
|
<p>Operatorul datelor este <strong>ROMFAST</strong>, furnizorul serviciului ROA AUTOPASS.
|
||||||
|
Ne poți contacta oricând la <a href="mailto:contact@romfast.ro">contact@romfast.ro</a>.</p>
|
||||||
|
|
||||||
|
<h2>2. Ce date prelucrăm și de ce</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Date</th><th>Scop</th><th>Temei</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Date de cont: denumire firmă, CUI, email, parolă (criptată)</td>
|
||||||
|
<td>Crearea și administrarea contului, facturare</td>
|
||||||
|
<td>Executarea contractului</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Date din prezentări: VIN, număr de înmatriculare, kilometraj, data prestației, operațiile efectuate</td>
|
||||||
|
<td>Transmiterea prestațiilor la RAR Auto-Pass în numele tău</td>
|
||||||
|
<td>Obligația legală a service-ului (Legea 142/2023, OMTI 210/2024) + contractul cu tine</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Credențialele tale RAR Auto-Pass</td>
|
||||||
|
<td>Autentificarea la RAR pentru trimitere</td>
|
||||||
|
<td>Executarea contractului; stocate criptat</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Jurnal tehnic (evenimente de trimitere, erori)</td>
|
||||||
|
<td>Funcționarea serviciului, suport, dovada transmiterii</td>
|
||||||
|
<td>Interes legitim</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>Nu colectăm date suplimentare față de cele necesare trimiterii la RAR — nici pe conturile gratuite —
|
||||||
|
și nu folosim datele clienților tăi în scop de marketing.</p>
|
||||||
|
|
||||||
|
<h2>3. Cui le transmitem</h2>
|
||||||
|
<p>Exclusiv <strong>Registrului Auto Român (RAR)</strong>, prin API-ul oficial Auto-Pass, în scopul declarat.
|
||||||
|
Nu vindem și nu cedăm datele către alte părți. Datele sunt găzduite pe infrastructura noastră.</p>
|
||||||
|
|
||||||
|
<h2>4. Cât le păstrăm</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Prezentările trimise și loturile de import se șterg <strong>automat după 90 de zile</strong>.</li>
|
||||||
|
<li>Poți șterge datele și mai devreme, direct din aplicație.</li>
|
||||||
|
<li>Datele de cont se păstrează cât timp contul este activ.</li>
|
||||||
|
<li>Credențialele RAR trimise punctual prin API se șterg imediat după prima autentificare reușită.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Cum le protejăm</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Datele cu caracter personal din trimiteri și credențialele RAR sunt <strong>criptate</strong> în baza de date.</li>
|
||||||
|
<li>Parolele de cont se stochează doar ca hash; cheile API doar ca amprentă SHA-256.</li>
|
||||||
|
<li>Accesul la date este separat per cont: un cont nu poate vedea datele altui cont.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>6. Drepturile tale</h2>
|
||||||
|
<p>Conform GDPR ai dreptul de acces, rectificare, ștergere, restricționare, portabilitate și opoziție,
|
||||||
|
precum și dreptul de a depune o plângere la ANSPDCP (<a href="https://www.dataprotection.ro" target="_blank" rel="noopener">dataprotection.ro</a>).
|
||||||
|
Pentru exercitarea drepturilor scrie-ne la <a href="mailto:contact@romfast.ro">contact@romfast.ro</a> —
|
||||||
|
răspundem în cel mult 30 de zile.</p>
|
||||||
|
|
||||||
|
<h2>7. Cookies</h2>
|
||||||
|
<p>Folosim doar cookie-uri strict necesare (sesiunea de autentificare și protecția CSRF).
|
||||||
|
Nu folosim cookie-uri de tracking sau de marketing.</p>
|
||||||
|
{% endblock %}
|
||||||
52
app/web/templates/documentatie_api.html
Normal file
52
app/web/templates/documentatie_api.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "landing_static.html" %}
|
||||||
|
{% block titlu %}Documentație API{% endblock %}
|
||||||
|
{% block descriere %}Documentația API ROA AUTOPASS: autentificare, endpoint-uri, exemple de cerere și răspuns.{% endblock %}
|
||||||
|
{% block continut %}
|
||||||
|
<h1>Documentație API</h1>
|
||||||
|
<p class="lp-sub">Conectează softul tău de service direct la ROA AUTOPASS (plan Pro).</p>
|
||||||
|
|
||||||
|
<h2>Cum funcționează</h2>
|
||||||
|
<p>Softul tău trimite prezentările printr-un singur apel HTTP; noi validăm datele, mapăm operațiile pe coduri RAR,
|
||||||
|
oprim duplicatele și transmitem la RAR Auto-Pass. Autentificarea se face cu o <strong>cheie API</strong>
|
||||||
|
(antetul <code>X-API-Key</code>), generată din contul tău. Credențialele RAR le configurezi o singură dată în cont.</p>
|
||||||
|
|
||||||
|
<h2>Endpoint-uri principale</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Endpoint</th><th>Ce face</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>POST /v1/prezentari</code></td><td>Trimite una sau mai multe prezentări spre declarare</td></tr>
|
||||||
|
<tr><td><code>POST /v1/prezentari/valideaza</code></td><td>Dry-run: validare fără trimitere</td></tr>
|
||||||
|
<tr><td><code>GET /v1/prezentari/{id}</code></td><td>Starea unei trimiteri</td></tr>
|
||||||
|
<tr><td><code>POST /v1/import</code></td><td>Import fișier xlsx/csv prin API</td></tr>
|
||||||
|
<tr><td><code>GET /v1/nomenclator</code></td><td>Nomenclatorul de coduri RAR (public)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Exemplu: cerere</h2>
|
||||||
|
<pre><code>curl -X POST "{{ base_url }}/v1/prezentari" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: rfak_..." \
|
||||||
|
-d '{{ exemplu_json["cerere"] }}'</code></pre>
|
||||||
|
<p>Fiecare prestație are fie <code>cod_prestatie</code> (cod RAR din nomenclator), fie
|
||||||
|
<code>cod_op_service</code> + <code>denumire</code> — codurile tale interne le mapăm noi pe coduri RAR,
|
||||||
|
iar maparea rămâne salvată pentru trimiterile următoare.</p>
|
||||||
|
|
||||||
|
<h2>Exemplu: răspuns</h2>
|
||||||
|
<pre><code>{{ exemplu_json["raspuns"] }}</code></pre>
|
||||||
|
<p>Un rezultat per prezentare, în aceeași ordine ca cererea. <code>status</code> poate fi
|
||||||
|
<code>queued</code> (în coadă spre RAR) sau, dacă ceva blochează trimiterea,
|
||||||
|
<code>needs_mapping</code> / <code>needs_data</code> / <code>error</code> — cu explicația în <code>motiv</code>.</p>
|
||||||
|
|
||||||
|
<h2>Referință completă</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/docs" target="_blank" rel="noopener">Swagger UI — /docs</a></li>
|
||||||
|
<li><a href="/openapi.json" target="_blank" rel="noopener">Schema OpenAPI — /openapi.json</a></li>
|
||||||
|
</ul>
|
||||||
|
<p>După autentificare găsești în aplicație, la <strong>Integrare</strong>, exemple gata de copiat pentru
|
||||||
|
curl, Python, PHP, C#, Node și Visual FoxPro, plus testarea cheii API și a credențialelor RAR.</p>
|
||||||
|
|
||||||
|
<div class="lp-card">
|
||||||
|
<p style="margin:0;">Nu ai încă un cont? <a href="/#inregistrare">Creează unul gratuit</a> — primești 30 de zile
|
||||||
|
de Pro, cu acces API inclus. Întrebări de integrare: <a href="mailto:contact@romfast.ro">contact@romfast.ro</a>.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,96 +4,26 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>ROA AUTOPASS — declari prestațiile la RAR din câteva click-uri</title>
|
<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.">
|
<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 Auto-Pass în locul tău, fără tastat manual. Conform Legii 142/2023.">
|
||||||
<style>
|
{% include "_lp_style.html" %}
|
||||||
|
|
||||||
/* 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}
|
|
||||||
/* Teme aliniate cu aplicatia (base.html) — acelasi set de 8; 'auto' se rezolva la light/dark. */
|
|
||||||
body[data-theme="light"]{--bg:#f5f7fa;--card:#ffffff;--card2:#f5f7fa;--text:#1a1d24;--sub:#5c6473;--line:#e2e5ea;--line2:#eaedf2;--accent:#1F66C9;--hbg:rgba(245,247,250,.9);--okt:#15803d;--infot:#1F66C9;--errt:#dc2626;--mut:#8a92a0}
|
|
||||||
body[data-theme="dark"]{--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="petrol"]{--bg:#0e1416;--card:#161e20;--card2:#0e1416;--text:#e6e9ef;--sub:#8b93a7;--line:#232c2e;--line2:#1c2426;--accent:#0E7C7B;--hbg:rgba(14,20,22,.9);--okt:#2FBF8F;--infot:#3fb9b6;--errt:#E05D5D;--mut:#5c6473}
|
|
||||||
.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>
|
</head>
|
||||||
<body data-theme="grafit">
|
<body data-theme="grafit">
|
||||||
<script>try{var _t=localStorage.getItem('theme');var _V={light:1,dark:1,petrol:1,grafit:1,cobalt:1,cupru:1,hartie:1,auto:1};if(_t&&_V[_t]){var _r=_t==='auto'?(window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'):_t;document.body.setAttribute('data-theme',_r);}}catch(e){}</script>
|
<script>try{var _t=localStorage.getItem('theme');var _V={light:1,dark:1,petrol:1,grafit:1,cobalt:1,cupru:1,hartie:1,auto:1};if(_t&&_V[_t]){var _r=_t==='auto'?(window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'):_t;document.body.setAttribute('data-theme',_r);}}catch(e){}</script>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<!-- HEADER -->
|
<!-- HEADER (comun cu paginile statice) -->
|
||||||
<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;">
|
{% include "_lp_header.html" %}
|
||||||
<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 -->
|
<!-- HERO -->
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
|
||||||
<div>
|
<div>
|
||||||
<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>
|
<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ă operațiile de service la RAR Auto-Pass, <span style="color:var(--accent,#2E74D6);">automat</span></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>
|
<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 Auto-Pass în locul tău. Fără tastat câmp cu câmp.</p>
|
||||||
<div style="margin-bottom:32px;">
|
<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>
|
<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>
|
||||||
<div style="display:flex;gap:12px;margin-bottom:22px;">
|
<div style="display:flex;gap:12px;margin-bottom:22px;">
|
||||||
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px 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>
|
<a href="#pret" style="display:inline-flex;align-items:center;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;text-decoration:none;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</a>
|
||||||
<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>
|
<a href="#cum-functioneaza" style="display:inline-flex;align-items:center;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;text-decoration:none;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</a>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;gap:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);flex-wrap:wrap;">
|
||||||
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
|
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
|
||||||
@@ -106,7 +36,7 @@
|
|||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);overflow:hidden;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);overflow:hidden;">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);">
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#262b36);">
|
||||||
<div>
|
<div>
|
||||||
<div style="font:700 14px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri RAR AUTOPASS</div>
|
<div style="font:700 14px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri RAR Auto-Pass</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 style="font:400 12px var(--font-mono);color:var(--sub,#8b93a7);margin-top:2px;">Service Auto Vâlcea · 28 iun 2026</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;">
|
<div style="display:flex;gap:8px;">
|
||||||
@@ -142,14 +72,14 @@
|
|||||||
<!-- PROBLEM + CALCULATOR (combinat) -->
|
<!-- PROBLEM + CALCULATOR (combinat) -->
|
||||||
<div style="padding:80px 40px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
|
<div style="padding:80px 40px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
|
||||||
<div style="text-align:center;max-width:760px;margin:0 auto 40px;">
|
<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>
|
<h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);"><span style="color:var(--errt,#E05D5D);">Pierzi 6 zile pe an</span><br>introducând manual în Auto-Pass ...</h2>
|
||||||
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi. Mută cursorul la volumul service-ului tău și vezi cât te costă.</p>
|
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">... VIN de 2 ori, număr de înmatriculare, kilometraj, dată, operații…<br>în loc să pleci mai devreme acasă.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:stretch;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:stretch;">
|
||||||
<!-- STANGA: formularul RAR AUTOPASS -->
|
<!-- STANGA: formularul RAR Auto-Pass -->
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:24px;display:flex;flex-direction:column;">
|
<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;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">RAR Auto-Pass · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px var(--font-mono);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;">
|
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||||
<div><div style="font:400 11px 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;">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>
|
||||||
@@ -172,11 +102,13 @@
|
|||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;">
|
||||||
<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);">Trimiteri/lună</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);" id="out-pres">100</span></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;" />
|
<input type="range" min="0" max="5" step="1" value="1" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-top:5px;font:400 11px var(--font-mono);color:var(--mut,#5c6473);"><span>50</span><span>100</span><span>150</span><span>200</span><span>300</span><span>500</span></div>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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;" />
|
<input type="range" min="0" max="5" step="1" value="2" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-top:5px;font:400 11px var(--font-mono);color:var(--mut,#5c6473);"><span>30</span><span>50</span><span>60</span><span>80</span><span>100</span><span>150</span></div>
|
||||||
</div>
|
</div>
|
||||||
</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="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;">
|
||||||
@@ -208,13 +140,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SOLVE -->
|
<!-- SOLVE: fragment de wizard import (pasii reali din aplicatie) -->
|
||||||
<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 id="cum-functioneaza" style="padding:80px 40px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
|
||||||
<div style="max-width:780px;margin:0 auto;text-align:center;">
|
<div style="max-width:720px;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>
|
<h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Uite cât de simplu e</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>
|
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;">Patru pași pe ecran, exact așa cum arată în aplicație: <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">încarci</span> fișierul, <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi</span> potrivirile propuse de noi, <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">verifici</span> lista și apeși <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Trimite</span>. Prima dată durează câteva minute — de la a doua, câteva secunde.</p>
|
||||||
</div>
|
</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);">
|
|
||||||
|
<div style="max-width:980px;margin:34px auto 0;background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;box-shadow:0 24px 60px -24px rgba(0,0,0,.45);overflow:hidden;">
|
||||||
|
<div style="display:flex;align-items:center;gap:7px;padding:10px 14px;border-bottom:1px solid var(--line,#262b36);background:var(--card2,#0f1218);">
|
||||||
|
<span style="width:10px;height:10px;border-radius:99px;background:var(--errt,#E05D5D);"></span><span style="width:10px;height:10px;border-radius:99px;background:#E0A93B;"></span><span style="width:10px;height:10px;border-radius:99px;background:var(--okt,#2FBF8F);"></span>
|
||||||
|
<span style="font:400 12px var(--font-mono);color:var(--sub,#8b93a7);margin-left:8px;">ROA AUTOPASS · Import fișier</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;padding:16px 18px 0;">
|
||||||
|
<div style="flex:1;min-width:150px;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:2px solid var(--okt,#2FBF8F);font:500 12.5px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:20px;height:20px;border-radius:99px;border:1.5px solid currentColor;display:flex;align-items:center;justify-content:center;font:700 11px var(--font-ui);flex-shrink:0;">✓</span>Încarcă fișier</div>
|
||||||
|
<div style="flex:1;min-width:150px;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:2px solid var(--okt,#2FBF8F);font:500 12.5px var(--font-ui);color:var(--okt,#2FBF8F);"><span style="width:20px;height:20px;border-radius:99px;border:1.5px solid currentColor;display:flex;align-items:center;justify-content:center;font:700 11px var(--font-ui);flex-shrink:0;">✓</span>Potrivește coloanele</div>
|
||||||
|
<div style="flex:1;min-width:150px;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:2px solid var(--accent,#2E74D6);font:500 12.5px var(--font-ui);color:var(--text,#e6e9ef);"><span style="width:20px;height:20px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;display:flex;align-items:center;justify-content:center;font:700 11px var(--font-ui);flex-shrink:0;">3</span>Verifică</div>
|
||||||
|
<div style="flex:1;min-width:150px;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:2px solid var(--line,#262b36);font:500 12.5px var(--font-ui);color:var(--mut,#5c6473);"><span style="width:20px;height:20px;border-radius:99px;border:1.5px solid var(--line,#262b36);display:flex;align-items:center;justify-content:center;font:700 11px var(--font-ui);flex-shrink:0;">4</span>Confirmă trimiterea</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1.15fr 1.35fr;gap:14px;padding:18px;">
|
||||||
|
<!-- Pas 1: incarcare -->
|
||||||
|
<div style="background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px;min-width:0;">
|
||||||
|
<div style="font:700 12px var(--font-ui);letter-spacing:.06em;text-transform:uppercase;color:var(--sub,#8b93a7);display:flex;align-items:center;gap:7px;"><span style="width:17px;height:17px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;display:inline-flex;align-items:center;justify-content:center;font:700 10px var(--font-ui);flex-shrink:0;">1</span>Încarcă fișier</div>
|
||||||
|
<div style="border:1.5px dashed color-mix(in srgb,var(--accent,#2E74D6) 45%,var(--line,#262b36));border-radius:8px;padding:16px 12px;text-align:center;font:400 12.5px/1.5 var(--font-ui);color:var(--sub,#8b93a7);">
|
||||||
|
Trage fișierul aici sau alege-l de pe calculator
|
||||||
|
<br><span style="display:inline-flex;align-items:center;gap:7px;margin-top:9px;padding:5px 11px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);font:500 12px var(--font-mono);color:var(--text,#e6e9ef);">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--okt,#2FBF8F)" stroke-width="1.8"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
|
comenzi_iunie.xlsx
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="font:400 11px/1.5 var(--font-ui);color:var(--okt,#2FBF8F);display:flex;gap:6px;align-items:flex-start;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Merge fișierul tău, așa cum îl scoți din programul de facturare — xlsx sau csv.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Pas 2: mapare coloane -->
|
||||||
|
<div style="background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px;min-width:0;">
|
||||||
|
<div style="font:700 12px var(--font-ui);letter-spacing:.06em;text-transform:uppercase;color:var(--sub,#8b93a7);display:flex;align-items:center;gap:7px;"><span style="width:17px;height:17px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;display:inline-flex;align-items:center;justify-content:center;font:700 10px var(--font-ui);flex-shrink:0;">2</span>Potrivește coloanele</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:6px;"><span style="padding:5px 8px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:400 11px var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Serie sasiu</span><span style="color:var(--mut,#5c6473);text-align:center;font:400 11px var(--font-ui);">→</span><span style="padding:5px 8px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 12%,var(--card,#181c24));border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 35%,var(--line,#262b36));color:var(--text,#e6e9ef);font:600 11px var(--font-ui);display:flex;align-items:center;justify-content:space-between;gap:6px;overflow:hidden;white-space:nowrap;">VIN <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.6;"><path d="M6 9l6 6 6-6"/></svg></span></div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:6px;"><span style="padding:5px 8px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:400 11px var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Data iesire</span><span style="color:var(--mut,#5c6473);text-align:center;font:400 11px var(--font-ui);">→</span><span style="padding:5px 8px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 12%,var(--card,#181c24));border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 35%,var(--line,#262b36));color:var(--text,#e6e9ef);font:600 11px var(--font-ui);display:flex;align-items:center;justify-content:space-between;gap:6px;overflow:hidden;white-space:nowrap;">Data prestației <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.6;"><path d="M6 9l6 6 6-6"/></svg></span></div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:6px;"><span style="padding:5px 8px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:400 11px var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Km bord</span><span style="color:var(--mut,#5c6473);text-align:center;font:400 11px var(--font-ui);">→</span><span style="padding:5px 8px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 12%,var(--card,#181c24));border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 35%,var(--line,#262b36));color:var(--text,#e6e9ef);font:600 11px var(--font-ui);display:flex;align-items:center;justify-content:space-between;gap:6px;overflow:hidden;white-space:nowrap;">Odometru <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.6;"><path d="M6 9l6 6 6-6"/></svg></span></div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:6px;"><span style="padding:5px 8px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:400 11px var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Denumire lucrare</span><span style="color:var(--mut,#5c6473);text-align:center;font:400 11px var(--font-ui);">→</span><span style="padding:5px 8px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 12%,var(--card,#181c24));border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 35%,var(--line,#262b36));color:var(--text,#e6e9ef);font:600 11px var(--font-ui);display:flex;align-items:center;justify-content:space-between;gap:6px;overflow:hidden;white-space:nowrap;">Operații <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.6;"><path d="M6 9l6 6 6-6"/></svg></span></div>
|
||||||
|
<div style="font:400 11px/1.5 var(--font-ui);color:var(--okt,#2FBF8F);display:flex;gap:6px;align-items:flex-start;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Le potrivești o singură dată — la următorul fișier le ținem minte noi.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Pas 3: verificare -->
|
||||||
|
<div style="background:var(--card2,#0f1218);border:1px solid var(--line,#262b36);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px;min-width:0;">
|
||||||
|
<div style="font:700 12px var(--font-ui);letter-spacing:.06em;text-transform:uppercase;color:var(--sub,#8b93a7);display:flex;align-items:center;gap:7px;"><span style="width:17px;height:17px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;display:inline-flex;align-items:center;justify-content:center;font:700 10px var(--font-ui);flex-shrink:0;">3</span>Verifică</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#2FBF8F 13%,transparent);color:var(--okt,#2FBF8F);white-space:nowrap;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>22 gata de trimis</span>
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#E0A93B 14%,transparent);color:#E0A93B;white-space:nowrap;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>1 verifică valori</span>
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#E05D5D 13%,transparent);color:var(--errt,#E05D5D);white-space:nowrap;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>1 cod RAR lipsă</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;border-radius:7px;background:var(--card,#181c24);border:1px solid var(--line2,#1f2530);min-width:0;">
|
||||||
|
<div style="min-width:0;"><div style="font:500 11px var(--font-mono);color:var(--text,#e6e9ef);">WBA8E9...K7F2</div><div style="font:400 10.5px var(--font-ui);color:var(--sub,#8b93a7);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Revizie periodică · 22.06.2026</div></div>
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#2FBF8F 13%,transparent);color:var(--okt,#2FBF8F);white-space:nowrap;flex-shrink:0;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>Gata de trimis</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;border-radius:7px;background:var(--card,#181c24);border:1px solid color-mix(in srgb,#E0A93B 40%,var(--line,#262b36));min-width:0;">
|
||||||
|
<div style="min-width:0;"><div style="font:500 11px var(--font-mono);color:var(--text,#e6e9ef);">WVWZZZ...3M1</div><div style="font:400 10.5px var(--font-ui);color:var(--sub,#8b93a7);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Schimb plăcuțe frână · 22.06.2026</div></div>
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#E0A93B 14%,transparent);color:#E0A93B;white-space:nowrap;flex-shrink:0;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>Verifică valori</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;border-radius:7px;background:var(--card,#181c24);border:1px solid color-mix(in srgb,#E05D5D 40%,var(--line,#262b36));min-width:0;">
|
||||||
|
<div style="min-width:0;"><div style="font:500 11px var(--font-mono);color:var(--text,#e6e9ef);">VF1RFB...A88</div><div style="font:400 10.5px var(--font-ui);color:var(--sub,#8b93a7);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">REP. ELECTROMOTOR · 21.06.2026</div></div>
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font:600 10.5px var(--font-ui);background:color-mix(in srgb,#E05D5D 13%,transparent);color:var(--errt,#E05D5D);white-space:nowrap;flex-shrink:0;"><span style="width:5px;height:5px;border-radius:99px;background:currentColor;"></span>Cod RAR lipsă</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center;min-width:0;">
|
||||||
|
<span style="flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:6px;padding:5px 8px;border-radius:6px;background:var(--card,#181c24);border:1px solid var(--line,#262b36);font:400 11px var(--font-ui);color:var(--text,#e6e9ef);white-space:nowrap;overflow:hidden;">63 — Instalație electrică <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.55;"><path d="M6 9l6 6 6-6"/></svg></span>
|
||||||
|
<span style="height:26px;padding:0 12px;border-radius:6px;background:var(--accent,#2E74D6);color:#fff;font:600 11px/26px var(--font-ui);flex-shrink:0;">Salvează</span>
|
||||||
|
</div>
|
||||||
|
<div style="font:400 10.5px/1.5 var(--font-ui);color:var(--accent,#2E74D6);display:flex;gap:6px;align-items:flex-start;">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>
|
||||||
|
Sugestia e preselectată — o confirmi cu un click și rămâne salvată.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin:0 18px 18px;padding:13px 16px;border-radius:10px;background:color-mix(in srgb,#1F9D5C 10%,var(--card2,#0f1218));border:1px solid color-mix(in srgb,#1F9D5C 32%,var(--line,#262b36));flex-wrap:wrap;">
|
||||||
|
<div style="font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);"><strong style="color:var(--text,#e6e9ef);font-weight:700;">22 de prezentări</strong> gata de trimis · duplicatele sunt oprite automat, tu doar confirmi numărul</div>
|
||||||
|
<span style="height:38px;padding:0 18px;border-radius:6px;background:#1F9D5C;color:#fff;font:700 13px/38px var(--font-ui);">Trimite la RAR →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center;max-width:880px;margin:30px auto 0;font:400 16px/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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,7 +234,7 @@
|
|||||||
<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>
|
<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>
|
<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>
|
<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>
|
<a href="/documentatie-api" 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;text-decoration:none;" 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></a>
|
||||||
</div>
|
</div>
|
||||||
<div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;">
|
<div style="background:#0d1015;border:1px solid #262b36;border-radius:10px;overflow:hidden;">
|
||||||
<div style="display:flex;align-items:center;gap:7px;padding:11px 14px;border-bottom:1px solid #262b36;">
|
<div style="display:flex;align-items:center;gap:7px;padding:11px 14px;border-bottom:1px solid #262b36;">
|
||||||
@@ -264,20 +272,18 @@
|
|||||||
<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>
|
<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>
|
<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>
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:stretch;">
|
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:0 auto;align-items:stretch;max-width:920px;">
|
||||||
<!-- Gratuit -->
|
<!-- 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="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="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="display:flex;align-items:baseline;gap:5px;margin-bottom:14px;"><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-wrap:wrap;gap:6px;margin-bottom:12px;"><span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:7px;font:700 11.5px var(--font-ui);background:color-mix(in srgb,var(--accent,#2E74D6) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--line,#262b36));color:var(--accent,#2E74D6);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Import CSV/XLSX</span></div>
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:7px;padding:7px 10px;border-radius:7px;background:color-mix(in srgb,#2FBF8F 13%,transparent);color:var(--okt,#2FBF8F);font:600 12px/1.45 var(--font-ui);margin-bottom:14px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg><span>Economisești ~2,5 ore / ~150 lei pe lună <span style="font-weight:400;opacity:.8;">· la 60 de trimiteri</span></span></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;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>Până la 60 de trimiteri/lună</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 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>Îți sugerăm codul RAR pentru fiecare operație</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>Alegerile se salvează — la următorul fișier nu mai mapezi nimic</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(--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>Protecție la duplicate</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 style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, în 48h</div>
|
||||||
</div>
|
</div>
|
||||||
<button data-act="auth" data-tab="register" data-plan="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>
|
<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>
|
||||||
@@ -286,16 +292,12 @@
|
|||||||
<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="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="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="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="display:flex;align-items:baseline;gap:5px;margin-bottom:14px;"><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-wrap:wrap;gap:6px;margin-bottom:12px;"><span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:7px;font:700 11.5px var(--font-ui);background:color-mix(in srgb,var(--accent,#2E74D6) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--line,#262b36));color:var(--accent,#2E74D6);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Import CSV/XLSX</span><span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:7px;font:500 11.5px var(--font-ui);background:transparent;border:1px solid var(--line,#262b36);color:var(--mut,#5c6473);text-decoration:line-through;text-decoration-thickness:1px;">API</span></div>
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:7px;padding:7px 10px;border-radius:7px;background:color-mix(in srgb,#2FBF8F 13%,transparent);color:var(--okt,#2FBF8F);font:600 12px/1.45 var(--font-ui);margin-bottom:14px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg><span>Economisești 4+ ore / 250+ lei pe lună <span style="font-weight:400;opacity:.8;">· la 100+ trimiteri</span></span></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;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><span><strong style="font-weight:700;">Tot ce este la Gratuit</strong> +</span></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>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 style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 24h</div>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;height:46px;border-radius:6px;background:#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>
|
<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>
|
||||||
@@ -303,37 +305,17 @@
|
|||||||
<!-- Pro -->
|
<!-- 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="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="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="display:flex;align-items:baseline;gap:5px;margin-bottom:14px;"><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-wrap:wrap;gap:6px;margin-bottom:12px;"><span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:7px;font:700 11.5px var(--font-ui);background:color-mix(in srgb,var(--accent,#2E74D6) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--line,#262b36));color:var(--accent,#2E74D6);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Import CSV/XLSX</span><span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:7px;font:700 11.5px var(--font-ui);background:color-mix(in srgb,#2FBF8F 13%,transparent);border:1px solid color-mix(in srgb,#2FBF8F 40%,var(--line,#262b36));color:var(--okt,#2FBF8F);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/></svg>+ API</span></div>
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:7px;padding:7px 10px;border-radius:7px;background:color-mix(in srgb,#2FBF8F 13%,transparent);color:var(--okt,#2FBF8F);font:600 12px/1.45 var(--font-ui);margin-bottom:14px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg><span>Economisești 4+ ore / 250+ lei pe lună <span style="font-weight:400;opacity:.8;">· la 100+ trimiteri</span></span></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;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><span><strong style="font-weight:700;">Tot ce este la Standard</strong> +</span></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>Trimite direct din softul de service (API)</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
<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>Confirmi mapările fie prin API, fie din browser</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 style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 8h</div>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px 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>
|
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="pro">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Premium -->
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -366,7 +348,7 @@
|
|||||||
<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>
|
<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>
|
<p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit. Imediat poți încărca primul fișier sau conecta softul de service.</p>
|
||||||
<div style="display:flex;flex-direction:column;gap:12px;">
|
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px 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>Ai Pro gratuit primele 30 de zile, apoi pachetul ales</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>Conform Legii 142/2023 și OMTI 210/2024</div>
|
||||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Datele cu caracter personal criptate (GDPR)</div>
|
<div style="display:flex;gap:10px;align-items:center;font:400 15px 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>
|
||||||
@@ -382,8 +364,8 @@
|
|||||||
<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);">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);">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: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: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></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>
|
<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;" /><span>Sunt de acord cu <a href="/termeni" target="_blank" rel="noopener" style="color:var(--accent,#2E74D6);">Termenii</a> și cu prelucrarea datelor conform <a href="/confidentialitate" target="_blank" rel="noopener" style="color:var(--accent,#2E74D6);">politicii de confidențialitate (GDPR)</a>.</span></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>
|
<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>
|
<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>
|
||||||
@@ -399,14 +381,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FOOTER -->
|
<!-- FOOTER (comun cu paginile statice) -->
|
||||||
<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;">
|
{% include "_lp_footer.html" %}
|
||||||
<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>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
@@ -434,12 +410,13 @@
|
|||||||
el.addEventListener('mouseleave',function(){keys.forEach(function(k){el.style.setProperty(k,saved[k]);});});
|
el.addEventListener('mouseleave',function(){keys.forEach(function(k){el.style.setProperty(k,saved[k]);});});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculator: cost raportare manuala (2,5 min/prestatie).
|
// Calculator: cost raportare manuala (2,5 min/prestatie). Slidere cu trepte discrete.
|
||||||
var pres=document.getElementById('calc-pres'),rate=document.getElementById('calc-rate');
|
var pres=document.getElementById('calc-pres'),rate=document.getElementById('calc-rate');
|
||||||
|
var PRES_STEPS=[50,100,150,200,300,500],RATE_STEPS=[30,50,60,80,100,150];
|
||||||
var nf=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:0});
|
var nf=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:0});
|
||||||
var nf1=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:1});
|
var nf1=new Intl.NumberFormat('ro-RO',{maximumFractionDigits:1});
|
||||||
function recalc(){
|
function recalc(){
|
||||||
var p=+pres.value,r=+rate.value,minPer=2.5;
|
var p=PRES_STEPS[+pres.value],r=RATE_STEPS[+rate.value],minPer=2.5;
|
||||||
var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
|
var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
|
||||||
document.getElementById('out-pres').textContent=p;
|
document.getElementById('out-pres').textContent=p;
|
||||||
document.getElementById('out-rate').textContent=r;
|
document.getElementById('out-rate').textContent=r;
|
||||||
|
|||||||
72
app/web/templates/landing_static.html
Normal file
72
app/web/templates/landing_static.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ro">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block titlu %}ROA AUTOPASS{% endblock %} — ROA AUTOPASS</title>
|
||||||
|
<meta name="description" content="{% block descriere %}ROA AUTOPASS — gateway de declarare a prestatiilor la RAR Auto-Pass.{% endblock %}">
|
||||||
|
{% include "_lp_style.html" %}
|
||||||
|
<style>
|
||||||
|
/* Prose pentru paginile statice (termeni, GDPR, contact, documentatie API). */
|
||||||
|
.lp-prose{max-width:820px;margin:0 auto;padding:56px 40px 80px;}
|
||||||
|
.lp-prose h1{font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 8px;color:var(--text,#e6e9ef);}
|
||||||
|
.lp-prose .lp-sub{font:400 14px var(--font-ui);color:var(--mut,#5c6473);margin:0 0 36px;}
|
||||||
|
.lp-prose h2{font:700 20px/1.3 var(--font-ui);letter-spacing:-.01em;margin:36px 0 10px;color:var(--text,#e6e9ef);}
|
||||||
|
.lp-prose p,.lp-prose li{font:400 15px/1.7 var(--font-ui);color:var(--sub,#8b93a7);}
|
||||||
|
.lp-prose p{margin:0 0 12px;}
|
||||||
|
.lp-prose ul{margin:0 0 12px;padding-left:22px;}
|
||||||
|
.lp-prose li{margin-bottom:6px;}
|
||||||
|
.lp-prose strong{color:var(--text,#e6e9ef);font-weight:600;}
|
||||||
|
.lp-prose a{color:var(--accent,#2E74D6);}
|
||||||
|
.lp-prose code{font:400 13px var(--font-mono);color:var(--text,#e6e9ef);background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:4px;padding:1px 5px;}
|
||||||
|
.lp-prose pre{background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:8px;padding:16px;overflow-x:auto;margin:0 0 12px;}
|
||||||
|
.lp-prose pre code{background:none;border:none;padding:0;font:400 13px/1.6 var(--font-mono);}
|
||||||
|
.lp-prose table{width:100%;border-collapse:collapse;margin:0 0 12px;}
|
||||||
|
.lp-prose th,.lp-prose td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line,#262b36);font:400 14px/1.5 var(--font-ui);color:var(--sub,#8b93a7);}
|
||||||
|
.lp-prose th{color:var(--text,#e6e9ef);font-weight:600;}
|
||||||
|
.lp-prose .lp-card{background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:10px;padding:18px 20px;margin:0 0 12px;}
|
||||||
|
@media (max-width:700px){.lp-prose{padding:36px 20px 48px;}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-theme="grafit">
|
||||||
|
<script>try{var _t=localStorage.getItem('theme');var _V={light:1,dark:1,petrol:1,grafit:1,cobalt:1,cupru:1,hartie:1,auto:1};if(_t&&_V[_t]){var _r=_t==='auto'?(window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'):_t;document.body.setAttribute('data-theme',_r);}}catch(e){}</script>
|
||||||
|
<main class="page">
|
||||||
|
{% with lp_root = "/" %}{% include "_lp_header.html" %}{% endwith %}
|
||||||
|
|
||||||
|
<div class="lp-prose">
|
||||||
|
{% block continut %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "_lp_footer.html" %}
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// Aceeasi rotatie de teme si cheie 'theme' ca landing/base.html.
|
||||||
|
var THEMES=[['light','Light'],['dark','Dark'],['petrol','Petrol'],['grafit','Grafit'],['cobalt','Cobalt'],['cupru','Cupru'],['hartie','Hârtie'],['auto','Auto']];
|
||||||
|
var VALID=THEMES.reduce(function(a,t){a[t[0]]=1;return a;},{});
|
||||||
|
var body=document.body;
|
||||||
|
function resolve(id){return id==='auto'?(window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'):id;}
|
||||||
|
function storedId(){try{var v=localStorage.getItem('theme');return (v&&VALID[v])?v:null;}catch(e){return null;}}
|
||||||
|
function curIndex(){var t=storedId()||body.getAttribute('data-theme')||'grafit';for(var i=0;i<THEMES.length;i++){if(THEMES[i][0]===t)return i;}return 3;}
|
||||||
|
function label(i){var l=document.getElementById('theme-label');if(l)l.textContent=THEMES[i][1];}
|
||||||
|
function applyTheme(i){i=((i%THEMES.length)+THEMES.length)%THEMES.length;var id=THEMES[i][0];body.setAttribute('data-theme',resolve(id));label(i);try{localStorage.setItem('theme',id);}catch(e){}}
|
||||||
|
(function(){var i=curIndex();body.setAttribute('data-theme',resolve(THEMES[i][0]));label(i);})();
|
||||||
|
|
||||||
|
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]);});});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pe paginile statice nu exista formularul #inregistrare: butonul de auth duce pe landing.
|
||||||
|
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==='auth'){e.preventDefault();window.location.href='/#inregistrare';}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block title %}Autentificare — ROA AUTOPASS{% endblock %}
|
{% block title %}Autentificare — ROA AUTOPASS{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{# /login — card de autentificare branduit, o singura coloana (simplificat din layout-ul
|
{# /login — card de autentificare branduit, o singura coloana (simplificat din layout-ul
|
||||||
2-coloane US-010 PRD 5.16). Formular: CSRF, POST /login, link signup. #}
|
2-coloane). Formular: CSRF, POST /login, link signup. #}
|
||||||
<div class="login-2col" style="max-width:860px; margin:32px auto;">
|
<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) #}
|
{# Antet minimal deja randat in base.html (fara RAR dot, fara burger, fara account_name) #}
|
||||||
<div class="login-shell">
|
<div class="login-shell">
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Format aliniat la formularul de inregistrare din landing (#inregistrare): aceleasi campuri,
|
{# Format aliniat la formularul de inregistrare din landing (#inregistrare): aceleasi campuri,
|
||||||
etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro/premium),
|
etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro),
|
||||||
normalizate server-side. #}
|
normalizate server-side; 'premium' ramane valid in backend, doar alocat de admin. #}
|
||||||
<form method="post" action="/signup">
|
<form method="post" action="/signup">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label style="display:block;margin-bottom:14px;">
|
<label style="display:block;margin-bottom:14px;">
|
||||||
@@ -75,7 +75,6 @@
|
|||||||
<option value="free"{% if not plan or plan == 'free' %} selected{% endif %}>Gratuit — 0 lei/lună</option>
|
<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="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="pro"{% if plan == 'pro' %} selected{% endif %}>Pro — 59 lei/lună</option>
|
||||||
<option value="premium"{% if plan == 'premium' %} selected{% endif %}>Premium — la cerere</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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;">
|
<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;">
|
||||||
|
|||||||
53
app/web/templates/termeni.html
Normal file
53
app/web/templates/termeni.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "landing_static.html" %}
|
||||||
|
{% block titlu %}Termeni și condiții{% endblock %}
|
||||||
|
{% block descriere %}Termenii și condițiile de utilizare a serviciului ROA AUTOPASS.{% endblock %}
|
||||||
|
{% block continut %}
|
||||||
|
<h1>Termeni și condiții</h1>
|
||||||
|
<p class="lp-sub">Ultima actualizare: 9 iulie 2026</p>
|
||||||
|
|
||||||
|
<h2>1. Serviciul</h2>
|
||||||
|
<p><strong>ROA AUTOPASS</strong> este un serviciu operat de <strong>ROMFAST</strong> care preia prezentările
|
||||||
|
(prestațiile) din service-ul tău auto — prin import de fișier sau prin API — și le transmite în numele tău
|
||||||
|
către sistemul <strong>RAR Auto-Pass</strong>, conform Legii nr. 142/2023 și OMTI nr. 210/2024.</p>
|
||||||
|
<p>Serviciul nu înlocuiește obligațiile legale ale service-ului: tu rămâi responsabil de corectitudinea
|
||||||
|
datelor declarate (VIN, kilometraj, data prestației, operațiile efectuate).</p>
|
||||||
|
|
||||||
|
<h2>2. Contul</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Contul se creează gratuit, pe baza datelor firmei (denumire, CUI, email).</li>
|
||||||
|
<li>Ești responsabil de confidențialitatea parolei și a cheilor API generate pe cont.</li>
|
||||||
|
<li>Credențialele tale RAR Auto-Pass sunt folosite exclusiv pentru transmiterea prestațiilor tale și sunt stocate criptat.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Planuri și prețuri</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Gratuit</strong> — 0 lei/lună, până la 60 de trimiteri/lună, import CSV/XLSX.</li>
|
||||||
|
<li><strong>Standard</strong> — 49 lei/lună (fără TVA), trimiteri nelimitate.</li>
|
||||||
|
<li><strong>Pro</strong> — 59 lei/lună (fără TVA), trimiteri nelimitate + acces API.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Fiecare cont nou primește 30 de zile de Pro gratuit; la final trece automat pe planul Gratuit, fără plată.
|
||||||
|
Poți schimba sau anula planul oricând; prețurile pot fi actualizate cu notificare prealabilă.</p>
|
||||||
|
|
||||||
|
<h2>4. Ce garantăm și ce nu</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Depunem eforturi rezonabile ca serviciul să fie disponibil continuu, dar nu garantăm funcționarea
|
||||||
|
neîntreruptă — inclusiv pentru că transmiterea depinde de disponibilitatea sistemului RAR Auto-Pass.</li>
|
||||||
|
<li>Fiecare trimitere are protecție la duplicate și stare vizibilă în dashboard (trimisă, în coadă, de corectat).</li>
|
||||||
|
<li>O prezentare înregistrată la RAR este definitivă în sistemul RAR; corecțiile ulterioare se fac prin suportul RAR.</li>
|
||||||
|
<li>Răspunderea ROMFAST este limitată la contravaloarea abonamentului plătit pe ultimele 3 luni.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Utilizare acceptabilă</h2>
|
||||||
|
<p>Este interzisă folosirea serviciului pentru declararea de date false, pentru conturi ale altor firme fără
|
||||||
|
acordul acestora sau în orice mod care afectează funcționarea platformei ori a sistemului RAR.
|
||||||
|
Conturile care încalcă aceste reguli pot fi suspendate.</p>
|
||||||
|
|
||||||
|
<h2>6. Datele tale</h2>
|
||||||
|
<p>Prelucrarea datelor cu caracter personal este descrisă în
|
||||||
|
<a href="/confidentialitate">Politica de confidențialitate / GDPR</a>. Pe scurt: reținem doar strictul necesar,
|
||||||
|
datele sunt criptate, iar trimiterile se șterg automat după 90 de zile.</p>
|
||||||
|
|
||||||
|
<h2>7. Contact</h2>
|
||||||
|
<p>Pentru orice întrebare legată de acești termeni: <a href="mailto:contact@romfast.ro">contact@romfast.ro</a>
|
||||||
|
sau pagina de <a href="https://romfast.ro/menu/contact.html" target="_blank" rel="noopener">Contact</a>.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -16,7 +16,7 @@ Worker-ul face login per CONT cu acele creds, cache-uieste JWT (30h) in memorie
|
|||||||
STERGE creds-urile efemere dupa primul login reusit. Token-ul in memorie acopera
|
STERGE creds-urile efemere dupa primul login reusit. Token-ul in memorie acopera
|
||||||
restul trimiterilor; la restart token-ul se pierde si contul re-logheaza la urmatorul
|
restul trimiterilor; la restart token-ul se pierde si contul re-logheaza la urmatorul
|
||||||
submission care aduce creds proaspete (degradare acceptata). Fallback durabil: slotul
|
submission care aduce creds proaspete (degradare acceptata). Fallback durabil: slotul
|
||||||
per-env al contului (accounts.rar_creds_{env}_enc, US-013; coloana legacy dropata).
|
per-env al contului (accounts.rar_creds_{env}_enc; coloana legacy dropata).
|
||||||
Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc.
|
Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc.
|
||||||
|
|
||||||
Pornire: python -m app.worker
|
Pornire: python -m app.worker
|
||||||
@@ -37,7 +37,7 @@ from ..config import Settings, get_settings, load_test_credentials
|
|||||||
from ..crypto import decrypt_creds
|
from ..crypto import decrypt_creds
|
||||||
from ..db import get_connection, init_db, read_heartbeat, write_heartbeat
|
from ..db import get_connection, init_db, read_heartbeat, write_heartbeat
|
||||||
from ..observ import log_event, set_source
|
from ..observ import log_event, set_source
|
||||||
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
|
from ..mapping import DEFAULT_ACCOUNT_ID, split_prestatii_excluse, upsert_nomenclator
|
||||||
from ..payload import build_rar_payload
|
from ..payload import build_rar_payload
|
||||||
from ..reconcile import match_finalizata
|
from ..reconcile import match_finalizata
|
||||||
from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env
|
from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env
|
||||||
@@ -142,6 +142,34 @@ def purge_expired(conn) -> dict[str, int]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def expire_held(conn, settings: Settings) -> int:
|
||||||
|
"""Expira randurile tinute manual (`queued AND held=1`) mai vechi de `held_retention_days`.
|
||||||
|
|
||||||
|
Inchide gaura GDPR/L.142: un `queued held=1` nu e nici `sent` nici
|
||||||
|
blocat -> altfel NU primeste `purge_after` -> PII criptat ar sta la nesfarsit.
|
||||||
|
Le trece la `error`/`TINUT_EXPIRAT` (terminal) si seteaza `purge_after` DIRECT la
|
||||||
|
momentul expirarii (NU lasa retentia de 30z a randurilor blocate sa se adauge; altfel
|
||||||
|
viata reala = 90 + 30 = 120 zile, nu 90). `purge_after` e setat cu o secunda IN TRECUT
|
||||||
|
(`datetime('now','-1 second')`): `purge_expired` foloseste comparatie STRICTA
|
||||||
|
(`purge_after < datetime('now')`), iar `datetime('now')` are rezolutie de o secunda —
|
||||||
|
cu `purge_after=now` cele doua ar fi egale in acelasi ciclu si randul NU s-ar purja
|
||||||
|
decat la ciclul urmator (~1h de PII in plus). Cu `-1 second` randul e purjabil IMEDIAT
|
||||||
|
in acelasi ciclu de purjare.
|
||||||
|
|
||||||
|
Intoarce numarul de randuri expirate.
|
||||||
|
"""
|
||||||
|
# Numar de zile validat (int) — construim string-ul '-N days' fara input extern.
|
||||||
|
days = int(settings.held_retention_days)
|
||||||
|
cutoff = f"-{days} days"
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE submissions SET status='error', rar_error='TINUT_EXPIRAT', "
|
||||||
|
"purge_after=datetime('now', '-1 second'), sending_since=NULL, updated_at=datetime('now') "
|
||||||
|
"WHERE status='queued' AND held=1 AND created_at < datetime('now', ?)",
|
||||||
|
(cutoff,),
|
||||||
|
)
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
def requeue_with_backoff(conn, settings: Settings, submission_id: int, *, reason: str) -> None:
|
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."""
|
"""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()
|
row = conn.execute("SELECT retry_count FROM submissions WHERE id=?", (submission_id,)).fetchone()
|
||||||
@@ -171,6 +199,10 @@ def claim_one(conn) -> dict | None:
|
|||||||
"SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc, s.rar_env "
|
"SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc, s.rar_env "
|
||||||
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
||||||
"WHERE s.status='queued' "
|
"WHERE s.status='queued' "
|
||||||
|
# Randurile tinute manual (held=1) NU se revendica —
|
||||||
|
# asteapta eliberarea umana (manual/auto). Se aplica DOAR la claim din
|
||||||
|
# 'queued'; randurile deja 'sending' (orfani/reconciliere) sunt neafectate.
|
||||||
|
"AND s.held = 0 "
|
||||||
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
||||||
# Gate pe stare de cont: doar 'active' trimite. Derivam defensiv din `active`
|
# Gate pe stare de cont: doar 'active' trimite. Derivam defensiv din `active`
|
||||||
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
|
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
|
||||||
@@ -226,7 +258,10 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d
|
|||||||
sid = claimed["id"]
|
sid = claimed["id"]
|
||||||
account_id = claimed.get("account_id")
|
account_id = claimed.get("account_id")
|
||||||
content = claimed["content"]
|
content = claimed["content"]
|
||||||
payload = build_rar_payload(content)
|
# Prestatiile excluse de la declarare raman in content (persistat neatins) dar
|
||||||
|
# nu pleaca niciodata la RAR: ingustare la declarabile chiar la momentul trimiterii.
|
||||||
|
declarabile, _excluse = split_prestatii_excluse(content.get("prestatii"))
|
||||||
|
payload = build_rar_payload({**content, "prestatii": declarabile})
|
||||||
try:
|
try:
|
||||||
data = rar.post_prezentare(token, payload)
|
data = rar.post_prezentare(token, payload)
|
||||||
mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id"))
|
mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id"))
|
||||||
@@ -432,7 +467,7 @@ def _creds_for(claimed: dict, settings: Settings) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def _creds_from_account(conn, account_id: int, rar_env: str = "test") -> dict | None:
|
def _creds_from_account(conn, account_id: int, rar_env: str = "test") -> dict | None:
|
||||||
"""Creds RAR durabile per-cont din slotul per-env (US-013 — coloana legacy dropata).
|
"""Creds RAR durabile per-cont din slotul per-env (coloana legacy dropata).
|
||||||
|
|
||||||
Canal web: creds in accounts.rar_creds_{rar_env}_enc (per-env, singurul slot valid).
|
Canal web: creds in accounts.rar_creds_{rar_env}_enc (per-env, singurul slot valid).
|
||||||
"""
|
"""
|
||||||
@@ -450,7 +485,7 @@ 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).
|
"""Un cont cu creds durabile pentru login-ul de proba (sau creds <test> in dev).
|
||||||
|
|
||||||
Ancora M2: cauta in slotul per-env al mediului `settings.rar_env` (ancora globala).
|
Ancora M2: cauta in slotul per-env al mediului `settings.rar_env` (ancora globala).
|
||||||
US-013: coloana legacy accounts.rar_creds_enc a fost dropata — se foloseste EXCLUSIV
|
Coloana legacy accounts.rar_creds_enc a fost dropata — se foloseste EXCLUSIV
|
||||||
slotul per-env. Sare conturile ale caror creds NU se decripteaza sub cheia curenta
|
slotul per-env. Sare conturile ale caror creds NU se decripteaza sub cheia curenta
|
||||||
(in dev `start.sh both` genereaza o cheie efemera noua la fiecare pornire).
|
(in dev `start.sh both` genereaza o cheie efemera noua la fiecare pornire).
|
||||||
"""
|
"""
|
||||||
@@ -533,6 +568,14 @@ def run() -> int:
|
|||||||
# Purjare periodica (odata pe ora) — NU mai frecvent.
|
# Purjare periodica (odata pe ora) — NU mai frecvent.
|
||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
|
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
|
||||||
|
# Expira intai randurile tinute imbatranite, apoi purjeaza:
|
||||||
|
# expire_held seteaza purge_after=now-1s -> randul e purjabil IMEDIAT in
|
||||||
|
# acelasi ciclu (purge_expired foloseste comparatie stricta purge_after<now).
|
||||||
|
expired = expire_held(conn, settings)
|
||||||
|
if expired:
|
||||||
|
_wlog(conn, "held_expirat",
|
||||||
|
f"{expired} randuri tinute expirate (>{settings.held_retention_days}z) -> error/TINUT_EXPIRAT",
|
||||||
|
nivel="WARNING", context={"expirate": expired})
|
||||||
stats = purge_expired(conn)
|
stats = purge_expired(conn)
|
||||||
if stats["submissions_purged"] or stats["batches_purged"] or stats["events_purged"]:
|
if stats["submissions_purged"] or stats["batches_purged"] or stats["events_purged"]:
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Liveness probe pentru worker (T6) — folosit de healthcheck-ul Docker.
|
"""Liveness probe pentru worker — folosit de healthcheck-ul Docker.
|
||||||
|
|
||||||
Worker-ul nu e server HTTP, deci `restart: always` prinde doar procesul MORT,
|
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
|
nu si worker-ul AGATAT (proces viu care nu mai bate heartbeat). Acest probe
|
||||||
|
|||||||
@@ -13,15 +13,35 @@ services:
|
|||||||
- autopass-data:/data
|
- autopass-data:/data
|
||||||
environment:
|
environment:
|
||||||
AUTOPASS_DB_PATH: /data/autopass.db
|
AUTOPASS_DB_PATH: /data/autopass.db
|
||||||
# Override din environment (Dokploy) pentru staging; default = prod.
|
# Ancora globala, NU tinta trimiterilor: mediul RAR e per
|
||||||
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-prod}
|
# submission/cont (worker-ul trimite dupa submissions.rar_env). Variabila
|
||||||
|
# asta serveste doar ca fallback API pentru conturi fara medii disponibile,
|
||||||
|
# backfill la migrare (db.py) si afisaj dashboard (routes.py). Obligatorie
|
||||||
|
# ca sa nu difere tacit intre api si worker (vezi worker mai jos).
|
||||||
|
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:?seteaza AUTOPASS_RAR_ENV (test|prod) in .env (vezi .env.example)}
|
||||||
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
|
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
|
||||||
TZ: ${TZ:-Europe/Bucharest}
|
TZ: ${TZ:-Europe/Bucharest}
|
||||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||||
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false}
|
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:?seteaza AUTOPASS_REQUIRE_API_KEY (true in prod) in .env (vezi .env.example)}
|
||||||
# Embeddings (sugestie mapare, Stratul 2): prima cerere /mapari lazy-load-eaza
|
# Embeddings (sugestie mapare, Stratul 2): prima cerere /mapari lazy-load-eaza
|
||||||
# modelul ~230MB. Doar API-ul il incarca (worker-ul nu). Default off.
|
# modelul ~230MB. Doar API-ul il incarca (worker-ul nu). Default off.
|
||||||
AUTOPASS_EMBEDDINGS_ENABLED: ${AUTOPASS_EMBEDDINGS_ENABLED:-false}
|
AUTOPASS_EMBEDDINGS_ENABLED: ${AUTOPASS_EMBEDDINGS_ENABLED:-false}
|
||||||
|
# uvicorn 0.30.0 are proxy_headers=True implicit; lipsea doar increderea in
|
||||||
|
# sursa headerelor, altfel request.client.host = IP-ul Traefik pentru toti
|
||||||
|
# vizitatorii (rate-limit login/signup devine o galeata GLOBALA). Uvicorn
|
||||||
|
# citeste FORWARDED_ALLOW_IPS direct din mediu (fara flag CLI necesar).
|
||||||
|
# ATENTIE, doua invariante trebuie sa ramana adevarate impreuna cu "*":
|
||||||
|
# (1) serviciul api NU publica `ports:` pe host aici — accesibil DOAR prin Traefik;
|
||||||
|
# (2) Traefik NU e configurat cu forwardedHeaders.insecure=true.
|
||||||
|
# Daca (2) e incalcat, un atacator poate falsifica X-Forwarded-For si ocoli
|
||||||
|
# rate-limit-ul (galeata noua per cerere = brute-force nelimitat).
|
||||||
|
FORWARDED_ALLOW_IPS: "*"
|
||||||
|
# Fara secret persistent, fiecare redeploy delogheaza toti userii (main.py
|
||||||
|
# genereaza unul efemer la runtime). HTTPS_ONLY=true e corect pentru prod,
|
||||||
|
# dar inseamna ca un smoke-test simplu pe HTTP NU poate face login (browserul
|
||||||
|
# dropeaza cookie-ul Secure) — pe HTTP local seteaza explicit false.
|
||||||
|
AUTOPASS_SESSION_SECRET: ${AUTOPASS_SESSION_SECRET:?seteaza AUTOPASS_SESSION_SECRET in .env (vezi .env.example)}
|
||||||
|
AUTOPASS_SESSION_HTTPS_ONLY: ${AUTOPASS_SESSION_HTTPS_ONLY:-true}
|
||||||
restart: always
|
restart: always
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8010/healthz').status==200 else 1)"]
|
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8010/healthz').status==200 else 1)"]
|
||||||
@@ -36,11 +56,15 @@ services:
|
|||||||
- autopass-data:/data
|
- autopass-data:/data
|
||||||
environment:
|
environment:
|
||||||
AUTOPASS_DB_PATH: /data/autopass.db
|
AUTOPASS_DB_PATH: /data/autopass.db
|
||||||
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-test}
|
# Vezi comentariul de la serviciul api: ancora globala, NU tinta trimiterilor
|
||||||
|
# (worker-ul trimite dupa submissions.rar_env, per cont). Aceeasi variabila
|
||||||
|
# obligatorie AICI, ca sa nu diverga tacit fata de api (era prod/test split-brain).
|
||||||
|
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:?seteaza AUTOPASS_RAR_ENV (test|prod) in .env (vezi .env.example)}
|
||||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||||
# Send activ by default (prod); pe staging seteaza AUTOPASS_WORKER_SEND_ENABLED=false
|
# Send catre RAR. Obligatoriu explicit — fara ea, un misconfig ar putea lasa
|
||||||
# in Dokploy ca worker-ul sa NU trimita declaratii reale la RAR (Legea 142/2023).
|
# worker-ul sa trimita declaratii reale neintentionat (Legea 142/2023).
|
||||||
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:-true}
|
# false = nu trimite (sigur pentru probe). true = end-to-end.
|
||||||
|
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:?seteaza AUTOPASS_WORKER_SEND_ENABLED (true|false) in .env (vezi .env.example)}
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
@@ -68,5 +92,33 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
# Backup SQLite criptat (T2/P0-4, evidenta legala L.142/2023) — vezi docs/backup.md.
|
||||||
|
# Serviciu OPTIONAL, profil "backup": nu porneste la `docker compose up` normal.
|
||||||
|
# Activare: `docker compose --profile backup up -d backup` (sau COMPOSE_PROFILES=backup
|
||||||
|
# in mediul Dokploy). Alternativa fara profil compose: cron pe host care ruleaza
|
||||||
|
# `docker compose exec api bash tools/backup_db.sh` — mai simpla daca ai deja acces SSH
|
||||||
|
# la host, dar in Dokploy hostul poate fi gestionat/efemer, de aceea preferam varianta
|
||||||
|
# de mai jos (calatoreste cu image-ul, nu depinde de un cron configurat manual pe VM).
|
||||||
|
# backup:
|
||||||
|
# build: .
|
||||||
|
# profiles: ["backup"]
|
||||||
|
# entrypoint: ["bash", "-c"]
|
||||||
|
# command:
|
||||||
|
# - |
|
||||||
|
# while true; do
|
||||||
|
# bash tools/backup_db.sh || echo "[backup] rulare esuata, reincerc la urmatorul ciclu"
|
||||||
|
# sleep 86400
|
||||||
|
# done
|
||||||
|
# volumes:
|
||||||
|
# - autopass-data:/data
|
||||||
|
# environment:
|
||||||
|
# AUTOPASS_DB_PATH: /data/autopass.db
|
||||||
|
# AUTOPASS_BACKUP_DIR: ${AUTOPASS_BACKUP_DIR:-/data/backups}
|
||||||
|
# AUTOPASS_BACKUP_KEEP: ${AUTOPASS_BACKUP_KEEP:-14}
|
||||||
|
# AUTOPASS_BACKUP_PASSPHRASE_FILE: ${AUTOPASS_BACKUP_PASSPHRASE_FILE:-}
|
||||||
|
# AUTOPASS_BACKUP_PASSPHRASE: ${AUTOPASS_BACKUP_PASSPHRASE:-}
|
||||||
|
# AUTOPASS_BACKUP_RCLONE_REMOTE: ${AUTOPASS_BACKUP_RCLONE_REMOTE:-}
|
||||||
|
# restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
autopass-data:
|
autopass-data:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -444,6 +444,26 @@ randul se RE-ACTIVEAZA (re-clasificat + creds actualizate) si raspunsul poarta
|
|||||||
care testeaza `deduped` nu se sparg). Pentru `sent`/`queued`/`sending`/`needs_*` ->
|
care testeaza `deduped` nu se sparg). Pentru `sent`/`queued`/`sending`/`needs_*` ->
|
||||||
`deduped: true` (neschimbat).
|
`deduped: true` (neschimbat).
|
||||||
|
|
||||||
|
**Forma raspunsului `POST /v1/prezentari` (2026-07-08).** Camp optional `raspuns` in
|
||||||
|
cerere: `"minim"` (implicit) sau `"complet"`. Rezultatele vin mereu in aceeasi ordine
|
||||||
|
cu lista `prezentari` din cerere.
|
||||||
|
|
||||||
|
- `minim` — 6 chei fixe per rezultat, usor de parsat de clienti simpli (VFP):
|
||||||
|
`index` (pozitia 0-based in cerere), `submission_id`, `vehicul` (nr. inmatriculare,
|
||||||
|
ecou normalizat), `status`, `motiv` (rezumat uman pe o linie; null pe queued curat),
|
||||||
|
`id_prezentare` (non-null doar pe dedup al unui rand deja trimis la RAR).
|
||||||
|
- `complet` — `SubmissionResult` intreg: in plus `erori`/`nemapate` (3 niveluri),
|
||||||
|
`deduped`, `reactivated`, `held`, `rar_env`, plus corelarea `index`/`vin`/
|
||||||
|
`nr_inmatriculare`. Formatul detaliat de dinainte, neschimbat.
|
||||||
|
|
||||||
|
**Sugestia principala pe coduri nemapate (2026-07-09).** Pe `needs_mapping`, `motiv`
|
||||||
|
include si propunerea de cod cand exista una: `Coduri fara mapare RAR: REP-MOT
|
||||||
|
(sugestie: OE-1 REPARATIE)`. In formatul `complet` (si in dry-run `/valideaza`),
|
||||||
|
fiecare element din `nemapate` poarta `sugestie: {cod_prestatie, nume_prestatie,
|
||||||
|
sursa} | null` (sursa: `gold_partajat` | `silver` | `embedding` | `fuzzy`).
|
||||||
|
SUGGESTION-ONLY: nu se aplica automat; confirmarea se face in tab-ul Mapari sau
|
||||||
|
prin `POST /v1/mapari`.
|
||||||
|
|
||||||
- `DELETE /v1/prezentari/{id}` — sterge o trimitere blocata a contului cheii API.
|
- `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).
|
**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);
|
Scope evaluat INAINTEA starii: cross-account / inexistent -> **404** (acelasi mesaj, B3);
|
||||||
|
|||||||
99
docs/backup.md
Normal file
99
docs/backup.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Backup SQLite (T2/P0-4)
|
||||||
|
|
||||||
|
## Ce se salveaza si de ce
|
||||||
|
|
||||||
|
Baza SQLite (`AUTOPASS_DB_PATH`, prod `/data/autopass.db`) e sistemul legal de evidenta
|
||||||
|
al declaratiilor RAR AUTOPASS (Legea 142/2023). `FINALIZATA` e **terminal** la RAR — nu
|
||||||
|
exista anulare/corectie prin API. Pierderea volumului fara backup inseamna, la restaurare
|
||||||
|
dintr-un mediu gol, retrimiterea acelorasi randuri si duplicate pe care RAR le accepta
|
||||||
|
fara sa poata fi corectate. De aceea backup-ul e trigger dur: **configureaza-l inainte
|
||||||
|
de prima declaratie reala in prod**, nu dupa.
|
||||||
|
|
||||||
|
Backup-urile sunt criptate (gpg AES256) pentru ca snapshot-ul contine PII criptat Fernet
|
||||||
|
(nume, VIN, date client) plus metadate — un backup necriptat pe disc/remote ar fi un al
|
||||||
|
doilea loc unde datele astea pot scapa.
|
||||||
|
|
||||||
|
## Rulare manuala
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in container (api sau worker, acelasi image + volum):
|
||||||
|
docker compose exec api bash tools/backup_db.sh
|
||||||
|
|
||||||
|
# local (dev), cu parola de test:
|
||||||
|
AUTOPASS_DB_PATH=./data/autopass.db \
|
||||||
|
AUTOPASS_BACKUP_DIR=./data/backups \
|
||||||
|
AUTOPASS_BACKUP_PASSPHRASE_FILE=/cale/catre/parola \
|
||||||
|
bash tools/backup_db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Scriptul foloseste `sqlite3.Connection.backup` (online, sigur cu WAL activ — NU `cp`),
|
||||||
|
comprima (gzip) si cripteaza (gpg simetric). Fara `AUTOPASS_BACKUP_PASSPHRASE_FILE` sau
|
||||||
|
`AUTOPASS_BACKUP_PASSPHRASE` seteaza, scriptul **refuza** sa scrie un backup necriptat si
|
||||||
|
iese cu cod diferit de 0. Pastreaza ultimele `AUTOPASS_BACKUP_KEEP` (implicit 14) fisiere
|
||||||
|
in `AUTOPASS_BACKUP_DIR` (implicit `/data/backups`, pe volumul persistent). Daca
|
||||||
|
`AUTOPASS_BACKUP_RCLONE_REMOTE` e setat si `rclone` e disponibil, urca automat
|
||||||
|
backup-ul nou catre remote (off-site); altfel sare peste, cu log explicit.
|
||||||
|
|
||||||
|
## Programare
|
||||||
|
|
||||||
|
Doua variante, alege una:
|
||||||
|
|
||||||
|
1. **Serviciu compose optional** (`docker-compose.yml`, serviciu `backup`, profil
|
||||||
|
`backup`, comentat implicit) — ruleaza scriptul intr-o bucla cu `sleep 86400` in
|
||||||
|
propriul container, pe acelasi volum `autopass-data`. Activare:
|
||||||
|
```bash
|
||||||
|
docker compose --profile backup up -d backup
|
||||||
|
```
|
||||||
|
sau, in Dokploy, seteaza `COMPOSE_PROFILES=backup` in mediul aplicatiei. Preferata
|
||||||
|
pentru un deploy Dokploy: calatoreste cu image-ul si nu depinde de acces SSH separat
|
||||||
|
la hostul care ruleaza containerele (host care in Dokploy poate fi gestionat/efemer).
|
||||||
|
2. **Cron pe host** — daca ai acces SSH direct la masina care ruleaza containerele:
|
||||||
|
```cron
|
||||||
|
0 3 * * * cd /cale/catre/autopass && docker compose exec -T api bash tools/backup_db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
In ambele cazuri, parola de criptare trebuie sa fie accesibila containerului (secret
|
||||||
|
montat, indicat prin `AUTOPASS_BACKUP_PASSPHRASE_FILE`) — nu doar in mediul shell al
|
||||||
|
cron-ului de pe host.
|
||||||
|
|
||||||
|
## Restaurare (pas cu pas)
|
||||||
|
|
||||||
|
1. **Opreste worker-ul** (`docker compose stop worker`) — altfel worker-ul poate scrie
|
||||||
|
in baza in timp ce o inlocuiesti, sau poate re-prelua randuri `sending` in timp ce
|
||||||
|
restaurarea e pe jumatate facuta.
|
||||||
|
2. Alege backup-ul de restaurat (fisier `autopass-YYYYmmdd-HHMMSS.db.gz.gpg` din
|
||||||
|
`AUTOPASS_BACKUP_DIR`).
|
||||||
|
3. Decripteaza + dezarhiveaza intr-un fisier temporar (NU suprascrie direct baza vie):
|
||||||
|
```bash
|
||||||
|
gpg --batch --decrypt --passphrase-file /cale/catre/parola \
|
||||||
|
--output /tmp/restore.db.gz autopass-20260706-030000.db.gz.gpg
|
||||||
|
gzip -d /tmp/restore.db.gz
|
||||||
|
```
|
||||||
|
4. Verifica integritatea inainte sa promovezi fisierul (vezi sectiunea urmatoare).
|
||||||
|
5. Muta fisierul verificat peste `AUTOPASS_DB_PATH` (backup-uieste intai baza curenta
|
||||||
|
daca mai exista, chiar corupta — pastreaz-o pentru investigare).
|
||||||
|
6. Porneste din nou worker-ul (`docker compose start worker`).
|
||||||
|
|
||||||
|
## Verificare (restore_check)
|
||||||
|
|
||||||
|
Un backup neverificat nu e backup. `tools/restore_check.sh` decripteaza + dezarhiveaza
|
||||||
|
cel mai recent backup (sau unul explicit, ca argument) intr-un fisier temporar si ruleaza
|
||||||
|
`PRAGMA integrity_check` + `SELECT count(*) FROM submissions`. Iese cu cod diferit de 0
|
||||||
|
daca decriptarea, integritatea sau interogarea esueaza.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTOPASS_BACKUP_PASSPHRASE_FILE=/cale/catre/parola tools/restore_check.sh
|
||||||
|
# sau explicit:
|
||||||
|
tools/restore_check.sh /data/backups/autopass-20260706-030000.db.gz.gpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Ruleaza-l periodic (ex. dupa fiecare backup programat) — un backup care nu se poate
|
||||||
|
restaura si verifica nu ofera nicio garantie reala.
|
||||||
|
|
||||||
|
## Unde stau parolele
|
||||||
|
|
||||||
|
Parola de criptare (`AUTOPASS_BACKUP_PASSPHRASE_FILE`) e un secret separat de
|
||||||
|
`AUTOPASS_CREDS_KEY` (Fernet, creds RAR) — nu le refolosi una pe cealalta. In prod,
|
||||||
|
monteaz-o ca fisier secret (nu variabila de mediu inline in `.env` necriptat pe disc)
|
||||||
|
si restrictioneaza accesul la fisier. `AUTOPASS_BACKUP_PASSPHRASE` (variabila inline)
|
||||||
|
e doar pentru teste locale/CI, nu pentru prod.
|
||||||
36
docs/deploy-dokploy.md
Normal file
36
docs/deploy-dokploy.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Deploy pe Dokploy (autopass.romfast.ro)
|
||||||
|
|
||||||
|
Serviciul ruleaza in Dokploy (LXC 103, dokploy.romfast.ro), proiect AUTOPASS/production,
|
||||||
|
compose `autopass-roaautopass-jqqw1l`. Provider: Gitea → repo `rar-autopass` → branch `main`
|
||||||
|
→ `./docker-compose.yml`, autodeploy la push. Detalii de infrastructura in
|
||||||
|
`proxmox/lxc103-dokploy/docs/autopass.md` (repo-ul de infrastructura, nu acesta).
|
||||||
|
|
||||||
|
## Reguli la modificarea docker-compose.yml / Dockerfile
|
||||||
|
|
||||||
|
1. **Variabila noua obligatorie (`${VAR:?...}`)** → seteaz-o in Dokploy → service autopass →
|
||||||
|
Environment INAINTE de push. Altfel `docker compose` pica la interpolare, inainte de build,
|
||||||
|
cu `required variable VAR is missing a value`. Compose se opreste la PRIMA variabila lipsa,
|
||||||
|
deci mai multe variabile lipsa ies la iveala una pe runda de deploy.
|
||||||
|
Semnatura in Dokploy: deployment "Error" cu durata **0s** = interpolare env, nu build.
|
||||||
|
|
||||||
|
2. **Schimbarea uid-ului userului non-root din Dockerfile** → volumul de date pastreaza
|
||||||
|
ownership-ul vechi si SQLite devine readonly (`attempt to write a readonly database`),
|
||||||
|
api + worker intra in crash-loop, Traefik ramane fara backend sanatos → site 404.
|
||||||
|
Inainte de deploy, pe hostul Dokploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume inspect autopass-roaautopass-jqqw1l_autopass-data --format '{{.Mountpoint}}'
|
||||||
|
docker run --rm <image-api> id app # afla uid/gid curent (acum 10001:10001)
|
||||||
|
chown -R 10001:10001 /var/lib/docker/volumes/autopass-roaautopass-jqqw1l_autopass-data/_data
|
||||||
|
cd /etc/dokploy/compose/autopass-roaautopass-jqqw1l/code
|
||||||
|
docker compose -p autopass-roaautopass-jqqw1l -f ./docker-compose.yml restart api worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Incident 2026-07-09 (referinta)
|
||||||
|
|
||||||
|
Deploy-urile picau la 0s dupa commit-ul care a facut obligatorii `AUTOPASS_RAR_ENV`,
|
||||||
|
`AUTOPASS_SESSION_SECRET` si `AUTOPASS_WORKER_SEND_ENABLED` (cauza 1, doua runde de fix).
|
||||||
|
Dupa setarea variabilelor (`AUTOPASS_RAR_ENV=prod`, `AUTOPASS_SESSION_SECRET=<hex32>`,
|
||||||
|
`AUTOPASS_WORKER_SEND_ENABLED=true`), site-ul a dat 404 din cauza volumului detinut de root
|
||||||
|
in fata userului `app` uid 10001 (cauza 2); rezolvat cu chown-ul de mai sus, fara pierdere
|
||||||
|
de date.
|
||||||
334
docs/prd/prd-5.21-embedding-cache-sqlite.md
Normal file
334
docs/prd/prd-5.21-embedding-cache-sqlite.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
# PRD: Cache persistent de vectori embeddings in SQLite
|
||||||
|
|
||||||
|
## 1. Introducere
|
||||||
|
|
||||||
|
Vectorii corpusului k-NN (17.181 exemple etichetate din `mapping_suggestions`) traiesc doar in RAM si se recalculeaza integral la fiecare pornire a API-ului (~1-2 minute de CPU), desi textele nu s-au schimbat. Warmup-ul in fundal (deja implementat) tine pagina Mapari rapida, dar lasa o fereastra de 1-2 minute dupa restart in care sugestiile embeddings lipsesc. Acest feature persista vectorii intr-o tabela SQLite si vectorizeaza incremental doar textele noi/modificate, reducand fereastra la doar incarcarea modelului (~10-20s).
|
||||||
|
|
||||||
|
## 2. Obiective
|
||||||
|
|
||||||
|
### Obiectiv Principal
|
||||||
|
- Elimina re-vectorizarea integrala a corpusului la fiecare restart: vectorii se calculeaza o singura data si se refolosesc.
|
||||||
|
|
||||||
|
### Obiective Secundare
|
||||||
|
- Fereastra fara sugestii embeddings dupa restart scade de la ~1-2 minute la ~10-20s (doar incarcarea modelului ONNX).
|
||||||
|
- Cresterea corpusului SILVER nu mai scumpeste restarturile (cost proportional doar cu randurile NOI).
|
||||||
|
- Cache reconstruibil oricand (`DELETE FROM embedding_cache` + restart), fara alte efecte.
|
||||||
|
|
||||||
|
### Metrici de Succes
|
||||||
|
- La corpus neschimbat: zero apeluri `embed()` pe corpus la startup; indexarea din cache < 5s.
|
||||||
|
- La N randuri noi in corpus: exact N texte vectorizate la urmatorul warmup.
|
||||||
|
- Sugestiile embeddings (sursa "similaritate") functioneaza identic inainte/dupa (acelasi cod sugerat pentru acelasi query).
|
||||||
|
|
||||||
|
## 3. User Stories
|
||||||
|
|
||||||
|
### US-001: Tabela embedding_cache in schema
|
||||||
|
**Ca** sistem
|
||||||
|
**Vreau** o tabela dedicata pentru vectorii corpusului
|
||||||
|
**Pentru ca** vectorii sa supravietuiasca restartului fara sa ating schema `mapping_suggestions`.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `app/schema.sql` contine tabela `embedding_cache` cu: `text_hash TEXT NOT NULL` (SHA-256 al textului normalizat), `model TEXT NOT NULL` (numele modelului fastembed), `vector BLOB NOT NULL` (float32 little-endian, 384 valori), `created_at TEXT NOT NULL DEFAULT (datetime('now'))`, `PRIMARY KEY (model, text_hash)`.
|
||||||
|
- [ ] `init_db()` creeaza tabela idempotent (CREATE TABLE IF NOT EXISTS), pe baza de date existenta si pe una noua.
|
||||||
|
- [ ] `python3 -m pytest -q` trece.
|
||||||
|
|
||||||
|
### US-002: Serializare + acces cache (functii pure)
|
||||||
|
**Ca** developer
|
||||||
|
**Vreau** functii de citire/scriere vectori in cache
|
||||||
|
**Pentru ca** logica de (de)serializare sa fie testabila izolat.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Functii in `app/embeddings.py` (sau modul nou `app/embedding_cache.py`): `vector_to_blob(list[float]) -> bytes` si `blob_to_vector(bytes) -> list[float]` (float32 LE, `array('f')` din stdlib, fara dependinta noua).
|
||||||
|
- [ ] `load_cached_vectors(conn, model, hashes) -> dict[hash, vector]` — un singur SELECT (chunk-uit la limita de parametri SQLite), intoarce doar hash-urile gasite.
|
||||||
|
- [ ] `save_vectors(conn, model, items: list[(hash, vector)])` — INSERT OR REPLACE batch, commit la final.
|
||||||
|
- [ ] Round-trip exact: `blob_to_vector(vector_to_blob(v)) == pytest.approx(v)` pentru vectori de 384 float.
|
||||||
|
- [ ] `python3 -m pytest -q` trece.
|
||||||
|
|
||||||
|
### US-003: ensure_embeddings_corpus foloseste cache-ul
|
||||||
|
**Ca** operator
|
||||||
|
**Vreau** ca warmup-ul sa vectorizeze doar textele lipsa din cache
|
||||||
|
**Pentru ca** restartul sa nu mai coste 1-2 minute de CPU.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `ensure_embeddings_corpus(conn, block=True)`: calculeaza hash per `denumire_normalizata`, citeste vectorii existenti din `embedding_cache` pentru modelul curent, apeleaza `embed()` DOAR pe textele fara vector in cache, salveaza vectorii noi in cache, apoi indexeaza corpusul complet (cache + noi) in `EmbeddingEngine`.
|
||||||
|
- [ ] `EmbeddingEngine.index_corpus` accepta vectori precalculati (parametru nou `vectors` sau metoda `index_corpus_precomputed`) fara sa apeleze backend-ul pentru ei; comportamentul existent (fara vectori -> embed tot) ramane pentru compatibilitate.
|
||||||
|
- [ ] Semantica semnaturii corpusului ramane neschimbata: acelasi corpus SILVER -> nu se reindexeaza; corpus schimbat -> reindexare (dar embed doar pe diferenta).
|
||||||
|
- [ ] Calea de request (block=False) ramane non-blocanta, neatinsa.
|
||||||
|
- [ ] Degradare gratioasa pastrata: orice eroare de cache (tabela lipsa, blob corupt) -> fallback pe embed complet, fara exceptie propagata.
|
||||||
|
- [ ] Blob corupt (lungime gresita) e ignorat si re-vectorizat, nu crapa indexarea.
|
||||||
|
- [ ] `python3 -m pytest -q` trece.
|
||||||
|
|
||||||
|
### US-004: Invalidare pe model + curatare orfane
|
||||||
|
**Ca** sistem
|
||||||
|
**Vreau** cache-ul legat de numele modelului si curatat de intrari moarte
|
||||||
|
**Pentru ca** schimbarea modelului sa nu serveasca vectori incompatibili, iar cache-ul sa nu creasca nelimitat.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Cheia de cache include `model` (= `FASTEMBED_MODEL`): la schimbarea constantei, vectorii vechi NU sunt folositi (se re-vectorizeaza sub noul model, intrarile vechi raman inerte).
|
||||||
|
- [ ] La finalul unei indexari reusite, intrarile `embedding_cache` ale modelului curent cu `text_hash` care nu mai exista in corpus sunt sterse (DELETE orfane).
|
||||||
|
- [ ] Test: schimbarea modelului (mock cu nume diferit) -> zero hit-uri din cache; stergerea unui rand din corpus -> intrarea orfana dispare dupa reindexare.
|
||||||
|
- [ ] `python3 -m pytest -q` trece.
|
||||||
|
|
||||||
|
### US-005: Teste de flux cold/warm start
|
||||||
|
**Ca** developer
|
||||||
|
**Vreau** teste care fixeaza comportamentul incremental
|
||||||
|
**Pentru ca** regresiile de performanta la startup sa fie prinse de suita.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Test cold start: cache gol + corpus de N randuri -> backend mock primeste exact N texte, cache-ul contine N intrari dupa.
|
||||||
|
- [ ] Test warm start: al doilea proces (engine nou, acelasi conn) -> backend mock primeste ZERO texte de corpus, `has_corpus()` True, `suggest_nearest` functioneaza cu vectorii din cache.
|
||||||
|
- [ ] Test incremental: se adauga 1 rand in `mapping_suggestions` -> backend mock primeste exact 1 text la reindexare.
|
||||||
|
- [ ] Testele folosesc backend mock (fara fastembed real), ruleaza in suita implicita (fara marker `live`).
|
||||||
|
- [ ] `python3 -m pytest -q` trece.
|
||||||
|
|
||||||
|
### US-006: Verificare end-to-end pe instanta reala
|
||||||
|
**Ca** operator
|
||||||
|
**Vreau** confirmarea pe serverul real ca restartul e ieftin
|
||||||
|
**Pentru ca** metrica de succes sa fie masurata, nu presupusa.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Log la finalul warmup-ului: numar vectori din cache / numar vectori calculati / durata totala (ex. `embeddings: warmup ok cache=17181 embed=0 in 12.3s`).
|
||||||
|
- [ ] Verify in browser: dupa `./start.sh stop && ./start.sh test both --send`, primul restart populeaza cache-ul; la al DOILEA restart, log-ul arata `embed=0` si sugestia "similaritate" apare in preview-ul regulilor text in < 30s de la pornire.
|
||||||
|
- [ ] Dimensiunea bazei creste cu ~26-30MB (17k x 384 float32) — documentat in log/PRD, acceptat.
|
||||||
|
|
||||||
|
## 4. Cerinte Functionale
|
||||||
|
|
||||||
|
1. [REQ-001] Sistemul trebuie sa persiste vectorii corpusului SILVER in tabela `embedding_cache`, cheie `(model, text_hash)`.
|
||||||
|
2. [REQ-002] La warmup, sistemul trebuie sa vectorizeze DOAR textele fara intrare in cache pentru modelul curent.
|
||||||
|
3. [REQ-003] Cand corpusul SILVER se schimba (semnatura diferita), sistemul reindexeaza folosind cache-ul si vectorizeaza doar diferenta.
|
||||||
|
4. [REQ-004] Cand modelul se schimba, cache-ul vechi nu este folosit; vectorii se recalculeaza integral sub noua cheie de model.
|
||||||
|
5. [REQ-005] Orice eroare legata de cache degradeaza la comportamentul actual (embed complet), fara a bloca API-ul sau a arunca in request.
|
||||||
|
6. [REQ-006] Intrarile orfane (texte disparute din corpus) se curata la reindexare.
|
||||||
|
7. [REQ-007] Vectorii query (textul operatiei/pattern-ului cautat) NU se cacheaza — se calculeaza la cerere (milisecunde).
|
||||||
|
|
||||||
|
## 5. Non-Goals (Ce NU facem)
|
||||||
|
|
||||||
|
- NU introducem vector store extern (sqlite-vec, Qdrant, FAISS) — cosine in Python peste 17k vectori ramane suficient.
|
||||||
|
- NU cacheam embeddings pentru query-uri (texte noi cautate de utilizatori).
|
||||||
|
- NU modificam pipeline-ul de etichetare (`tools/mapare-llm/`) sau formatul seedului `app/data/operatii-etichetate.json`.
|
||||||
|
- NU vectorizam GOLD (`shared_mappings`) sau nomenclatorul — sursa corpusului ramane exclusiv `mapping_suggestions`.
|
||||||
|
- NU schimbam modelul de embeddings, pragul `EMB_MIN_SIMILARITATE` sau precedenta GOLD > SILVER > embeddings.
|
||||||
|
- NU eliminam warmup-ul din fundal — ramane, doar devine ieftin.
|
||||||
|
|
||||||
|
## 6. Consideratii Tehnice
|
||||||
|
|
||||||
|
### Stack/Tehnologii
|
||||||
|
- SQLite existent (acelasi fisier `data/autopass.db`, WAL), stdlib `array`/`hashlib` — zero dependinte noi.
|
||||||
|
- fastembed/ONNX ramane sursa vectorilor la miss.
|
||||||
|
|
||||||
|
### Patterns de Urmat
|
||||||
|
- Degradare gratioasa ca in `ensure_embeddings_corpus`/`enrich_suggestions` (except -> pass, corpus gol e acceptabil).
|
||||||
|
- Schema idempotenta in `app/schema.sql` + `init_db()` (CREATE IF NOT EXISTS), ca restul tabelelor.
|
||||||
|
- Semnatura corpusului (`_corpus_signature_silver`) ramane mecanismul de decizie "reindexez sau nu".
|
||||||
|
- Backend injectabil in `EmbeddingEngine` pentru teste (mock, fara model real).
|
||||||
|
|
||||||
|
### Dependente
|
||||||
|
- Warmup-ul din fundal existent (`app/main.py::_warmup_embeddings`, `ensure_embeddings_corpus(block=True)`).
|
||||||
|
- `mapping_suggestions` populat la `init_db` din seedul comis (`app/operatii_seed.py`).
|
||||||
|
|
||||||
|
### Riscuri Tehnice
|
||||||
|
- Scriere concurenta API/worker pe SQLite: scrierile de cache se fac in tranzactii scurte, batch, doar din thread-ul de warmup al API-ului (worker-ul NU incarca modelul — invariant existent).
|
||||||
|
- Blob corupt/lungime gresita: tratat ca miss (re-embed), nu ca eroare.
|
||||||
|
- Crestere DB ~26-30MB: acceptata; curatarea orfanelor previne cresterea nelimitata.
|
||||||
|
- Hash-ul textului trebuie calculat pe `denumire_normalizata` EXACT cum intra in `index_corpus` — altfel miss permanent si cache inutil (test dedicat in US-005).
|
||||||
|
|
||||||
|
## 7. Consideratii UI/UX
|
||||||
|
|
||||||
|
Fara schimbari de UI. Efect indirect: sugestia "similaritate" (editor mapari + preview reguli text) devine disponibila la ~10-20s dupa restart in loc de 1-2 minute.
|
||||||
|
|
||||||
|
## 8. Success Metrics
|
||||||
|
|
||||||
|
- Apeluri `embed()` pe corpus la restart cu corpus neschimbat: 0 (log `embed=0`).
|
||||||
|
- Durata warmup la warm start: < 30s total (dominata de incarcarea modelului), fata de ~1-2 min acum.
|
||||||
|
- Zero regresii in suita: `python3 -m pytest -q` verde.
|
||||||
|
|
||||||
|
## 9. Open Questions
|
||||||
|
|
||||||
|
- [x] Vrem o comanda CLI de administrare (`python3 -m tools.embcache rebuild|clear|stats`)? DECIS (/autoplan): manual (`DELETE FROM embedding_cache`) suficient in v1; CLI deferat la TODOS.md.
|
||||||
|
- [x] Purjarea orfanelor sterge si intrarile modelelor VECHI? DECIS (/autoplan): DA, la aceeasi trecere — simplu si sigur.
|
||||||
|
|
||||||
|
## 10. Amendamente /autoplan (2026-07-06)
|
||||||
|
|
||||||
|
Acceptate in scope (auto-decizii, vezi Decision Audit Trail):
|
||||||
|
|
||||||
|
1. **A1** Codul de cache traieste in modul NOU `app/embedding_cache.py` (functiile cu `conn`); `app/embeddings.py` ramane fara acces DB. `EmbeddingEngine.index_corpus` primeste parametru nou `vectors: list[list[float]] | None`, aliniat pozitional cu `items`; `None` = comportamentul actual (embed tot).
|
||||||
|
2. **A2** `save_vectors` scrie in tranzactii chunk-uite de **500 randuri/commit** — AMENDEAZA AC-ul US-002 "commit la final". Motiv: un commit unic de ~26MB tine write-lock in timp ce worker-ul face `BEGIN IMMEDIATE` pe `submissions`.
|
||||||
|
3. **A3** Purjarea orfanelor: diff in Python (set hash-uri corpus vs set hash-uri cache), apoi `DELETE` pe chunk-uri de 500 pe PK. Include intrarile modelelor vechi (`model != curent`). Motiv: tranzactii scurte; (limita de parametri SQLite e 32766 pe 3.32+, nu 999 — chunking-ul e pentru durata lock-ului).
|
||||||
|
4. **A4** Orice eroare a cailor de cache (SELECT/INSERT/blob corupt) se logheaza cu `log.warning` INAINTE de fallback-ul pe embed complet. Degradarea existenta (`except -> pass` pe indexare) ramane neschimbata — scoping strict pe codul nou.
|
||||||
|
5. **A5** Metrica "acelasi cod sugerat" se verifica cu toleranta float32 (round-trip float64->float32 poate muta scoruri la a 7-a zecimala): test de echivalenta a RANKING-ULUI, nu egalitate stricta de scor.
|
||||||
|
6. **A6** Teste suplimentare la US-005: (a) chunking >500 randuri (scriere multi-tranzactie completa), (b) idempotenta la cache partial (populare intrerupta -> urmatorul warmup completeaza, fara duplicate, `INSERT OR REPLACE`).
|
||||||
|
7. **A7** Nota bounded acceptata: purjarea ruleaza doar la finalul unei indexari reusite; cu embeddings dezactivat sau warmup esuat permanent, cache-ul mort ramane pana la reset manual (`DELETE FROM embedding_cache`).
|
||||||
|
|
||||||
|
Adaugate de review-ul Eng (subagent independent, toate verificate in cod):
|
||||||
|
|
||||||
|
8. **A8** `index_corpus(items, signature, vectors)` VALIDEAZA `len(vectors) == len(items)` si absenta `None`-urilor; mismatch => `log.warning` + fallback pe embed complet. Test obligatoriu de ranking EXACT la warm start: >=2 query-uri distincte care aserteaza CODUL vecinului cel mai apropiat (nu doar non-empty) — singurul test care prinde o permutare de aliniere (misalignment = coduri gresite sugerate, silentios).
|
||||||
|
9. **A9** Secventa hash->load->embed->save->purge->index e protejata de un lock de modul (`threading.Lock`, pattern `_engine_lock` existent). CORECTEAZA riscul din sectiunea 6: "scrierile doar din thread-ul de warmup" e FALS — calea de request (`block=False`) trece de gate-ul `is_loaded()` (mapping.py:675) dupa incarcarea modelului si ar scrie/purja concurent cu warmup-ul (purjare falsa de randuri noi).
|
||||||
|
10. **A10** Conexiunile sunt autocommit (`isolation_level=None`, db.py:16): `save_vectors`/`purge_stale` emit EXPLICIT `BEGIN`/`COMMIT` per chunk — `conn.commit()` singur e no-op si ar produce silentios o tranzactie per rand (17k commit-uri), anuland exact motivul A2.
|
||||||
|
11. **A11** Numele modelului = PARAMETRU explicit pe tot lantul (inclusiv `ensure_embeddings_corpus`); nu se hardcodeaza `FASTEMBED_MODEL` in adancime — altfel testele cu mock scriu sub cheia modelului real si US-004 nu are unde injecta numele.
|
||||||
|
12. **A12** Vectorii din cache raman `array('f')` end-to-end (fara conversie la `list[float]`) — RAM ~8x mai mic; prag documentat: designul curent (cosine pur Python) e validat pana la ~50k randuri de corpus; peste, vezi TODOS (numpy).
|
||||||
|
13. **A13** Teste suplimentare obligatorii: (a) purjarea NU ruleaza cand indexarea a esuat (altfel un esec golește cache-ul permanent); (b) `len(vectors) != len(items)` => fallback gratios; (c) save partial esuat => indexarea continua din vectorii din RAM; (d) TOATE testele noi seteaza explicit `AUTOPASS_EMBEDDINGS_ENABLED=1` — gate-ul din mapping.py:671 face altfel testele vacuos-verzi.
|
||||||
|
14. **A14** Validare `len(vector) == 384` si la SCRIERE (nu doar la citire) — un backend defect nu umple DB-ul cu blob-uri arbitrare.
|
||||||
|
15. **A15** Orchestrarea hash->load->embed->save->purge se extrage intr-o functie in `embedding_cache.py` care primeste `embed_fn` — `ensure_embeddings_corpus` ramane citibila (~30 linii, nu ~80 cu 6 responsabilitati).
|
||||||
|
|
||||||
|
### Diagrama arhitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
API startup (lifespan) request path (neatins)
|
||||||
|
| |
|
||||||
|
_warmup_embeddings (thread) enrich_suggestions
|
||||||
|
| |
|
||||||
|
ensure_embeddings_corpus(conn, block=True) suggest_nearest (query embed la cerere)
|
||||||
|
|
|
||||||
|
|-- SELECT mapping_suggestions ----------- corpus SILVER (sursa)
|
||||||
|
|-- _corpus_signature_silver ------------- decizia reindexarii (NESCHIMBATA)
|
||||||
|
|-- [NOU] embedding_cache.load_cached_vectors(conn, model, hashes)
|
||||||
|
| (SELECT chunk-uit; blob corupt => miss)
|
||||||
|
|-- embed() DOAR pe miss-uri (backend fastembed/mock)
|
||||||
|
|-- [NOU] embedding_cache.save_vectors(conn, model, items_noi)
|
||||||
|
| (INSERT OR REPLACE, commit la 500)
|
||||||
|
|-- [NOU] purge_stale(conn, model, hash-uri_corpus)
|
||||||
|
| (orfane + modele vechi, DELETE chunk-uit)
|
||||||
|
`-- EmbeddingEngine.index_corpus(items, signature, vectors=toate)
|
||||||
|
|
||||||
|
SQLite (WAL, busy_timeout 15s): embedding_cache (model, text_hash) PK
|
||||||
|
Worker: NU atinge embedding_cache (nu incarca modelul — invariant existent)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data flow cu shadow paths
|
||||||
|
|
||||||
|
```
|
||||||
|
corpus rows ─▶ filtrare denumire goala ─▶ hash sha256(text) ─▶ cache lookup ─▶ embed(miss) ─▶ save ─▶ index
|
||||||
|
│ │ │ │ │ │
|
||||||
|
[gol? => return] [toate goale? => [hash pe lista [tabela lipsa/ [backend [OperationalError
|
||||||
|
cache neatins] corpus gol, no-op] FILTRATA, nu pe DB locked => esueaza => la commit =>
|
||||||
|
rows brute!] warning + warning + warning + fallback;
|
||||||
|
embed complet] corpus gol] cache partial OK]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error & Rescue Registry
|
||||||
|
|
||||||
|
| Codepath | Ce poate esua | Exceptie | Rescued? | Actiune | User vede |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| load_cached_vectors | DB locked/busy | sqlite3.OperationalError | DA | log.warning + fallback embed complet | nimic (warmup mai lung) |
|
||||||
|
| load_cached_vectors | blob lungime gresita | (validare explicita) | DA | tratat ca miss, re-embed, log count | nimic |
|
||||||
|
| save_vectors | lock la commit chunk | sqlite3.OperationalError | DA | log.warning; cache partial ramane valid (idempotent) | nimic |
|
||||||
|
| purge_stale | lock la DELETE | sqlite3.OperationalError | DA | log.warning; orfanele raman pana la urmatoarea trecere | nimic |
|
||||||
|
| hash drift (normalizare schimbata) | miss permanent | — (nu e exceptie) | DA (observabil) | log warmup arata embed=N cand se astepta 0 (US-006) | nimic |
|
||||||
|
| ensure_embeddings_corpus | orice alta exceptie | Exception (catch-all EXISTENT) | DA | pass (degradare documentata REQ-005) | sugestii embeddings lipsesc |
|
||||||
|
|
||||||
|
### Failure Modes Registry
|
||||||
|
|
||||||
|
| Codepath | Failure mode | Rescued? | Test? | User vede? | Logged? |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| cold start | cache gol | DA (embed tot) | US-005 | fereastra 1-2 min (o data) | DA (US-006) |
|
||||||
|
| warm start | cache complet | — | US-005 | fereastra ~10-20s | DA |
|
||||||
|
| incremental | N texte noi | DA | US-005 | — | DA |
|
||||||
|
| blob corupt | re-embed silentios | DA | US-003 AC | nimic | DA (A4) |
|
||||||
|
| populare intrerupta | cache partial | DA (idempotent) | A6b | nimic | DA |
|
||||||
|
| model schimbat | zero hit, re-embed sub cheie noua | DA | US-004 | fereastra 1-2 min (o data) | DA |
|
||||||
|
| warmup esuat permanent | cache mort ne-purjat | partial (A7, manual) | — | nimic (bounded, ~30MB) | DA |
|
||||||
|
|
||||||
|
**Zero CRITICAL GAP** (niciun rand cu Rescued=N + Test=N + Silent).
|
||||||
|
|
||||||
|
### NOT in scope (deferate, cu motiv)
|
||||||
|
|
||||||
|
- CLI `tools/embcache` — manual suficient in v1 (Open Q1).
|
||||||
|
- Precompute vectori la build (artefact Docker) — ar face si primul start ieftin; separat.
|
||||||
|
- Graceful reload / zero-downtime — ar elimina complet fereastra post-restart; infra, nu app.
|
||||||
|
- Optimizare latenta per-query `suggest_nearest` (numpy) — de masurat intai; PRD-ul declara cosine Python suficient (non-goal).
|
||||||
|
- Cache query-uri (REQ-007), vector store extern, panou admin stats — non-goals/skip.
|
||||||
|
|
||||||
|
### What already exists (reuse)
|
||||||
|
|
||||||
|
- `_corpus_signature_silver` (mapping.py:635) — decizia reindexarii, pastrata identic.
|
||||||
|
- `ensure_embeddings_corpus` (mapping.py:649) — singurul punct de interceptie pe flux.
|
||||||
|
- `EmbeddingEngine(backend=...)` (embeddings.py:84) — mock-ul de test existent acopera US-005.
|
||||||
|
- Schema idempotenta `schema.sql` + `init_db()` — pattern-ul tabelei noi.
|
||||||
|
- `PRAGMA busy_timeout=15000` + WAL (db.py:18-20) — deja setate, sustin scrierile concurente.
|
||||||
|
|
||||||
|
### Dream state delta
|
||||||
|
|
||||||
|
Planul duce sistemul de la "restart plateste intreg corpusul" la "restart plateste doar delta"; cheia per-text face re-etichetarile (cod/is_nul) GRATUITE la restart — exact traiectoria 12 luni (corpus SILVER in crestere organica). Ramane in afara idealului: fereastra de incarcare a modelului (~10-20s) si costul per-query al cosine-ului pur Python (ambele in TODOS).
|
||||||
|
|
||||||
|
### Diagrama de acoperire teste (Eng S3)
|
||||||
|
|
||||||
|
```
|
||||||
|
CODE PATHS (toate NOI — teste planificate, nu existente) ACOPERIT DE
|
||||||
|
[+] app/embedding_cache.py
|
||||||
|
├── vector_to_blob/blob_to_vector round-trip US-002 AC (+A5 toleranta)
|
||||||
|
├── blob corupt (lungime gresita) => miss US-003 AC
|
||||||
|
├── len(vector)!=384 la SCRIERE => respins A14
|
||||||
|
├── save_vectors chunk 500 + BEGIN/COMMIT explicit A6a + A10
|
||||||
|
├── save partial esuat => indexare continua din RAM A13c
|
||||||
|
└── purge orfane + modele vechi; NU ruleaza la index esuat US-004 + A13a
|
||||||
|
[+] app/embeddings.py :: index_corpus(vectors=)
|
||||||
|
├── len(vectors)!=len(items) => fallback embed complet A8/A13b
|
||||||
|
└── ranking EXACT la warm start (>=2 query-uri, cod asertat) A8
|
||||||
|
[+] app/mapping.py :: ensure_embeddings_corpus
|
||||||
|
├── cold start: exact N texte la backend mock US-005
|
||||||
|
├── warm start: ZERO texte, has_corpus True, suggest ok US-005
|
||||||
|
├── incremental: +1 rand => exact 1 embed US-005
|
||||||
|
├── model schimbat => zero hit-uri cache US-004
|
||||||
|
├── hash pe lista FILTRATA (denumire goala exclusa) risc §6, test dedicat
|
||||||
|
└── concurenta warmup/request sub lock A9 (test cu 2 threaduri)
|
||||||
|
TOATE testele: AUTOPASS_EMBEDDINGS_ENABLED=1 explicit A13d (anti-vacuos)
|
||||||
|
|
||||||
|
COVERAGE PLANIFICAT: 15/15 cai identificate au test specificat | GAPS ramase: 0
|
||||||
|
E2E: verify pe instanta reala (US-006) — operational, in afara suitei
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paralelizare worktree (Eng)
|
||||||
|
|
||||||
|
Implementare secventiala, fara oportunitate de paralelizare: lantul schema.sql → embedding_cache.py → embeddings.py (vectors=) → mapping.py (wiring) → teste e strict dependent, toate in acelasi modul de suggestii. Un singur lane.
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
Sintetizate din finding-urile review-ului. P1 blocheaza ship-ul.
|
||||||
|
|
||||||
|
- [ ] **T1 (P1, human: ~3h / CC: ~20min)** — embedding_cache — Modul nou: serializare array('f') end-to-end, load/save chunk 500 cu BEGIN/COMMIT explicit, validare dim la scriere/citire, purge diff-Python (orfane + modele vechi), log.warning pe erori — Surfaced by: US-001/002/004 + A2/A3/A4/A10/A12/A14 — Files: app/schema.sql, app/embedding_cache.py — Verify: pytest teste noi serializare/chunk/purge
|
||||||
|
- [ ] **T2 (P1, human: ~2h / CC: ~15min)** — embeddings — `index_corpus(vectors=)` cu validare aliniere + fallback; model ca parametru pe lant — Surfaced by: A8/A11 — Files: app/embeddings.py — Verify: test ranking exact + test mismatch
|
||||||
|
- [ ] **T3 (P1, human: ~2h / CC: ~15min)** — mapping — Wiring `ensure_embeddings_corpus`: orchestrare extrasa cu embed_fn (A15), lock de modul (A9), hash pe lista filtrata — Surfaced by: US-003 + A9/A15 — Files: app/mapping.py, app/embedding_cache.py — Verify: teste cold/warm/incremental + test concurenta
|
||||||
|
- [ ] **T4 (P1, human: ~2h / CC: ~15min)** — teste — Suita completa din diagrama (15 cai), toate cu AUTOPASS_EMBEDDINGS_ENABLED=1 — Surfaced by: US-005 + A6/A13 — Files: tests/ — Verify: python3 -m pytest -q
|
||||||
|
- [ ] **T5 (P2, human: ~30min / CC: ~5min)** — observabilitate — Log warmup `cache=N embed=M in Xs` — Surfaced by: US-006 — Files: app/mapping.py sau app/main.py — Verify: manual pe ./start.sh, doua restarturi
|
||||||
|
- [ ] **T6 (P2, human: ~15min / CC: ~2min)** — teste — Test echivalenta ranking cu toleranta float32 — Surfaced by: A5 — Files: tests/ — Verify: pytest
|
||||||
|
|
||||||
|
### Decision Audit Trail
|
||||||
|
|
||||||
|
<!-- AUTONOMOUS DECISION LOG -->
|
||||||
|
|
||||||
|
| # | Phase | Decision | Classification | Principle | Rationale | Rejected |
|
||||||
|
|---|-------|----------|----------------|-----------|-----------|----------|
|
||||||
|
| 1 | CEO 0C-bis | Design per-text (model, text_hash), nu blob monolit pe semnatura | Mechanical | P1 | Doar per-text da embed=0 la relabel (workflow activ: re-etichetare Haiku); semnatura include cod/is_nul, textul nu se schimba | blob monolit (re-embed tot la orice relabel) |
|
||||||
|
| 2 | CEO 0E | Modul nou app/embedding_cache.py; embeddings.py ramane fara DB | Mechanical | P5 | Separare existenta curata (engine pur / mapping face DB) | functii in embeddings.py |
|
||||||
|
| 3 | CEO 0D | Open Q2: purjare include modelele vechi | Mechanical | P2/P3 | ~5 linii, previne crestere moarta; propunerea PRD | lasare inerta |
|
||||||
|
| 4 | CEO 0D | CLI embcache deferat la TODOS | Mechanical | P3/P6 | PRD propune manual in v1 | build acum |
|
||||||
|
| 5 | CEO S2 | log.warning pe erorile cailor de cache (A4) | Mechanical | P1 (zero silent failures) | fallback-ul ramane, dar devine vizibil | pass mut |
|
||||||
|
| 6 | CEO S6/S5 | Metrica cu toleranta float32 + test echivalenta ranking (A5) | Mechanical | P1 | egalitate stricta ar esua legitim pe round-trip float32 | egalitate stricta |
|
||||||
|
| 7 | CEO S7/S1 | Commit chunk-uit 500 (A2) + purjare diff-Python chunk-uita (A3) | Mechanical | P5 | tranzactii scurte vs worker BEGIN IMMEDIATE | commit unic ~26MB |
|
||||||
|
| 8 | CEO S6 | Teste chunking + idempotenta cache partial (A6) | Mechanical | P1 | fixeaza comportamentul multi-tranzactie | doar testele US-005 |
|
||||||
|
| 9 | CEO 0D | Metrici Prometheus warmup | TASTE | — | log-ul US-006 acopera nevoia; counterul e nice-to-have | — (la gate) |
|
||||||
|
| 10 | CEO OV | Sidecar DB separat (voce externa) vs acelasi autopass.db | TASTE | — | user a confirmat P3 la D1; vocea externa ridica bloat backup + no-shrink fara VACUUM | — (la gate) |
|
||||||
|
| 11 | CEO 0A | Landscape check din cunostinte in-distributie (fara WebSearch) | Mechanical | P3 | pattern Layer 1 arhicunoscut (cache pe (model, hash(text))) | cautare web |
|
||||||
|
| 12 | CEO S4 | Dublu-index teoretic warmup/request: no action | Mechanical | P6 | SUPERSEDED de #14 — evaluarea era corecta doar pentru planul FARA cache; cu scrieri+DELETE, riscul devine real | lock nou |
|
||||||
|
| 13 | Eng S1 | A8: validare aliniere vectors/items + test ranking exact | Mechanical | P1 | misalignment = coduri gresite silentios; zip nu detecteaza nimic (embeddings.py:158) | liste paralele nevalidate |
|
||||||
|
| 14 | Eng S1 | A9: lock de modul pe secventa de cache (supersedes #12) | Mechanical | P1 | calea de request scrie si ea dupa is_loaded() (mapping.py:675); purjare concurenta = stergere falsa de randuri noi | write-uri doar pe block=True (re-embed repetat pe request path) |
|
||||||
|
| 15 | Eng S2 | A10: BEGIN/COMMIT explicit per chunk (autocommit db.py:16) | Mechanical | P5 | conn.commit() e no-op in autocommit; chunking-ul A2 nu s-ar intampla de fapt | commit() naiv |
|
||||||
|
| 16 | Eng S2 | A11: model ca parametru explicit pe tot lantul | Mechanical | P5 | testabilitate US-004; mock-urile nu trebuie sa scrie sub cheia modelului real | FASTEMBED_MODEL hardcodat |
|
||||||
|
| 17 | Eng S4 | A12: array('f') end-to-end + prag documentat ~50k | Mechanical | P1 | RAM ~8x mai mic gratis; blob_to_vector trece oricum prin array('f') | list[float] boxed (~2GB la 170k) |
|
||||||
|
| 18 | Eng S3 | A13: 4 teste suplimentare (purge-dupa-esec, mismatch, save partial, env flag explicit) | Mechanical | P1 | teste vacuos-verzi cu embeddings dezactivat = mai rau decat lipsa lor | doar US-005+A6 |
|
||||||
|
| 19 | Eng S3 | A14: validare dimensiune vector la scriere | Mechanical | P1 | o linie; previne umplerea DB cu blob-uri arbitrare | validare doar la citire |
|
||||||
|
| 20 | Eng S2 | A15: orchestrare extrasa cu embed_fn injectat | Mechanical | P5 | ensure_embeddings_corpus ramane citibila | functie de 80 linii cu 6 responsabilitati |
|
||||||
|
|
||||||
|
## GSTACK REVIEW REPORT
|
||||||
|
|
||||||
|
| Review | Trigger | Why | Runs | Status | Findings |
|
||||||
|
|--------|---------|-----|------|--------|----------|
|
||||||
|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | issues_open (via /autoplan) | 6 propuneri, 2 acceptate, 2 deferate, 2 taste la gate |
|
||||||
|
| Codex Review | `/codex review` | Independent 2nd opinion | 1 | issues_found (subagent-only) | Codex indisponibil (usage limit); voce externa = subagent Claude, 6 finding-uri CEO + 8 Eng |
|
||||||
|
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | clean (via /autoplan) | 8 issues, 0 critical gaps — toate absorbite ca A8-A15 |
|
||||||
|
| Design Review | `/plan-design-review` | UI/UX gaps | 0 | SKIPPED | fara scope UI (grep 0 potriviri) |
|
||||||
|
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | SKIPPED | fara suprafata developer-facing noua |
|
||||||
|
|
||||||
|
**CROSS-MODEL:** indisponibil — Codex pe usage limit pana la 18 iul; toate vocile externe au rulat ca subagenti Claude independenti (context proaspat, fara istoricul review-ului).
|
||||||
|
|
||||||
|
**VERDICT:** CEO + ENG CLEARED — APPROVED la gate-ul /autoplan (2026-07-06): taste #9 = SKIP metrici Prometheus (log-ul US-006 suficient); taste #10 = cache ramane in autopass.db (sidecar respins; caveat VACUUM documentat). Ready to implement.
|
||||||
|
|
||||||
|
NO UNRESOLVED DECISIONS
|
||||||
209
docs/prd/prd-5.22-ui-compact-trimiteri-reguli-fix-chips.md
Normal file
209
docs/prd/prd-5.22-ui-compact-trimiteri-reguli-fix-chips.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# PRD 5.22 — UI compact Trimiteri + bara contextuala "Aplica cod" + exemplu Reguli automate + fix buton "+" editor import
|
||||||
|
|
||||||
|
**Stare**: inchis
|
||||||
|
|
||||||
|
> Mockup aprobat de utilizator (artifact `6efc43e0`, v2, 2026-07-07). Decizii inchise:
|
||||||
|
> tabel desktop pe un rand cu header; status = bulina + tooltip; mediu = litera T/P;
|
||||||
|
> multi-cod = primul cod + badge `+N` cu tooltip; sub-linie de problema = textul brut
|
||||||
|
> al erorii, mono, trunchiat, doar pe erori; bara "Aplica cod" contextuala la selectie;
|
||||||
|
> Reguli automate = exemplu concret in loc de paragraf descriptiv; fix no-op silentios
|
||||||
|
> la butonul "+" din editorul de import.
|
||||||
|
|
||||||
|
## 1. Obiectiv
|
||||||
|
|
||||||
|
Patru imbunatatiri UI/UX aprobate pe mockup: (a) lista Trimiteri devine tabel compact
|
||||||
|
pe un singur rand pe desktop, cu header de coloane si semnalizare compacta
|
||||||
|
status/mediu; (b) actiunea bulk "Aplica cod" devine bara contextuala vizibila doar la
|
||||||
|
selectie; (c) sectiunea "Reguli automate (text)" din Mapari isi inlocuieste descrierea
|
||||||
|
cu un exemplu concret; (d) butonul "+" din editorul de prestatii (import) nu mai
|
||||||
|
esueaza silentios — feedback vizibil la succes si la refuz.
|
||||||
|
|
||||||
|
## 2. Non-Goals (anti scope-creep)
|
||||||
|
|
||||||
|
- NU se schimba logica backend a `bulk-fix` (`POST /trimiteri/bulk-fix`) — doar prezentarea.
|
||||||
|
- NU se schimba masina de stari `submissions`, filtrele, paginarea sau modalul de detaliu.
|
||||||
|
- NU se adauga functionalitate noua la regulile text (`/mapari/reguli-text`) — doar microcopy.
|
||||||
|
- NU se redeseneaza restul editorului de rand din import (doar zona chips "+").
|
||||||
|
- NU se ating API v1, worker-ul sau schema DB.
|
||||||
|
|
||||||
|
## 3. Stories atomice
|
||||||
|
|
||||||
|
### US-001: Fix buton "+" chips prestatii — feedback la refuz si la succes
|
||||||
|
**Ca** operator care editeaza un rand de import **vreau** ca apasarea pe "+" sa aiba
|
||||||
|
mereu un efect vizibil **pentru ca** azi codul invalid sau duplicat produce un raspuns
|
||||||
|
identic, fara niciun mesaj ("nu se intampla nimic").
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `app/web/routes.py` (`post_form_chips`), `app/web/templates/_chips_prestatii.html`, `tests/test_web_corectie_prestatii.py` (~3 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_corectie_prestatii.py` — `test_add_cod_duplicat_afiseaza_mesaj`, `test_add_cod_invalid_afiseaza_mesaj`, `test_add_cod_reusit_marcheaza_chip_nou`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Ramurile `add`, `add_extra`, `add_flat` din `post_form_chips`: cod care nu trece
|
||||||
|
verificarea in `nomenclator_rar` → mesaj de eroare in fragmentul re-randat
|
||||||
|
("Cod necunoscut in nomenclator"), nu raspuns identic.
|
||||||
|
- [ ] Cod deja prezent in lista (dedup) → mesaj "X este deja in lista — nu a fost
|
||||||
|
adaugat inca o data", pe TOATE cele 3 ramuri.
|
||||||
|
- [ ] Adaugare reusita → chip-ul nou primeste clasa de evidentiere (`chip-nou`,
|
||||||
|
outline verde) + mesaj de confirmare "X adaugat" (aria-live pentru SR).
|
||||||
|
- [ ] Mesajele traiesc in fragmentul `_chips_prestatii.html` (swap `outerHTML` pe
|
||||||
|
`#chips-section`) si dispar la urmatoarea actiune reusita.
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: browser pe import → Editeaza rand → alege cod deja adaugat → "+"
|
||||||
|
→ mesaj rosu vizibil; alege cod nou → "+" → chip verde evidentiat + mesaj.
|
||||||
|
|
||||||
|
### US-002: Reguli automate — exemplu concret in loc de descriere
|
||||||
|
**Ca** utilizator al paginii Mapari **vreau** sa vad un exemplu de regula in loc de un
|
||||||
|
paragraf explicativ **pentru ca** exemplul arata direct formatul si scuteste cititul.
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_mapari_text_rules.py` (~2 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_mapari_text_rules.py` — `test_sectiune_reguli_afiseaza_exemplu_nu_descriere`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Textul descriptiv din capul sectiunii "Reguli automate (text)" (inclusiv
|
||||||
|
empty-state) e inlocuit cu un singur exemplu formatat:
|
||||||
|
`Daca operatia contine "verificare" → Cod RAR = OE-2 Intretinere si revizie`,
|
||||||
|
cu pattern-ul si codul evidentiate vizual (chip warn / chip accent, ca in mockup).
|
||||||
|
- [ ] Nicio schimbare functionala la tabel, adaugare, preview sau stergere reguli.
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: browser pe Mapari → sectiunea Reguli automate afiseaza exemplul.
|
||||||
|
|
||||||
|
### US-003: View-model rand trimitere — coduri multiple + eroare bruta
|
||||||
|
**Ca** dashboard **vreau** ca randul de trimitere sa expuna lista completa de coduri
|
||||||
|
RAR si textul brut al erorii **pentru ca** UI-ul compact afiseaza `primul cod +N`
|
||||||
|
(tooltip cu toate) si sub-linia de eroare cu mesajul RAR, nu eticheta explicativa.
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `app/payload_view.py`, `app/web/routes.py` (`_submission_row_view`), `tests/test_payload_view.py`, `tests/test_web_submissions.py` (~4 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_payload_view.py` — `test_coduri_rar_lista_ordonata_dedup`, `test_coduri_rar_gol_fara_prestatii`; `tests/test_web_submissions.py` — `test_rand_expune_eroare_bruta_doar_pe_error`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `prezentare_din_payload` intoarce camp nou `coduri_rar`: lista ordonata,
|
||||||
|
dedup, a codurilor RAR (`cod_prestatie`) din TOATE prestatiile (lista goala
|
||||||
|
cand nu exista); campurile existente raman neschimbate (compatibilitate).
|
||||||
|
- [ ] `_submission_row_view` expune `eroare_bruta`: mesajul RAR brut cand exista
|
||||||
|
(`rar_error` → mesajul serverului, ex. `ORA-12899...`), altfel `motiv` scurt;
|
||||||
|
sir gol pe stari fara problema. `needs_mapping` NU produce `eroare_bruta`
|
||||||
|
(informatia "nemapat" traieste in coloana Cod RAR).
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: — (acoperit de US-004).
|
||||||
|
|
||||||
|
### US-004: Trimiteri desktop — tabel cu header, un rand per trimitere
|
||||||
|
**Ca** operator pe desktop **vreau** trimiterile intr-un tabel compact cu header de
|
||||||
|
coloane **pentru ca** este loc pe orizontala si scanez mai repede o lista pe un rand.
|
||||||
|
|
||||||
|
- **Depinde de**: US-003
|
||||||
|
- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/templates/base.html` (CSS), `tests/test_web_submissions_layout.py` (~3 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_submissions_layout.py` — `test_desktop_header_coloane`, `test_status_bulina_cu_tooltip`, `test_mediu_litera_t_p`, `test_multicod_badge_plus_n`, `test_sublinie_eroare_bruta_doar_pe_error`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Pe desktop (>= breakpoint existent), lista devine tabel/grid cu header:
|
||||||
|
(checkbox) · Vehicul · Cod RAR · Operatie · Data · Status · Mediu · (actiuni).
|
||||||
|
Un rand per trimitere, fara wrap; operatia primeste ellipsis.
|
||||||
|
- [ ] Status = DOAR bulina colorata (culorile claselor `s-*` existente), nume stare
|
||||||
|
complet in `title` (tooltip); coloana are headerul "Status".
|
||||||
|
- [ ] Mediu = litera in coloana proprie cu header "Mediu": `P` alb pe fundal
|
||||||
|
`--err` (productie), `T` contur discret (testare); `title` cu numele complet.
|
||||||
|
- [ ] Mai multe coduri: primul cod + badge `+N` cu `title` care listeaza toate
|
||||||
|
codurile din `coduri_rar`; un singur cod → fara badge; niciun cod → `nemapat`.
|
||||||
|
- [ ] Sub-linia de problema apare DOAR cand `eroare_bruta` e ne-goala: text mono,
|
||||||
|
`--fs-xs`, culoare `--err`, un singur rand cu `text-overflow:ellipsis`,
|
||||||
|
`title` cu textul complet.
|
||||||
|
- [ ] Comportamente pastrate: click pe rand → modal detaliu; checkbox doar pe
|
||||||
|
`gestionabil`; buton "Trimite" doar pe `tinut`; paginare/filtre neatinse.
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: browser HTMX pe http://localhost:8010/ (desktop 1280px) —
|
||||||
|
randuri pe o singura linie, header vizibil, tooltip-uri corecte.
|
||||||
|
|
||||||
|
### US-005: Trimiteri mobil — bulina status + litera mediu
|
||||||
|
**Ca** operator pe mobil **vreau** aceleasi semnale compacte (bulina + T/P) pe lista
|
||||||
|
de 2 linii **pentru ca** pill-urile cu text ocupa spatiu inutil pe ecran mic.
|
||||||
|
|
||||||
|
- **Depinde de**: US-004
|
||||||
|
- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/templates/base.html` (media query), `tests/test_web_submissions_layout.py` (~3 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_submissions_layout.py` — `test_mobil_pastreaza_doua_linii_cu_bulina_si_litera`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Sub breakpoint, randul revine la 2 linii (placuta / cod·operatie·data), iar in
|
||||||
|
dreapta stau litera de mediu + bulina de status (+ butonul "Trimite" pe tinute).
|
||||||
|
- [ ] Fara header de tabel pe mobil; fara scroll orizontal la 390px.
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: browser la 390px — lista lizibila, fara overflow orizontal.
|
||||||
|
|
||||||
|
### US-006: Bara contextuala "Aplica cod" — vizibila doar la selectie
|
||||||
|
**Ca** operator **vreau** ca actiunile bulk sa apara doar cand am selectat randuri,
|
||||||
|
cu text explicit **pentru ca** butonul permanent "Aplica cod" fara context e confuz.
|
||||||
|
|
||||||
|
- **Depinde de**: US-004
|
||||||
|
- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/templates/base.html` (CSS + JS numarare selectie), `tests/test_web_bulk_fix.py` (~3 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_bulk_fix.py` — `test_bara_bulk_ascunsa_implicit`, `test_bara_bulk_texte_explicite`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Inputul de cod + "Aplica cod" + "Sterge selectate" dispar din capul listei;
|
||||||
|
apar intr-o bara contextuala (stil accent, ca in mockup) randata ascunsa si
|
||||||
|
afisata prin JS cand >= 1 checkbox e bifat; dispare la deselectare.
|
||||||
|
- [ ] Bara contine: contor "N selectate", input cod RAR, buton "Aplica acest cod la
|
||||||
|
N randuri", separator, buton "Sterge N randuri", actiune "Deselecteaza";
|
||||||
|
N se actualizeaza la fiecare bifare (JS, fara roundtrip).
|
||||||
|
- [ ] Sub bara: hint pe un rand — "Aplica codul RAR doar operatiilor fara cod din
|
||||||
|
randurile selectate, apoi le repune in coada."
|
||||||
|
- [ ] Endpoint-urile, CSRF, `hx-confirm` pe stergere si `hx-disinherit` raman
|
||||||
|
functional identice; `bulk_message` se afiseaza ca acum.
|
||||||
|
- [ ] Starea barei supravietuieste re-randarii fragmentului (re-evaluare la
|
||||||
|
`htmx:afterSwap` — dupa swap selectia se pierde, bara se ascunde).
|
||||||
|
- [ ] `python3 -m pytest -q` verde.
|
||||||
|
- **Verificare E2E**: browser — capul listei curat; bifez 2 randuri blocate → bara
|
||||||
|
apare cu "2 selectate"; aplic cod → lista se reincarca, bara dispare, sumar vizibil.
|
||||||
|
|
||||||
|
## 4. Riscuri
|
||||||
|
|
||||||
|
- **Tabel vs. lista pe acelasi fragment**: `_submissions.html` e folosit si de
|
||||||
|
paginare/filtre/OOB — orice schimbare de structura trebuie sa pastreze id-urile
|
||||||
|
(`#submissions-wrap`, `#bulk-trimiteri`, OOB-urile). Mitigare: pastram form-ul si
|
||||||
|
id-urile existente, schimbam doar interiorul listei; testele de layout existente
|
||||||
|
(`test_web_submissions_layout.py`) ruleaza inainte si dupa.
|
||||||
|
- **`rar_error` brut poate contine detalii tehnice lungi**: afisare pe un rand cu
|
||||||
|
ellipsis + `title`; nu se logheaza/expune nimic nou (textul exista deja in modal).
|
||||||
|
- **JS-ul de numarare selectie** ruleaza peste randuri re-randate de HTMX: legare prin
|
||||||
|
delegare pe container, nu pe noduri (altfel handler-ii mor la swap).
|
||||||
|
- **Doua semnale rotunde alaturate pe mobil** (bulina status + litera mediu): litera
|
||||||
|
are forma/continut diferit de bulina — validat in mockup; daca in practica se
|
||||||
|
confunda, fallback aprobat: mediu doar pe randurile de productie.
|
||||||
|
|
||||||
|
## 5. Intrebari deschise
|
||||||
|
|
||||||
|
— (toate inchise prin mockup v2 aprobat)
|
||||||
|
|
||||||
|
## 6. Valuri de executie (graful de dependente)
|
||||||
|
|
||||||
|
```
|
||||||
|
Val 1: [US-001] [US-002] [US-003] ← fara dependente, fisiere disjuncte → paralel
|
||||||
|
Val 2: [US-004] ← deblocat de US-003
|
||||||
|
Val 3: [US-005] [US-006] ← deblocate de US-004; ating aceleasi fisiere → secvential intre ele
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Raport VERIFY
|
||||||
|
|
||||||
|
**Verdict: PASS** — 2026-07-07, E2E in browser (Playwright, uvicorn pe DB temporar, 7 trimiteri
|
||||||
|
seedate: sent/queued/held/needs_mapping/error, test+prod, multi-cod).
|
||||||
|
|
||||||
|
- Desktop 1280px: header Vehicul/Cod RAR/Operatie/Data/Status/Mediu; un rand per trimitere;
|
||||||
|
bulina colorata cu title; `P` fill rosu / `T` contur; `OE-1 +2` cu tooltip; sub-linie
|
||||||
|
`ORA-12899...` mono trunchiata doar pe rândul error; `nemapat` italic fara sub-linie.
|
||||||
|
- Bara bulk: ascunsa implicit; la 2 checkbox-uri → "2 selectate · Aplica acest cod la 2 randuri |
|
||||||
|
Sterge 2 randuri | Deselecteaza" + hint; aplicat OE-2 → ambele randuri → `queued`, bara dispare.
|
||||||
|
- Mapari: exemplul "Daca operatia contine «verificare» → Cod RAR = OE-2 Intretinere si revizie"
|
||||||
|
inlocuieste descrierea.
|
||||||
|
- Chips "+": adaugare OE-1 → chip evidentiat (chip-nou) + "OE-1 adaugat."; duplicat → "OE-1 este
|
||||||
|
deja in lista — nu a fost adaugat inca o data."; select gol → "Selecteaza un cod RAR din lista".
|
||||||
|
- Mobil 390px: fara header, 2 linii, T/P + bulina in dreapta, fara scroll orizontal.
|
||||||
|
- Suita completa: **1622 passed, 1 skipped**.
|
||||||
|
|
||||||
|
**Cauza-radacina REALA a bug-ului "+" (gasita LA VERIFY, nu in analiza statica)**: form-urile
|
||||||
|
parinte din modalele de editare au `hx-disabled-elt="find button"`; atributul se MOSTENESTE pe
|
||||||
|
butoanele chips, iar `find button` rezolvat relativ la butonul "+" (fara butoane descendente) →
|
||||||
|
`null` → TypeError intern htmx 1.9.12 INAINTE de request → nicio cerere, nicio eroare vizibila.
|
||||||
|
Fix: `hx-disabled-elt="this"` explicit pe toate butoanele hx-post din `_chips_prestatii.html`
|
||||||
|
(care si dezactiveaza butonul cat cererea e in zbor). No-op-urile silentioase server-side
|
||||||
|
(cod invalid pe add/add_flat, duplicat) erau defecte reale suplimentare, reparate separat.
|
||||||
|
|
||||||
|
Observatii non-blocante: (1) `bulk_message` ("N reusite") dispare imediat — HX-Trigger
|
||||||
|
`trimiteriChanged` reincarca lista peste raspunsul cu mesaj (comportament pre-existent);
|
||||||
|
(2) pe mobil, operatia e trunchiata agresiv pe randurile cu buton "Trimite" (spatiul e
|
||||||
|
consumat de actiune + mediu + status) — acceptat, informatia completa e in modal.
|
||||||
514
docs/prd/prd-5.23-editor-prestatii-unificat.md
Normal file
514
docs/prd/prd-5.23-editor-prestatii-unificat.md
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260707-194818.md -->
|
||||||
|
# PRD 5.23 — Editor prestatii unificat in modalul "Editare rand" (un select, un rand de chips)
|
||||||
|
|
||||||
|
**Stare**: inchis (2026-07-08; executie echipa agenti Sonnet TDD, VERIFY context curat PASS,
|
||||||
|
code-review CLOSE = 1 blocker de siguranta prins + reparat. Vezi Anexa /autoplan, GSTACK REVIEW
|
||||||
|
REPORT, Raport VERIFY; mockup final aprobat: select cu placeholder-tinta)
|
||||||
|
|
||||||
|
> Proces: `docs/ROADMAP.md` §5. Contract RAR: `docs/api-rar-contract.md`.
|
||||||
|
> Context vizual: screenshot `2026-07-07-003.jpg` (modal "Editare rand 4", badge "Cod RAR lipsa").
|
||||||
|
|
||||||
|
## 1. Obiectiv
|
||||||
|
|
||||||
|
Modalul "Editare rand" (import preview + corectie Trimiteri) are azi TREI controale pentru aceeasi
|
||||||
|
sarcina — asocierea codurilor RAR la prestatii: (1) rand per operatie cu select "— alege cod RAR —"
|
||||||
|
+ buton "Adauga", (2) container separat de chips pentru coduri libere (ex. `OE-1 ×`), (3) select
|
||||||
|
"+ Adauga alta operatie / cod RAR" cu buton "+". Utilizatorul nu stie care control face ce, iar
|
||||||
|
chips-urile apar in containere diferite, pe randuri diferite.
|
||||||
|
|
||||||
|
Livrabila: **un singur control** — un rand unic de chips (toate codurile, legate de operatie sau
|
||||||
|
libere) + **un singur select** care adauga chip instant la selectie (fara butoane "Adauga"/"+").
|
||||||
|
Selectul primeste sugestii fuzzy/k-NN pentru operatia nemapata si optiunea "Nu se declara la RAR".
|
||||||
|
|
||||||
|
## 2. Non-Goals (anti scope-creep)
|
||||||
|
|
||||||
|
- NU se schimba panoul de mapare (`_mapari.html`, `_preview_import.html`). EXCEPTIE confirmata in
|
||||||
|
review (eng, 2026-07-07): `resolve_prestatii` primeste O SINGURA treapta noua de precedenta —
|
||||||
|
`exclus=1` explicit pe item (din override/corectie) supravietuieste re-rezolvarii (azi
|
||||||
|
`it.pop("exclus", None)` la `mapping.py:298` il sterge, ceea ce face US-004 imposibil altfel).
|
||||||
|
Restul precedentei ramane neatins.
|
||||||
|
- NU se schimba contractul API `POST /v1/prezentari`. `override_json["prestatii"]` castiga campul
|
||||||
|
`exclus` pe item; itemii exclusi RAMAN in `payload_json` (adnotati), nu mai sunt eliminati la
|
||||||
|
salvare — `split_prestatii_excluse` ii scoate doar din payload-ul trimis la RAR.
|
||||||
|
- NU se schimba validarea codului fata de nomenclator la salvare (invariant ORA-12899) — ramane.
|
||||||
|
- NU se adauga cautare/autocomplete text in select (ramane `<select>` nativ; imbunatatire separata).
|
||||||
|
- NU se atinge butonul "salveaza ca regula" de la Trimiteri — ramane, doar mutat pe chip-ul nou.
|
||||||
|
|
||||||
|
## 3. Comportamentul controlului unificat (spec)
|
||||||
|
|
||||||
|
```
|
||||||
|
Prestatii — coduri RAR
|
||||||
|
┌───────────────────────────────────────────────────────────────┐
|
||||||
|
│ [⚠ OP-ITP · fara cod] [OE-1 ×] [— alege cod RAR — ▾] │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Un singur container** `.chips` cu toate elementele: chips-uri cod (× sterge), chips-uri warning
|
||||||
|
pentru operatii nemapate, si selectul unic la capat. Wrap natural doar la lipsa de spatiu —
|
||||||
|
niciodata containere/chenare separate pe zone.
|
||||||
|
- **Chip cod legat de operatie**: eticheta `COD`, titlu/tooltip cu denumirea operatiei sursa
|
||||||
|
(ex. `OE-8 — Inlocuire anvelope`). `×` scoate codul; daca operatia ramane fara cod, reapare
|
||||||
|
chip-ul warning al operatiei.
|
||||||
|
- **Chip cod liber** (fara operatie sursa): identic vizual, tooltip "cod adaugat manual".
|
||||||
|
- **Chip warning** (`⚠ OP-ITP · fara cod`): reprezinta operatia din import nemapata. Click pe el
|
||||||
|
il face **tinta activa** (evidentiat); implicit tinta = prima operatie nemapata.
|
||||||
|
- **Selectul unic**, `hx-trigger="change"` → `POST /form-chips` cu `chips_action=add`:
|
||||||
|
- daca exista tinta activa (operatie nemapata) → codul se leaga de acea operatie
|
||||||
|
(chip warning → chip cod); tinta trece automat la urmatoarea operatie nemapata;
|
||||||
|
- **placeholder-ul selectului numeste tinta** (decizie gate D2, 2026-07-07): cu tinta activa →
|
||||||
|
`Cod pentru OP-X — alege`; fara tinta → `— adauga cod RAR —`. Legatura cod↔operatie e
|
||||||
|
explicita la momentul alegerii, fara text suplimentar sub control;
|
||||||
|
- fara operatii nemapate → codul se adauga ca chip liber;
|
||||||
|
- dupa adaugare selectul revine la placeholder; chip-ul nou primeste evidentierea `.chip-nou`.
|
||||||
|
- **Continutul selectului**, cand tinta e o operatie nemapata:
|
||||||
|
- `optgroup "Sugestii"` sus: sugestia principala + fuzzy/k-NN top-3 pentru denumirea operatiei
|
||||||
|
(aceleasi surse ca in `_preview_import.html:89-121`);
|
||||||
|
- optiunea `"Nu se declara la RAR (exclude operatia)"` = sentinel `__NEDECLARAT__`
|
||||||
|
(`EXCLUDE_SENTINEL`, `app/mapping.py:44`) → operatia primeste chip distinct `Nedeclarat`
|
||||||
|
(stil mut, ×-ul readuce chip-ul warning), item anotat `exclus` in starea chips;
|
||||||
|
- restul nomenclatorului, ca azi.
|
||||||
|
Fara tinta (doar cod liber): fara optgroup Sugestii si fara `__NEDECLARAT__`.
|
||||||
|
- **Dedup**: acelasi `(operatie, cod)` nu se adauga de doua ori (comportament existent pastrat).
|
||||||
|
- **Fara text explicativ permanent** sub control (decizie utilizator la aprobarea mockup-ului,
|
||||||
|
2026-07-07): starea tintei se comunica DOAR vizual (stilul chip-ului warning evidentiat);
|
||||||
|
mesajele `chips_ok`/`chips_error` raman exclusiv ca feedback tranzitoriu la actiuni.
|
||||||
|
- Mockup aprobat: artifact "Mockup — Editor prestatii unificat (PRD 5.23)".
|
||||||
|
- **Modul plat** (corectii fara operatii din import) foloseste ACELASI control: zero chips warning,
|
||||||
|
selectul adauga doar coduri libere. Logica `has_r_odo`/`data-has-r-odo` (reveal odometru initial
|
||||||
|
pe R-ODO/I-ODO) si stilul `chip-warn` pe aceste coduri se pastreaza neschimbate.
|
||||||
|
|
||||||
|
### 3.1 Specificatie vizuala si de stari (din review-ul de design, 2026-07-07)
|
||||||
|
|
||||||
|
**Scara de dominanta vizuala — UN SINGUR tratament puternic simultan:**
|
||||||
|
|
||||||
|
| Element | Fundal / chenar / text | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| Chip tinta activa | fundal warn-soft, chenar SOLID 1px `--warn` + inel 2px warn-transparent | singurul element "tare" din rand |
|
||||||
|
| Chip warning (ne-tinta) | fundal warn-soft, chenar DASHED 1px `--warn` | click → devine tinta |
|
||||||
|
| Chip cod | fundal `--card2`-albastru (`--accent` 15%), text `--accent`, chenar subtil | eticheta `COD` mono |
|
||||||
|
| Chip Nedeclarat | fundal gri mut, text taiat (line-through), chenar dashed gri | NU discret pana la invizibil |
|
||||||
|
| `.chip-nou` | animatie de emfaza >=1.5s (fade), tranzitorie | nu concureaza cu tinta |
|
||||||
|
| Chip R-ODO/I-ODO | `chip-warn` existent, neschimbat | reveal odometru pastrat |
|
||||||
|
|
||||||
|
**Invariante de interactiune:**
|
||||||
|
- Ordinea chips e STABILA: operatiile in ordinea din import, codurile libere la coada; o rezolvare
|
||||||
|
nu re-ordoneaza randul (warning → cod in acelasi slot).
|
||||||
|
- Cat exista operatii nemapate, EXISTA tinta (implicit prima nemapata); click pe tinta activa =
|
||||||
|
no-op (nu se poate "deselecta" — adaugarile libere sunt posibile doar fara warnings).
|
||||||
|
- Click pe chip warning = comutare de tinta CLIENT-SIDE (toggle clasa + hidden `chips_target_index`),
|
||||||
|
fara round-trip; indexul tintei pleaca la server la urmatorul add.
|
||||||
|
- Selectul are id stabil (`chips-picker`); dupa swap `outerHTML`, focusul revine pe select.
|
||||||
|
- Optiunile selectului: `COD — denumire nomenclator`; in optgroup "Sugestii" acelasi format
|
||||||
|
(optgroup-ul e singura marca de sugestie). Eticheta chip warning: max ~18ch + ellipsis,
|
||||||
|
text complet in `title`.
|
||||||
|
|
||||||
|
**Tabel de stari (ce VEDE utilizatorul):**
|
||||||
|
|
||||||
|
| Stare | Ce se vede |
|
||||||
|
|---|---|
|
||||||
|
| Loading (add in zbor) | selectul disabled (`hx-disabled-elt="this"`) + stil `.htmx-request` pe sectiune; dedup face inofensiv un dublu-fire |
|
||||||
|
| Eroare add (400/500/retea) | mesaj `chips_error` SUB control (pattern 5.22, persista pana la urmatoarea actiune); selectul re-activat; tinta neschimbata; `hx-on::response-error` acopera esecul de retea |
|
||||||
|
| Gol (mod plat, zero chips) | doar selectul cu placeholder "— adauga cod RAR —"; container cu min-height (modalul nu salta la primul chip) |
|
||||||
|
| Succes partial | chip-ul nou cu emfaza >=1.5s; tinta avanseaza (vezi decizia de la gate) |
|
||||||
|
| Complet (zero nemapate) | mesaj tranzitoriu "Toate operatiile au cod" (`chips_ok`); optgroup Sugestii si `__NEDECLARAT__` dispar din select |
|
||||||
|
| Nedeclarat | mesajul numeste operatia: "OP-X nu se va declara la RAR" |
|
||||||
|
| Nomenclator gol | empty state existent (pastrat) |
|
||||||
|
|
||||||
|
## 4. Stories atomice
|
||||||
|
|
||||||
|
### US-001: Stare chips extinsa cu `exclus` + actiuni add/remove unificate in backend
|
||||||
|
**Ca** dezvoltator **vreau** o singura pereche de actiuni `add`/`remove` pe starea chips (cu tinta
|
||||||
|
optionala si suport sentinel `__NEDECLARAT__`) **pentru ca** azi exista 4 actiuni suprapuse
|
||||||
|
(`add`, `add_flat`, `add_extra`, `remove_flat`) care dubleaza logica.
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `app/web/routes.py` (`post_form_chips` :2402-2521, `_chips_state_from_form` :3862-3918), `tests/test_web_chips.py` (~2 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_chips.py` — `test_add_cu_tinta_leaga_de_operatie`,
|
||||||
|
`test_add_fara_tinta_adauga_cod_liber`, `test_add_nedeclarat_marcheaza_exclus`,
|
||||||
|
`test_remove_cod_readuce_operatia_nemapata`, `test_dedup_operatie_cod`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [x] Starea chips serializata intr-UN SINGUR hidden `chips_state` = JSON versionat
|
||||||
|
(`{"v":1,"items":[{"cod":"","op":"","den":"","exclus":0},...]}`) — decizie gate D4:
|
||||||
|
elimina structural clasa de bug-uri de aliniere pe index (2 regresii in 2 release-uri).
|
||||||
|
`_chips_state_from_form` parseaza JSON-ul; JSON invalid → eroare vizibila, nu stare goala.
|
||||||
|
- [x] Compat tranzitorie la deploy: daca `chips_state` lipseste dar exista listele vechi
|
||||||
|
(`cod_prestatie`/`chip_op_service`/`chip_denumire`), parserul cade inapoi pe ele cu
|
||||||
|
`exclus=0` (form-uri deschise peste deploy); fallback-ul se sterge in release-ul urmator.
|
||||||
|
- [x] `chips_state` prezent mereu (lista goala = `{"v":1,"items":[]}`): golirea intentionata a
|
||||||
|
chips se persista la salvare (inlocuieste santinela `chips_present`).
|
||||||
|
- [x] `chips_action=add` + `chips_target_index=i` leaga codul de operatia i; fara target →
|
||||||
|
chip liber; cod `__NEDECLARAT__` + target → item cu `exclus=1` si fara `cod_prestatie`.
|
||||||
|
- [x] `chips_action=remove` (index unic) acopera si fostul `remove_flat`; actiunile
|
||||||
|
`add_flat`/`add_extra`/`chips_add_cod_{i}`/`chips_add_cod_flat` sunt eliminate.
|
||||||
|
- [x] Tinta implicita dupa un add reusit = urmatoarea operatie nemapata (sau nici una).
|
||||||
|
- [x] Validare tinta SEMANTICA, nu doar in-range: `chips_target_index` trebuie sa arate spre o
|
||||||
|
operatie nemapata si ne-exclusa; altfel `chips_error` (acopera POST fabricat si index
|
||||||
|
stale dupa un remove).
|
||||||
|
- [x] `chip_exclus` parsat strict ("1"→1, orice altceva→0); `__NEDECLARAT__` fara tinta valida →
|
||||||
|
`chips_error` explicit.
|
||||||
|
- [x] Testele existente pe actiunile vechi (`tests/test_web_corectie_prestatii.py`,
|
||||||
|
`tests/test_web_form_editare_slim.py`) sunt ACTUALIZATE in acelasi val (suita nu ramane
|
||||||
|
rosie intre valuri).
|
||||||
|
- [x] Emfaza chip nou pe INDEX (`chips_added_index`), nu pe valoarea codului (doua operatii pot
|
||||||
|
avea legitim acelasi cod).
|
||||||
|
- [x] `python3 -m pytest -q` trece.
|
||||||
|
- **Verificare E2E**: acoperit de US-005.
|
||||||
|
|
||||||
|
### US-002: Template unificat — un container, un select, fara butoane Adauga/+
|
||||||
|
**Ca** operator **vreau** un singur rand de chips si un singur select care adauga instant
|
||||||
|
**pentru ca** azi nu stiu care din cele 3 controale trebuie folosit.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/web/templates/_chips_prestatii.html` (rescriere), `app/web/templates/base.html`
|
||||||
|
(CSS `.chips`/`.chip*` :815-843, eliminare `.op-row`), `tests/test_web_chips.py` (~3 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_chips.py` — `test_render_un_singur_container_chips`,
|
||||||
|
`test_render_fara_op_row_si_fara_butoane_adauga`, `test_chip_warning_pentru_operatie_nemapata`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [x] Un singur container `.chips` contine: chips cod (cu tooltip operatia sursa), chips warning
|
||||||
|
operatii nemapate, chip `Nedeclarat` pentru itemi exclusi, selectul unic la capat.
|
||||||
|
- [x] Selectul (nume camp: `chips_pick`, id stabil `chips-picker`) are `hx-trigger="change"`,
|
||||||
|
`hx-post="/form-chips"`, `hx-target="#chips-section"`, `hx-swap="outerHTML"`,
|
||||||
|
`hx-include="closest form"` (transporta CSRF + starea hidden — omiterea = 403) si
|
||||||
|
`hx-disabled-elt="this"` (capcana htmx din 5.22 — NU mosteni de la form).
|
||||||
|
- [x] Selectul revine la placeholder dupa ORICE raspuns (succes SI eroare); valoarea ramasa in
|
||||||
|
`chips_pick` NU e aplicata la Salveaza pe niciuna din cai (regresia c6e29aa nu reapare
|
||||||
|
sub alt nume — codul se adauga DOAR prin actiunea add).
|
||||||
|
- [x] Pe durata unui request /form-chips, INTREAGA sectiune e inerta (`pointer-events:none` pe
|
||||||
|
`.htmx-request` sau echivalent) — click pe × / chip warning mid-flight nu pierde actiuni.
|
||||||
|
- [x] Click pe chip warning comuta tinta CLIENT-SIDE (clasa + hidden `chips_target_index`),
|
||||||
|
fara round-trip; click pe tinta activa = no-op.
|
||||||
|
- [x] Focus restaurat pe `#chips-picker` dupa swap.
|
||||||
|
- [x] Zero aparitii `op-row`, `Adauga`, `add_flat`, `chips_add_cod_` in template.
|
||||||
|
- [x] `chips_submission_id` e emis ca hidden in sectiune si ecouat de `/form-chips`, astfel
|
||||||
|
incat "salveaza ca regula" ramane disponibil si DUPA re-randari (azi dispare la prima
|
||||||
|
interactiune — bug perpetuat altfel).
|
||||||
|
- **Verificare E2E**: acoperit de US-005.
|
||||||
|
|
||||||
|
### US-003: Sugestii fuzzy/k-NN + optiunea "Nu se declara la RAR" in selectul unic
|
||||||
|
**Ca** operator **vreau** sugestiile de mapare direct in selectul din modal **pentru ca** azi ele
|
||||||
|
exista doar in panoul de mapare, iar in modal caut manual prin tot nomenclatorul.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001, US-002
|
||||||
|
- **Fisiere**: `app/web/routes.py` (`post_form_chips` — construire sugestii pentru operatia tinta,
|
||||||
|
refolosind sursele din preview), `app/web/templates/_chips_prestatii.html`, `tests/test_web_chips.py` (~3 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_chips.py` — `test_select_are_optgroup_sugestii_pt_tinta`,
|
||||||
|
`test_select_are_nedeclarat_doar_cu_tinta`, `test_select_fara_tinta_fara_sugestii`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [x] Cu tinta activa: `optgroup "Sugestii"` cu sugestia principala + top-3 fuzzy/k-NN pentru
|
||||||
|
`denumire`-a operatiei; sub el optiunea `__NEDECLARAT__`; apoi nomenclatorul complet.
|
||||||
|
- [x] Fara tinta: selectul listeaza doar nomenclatorul (fara Sugestii, fara `__NEDECLARAT__`).
|
||||||
|
- [x] Sugestiile folosesc aceleasi functii ca `_preview_import.html` (fara drum nou de cod).
|
||||||
|
- [x] Degradare gratioasa: motor de sugestii rece/dezactivat → select DOAR cu nomenclatorul
|
||||||
|
(fara optgroup), fara eroare si fara blocare pe warmup (lock non-blocking, PRD 5.21).
|
||||||
|
- [x] `python3 -m pytest -q` trece.
|
||||||
|
- **Verificare E2E**: acoperit de US-005.
|
||||||
|
|
||||||
|
### US-004: Persistarea `exclus` prin salvare (import + Trimiteri) — cu round-trip complet
|
||||||
|
**Ca** operator **vreau** ca excluderea aleasa in modal sa se pastreze la salvare, sa produca
|
||||||
|
starea corecta si sa fie REVERSIBILA la redeschidere **pentru ca** altfel optiunea din select ar fi
|
||||||
|
cosmetica sau, mai rau, operatia ar fi declarata contra intentiei mele (RAR e ireversibil).
|
||||||
|
|
||||||
|
> Corectie din review (eng, CONFIRMATA in cod): pipeline-ul actual sterge `exclus` per-item
|
||||||
|
> (`resolve_prestatii` face `it.pop("exclus", None)`, `mapping.py:298`) si elimina itemii exclusi
|
||||||
|
> din `payload_json` la corectie (`routes.py:1744`). Fara fix-urile de mai jos, un item exclus din
|
||||||
|
> modal ar reveni `needs_mapping` sau — daca operatia are mapare pe cont — ar fi DECLARAT la RAR.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/mapping.py` (`resolve_prestatii` — treapta noua: item-exclus explicit
|
||||||
|
supravietuieste), `app/web/routes.py` (`web_editeaza_rand`, `post_corectie_trimitere` :1619,
|
||||||
|
`_prestatii_chips_from_payload` :1336 + toti constructorii de context chips),
|
||||||
|
`app/api/v1/import_router.py` (`apply_row_override` :319-366), `tests/test_web_editare_rand.py` (~4 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_editare_rand.py` — `test_salvare_pastreaza_exclus_in_override`,
|
||||||
|
`test_exclus_supravietuieste_resolve_chiar_cu_mapare_pe_cont`,
|
||||||
|
`test_redeschidere_modal_arata_chip_nedeclarat_si_x_readuce_warning`,
|
||||||
|
`test_rand_toate_operatiile_excluse_devine_excluded`, `test_salvare_trimitere_cu_exclus`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [x] `resolve_prestatii` pastreaza `exclus=1` venit explicit pe item (precedenta: item-exclus >
|
||||||
|
cod explicit > regula exclus cont > mapare > reguli text); restul treptelor neschimbate.
|
||||||
|
- [x] Itemii exclusi RAMAN in `payload_json`/`override_json` adnotati cu `exclus=1`;
|
||||||
|
`split_prestatii_excluse` ii scoate DOAR din payload-ul trimis la RAR si din cheia idempotenta.
|
||||||
|
- [x] Toti constructorii de chips (`_prestatii_chips_from_payload`, preview, re-render eroare)
|
||||||
|
propaga `exclus` → chip `Nedeclarat` reapare la redeschidere; `×` il readuce la warning.
|
||||||
|
- [x] Rand cu toate operatiile excluse → stare preview `excluded` ("Nedeclarat"); la Trimiteri →
|
||||||
|
`needs_data` cu motiv explicit (invariantele existente).
|
||||||
|
- [x] Validarea cod-vs-nomenclator la salvare ramane; sentinelul `__NEDECLARAT__` e RESPINS
|
||||||
|
explicit daca apare ca `cod_prestatie` in starea hidden (nu doar in select), pe ambele cai.
|
||||||
|
- [x] `python3 -m pytest -q` trece.
|
||||||
|
- **Verificare E2E**: acoperit de US-005.
|
||||||
|
|
||||||
|
### US-005: Verificare E2E browser pe ambele fluxuri
|
||||||
|
**Ca** lead **vreau** dovada in browser ca editarea e acum un singur control coerent
|
||||||
|
**pentru ca** bug-ul raportat e de UX vizual, nu doar de logica.
|
||||||
|
|
||||||
|
- **Depinde de**: US-002, US-003, US-004
|
||||||
|
- **Fisiere**: — (doar verificare; eventuale fix-uri in fisierele de mai sus)
|
||||||
|
- **Test intai (RED)**: —
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [x] Import preview: rand cu operatie nemapata + cod liber → modalul arata UN rand de chips
|
||||||
|
(warning + cod + select), fara chenare/zone separate; screenshot atasat la raport.
|
||||||
|
- [x] Selectarea unui cod din Sugestii transforma chip-ul warning in chip cod, instant, fara "+".
|
||||||
|
- [x] Alegerea "Nu se declara la RAR" produce chip `Nedeclarat`; salvare → rand "Nedeclarat".
|
||||||
|
- [x] Trimiteri (`/trimitere/{id}` corectie): acelasi control, "salveaza ca regula" functional.
|
||||||
|
- [x] Salvarea persista chips-urile (regresie c6e29aa) si nu reapar controale duble la re-deschidere.
|
||||||
|
- **Verificare E2E**: browser HTMX pe http://localhost:8010/ (import xlsx test + o trimitere
|
||||||
|
`needs_mapping`), conform pasilor de mai sus.
|
||||||
|
|
||||||
|
## 5. Riscuri
|
||||||
|
|
||||||
|
- **`hx-disabled-elt` mostenit de la form** a mai rupt butoanele chips o data (5.22) — selectul unic
|
||||||
|
primeste explicit `hx-disabled-elt="this"`; test de template pentru atribut.
|
||||||
|
- **`change` pe select nativ** se declanseaza si la navigarea cu tastatura pe unele browsere vechi;
|
||||||
|
acceptat (comportamentul modern e la confirmare), iar chip-ul gresit se sterge cu un click.
|
||||||
|
- **Compatibilitate stare veche**: submission-uri/override-uri existente fara `chip_exclus` trebuie
|
||||||
|
citite ca `exclus=0` (default la deserializare) — altfel salvarea de pe randuri vechi crapa.
|
||||||
|
- **Doua endpoint-uri de save** (import + Trimiteri) cu acelasi contract de campuri — orice
|
||||||
|
schimbare de nume de camp se face simultan in ambele, plus `_chips_state_from_form` unic partajat.
|
||||||
|
|
||||||
|
## 6. Intrebari deschise
|
||||||
|
|
||||||
|
> Rezolvate cu utilizatorul la aprobare (2026-07-07):
|
||||||
|
|
||||||
|
- `×` pe un chip `Nedeclarat` → **readuce chip-ul warning** al operatiei (operatia NU se sterge).
|
||||||
|
- Selectul unic ramane `<select>` nativ cu tot nomenclatorul — **acceptat**; autocomplete ramane
|
||||||
|
non-goal.
|
||||||
|
|
||||||
|
## 7. Valuri de executie (graful de dependente)
|
||||||
|
|
||||||
|
```
|
||||||
|
Val 1: [US-001] ← fara dependente
|
||||||
|
Val 2: [US-002] [US-004] ← deblocate de US-001, fisiere distincte → paralel
|
||||||
|
Val 3: [US-003] ← deblocat de US-002
|
||||||
|
Val 4: [US-005] ← verificare finala E2E
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anexa /autoplan — Review CEO (Faza 1, 2026-07-07, mod SELECTIVE EXPANSION, voci: subagent-only)
|
||||||
|
|
||||||
|
### NOT in scope (decizii din review)
|
||||||
|
- Autocomplete/cautare in picker — non-goal confirmat de utilizator.
|
||||||
|
- Schimbarea panoului de mapare / precedentei `resolve_prestatii` — non-goal PRD.
|
||||||
|
- Badge-uri sursa sugestie (GOLD/SILVER) in select — respins (P5): optgroup "Sugestii" e suficient.
|
||||||
|
- "Bulk: aplica maparea la N randuri identice" in preview — deferat TODOS (in afara blast radius).
|
||||||
|
- Metrici de baza (deschideri modal / % randuri mapate manual) — deferat TODOS.
|
||||||
|
|
||||||
|
### What already exists (refolosit, nu reconstruit)
|
||||||
|
- Stare chips in hidden inputs + `/form-chips` stateless (`_chips_prestatii.html`, `routes.py:2402`).
|
||||||
|
- Validare cod vs nomenclator la add si la save (c6e29aa); mesaje `chips_error`/`chips_ok` (5.22).
|
||||||
|
- Sugestii: `suggest_codes` + `sugestie_principala` (`mapping.py:117,868`) — aceleasi surse ca preview.
|
||||||
|
- `EXCLUDE_SENTINEL`/`split_prestatii_excluse` (0bc6187); `apply_row_override` (c6e29aa).
|
||||||
|
- Empty state nomenclator lipsa; `has_r_odo`/reveal odometru; buton "salveaza ca regula" (Trimiteri).
|
||||||
|
|
||||||
|
### Dream state delta
|
||||||
|
Planul muta editorul de la 3 controale/4 actiuni la 1 control/2 actiuni si aduce sugestiile in
|
||||||
|
modal — pas direct spre idealul 12 luni (o singura componenta de mapare peste tot). Ramase pe
|
||||||
|
traiectorie: autocomplete (daca nomenclatorul creste), componenta partajata cu panoul de mapare.
|
||||||
|
|
||||||
|
### Arhitectura (Sectiunea 1)
|
||||||
|
```
|
||||||
|
[modal Editare rand]──include──▶[_form_editare]──include──▶[_chips_prestatii (REWRITE)]
|
||||||
|
│ │ hx-post change/×
|
||||||
|
▼ ▼
|
||||||
|
POST editeaza (import) ─┐ POST /form-chips (add|remove)
|
||||||
|
POST corecteaza (Trim.) ─┴─▶ _chips_state_from_form ──▶ apply_row_override(prestatii+exclus)
|
||||||
|
│ (commit)
|
||||||
|
suggest_codes/mapping.py ◀──────┘ resolve + split_prestatii_excluse
|
||||||
|
```
|
||||||
|
Cuplaj NOU: /form-chips → motorul de sugestii (pana acum doar preview-ul) — justificat (reuse),
|
||||||
|
cu conditia caii NE-blocante pe request (lock embeddings non-blocking, PRD 5.21). Fara migrare DB
|
||||||
|
(`exclus` traieste in override_json). Rollback: git revert simplu. Fereastra de deploy: un modal
|
||||||
|
deschis peste deploy trimite campuri vechi (`chips_add_cod_{i}`) pe care handlerul nou le ignora
|
||||||
|
— utilizatorul redeschide editarea; acceptat (sesiune de editare scurta).
|
||||||
|
|
||||||
|
### Registru erori & rescue (Sectiunea 2)
|
||||||
|
```
|
||||||
|
CODEPATH | CE POATE ESUA | TRATARE | USER VEDE
|
||||||
|
--------------------------|------------------------------------|--------------------------------|-----------
|
||||||
|
/form-chips add | cod gol / necunoscut | chips_error (exista) | mesaj rosu
|
||||||
|
/form-chips add | index tinta iesit din forma | chips_error (exista) | mesaj rosu
|
||||||
|
/form-chips add NED | __NEDECLARAT__ fara tinta | GAP -> chips_error nou | mesaj rosu
|
||||||
|
/form-chips render | motor sugestii rece/dezactivat | GAP -> degradare la nomenclator| select fara optgroup
|
||||||
|
/form-chips | nomenclator gol | empty state (exista) | avertisment
|
||||||
|
save (ambele) | cod necunoscut in stare | re-render cu eroare (exista) | modal + eroare
|
||||||
|
save (ambele) | stare veche fara chip_exclus | default exclus=0 (PRD risc) | nimic (compat)
|
||||||
|
save (ambele) | CSRF invalid/expirat | 403 (existent) | eroare form
|
||||||
|
enqueue | toate operatiile excluse | excluded / needs_data (exista) | stare explicita
|
||||||
|
```
|
||||||
|
GAP-uri de inchis in implementare: NED-fara-tinta si degradarea sugestiilor (ambele = criterii noi).
|
||||||
|
|
||||||
|
### Securitate (Sectiunea 3)
|
||||||
|
Fara endpoint nou. Input nou `chip_exclus`: parsare stricta ("1" -> 1, orice altceva -> 0).
|
||||||
|
Sentinelul `__NEDECLARAT__` nu ajunge NICIODATA in payload RAR (split inainte de enqueue —
|
||||||
|
invariant existent; test explicit). XSS: `denumire` in options/title — autoescape Jinja (exista).
|
||||||
|
Sugestii cross-account: politica 5.14 (suggestion-only) neschimbata. CSRF pe /form-chips: exista.
|
||||||
|
Nicio amenintare High nemitigata.
|
||||||
|
|
||||||
|
### Edge cases date/interactiune (Sectiunea 4)
|
||||||
|
- Dublu `change` in zbor: `hx-disabled-elt="this"` pe select (criteriu US-002).
|
||||||
|
- Focus pierdut dupa swap `outerHTML`: focusul revine pe select dupa add (criteriu nou US-002).
|
||||||
|
- Navigare cu tastatura prin select declanseaza change pe unele browsere: acceptat (PRD risc),
|
||||||
|
chip-ul gresit se scoate cu un click.
|
||||||
|
- Iesire din modal mid-edit: stare pierduta (comportament existent, form-local) — neschimbat.
|
||||||
|
- Dedup (operatie,cod) si cod liber duplicat: exista, se pastreaza.
|
||||||
|
- Mod plat + `has_r_odo`: pastrate explicit (spec actualizat).
|
||||||
|
|
||||||
|
### Calitate (Sectiunea 5)
|
||||||
|
4 actiuni -> 2 reduce ramificarea din `post_form_chips` (~120 linii azi). `_chips_state_from_form`
|
||||||
|
ramane UNICUL parser, partajat de ambele endpoint-uri de save (risc PRD; test comun).
|
||||||
|
Alternativa "un singur camp hidden JSON" in loc de 4 liste paralele — decizie de gust (vezi gate).
|
||||||
|
|
||||||
|
### Teste (Sectiunea 6) — completari la RED-urile din PRD
|
||||||
|
- `test_add_nedeclarat_fara_tinta_da_eroare` (US-001)
|
||||||
|
- `test_sugestii_indisponibile_degradare_fara_optgroup` (US-003)
|
||||||
|
- `test_mod_plat_fara_operatii_select_doar_coduri_libere` (US-002)
|
||||||
|
- `test_has_r_odo_pastrat_dupa_actiuni` (US-002)
|
||||||
|
- `test_sentinel_nedeclarat_nu_ajunge_in_payload` (US-004)
|
||||||
|
- Template: `test_select_are_hx_disabled_elt_this` (capcana 5.22)
|
||||||
|
|
||||||
|
### Performanta (Sectiunea 7)
|
||||||
|
Sugestiile se calculeaza la FIECARE re-render /form-chips pentru operatia tinta — bounded
|
||||||
|
(matvec numpy 5.21, corpus 17k, <100ms tipic); calea request NU asteapta warmup (lock non-blocking).
|
||||||
|
18 optiuni in select — trivial. Fara probleme.
|
||||||
|
|
||||||
|
### Observabilitate (Sectiunea 8)
|
||||||
|
Starea randurilor e deja vizibila in dashboard (needs_mapping/excluded). Gap acceptat si deferat
|
||||||
|
TODOS: metrica "deschideri modal / sugestie acceptata" (leaga de decizia de produs, nu de 5.23).
|
||||||
|
|
||||||
|
### Deploy (Sectiunea 9)
|
||||||
|
Fara migrare, fara feature flag (UI inlocuit atomic in acelasi template). Worker neatins.
|
||||||
|
Post-deploy: smoke = deschide un rand needs_mapping, adauga un cod, salveaza.
|
||||||
|
|
||||||
|
### Traiectorie (Sectiunea 10)
|
||||||
|
Reversibilitate 4/5 (revert curat; starile cu exclus persistate raman valide — split exista
|
||||||
|
independent). Datorie REDUSA (actiuni duplicate eliminate). Comentariul de header din
|
||||||
|
`_chips_prestatii.html` se rescrie (diagrama noua a starii — parte din US-002).
|
||||||
|
|
||||||
|
### Design/UX la nivel CEO (Sectiunea 11)
|
||||||
|
Ierarhie: chips (starea) inaintea selectului (actiunea) — corect. Stari acoperite: gol (hint),
|
||||||
|
nomenclator lipsa, eroare, succes, exclus. Mobil: rand cu wrap; latimea selectului pe 390px =
|
||||||
|
criteriu in faza Design. Accesibilitate: aria-labels existente se pastreaza; focus dupa swap.
|
||||||
|
Riscul de mis-binding (tinta implicita + fara text permanent) — ridicat de vocea externa,
|
||||||
|
decizie la gate. Detaliu complet in Faza 2 (plan-design-review).
|
||||||
|
|
||||||
|
### Decision Audit Trail (/autoplan)
|
||||||
|
|
||||||
|
| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins |
|
||||||
|
|---|------|---------|-------------|-----------|-------------|---------|
|
||||||
|
| 1 | CEO | Abordare A (rescriere template + unificare actiuni) | Mecanica | P1,P5 | B pastreaza confuzia; C incalca non-goal aprobat | B, C |
|
||||||
|
| 2 | CEO | A11y tastatura + focus dupa swap → criterii US-002 | Mecanica | P1 | in blast radius, 2 fisiere | — |
|
||||||
|
| 3 | CEO | Badge sursa sugestie in select → respins | Mecanica | P5 | optgroup "Sugestii" spune deja asta | badge-uri |
|
||||||
|
| 4 | CEO | Bulk-apply mapare pe randuri identice → TODOS | Mecanica | P2,P3 | in afara blast radius (panou preview = non-goal) | — |
|
||||||
|
| 5 | CEO | Metrici deschideri-modal/sugestii → TODOS | Mecanica | P3 | produs, nu 5.23 | — |
|
||||||
|
| 6 | CEO | GAP-uri erori (NED-fara-tinta, degradare sugestii) → criterii noi | Mecanica | P1 | zero silent failures | — |
|
||||||
|
| 7 | CEO | Auto-advance tinta + fara eticheta pe select | TASTE (gate) | — | voce externa: risc mis-binding pe declaratii ireversibile | — |
|
||||||
|
| 8 | CEO | Paleta click 18 coduri vs select nativ | TASTE (gate) | — | premisa "tot nomenclatorul" e falsa (18 coduri fixe) | — |
|
||||||
|
| 9 | CEO | Stare chips: 4 liste hidden vs un camp JSON | TASTE (gate) | — | a 3-a regresie in zona; JSON = sursa unica | — |
|
||||||
|
| 10 | CEO | Test cu operator real in US-005 | Mecanica | P1 | verifica direct riscul de mis-binding; cost ~0 | — |
|
||||||
|
| 11 | Design | Scara de dominanta vizuala (tabel §3.1) | Mecanica | P1,P5 | 3 tratamente concurente fara ierarhie = confuzia initiala reambalata | — |
|
||||||
|
| 12 | Design | Stari loading/eroare/gol/complet specificate (tabel §3.1) | Mecanica | P1 | eroarea de retea lasa selectul disabled permanent altfel | — |
|
||||||
|
| 13 | Design | Tinta invarianta (exista cat sunt nemapate; click pe tinta = no-op) | Mecanica | P5 | interzice starea "cod liber cu warnings inca pe rand" tacuta | — |
|
||||||
|
| 14 | Design | Comutare tinta CLIENT-SIDE (fara round-trip) | Mecanica | P3 | round-trip pe click de highlight = lent + race cu add-ul in zbor | server-side |
|
||||||
|
| 15 | Design | Ordine chips stabila + focus pe select dupa swap | Mecanica | P1 | reflow-ul + focusul pierdut rup fluxul de tastatura pe 10 randuri | — |
|
||||||
|
| 16 | Design | Optiuni `COD — denumire`; chip warning ellipsis 18ch | Mecanica | P5 | 18 coduri criptice fara denumire = memorare fortata | — |
|
||||||
|
| 17 | Design | Nedeclarat vizibil (line-through, nu mut) + mesaj cu numele operatiei | Mecanica | P1 | cea mai riscanta actiune legala nu poate fi cea mai discreta | stil mut |
|
||||||
|
| 18 | Design | Mesaje chips_ok/err raman pattern 5.22 (persista pana la actiune) | Mecanica | P4 | timer de auto-dismiss = JS nou fara valoare | timer 4s |
|
||||||
|
| 19 | Design | Auto-advance tinta: pastrat cu emfaza vs eliminat | TASTE (gate) | — | CEO-voice: elimina (mis-binding); Design-voice: pastreaza (momentum) + emfaza | — |
|
||||||
|
| 20 | Design | Touch targets × pe chips sub 44px (UI compact 11px existent) | Mecanica | P4 | consecvent cu design-ul compact existent; nota in TODOS a11y | 44px |
|
||||||
|
| 21 | Eng | `exclus` supravietuieste `resolve_prestatii` (treapta noua, non-goal amendat) | Mecanica | P1 | P1 confirmat: `it.pop("exclus")` mapping.py:298 face US-004 imposibil; alternativa (regula pe cont) schimba semantica aprobata | regula per-cont; taierea US-004 |
|
||||||
|
| 22 | Eng | Itemii exclusi raman in payload_json + `exclus` in toti constructorii de chips | Mecanica | P1 | P1 confirmat: routes.py:1744 ii elimina → × pe Nedeclarat imposibil dupa salvare | — |
|
||||||
|
| 23 | Eng | `chips_submission_id` hidden + ecou in /form-chips ("salveaza ca regula" persistent) | Mecanica | P1,P3 | butonul dispare azi la prima re-randare; fluxul nou trece TOT prin /form-chips | — |
|
||||||
|
| 24 | Eng | `chips_pick` reset dupa orice raspuns; valoarea NEaplicata la Salveaza | Mecanica | P5 | clasa de bug c6e29aa; sentinel NED pozitional pe calea corectie | aplicare implicita |
|
||||||
|
| 25 | Eng | Validare semantica tinta + chip_exclus strict + sectiune inerta in request | Mecanica | P1 | POST fabricat / index stale / race mid-flight leaga codul de alt item | — |
|
||||||
|
| 26 | Eng | `chips_present=1` — golirea listei se persista (nu no-op) | Mecanica | P1 | `_chips_state_from_form` intoarce None pe form fara chips → golire imposibila | no-op actual |
|
||||||
|
| 27 | Eng | Actualizarea testelor existente pe actiunile vechi in acelasi val | Mecanica | P6 | altfel suita e rosie intre Val 1 si Val 2 | — |
|
||||||
|
| 28 | Eng | Emfaza chip nou pe index, nu pe cod | Mecanica | P5 | doua operatii cu acelasi cod = dubla evidentiere falsa | — |
|
||||||
|
| 29 | Eng | Fara memoizare sugestii per re-render | Mecanica | P3 | rapidfuzz pe 18 randuri + k-NN gardat = bounded; memoizarea = complexitate fara castig | memoizare |
|
||||||
|
| D2 | Gate | Auto-advance pastrat + placeholder-ul selectului numeste tinta | USER | — | leaga explicit codul de operatie la momentul alegerii, fara text sub control | doar emfaza; fara auto-advance |
|
||||||
|
| D3 | Gate | Select nativ pastrat (mockup v1); paleta v2 vazuta si RESPINSA de utilizator | USER | — | directia utilizatorului; paleta ramane alternativa daca selectul se dovedeste lent (US-005 cu operator) | paleta 18 coduri |
|
||||||
|
| D4 | Gate | Stare chips = UN hidden `chips_state` JSON versionat + fallback tranzitoriu liste vechi | USER | — | elimina clasa de regresii de aliniere pe index | 4 liste paralele |
|
||||||
|
| D5 | Gate | Plan revizuit APROBAT de utilizator | USER | — | — | — |
|
||||||
|
|
||||||
|
### Anexa Eng (Faza 3) — diagrama de acoperire teste
|
||||||
|
|
||||||
|
```
|
||||||
|
CODE PATHS USER FLOWS
|
||||||
|
[+] post_form_chips (rescris, 2 actiuni) [+] Corectie rand cu N operatii nemapate
|
||||||
|
├── add cu tinta valida [RED planificat] ├── sugestie→chip instant [RED + E2E US-005]
|
||||||
|
├── add fara tinta (liber) [RED planificat] ├── comutare tinta client [E2E US-005]
|
||||||
|
├── add NED cu/fara tinta [RED planificat] ├── × pe cod → warning [RED planificat]
|
||||||
|
├── tinta semantica invalida [RED planificat] ├── NED → Nedeclarat → × [RED planificat]
|
||||||
|
├── remove index/liber [RED planificat] ├── eroare retea la add [→E2E] [GAP inchis §3.1]
|
||||||
|
└── dedup + cod necunoscut [exista, se muta] └── golire totala + salvare [RED chips_present]
|
||||||
|
[+] resolve_prestatii treapta item-exclus [RED planificat — REGRESIUNE potentiala: IRON RULE,
|
||||||
|
test obligatoriu ca exclus NU e sters si ca restul precedentei ramane identic]
|
||||||
|
[+] _chips_state_from_form (4 liste + compat vechi + liste scurte) [RED planificat]
|
||||||
|
[+] save x2 (import/corectie): exclus round-trip, NED respins, chips_pick ignorat [RED planificat]
|
||||||
|
[+] template: hx-atribute, focus, sectiune inerta, mod plat, has_r_odo [RED planificat]
|
||||||
|
COVERAGE planificata: 100% din ramurile noi au RED numit in stories. Fara eval-uri LLM (nu se
|
||||||
|
ating prompturi). Regresiune-cheie: precedenta resolve_prestatii → test dedicat blocant.
|
||||||
|
```
|
||||||
|
|
||||||
|
ENG DUAL VOICES — CONSENS (subagent-only):
|
||||||
|
```
|
||||||
|
Dimensiune Claude Codex Consens
|
||||||
|
1. Arhitectura solida? DA* N/A OK (directia 4→2 actiuni corecta; * dupa fix P1-uri)
|
||||||
|
2. Acoperire teste suficienta? NU→DA N/A FLAGGED→INCHIS (6 teste noi adaugate in stories)
|
||||||
|
3. Riscuri de performanta? NU N/A OK (18 coduri, k-NN gardat, bounded)
|
||||||
|
4. Securitate acoperita? PARTIAL→DA N/A INCHIS (validare semantica tinta, chip_exclus strict, NED respins la save)
|
||||||
|
5. Drumuri de eroare tratate? PARTIAL→DA N/A INCHIS (§3.1 + criterii noi)
|
||||||
|
6. Risc de deploy gestionabil? DA N/A OK (fara migrare; fereastra modal-peste-deploy acceptata)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Raport VERIFY
|
||||||
|
|
||||||
|
Verificator independent (subagent context curat, doar PRD + §5.6), 2026-07-08. **VERDICT: PASS**
|
||||||
|
pe toate criteriile testabile; regresia de aur RAR live = NEPROBAT (contul QA fara creds RAR).
|
||||||
|
|
||||||
|
- **Suita**: `python3 -m pytest -q` → verde. La VERIFY: 1672 passed, 1 skipped. Dupa fix-urile
|
||||||
|
de code-review CLOSE: **1680 passed, 1 skipped** (+45 teste noi fata de baseline 1635).
|
||||||
|
- **US-001..US-004** PASS cu dovezi de cod + teste (chips_state JSON unic, 2 actiuni add/remove,
|
||||||
|
exclus supravietuieste resolve_prestatii cu test de regresie blocant pe precedenta, sentinel
|
||||||
|
respins pe ambele cai).
|
||||||
|
- **US-005 E2E browser** (import preview + Trimiteri corectie, Playwright): PASS pe (a) un singur
|
||||||
|
rand de chips cu tinta evidentiata + placeholder-tinta + optgroup Sugestii + optiune "Nu se
|
||||||
|
declara la RAR" la deschiderea initiala; (b) sugestie→chip cod instant fara "+"; (c) Nedeclarat
|
||||||
|
→ salvare → rand "Nedeclarat", redeschidere arata chipul, × readuce warning; (d) acelasi control
|
||||||
|
la Trimiteri, "salveaza ca regula" persistent; (e) fara controale duble, zero erori JS de flux.
|
||||||
|
- **Gap prins in executie** (dupa US-003, reparat inainte de VERIFY): tinta implicita + sugestiile
|
||||||
|
nu se calculau la randarea initiala a modalului — helper partajat `_chips_target_si_sugestii`
|
||||||
|
in toate caile de randare.
|
||||||
|
|
||||||
|
### Code-review CLOSE (high, 8 unghiuri + verify) — 1 BLOCKER de siguranta + 2 findings, toate reparate
|
||||||
|
|
||||||
|
- **BLOCKER (CONFIRMED, reparat)**: itemii exclusi (`exclus=1`, cod null) ramaneau in `payload_json`
|
||||||
|
pe calea de corectie, iar worker-ul ii trimitea la RAR ca `{codPrestatie: null}` → risc
|
||||||
|
ORA-12899 / FINALIZATA partial ireversibil. Nimic nu facea `split_prestatii_excluse` la MOMENTUL
|
||||||
|
trimiterii. Fix la chokepoint unic (worker, inainte de `build_rar_payload`) — acopera toate
|
||||||
|
canalele (API/import/corectie) — + filtru defensiv in `build_rar_payload` (sare itemii exclusi /
|
||||||
|
fara cod) + teste care dovedesc ca itemul exclus NU apare in payload-ul RAR.
|
||||||
|
- **Consistenta round-trip (reparat)**: `commit_import`/`web_confirma_import` pastreaza acum
|
||||||
|
prestatiile complete in `payload_json` (cheia idempotentei = doar declarabile, identica cu
|
||||||
|
preview); bonus: bug latent de idempotenta preview↔commit in `_web_compute_preview` reparat.
|
||||||
|
- **Conventii CLAUDE.md (reparat)**: referinte PRD/US-xxx/decizii scoase din comentariile noi.
|
||||||
|
- **Robustete tinta (reparat)**: un item fara `cod_op_service` nu mai poate deveni tinta
|
||||||
|
invizibila; sugestiile intorc gol pe denumire goala.
|
||||||
|
|
||||||
|
## GSTACK REVIEW REPORT
|
||||||
|
|
||||||
|
| Review | Trigger | Why | Runs | Status | Findings |
|
||||||
|
|--------|---------|-----|------|--------|----------|
|
||||||
|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | CLEAR (PLAN via /autoplan) | 5 proposals, 2 accepted, 3 deferred; 8 constatari voce externa |
|
||||||
|
| Codex Review | `/codex review` | Independent 2nd opinion | 0 | UNAVAILABLE (usage limit pana 2026-07-18) | voci = subagent-only |
|
||||||
|
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (PLAN via /autoplan) | 8 issues (4×P1 confirmate in cod), 0 critical gaps ramase |
|
||||||
|
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (FULL via /autoplan) | score: 6/10 → 9/10, 10 decizii in §3.1 |
|
||||||
|
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | SKIPPED | fara scope developer-facing |
|
||||||
|
|
||||||
|
- **CROSS-MODEL:** indisponibil (Codex la limita de utilizare); toate vocile externe = subagenti
|
||||||
|
Claude independenti (context curat, fara review-ul anterior).
|
||||||
|
- **VERDICT:** CEO + ENG + DESIGN CLEARED — gata de implementare. Decizii gate aplicate:
|
||||||
|
D2 placeholder-tinta, D3 select (paleta respinsa), D4 stare JSON, D5 aprobat.
|
||||||
|
|
||||||
|
NO UNRESOLVED DECISIONS
|
||||||
|
|
||||||
60
docs/runbook-rollback-5.19.md
Normal file
60
docs/runbook-rollback-5.19.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Runbook — Rollback sigur al feature-ului "Auto send / coada tinuta" (PRD 5.19)
|
||||||
|
|
||||||
|
Procedura operationala pentru cazul in care trebuie sa dai **revert pe cod** care
|
||||||
|
atinge `claim_one` / logica `held` din PRD 5.19.
|
||||||
|
|
||||||
|
## De ce e periculos revertul (Riscul R4)
|
||||||
|
|
||||||
|
Feature-ul 5.19 tine randuri manual: un rand `queued AND held=1` NU pleaca la RAR
|
||||||
|
pentru ca `claim_one` (worker) filtreaza cu `AND held=0`.
|
||||||
|
|
||||||
|
Daca dai revert pe codul worker-ului **DUPA** ce baza contine deja randuri cu
|
||||||
|
`held=1`, worker-ul revenit **pierde filtrul** `AND held=0` -> ar prelua si trimite
|
||||||
|
la RAR TOATE randurile tinute. **RAR FINALIZATA e IREVERSIBILA** (fara anulare prin
|
||||||
|
API) — declari real, definitiv, randuri care fusesera puse deoparte intentionat.
|
||||||
|
|
||||||
|
## Procedura (copy-paste, in ordine)
|
||||||
|
|
||||||
|
### 1. INAINTE de orice revert — carantineaza randurile tinute
|
||||||
|
|
||||||
|
Pe masina gateway, cu acelasi mediu ca aplicatia (acelasi `AUTOPASS_DB_PATH`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verifica intai cate randuri sunt tinute (nu scrie nimic):
|
||||||
|
python3 -m tools.carantina_held --dry-run
|
||||||
|
|
||||||
|
# Apoi carantineaza efectiv:
|
||||||
|
python3 -m tools.carantina_held
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional, opreste worker-ul in timp ce faci operatia, ca sa nu concureze:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Ce face carantina
|
||||||
|
|
||||||
|
`tools/carantina_held.py` ruleaza, atomic:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE submissions
|
||||||
|
SET status='error', rar_error='ROLLBACK_QUARANTINE', updated_at=datetime('now')
|
||||||
|
WHERE held=1 AND status='queued';
|
||||||
|
```
|
||||||
|
|
||||||
|
Adica scoate randurile tinute din coada (`queued` -> `error`) cu mesajul
|
||||||
|
`ROLLBACK_QUARANTINE`. Un worker fara filtrul `held` **nu mai poate prelua** un rand
|
||||||
|
`error` -> randurile NU pleaca la RAR pe timpul revertului. `sending` / `sent` NU se
|
||||||
|
ating (FINALIZATA e terminal la RAR). Tool-ul afiseaza cate randuri a carantinat.
|
||||||
|
|
||||||
|
### 3. DUPA revert (sau dupa ce revii pe codul 5.19) — reactiveaza randurile
|
||||||
|
|
||||||
|
Randurile carantinate sunt acum `error` / `ROLLBACK_QUARANTINE`. Ele NU sunt trimise
|
||||||
|
si NU se re-incearca automat. Cand vrei sa le declari la RAR, **repune-le manual din
|
||||||
|
dashboard** (butonul de repunere pe rand -> revine `queued`) si trimite-le controlat
|
||||||
|
(per rand sau "Trimite toate"), dupa ce te-ai asigurat ca filtrul `held` este din nou
|
||||||
|
in cod (revenire pe 5.19) sau ca trimiterea lor e intentionata.
|
||||||
|
|
||||||
|
> Nu exista un "un-quarantine" automat: reactivarea e deliberat manuala, ca sa nu
|
||||||
|
> repui accidental in coada exact randurile pe care le protejai.
|
||||||
@@ -25,3 +25,5 @@ dbfread==2.0.7
|
|||||||
# Model multilingv (~230MB pe disc, ONNX quantizat), fastembed fara torch, lazy-load la runtime.
|
# Model multilingv (~230MB pe disc, ONNX quantizat), fastembed fara torch, lazy-load la runtime.
|
||||||
# Degradare gratioasa daca lipseste la runtime (is_available()=False, suggest_nearest=[]).
|
# Degradare gratioasa daca lipseste la runtime (is_available()=False, suggest_nearest=[]).
|
||||||
fastembed>=0.8.0
|
fastembed>=0.8.0
|
||||||
|
# Folosit direct la cautarea NN (matvec pe corpus); oricum tras de fastembed/onnxruntime.
|
||||||
|
numpy>=1.26
|
||||||
|
|||||||
@@ -22,12 +22,75 @@ os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
|
|||||||
# lazy-load-eze modelul de ~230MB la fiecare test care atinge editorul de mapari
|
# lazy-load-eze modelul de ~230MB la fiecare test care atinge editorul de mapari
|
||||||
# (suita rapida, fara download in CI). Testele de embeddings il pornesc punctual.
|
# (suita rapida, fara download in CI). Testele de embeddings il pornesc punctual.
|
||||||
os.environ.setdefault("AUTOPASS_EMBEDDINGS_ENABLED", "false")
|
os.environ.setdefault("AUTOPASS_EMBEDDINGS_ENABLED", "false")
|
||||||
# Seed-ul de operatii etichetate (SILVER, PRD 5.18) e ON in app, dar OFF in teste:
|
# Seed-ul de operatii etichetate e ON in app, dar OFF in teste: multe teste presupun
|
||||||
# multe teste presupun mapping_suggestions GOL la init_db. Testele US-004/005/006 il
|
# mapping_suggestions GOL la init_db. Testele care au nevoie de el il pornesc punctual
|
||||||
# pornesc punctual (object.__setattr__ pe settings sau apel direct la seeder).
|
# (object.__setattr__ pe settings sau apel direct la seeder).
|
||||||
os.environ.setdefault("AUTOPASS_SEED_OPERATII_ENABLED", "false")
|
os.environ.setdefault("AUTOPASS_SEED_OPERATII_ENABLED", "false")
|
||||||
|
|
||||||
|
|
||||||
|
# Referinta la init_db-ul REAL (nemodificat), capturata O SINGURA DATA la prima rulare a
|
||||||
|
# fixturii de mai jos. Wrapper-ul o refoloseste ca sa nu se auto-imbrace recursiv.
|
||||||
|
_REAL_INIT_DB = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _auto_send_on_default_account(request, monkeypatch):
|
||||||
|
"""Contul default id=1 (seed schema.sql, INSERT OR IGNORE) porneste pe
|
||||||
|
`auto_send_enabled=0` (Auto OFF) — corect in productie (nimic nu pleaca fara confirmare).
|
||||||
|
|
||||||
|
Dar suita exercita lantul `POST -> claim_one -> sent` pe contul id=1
|
||||||
|
(test_import_e2e, test_creds_delivery, test_live_rar): cu Auto OFF, ingestia ar marca
|
||||||
|
`held=1` si worker-ul (`claim_one ... AND held=0`) nu ar mai lua randul
|
||||||
|
-> testele ar stagna tacit. E un fix de STARE DB, nu env var (coloana e per-rand in
|
||||||
|
`accounts`), deci il aplicam wraparind `init_db` (contul id=1 NU e creat de `create_account`
|
||||||
|
-> un patch pe factory NU l-ar acoperi).
|
||||||
|
|
||||||
|
Wrapam `init_db` astfel incat orice DB de test creat sa aiba id=1 pe Auto ON (held=0 la
|
||||||
|
ingestie), pastrand suita verde ca inainte de 5.19. Conturile create explicit de teste
|
||||||
|
(`create_account`) raman pe default OFF -> testele care VOR Auto OFF il primesc corect.
|
||||||
|
|
||||||
|
Testele care verifica DEFAULT-ul brut (id=1 == 0 imediat dupa init_db) se exclud cu
|
||||||
|
markerul `no_auto_send_seed`.
|
||||||
|
"""
|
||||||
|
global _REAL_INIT_DB
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import app.db as _db
|
||||||
|
|
||||||
|
if _REAL_INIT_DB is None:
|
||||||
|
_REAL_INIT_DB = _db.init_db
|
||||||
|
|
||||||
|
if request.node.get_closest_marker("no_auto_send_seed"):
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
def _init_db_auto_on():
|
||||||
|
_REAL_INIT_DB()
|
||||||
|
conn = _db.get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute("UPDATE accounts SET auto_send_enabled=1 WHERE id=1")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# app.db.init_db: acopera fixturile care fac `from app.db import init_db` in corpul lor
|
||||||
|
# (importul ruleaza DUPA acest patch -> primeste wrapper-ul) + apelurile `app.db.init_db()`.
|
||||||
|
monkeypatch.setattr(_db, "init_db", _init_db_auto_on)
|
||||||
|
# app.main.init_db: lifespan-ul TestClient cheama numele importat la nivel de modul
|
||||||
|
# (`from .db import init_db`); daca app.main e deja importat, patch-uim si referinta lui.
|
||||||
|
# Daca NU e inca importat, primul `from app.main import app` din test il va lega la
|
||||||
|
# wrapper-ul de mai sus automat (citeste app.db.init_db la momentul importului).
|
||||||
|
_main = sys.modules.get("app.main")
|
||||||
|
if _main is not None:
|
||||||
|
monkeypatch.setattr(_main, "init_db", _init_db_auto_on, raising=False)
|
||||||
|
yield
|
||||||
|
# Anti-leak: daca app.main a fost importat IN timpul testului (legand wrapper-ul la
|
||||||
|
# import, nu prin monkeypatch), readu-l la init_db-ul real ca sa nu ramana wrapat global.
|
||||||
|
_main = sys.modules.get("app.main")
|
||||||
|
if _main is not None and getattr(_main, "init_db", None) is _init_db_auto_on:
|
||||||
|
_main.init_db = _REAL_INIT_DB
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_embeddings_singleton():
|
def _reset_embeddings_singleton():
|
||||||
"""Reseteaza singleton-ul global de embeddings intre teste (izolare de ordine).
|
"""Reseteaza singleton-ul global de embeddings intre teste (izolare de ordine).
|
||||||
@@ -51,7 +114,7 @@ def _reset_embeddings_singleton():
|
|||||||
|
|
||||||
|
|
||||||
def make_test_cui(seed: str = "") -> str:
|
def make_test_cui(seed: str = "") -> str:
|
||||||
"""Factory centralizat (D#14, PRD 5.12 US-001): genereaza un CUI de test unic din seed.
|
"""Factory centralizat: genereaza un CUI de test unic din seed.
|
||||||
|
|
||||||
Folosit de fixture-urile de test care creeaza conturi via /signup sau create_account
|
Folosit de fixture-urile de test care creeaza conturi via /signup sau create_account
|
||||||
si au nevoie de un CUI unic per test (altfel unicitatea CUI-ului bloca al doilea signup
|
si au nevoie de un CUI unic per test (altfel unicitatea CUI-ului bloca al doilea signup
|
||||||
@@ -69,3 +132,8 @@ def pytest_configure(config):
|
|||||||
config.addinivalue_line(
|
config.addinivalue_line(
|
||||||
"markers", "live: test live pe RAR test (necesita AUTOPASS_LIVE_RAR=1 + creds reale)"
|
"markers", "live: test live pe RAR test (necesita AUTOPASS_LIVE_RAR=1 + creds reale)"
|
||||||
)
|
)
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers",
|
||||||
|
"no_auto_send_seed: dezactiveaza fixtura autouse care pune id=1 pe Auto ON "
|
||||||
|
"(teste care verifica default-ul brut auto_send_enabled=0 al contului id=1)",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Teste US-003 (PRD 3.6): Acasa unificata — Trimiteri ca sectiune permanenta.
|
"""Teste Acasa unificata — Trimiteri ca sectiune permanenta.
|
||||||
|
|
||||||
Tab-ul "Trimiteri" (coada) eliminat din tab-bar; Trimiterile devin o sectiune sub
|
Tab-ul "Trimiteri" (coada) eliminat din tab-bar; Trimiterile devin o sectiune sub
|
||||||
upload pe Acasa, cu heading "Trimiterile tale". ?tab=coada si /_fragments/coada
|
upload pe Acasa, cu heading "Trimiterile tale". ?tab=coada si /_fragments/coada
|
||||||
servesc continutul Acasa (nu 404, nu fragment orfan). Poll aliniat la 15s (M5).
|
servesc continutul Acasa (nu 404, nu fragment orfan).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -50,14 +50,14 @@ def _seed_submission(status: str = "sent", n: int = 1) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_tab_bar_fara_trimiteri(client):
|
def test_tab_bar_fara_trimiteri(client):
|
||||||
"""US-009: tab-bar eliminat; 'Coada' nu exista; Mapari/Cont/Nomenclator raman in meniu."""
|
"""Tab-bar eliminat; 'Coada' nu exista; Mapari/Cont/Nomenclator raman in meniu."""
|
||||||
r = client.get("/")
|
r = client.get("/")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
html = r.text
|
html = r.text
|
||||||
# "Coada" nu trebuie sa existe nici ca tab, nici ca link in meniu
|
# "Coada" nu trebuie sa existe nici ca tab, nici ca link in meniu
|
||||||
assert 'id="tab-coada"' not in html
|
assert 'id="tab-coada"' not in html
|
||||||
assert 'href="/?tab=coada"' not in html
|
assert 'href="/?tab=coada"' not in html
|
||||||
# US-009: tab-bar eliminat; Mapari/Cont/Nomenclator sunt in meniul hamburger
|
# Mapari/Cont/Nomenclator sunt in meniul hamburger
|
||||||
for label in ("Mapari", "Cont", "Nomenclator"):
|
for label in ("Mapari", "Cont", "Nomenclator"):
|
||||||
assert f">{label}" in html or f"{label}<" in html, f"lipseste intrarea {label} in meniu"
|
assert f">{label}" in html or f"{label}<" in html, f"lipseste intrarea {label} in meniu"
|
||||||
|
|
||||||
@@ -70,8 +70,7 @@ def test_acasa_contine_sectiunea_trimiteri(client):
|
|||||||
html = r.text
|
html = r.text
|
||||||
assert 'id="filtre-trimiteri"' in html
|
assert 'id="filtre-trimiteri"' in html
|
||||||
assert "/_fragments/submissions" in html
|
assert "/_fragments/submissions" in html
|
||||||
# PRD 5.9 US-003: detaliul s-a mutat in modalul global (#modal-detaliu); vechiul
|
# Detaliul e in modalul global (#modal-detaliu); panoul inert vechi eliminat.
|
||||||
# panou inert #trimitere-detaliu a fost eliminat.
|
|
||||||
assert 'id="modal-detaliu"' in html
|
assert 'id="modal-detaliu"' in html
|
||||||
assert 'id="trimitere-detaliu"' not in html
|
assert 'id="trimitere-detaliu"' not in html
|
||||||
|
|
||||||
@@ -108,7 +107,7 @@ def test_acasa_fara_linkuri_ajutor(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_acasa_fara_wayfinding_ajutor(client):
|
def test_acasa_fara_wayfinding_ajutor(client):
|
||||||
"""US-001 (5.5): randul 'Ajutor' (wayfinding Mapari/Coduri RAR) eliminat din Acasa —
|
"""Randul 'Ajutor' (wayfinding Mapari/Coduri RAR) eliminat din Acasa —
|
||||||
navigarea traieste in tab-bar si in meniul de cont."""
|
navigarea traieste in tab-bar si in meniul de cont."""
|
||||||
r = client.get("/?tab=acasa")
|
r = client.get("/?tab=acasa")
|
||||||
html = r.text
|
html = r.text
|
||||||
@@ -132,7 +131,7 @@ def test_trimiteri_fara_poll_periodic_pe_tabel(client):
|
|||||||
"""Tabelul de trimiteri NU se reimprospateaza periodic: #submissions-wrap se
|
"""Tabelul de trimiteri NU se reimprospateaza periodic: #submissions-wrap se
|
||||||
incarca la load / actiunile utilizatorului (trimiteriChanged) / reincarcaTrimiteri.
|
incarca la load / actiunile utilizatorului (trimiteriChanged) / reincarcaTrimiteri.
|
||||||
Reimprospatarea automata la date noi externe se face prin pollerul de versiune
|
Reimprospatarea automata la date noi externe se face prin pollerul de versiune
|
||||||
care cheama reincarcaTrimiteri() — fara nudge manual (US-008)."""
|
care cheama reincarcaTrimiteri()."""
|
||||||
_seed_submission("sent")
|
_seed_submission("sent")
|
||||||
r = client.get("/?tab=acasa")
|
r = client.get("/?tab=acasa")
|
||||||
html = r.text
|
html = r.text
|
||||||
@@ -142,6 +141,6 @@ def test_trimiteri_fara_poll_periodic_pe_tabel(client):
|
|||||||
assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}"
|
assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}"
|
||||||
assert "reincarcaTrimiteri" in wrap
|
assert "reincarcaTrimiteri" in wrap
|
||||||
assert "trimiteriChanged" in wrap
|
assert "trimiteriChanged" in wrap
|
||||||
# Pollerul de versiune exista (auto-refresh la date noi externe). Nudge eliminat (US-008).
|
# Pollerul de versiune exista (auto-refresh la date noi externe); fara nudge manual.
|
||||||
assert "/_fragments/trimiteri-versiune" in html
|
assert "/_fragments/trimiteri-versiune" in html
|
||||||
assert 'id="nudge-trimiteri"' not in html
|
assert 'id="nudge-trimiteri"' not in html
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Teste US-004 (PRD 5.5): model de stare a contului `accounts.status` + helperi.
|
"""Teste model de stare a contului `accounts.status` + helperi.
|
||||||
|
|
||||||
Acopera: derivarea status din active la migrare, invariantul active=1 <=> status='active',
|
Acopera: derivarea status din active la migrare, invariantul active=1 <=> status='active',
|
||||||
helperii set_status/delete_account, protectia contului de sistem id=1, excluderea 'deleted'
|
helperii set_status/delete_account, protectia contului de sistem id=1, excluderea 'deleted'
|
||||||
@@ -94,7 +94,7 @@ def test_delete_purjeaza_pii_si_elibereaza_cui(conn):
|
|||||||
"""Stergerea soft purjeaza creds RAR + revoca cheile API + elibereaza CUI (re-inregistrabil)."""
|
"""Stergerea soft purjeaza creds RAR + revoca cheile API + elibereaza CUI (re-inregistrabil)."""
|
||||||
from app.accounts import create_account, delete_account, list_accounts
|
from app.accounts import create_account, delete_account, list_accounts
|
||||||
acct_id = create_account(conn, "Service GDPR", cui="RO12345", active=True)
|
acct_id = create_account(conn, "Service GDPR", cui="RO12345", active=True)
|
||||||
# US-013: creds in sloturi per-env (rar_creds_enc legacy dropata)
|
# creds in sloturi per-env
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE accounts SET rar_creds_test_enc='secret_enc_test', rar_creds_prod_enc='secret_enc_prod' WHERE id=?",
|
"UPDATE accounts SET rar_creds_test_enc='secret_enc_test', rar_creds_prod_enc='secret_enc_prod' WHERE id=?",
|
||||||
(acct_id,),
|
(acct_id,),
|
||||||
@@ -138,7 +138,6 @@ def test_migrare_deriva_status_din_active(conn):
|
|||||||
from app.db import _migrate
|
from app.db import _migrate
|
||||||
|
|
||||||
# Reconstruim accounts fara `status` (rebuild de tabela — singura cale in SQLite vechi).
|
# Reconstruim accounts fara `status` (rebuild de tabela — singura cale in SQLite vechi).
|
||||||
# US-013: rar_creds_enc nu mai exista in accounts (dropata); nu e in legacy DDL.
|
|
||||||
conn.executescript(
|
conn.executescript(
|
||||||
"""
|
"""
|
||||||
PRAGMA foreign_keys=OFF;
|
PRAGMA foreign_keys=OFF;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Teste US-001 (PRD 3.1): coloana accounts.active + helper-e cont in app/accounts.py."""
|
"""Teste coloana accounts.active + helper-e cont in app/accounts.py."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ def test_list_accounts_ordonat_fara_creds(conn):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# US-001 (PRD 5.12): accounts.email + validari companie/email/CUI
|
# accounts.email + validari companie/email/CUI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_create_account_fara_email_ridica(conn):
|
def test_create_account_fara_email_ridica(conn):
|
||||||
@@ -176,7 +176,7 @@ def test_account_is_complete_false_pe_legacy_incomplet(conn):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5.17 US-001/US-008: schema tier + trial_until + set_tier + CLI set-tier
|
# schema tier + trial_until + set_tier + CLI set-tier
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_migrare_tier_trial_defensiva(conn):
|
def test_migrare_tier_trial_defensiva(conn):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Teste US-008 (PRD 5.5): rute admin pentru ciclul de viata al conturilor —
|
"""Rute admin pentru ciclul de viata al conturilor —
|
||||||
block/archive/delete + bulk pe lista account_id, require_admin + CSRF + PRG, dev id=1 protejat.
|
block/archive/delete + bulk pe lista account_id, require_admin + CSRF + PRG, dev id=1 protejat.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
"""Teste US-011 (PRD 3.3b): panou admin web /admin — conturi in asteptare + activare.
|
"""Teste panou admin web /admin — conturi in asteptare + activare.
|
||||||
|
|
||||||
TDD strict: testele se scriu INAINTE de implementare; la inceput pica (RED),
|
|
||||||
dupa implementare trec (GREEN).
|
|
||||||
|
|
||||||
Fisiere testate: app/web/admin_routes.py, app/web/templates/admin.html.
|
Fisiere testate: app/web/admin_routes.py, app/web/templates/admin.html.
|
||||||
"""
|
"""
|
||||||
@@ -175,7 +172,7 @@ def test_non_admin_403(client):
|
|||||||
Creeaza intai un cont admin (bootstrap: primul user devine admin),
|
Creeaza intai un cont admin (bootstrap: primul user devine admin),
|
||||||
apoi un al doilea cont (non-admin) si verifica ca al doilea primeste 403.
|
apoi un al doilea cont (non-admin) si verifica ca al doilea primeste 403.
|
||||||
"""
|
"""
|
||||||
# Primul signup devine automat admin (bootstrap US-010)
|
# Primul signup devine automat admin (bootstrap)
|
||||||
_signup(client, "Admin Bootstrap SA", "bootstrap@test.ro")
|
_signup(client, "Admin Bootstrap SA", "bootstrap@test.ro")
|
||||||
|
|
||||||
# Al doilea user NU e admin
|
# Al doilea user NU e admin
|
||||||
@@ -286,7 +283,7 @@ def test_set_tier_din_admin_incheie_trial(client):
|
|||||||
"""POST /admin/set-tier -> tier actualizat, trial_until=NULL (trial incheiat), 303.
|
"""POST /admin/set-tier -> tier actualizat, trial_until=NULL (trial incheiat), 303.
|
||||||
|
|
||||||
Contul nou are trial Pro 30z; alocarea manuala trebuie sa-l incheie ca alegerea
|
Contul nou are trial Pro 30z; alocarea manuala trebuie sa-l incheie ca alegerea
|
||||||
sa aiba efect imediat (decizie user 2026-06-29)."""
|
sa aiba efect imediat."""
|
||||||
target_id = _signup(client, "Firma Upgrade SRL", "upgrade@test.ro")
|
target_id = _signup(client, "Firma Upgrade SRL", "upgrade@test.ro")
|
||||||
tier0, trial0 = _get_tier_trial(target_id)
|
tier0, trial0 = _get_tier_trial(target_id)
|
||||||
assert tier0 == "free", "cont nou trebuie sa porneasca pe free"
|
assert tier0 == "free", "cont nou trebuie sa porneasca pe free"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Teste US-010 (PRD 3.3b): rol admin + bootstrap + guard require_admin.
|
"""Teste rol admin + bootstrap + guard require_admin.
|
||||||
|
|
||||||
Fisiere testate: app/users.py (helper-e admin), app/web/session.py (require_admin),
|
Fisiere testate: app/users.py (helper-e admin), app/web/session.py (require_admin),
|
||||||
tools/account.py (subcomanda set-admin).
|
tools/account.py (subcomanda set-admin).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Teste API /v1/prezentari — rutare validare (T3) + idempotenta."""
|
"""Teste API /v1/prezentari — rutare validare + idempotenta."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ def _body(**over):
|
|||||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
}
|
}
|
||||||
prez.update(over)
|
prez.update(over)
|
||||||
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
|
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
def test_prezentare_valida_queued(client):
|
def test_prezentare_valida_queued(client):
|
||||||
@@ -63,7 +63,7 @@ def test_idempotenta_dedup(client):
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# PRD 5.7 — raspuns onest: erori/nemapate/motiv pe randuri blocate #
|
# Raspuns onest: erori/nemapate/motiv pe randuri blocate #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
def test_needs_data_intoarce_erori(client):
|
def test_needs_data_intoarce_erori(client):
|
||||||
@@ -117,6 +117,66 @@ def test_raspuns_nu_reflecta_parola(client):
|
|||||||
assert "password" not in r.text
|
assert "password" not in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_rezultate_poarta_index_si_ecou_vehicul(client):
|
||||||
|
"""Fiecare rezultat e corelabil cu cererea: index 0-based + ecou vin/nr,
|
||||||
|
inclusiv pe randuri blocate si pe dedup."""
|
||||||
|
body = _body()
|
||||||
|
prez2 = dict(body["prezentari"][0])
|
||||||
|
prez2.update({"vin": "WAUZZZ8K9BA123456", "nr_inmatriculare": "cj45xyz",
|
||||||
|
"data_prestatie": "2099-01-01"})
|
||||||
|
body["prezentari"].append(prez2)
|
||||||
|
r = client.post("/v1/prezentari", json=body)
|
||||||
|
res = r.json()["results"]
|
||||||
|
assert [x["index"] for x in res] == [0, 1]
|
||||||
|
assert res[0]["vin"] == "WVWZZZ1KZAW000123"
|
||||||
|
assert res[1]["nr_inmatriculare"] == "CJ45XYZ" # ecou normalizat (upper)
|
||||||
|
assert res[1]["status"] == "needs_data"
|
||||||
|
|
||||||
|
# Dedup: acelasi continut -> rezultatul pastreaza corelarea.
|
||||||
|
r2 = client.post("/v1/prezentari", json=body)
|
||||||
|
res2 = r2.json()["results"]
|
||||||
|
assert res2[0]["deduped"] is True
|
||||||
|
assert res2[0]["index"] == 0
|
||||||
|
assert res2[0]["vin"] == "WVWZZZ1KZAW000123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nemapat_primeste_sugestie(client):
|
||||||
|
"""Cod nemapat cu denumire apropiata de nomenclator -> sugestie fuzzy in
|
||||||
|
nemapate[].sugestie (complet) si in motiv (ambele forme)."""
|
||||||
|
prestatii = [{"cod_op_service": "REV-PER", "denumire": "Revizie periodica"}]
|
||||||
|
r = client.post("/v1/prezentari", json=_body(prestatii=prestatii))
|
||||||
|
res = r.json()["results"][0]
|
||||||
|
assert res["status"] == "needs_mapping"
|
||||||
|
sug = res["nemapate"][0]["sugestie"]
|
||||||
|
assert sug["cod_prestatie"] == "OE-3" # seed: REVIZIE PERIODICA
|
||||||
|
assert sug["sursa"] == "fuzzy"
|
||||||
|
assert "sugestie: OE-3" in res["motiv"]
|
||||||
|
|
||||||
|
# Forma minima: sugestia ramane vizibila in motiv.
|
||||||
|
body = _body(prestatii=prestatii, vin="WAUZZZ8K9BA123456")
|
||||||
|
del body["raspuns"]
|
||||||
|
r2 = client.post("/v1/prezentari", json=body)
|
||||||
|
assert "sugestie: OE-3" in r2.json()["results"][0]["motiv"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_raspuns_minim_implicit(client):
|
||||||
|
"""Fara `raspuns` in cerere -> forma compacta cu chei fixe, fara campurile detaliate."""
|
||||||
|
body = _body(prestatii=[{"cod_op_service": "REP-MOT", "denumire": "Reparatie motor"}])
|
||||||
|
del body["raspuns"]
|
||||||
|
r = client.post("/v1/prezentari", json=body)
|
||||||
|
res = r.json()["results"][0]
|
||||||
|
assert set(res) == {"index", "submission_id", "vehicul", "status", "motiv", "id_prezentare"}
|
||||||
|
assert res["index"] == 0
|
||||||
|
assert res["vehicul"] == "B999TST"
|
||||||
|
assert res["status"] == "needs_mapping"
|
||||||
|
assert "REP-MOT" in res["motiv"]
|
||||||
|
|
||||||
|
# 'minim' explicit = acelasi lucru cu default-ul.
|
||||||
|
body["raspuns"] = "minim"
|
||||||
|
r2 = client.post("/v1/prezentari", json=body)
|
||||||
|
assert set(r2.json()["results"][0]) == set(res)
|
||||||
|
|
||||||
|
|
||||||
def test_json_malformat_422(client):
|
def test_json_malformat_422(client):
|
||||||
# Lipseste vin -> validare de shape Pydantic -> 422 (NU needs_data).
|
# Lipseste vin -> validare de shape Pydantic -> 422 (NU needs_data).
|
||||||
bad = {"rar_credentials": {"email": "x", "password": "y"},
|
bad = {"rar_credentials": {"email": "x", "password": "y"},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Teste US-010 (PRD 5.6): API DELETE + re-pune in coada, scoped + oracol scope/stare."""
|
"""Teste API DELETE + re-pune in coada, scoped + oracol scope/stare."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
156
tests/test_api_prezentari_filtru_perioada.py
Normal file
156
tests/test_api_prezentari_filtru_perioada.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""Teste API GET /v1/prezentari — filtrare pe perioada (data_de/data_pana) + obs expus.
|
||||||
|
|
||||||
|
data_prestatie traieste in payload_json (nu e coloana SQL), deci filtrul se aplica
|
||||||
|
in Python dupa derivarea prezentarii (vezi app/api/v1/router.py:list_prezentari),
|
||||||
|
acelasi pattern ca /_fragments/submissions (dashboard web).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.main import app
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _body(**over):
|
||||||
|
prez = {
|
||||||
|
"vin": "WVWZZZ1KZAW000123",
|
||||||
|
"nr_inmatriculare": "B999TST",
|
||||||
|
"data_prestatie": "2026-06-15",
|
||||||
|
"odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
|
}
|
||||||
|
prez.update(over)
|
||||||
|
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
|
def _trimite(client, data_prestatie: str, **over) -> int:
|
||||||
|
r = client.post("/v1/prezentari", json=_body(data_prestatie=data_prestatie, **over))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["results"][0]["submission_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filtrare_ambii_parametri(client):
|
||||||
|
id_inainte = _trimite(client, "2026-06-01")
|
||||||
|
id_in_interval = _trimite(client, "2026-06-15")
|
||||||
|
id_dupa = _trimite(client, "2026-07-01")
|
||||||
|
|
||||||
|
r = client.get("/v1/prezentari", params={"data_de": "2026-06-10", "data_pana": "2026-06-20"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
ids = [s["id"] for s in r.json()["submissions"]]
|
||||||
|
assert ids == [id_in_interval]
|
||||||
|
assert id_inainte not in ids
|
||||||
|
assert id_dupa not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_filtrare_doar_data_de(client):
|
||||||
|
id_vechi = _trimite(client, "2026-06-01")
|
||||||
|
id_nou = _trimite(client, "2026-06-20")
|
||||||
|
|
||||||
|
r = client.get("/v1/prezentari", params={"data_de": "2026-06-10"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
ids = [s["id"] for s in r.json()["submissions"]]
|
||||||
|
assert id_nou in ids
|
||||||
|
assert id_vechi not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_filtrare_doar_data_pana(client):
|
||||||
|
id_vechi = _trimite(client, "2026-06-01")
|
||||||
|
id_nou = _trimite(client, "2026-06-20")
|
||||||
|
|
||||||
|
r = client.get("/v1/prezentari", params={"data_pana": "2026-06-10"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
ids = [s["id"] for s in r.json()["submissions"]]
|
||||||
|
assert id_vechi in ids
|
||||||
|
assert id_nou not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_fara_parametri_comportament_neschimbat(client):
|
||||||
|
"""Fara data_de/data_pana, comportamentul ramane identic (SQL LIMIT, fara filtru Python)."""
|
||||||
|
id1 = _trimite(client, "2026-06-01")
|
||||||
|
id2 = _trimite(client, "2026-07-01")
|
||||||
|
|
||||||
|
r = client.get("/v1/prezentari")
|
||||||
|
assert r.status_code == 200
|
||||||
|
ids = [s["id"] for s in r.json()["submissions"]]
|
||||||
|
assert id1 in ids
|
||||||
|
assert id2 in ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("param", ["data_de", "data_pana"])
|
||||||
|
def test_format_invalid_422(client, param):
|
||||||
|
r = client.get("/v1/prezentari", params={param: "15-06-2026"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_invalid_data_nesexistenta_422(client):
|
||||||
|
r = client.get("/v1/prezentari", params={"data_de": "2026-02-30"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_interactiune_cu_status(client):
|
||||||
|
"""Filtrul de perioada se combina cu status (AND): doar randuri care satisfac AMBELE."""
|
||||||
|
id_queued_in_interval = _trimite(client, "2026-06-15")
|
||||||
|
# Al doilea rand, status diferit (needs_data via VIN invalid) dar tot in interval.
|
||||||
|
r = client.post("/v1/prezentari", json=_body(
|
||||||
|
data_prestatie="2026-06-16", vin="WVWZZZ1OZIQ45678",
|
||||||
|
))
|
||||||
|
assert r.status_code == 200
|
||||||
|
id_needs_data = r.json()["results"][0]["submission_id"]
|
||||||
|
|
||||||
|
lista = client.get("/v1/prezentari", params={
|
||||||
|
"status": "queued", "data_de": "2026-06-01", "data_pana": "2026-06-30",
|
||||||
|
}).json()["submissions"]
|
||||||
|
ids = [s["id"] for s in lista]
|
||||||
|
assert id_queued_in_interval in ids
|
||||||
|
assert id_needs_data not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_interactiune_cu_limit(client):
|
||||||
|
"""LIMIT se aplica DUPA filtrarea pe perioada, nu inainte (altfel ar trunchia gresit)."""
|
||||||
|
ids_in_interval = [
|
||||||
|
_trimite(client, "2026-06-10", vin=f"WVWZZZ1KZAW{i:06d}") for i in range(5)
|
||||||
|
]
|
||||||
|
# Randuri in afara intervalului, mai recente (ORDER BY id DESC) — daca LIMIT s-ar
|
||||||
|
# aplica INAINTE de filtrare, ar "manca" toate randurile din interval.
|
||||||
|
for i in range(5):
|
||||||
|
_trimite(client, "2026-08-01", vin=f"WVWZZZ1KZBW{i:06d}")
|
||||||
|
|
||||||
|
r = client.get("/v1/prezentari", params={
|
||||||
|
"data_de": "2026-06-01", "data_pana": "2026-06-30", "limit": 3,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
submissions = r.json()["submissions"]
|
||||||
|
assert len(submissions) == 3
|
||||||
|
ids = [s["id"] for s in submissions]
|
||||||
|
assert all(i in ids_in_interval for i in ids)
|
||||||
|
|
||||||
|
|
||||||
|
def test_obs_prezent_in_raspuns(client):
|
||||||
|
sid = _trimite(client, "2026-06-15", obs="observatie test")
|
||||||
|
|
||||||
|
lista = client.get("/v1/prezentari").json()["submissions"]
|
||||||
|
rand = next(s for s in lista if s["id"] == sid)
|
||||||
|
assert rand["prezentare"]["obs"] == "observatie test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_obs_gol_cand_lipseste(client):
|
||||||
|
sid = _trimite(client, "2026-06-15")
|
||||||
|
|
||||||
|
lista = client.get("/v1/prezentari").json()["submissions"]
|
||||||
|
rand = next(s for s in lista if s["id"] == sid)
|
||||||
|
assert rand["prezentare"]["obs"] == ""
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Teste US-005 — camp rar_env pe POST /v1/prezentari si /valideaza.
|
"""Teste camp rar_env pe POST /v1/prezentari si /valideaza.
|
||||||
|
|
||||||
Acopera: default din cont, tinta explicita, respingere tinta indisponibila,
|
Acopera: default din cont, tinta explicita, respingere tinta indisponibila,
|
||||||
echo GET, valoare invalida (422 Pydantic), echo dry-run valideaza.
|
echo GET, valoare invalida (422 Pydantic), echo dry-run valideaza.
|
||||||
@@ -38,7 +38,7 @@ def _body(rar_env=None, **over):
|
|||||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
}
|
}
|
||||||
prez.update(over)
|
prez.update(over)
|
||||||
body = {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
|
body = {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
if rar_env is not None:
|
if rar_env is not None:
|
||||||
body["rar_env"] = rar_env
|
body["rar_env"] = rar_env
|
||||||
return body
|
return body
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""US-011 (PRD 5.15): account-scope pe GET-urile de listare API (securitate).
|
"""Teste account-scope pe GET-urile de listare API (securitate).
|
||||||
|
|
||||||
Verifica:
|
Verifica:
|
||||||
- GET /v1/prezentari: un cont nu vede submissions ale altui cont
|
- GET /v1/prezentari: un cont nu vede submissions ale altui cont
|
||||||
@@ -6,9 +6,8 @@ Verifica:
|
|||||||
- Unauthenticated access cu require_api_key=True -> 401
|
- Unauthenticated access cu require_api_key=True -> 401
|
||||||
- Filtre si paginare nu sparg scope-ul
|
- Filtre si paginare nu sparg scope-ul
|
||||||
|
|
||||||
Legatura cu implementare: resolve_account_id + account_scope_clause (mecanisme
|
Foloseste resolve_account_id + account_scope_clause; verifica alinierea
|
||||||
existente, reutilizate). Testele LOCK DOWN comportamentul deja implementat
|
cu AUTOPASS_REQUIRE_API_KEY (dev vs prod).
|
||||||
si verifica alinierea cu AUTOPASS_REQUIRE_API_KEY (dev vs prod).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -227,7 +226,7 @@ def test_get_listare_filtru_status_nu_sparge_scope(client):
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PRD 5.17 — enforcement planuri: gate API (T4) + volum (T3) + kill-switch (T5)
|
# Enforcement planuri: gate API + volum + kill-switch
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
_PREZ_PLAN = {
|
_PREZ_PLAN = {
|
||||||
@@ -279,7 +278,7 @@ def _insert_n_submissions(account_id: int, n: int) -> None:
|
|||||||
def test_free_fara_api_403(client):
|
def test_free_fara_api_403(client):
|
||||||
"""Cont free (non-default, cu cheie API) → 403 PLAN_FARA_API pe POST /v1/prezentari.
|
"""Cont free (non-default, cu cheie API) → 403 PLAN_FARA_API pe POST /v1/prezentari.
|
||||||
|
|
||||||
T4 PRD 5.17: gate API refuza conturi cu api_access=False in PLANS.
|
Gate-ul API refuza conturi cu api_access=False in PLANS.
|
||||||
"""
|
"""
|
||||||
acct_id, key = _create_account_with_key("FreePlanGate")
|
acct_id, key = _create_account_with_key("FreePlanGate")
|
||||||
_set_tier_acct(acct_id, "free", trial_until=None)
|
_set_tier_acct(acct_id, "free", trial_until=None)
|
||||||
@@ -349,8 +348,8 @@ def test_trial_pro_api_ok(client):
|
|||||||
def test_dry_run_valideaza_ramane_permis(client):
|
def test_dry_run_valideaza_ramane_permis(client):
|
||||||
"""POST /v1/prezentari/valideaza (dry-run) e permis pe orice plan, inclusiv free.
|
"""POST /v1/prezentari/valideaza (dry-run) e permis pe orice plan, inclusiv free.
|
||||||
|
|
||||||
Decizie design (PRD 5.17): valideaza nu face enqueue si nu consuma cota,
|
Valideaza nu face enqueue si nu consuma cota, deci NU e protejat de
|
||||||
deci NU e protejat de gate-ul API — integratorii pot testa fara upgrade.
|
gate-ul API — integratorii pot testa fara upgrade.
|
||||||
"""
|
"""
|
||||||
acct_id, key = _create_account_with_key("FreeValideazaTest")
|
acct_id, key = _create_account_with_key("FreeValideazaTest")
|
||||||
_set_tier_acct(acct_id, "free", trial_until=None)
|
_set_tier_acct(acct_id, "free", trial_until=None)
|
||||||
@@ -374,8 +373,8 @@ def test_dry_run_valideaza_ramane_permis(client):
|
|||||||
def test_free_peste_60_respins_api(client):
|
def test_free_peste_60_respins_api(client):
|
||||||
"""Dev account (id=1) la 60/60 pe free → a 61-a prezentare respinsa 422 PLAN_LIMITA_LUNARA.
|
"""Dev account (id=1) la 60/60 pe free → a 61-a prezentare respinsa 422 PLAN_LIMITA_LUNARA.
|
||||||
|
|
||||||
Dev account (id=1) in dev mode (require_api_key=False) are bypass la gate-ul API (T4),
|
Dev account (id=1) in dev mode (require_api_key=False) are bypass la gate-ul API,
|
||||||
dar NU la verificarea de volum (T3). La 60/60, urmatoarea cerere trebuie respinsa 422.
|
dar NU la verificarea de volum. La 60/60, urmatoarea cerere trebuie respinsa 422.
|
||||||
"""
|
"""
|
||||||
_set_tier_acct(1, "free", trial_until=None)
|
_set_tier_acct(1, "free", trial_until=None)
|
||||||
_insert_n_submissions(1, 60)
|
_insert_n_submissions(1, 60)
|
||||||
@@ -392,7 +391,7 @@ def test_free_peste_60_respins_api(client):
|
|||||||
def test_eroare_3_niveluri_plan_limita(client):
|
def test_eroare_3_niveluri_plan_limita(client):
|
||||||
"""Eroarea PLAN_LIMITA_LUNARA contine toate 3 nivelurile: cod, problema, cauza, fix.
|
"""Eroarea PLAN_LIMITA_LUNARA contine toate 3 nivelurile: cod, problema, cauza, fix.
|
||||||
|
|
||||||
Pattern standard (PRD 5.17): eroare structurata pe 3 niveluri — nu 500, nu catch-all.
|
Pattern standard de eroare structurata pe 3 niveluri — nu 500, nu catch-all.
|
||||||
"""
|
"""
|
||||||
_set_tier_acct(1, "free", trial_until=None)
|
_set_tier_acct(1, "free", trial_until=None)
|
||||||
_insert_n_submissions(1, 60)
|
_insert_n_submissions(1, 60)
|
||||||
@@ -480,7 +479,7 @@ def test_retry_idempotent_nu_consuma_cota(client):
|
|||||||
assert usage_1 == 1, f"Dupa prima trimitere: asteptat usage=1, primit {usage_1}"
|
assert usage_1 == 1, f"Dupa prima trimitere: asteptat usage=1, primit {usage_1}"
|
||||||
|
|
||||||
# Retry (acelasi payload -> acelasi idempotency_key -> dedup, fara INSERT nou)
|
# Retry (acelasi payload -> acelasi idempotency_key -> dedup, fara INSERT nou)
|
||||||
r2 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]})
|
r2 = client.post("/v1/prezentari", json={"prezentari": [prez_unic], "raspuns": "complet"})
|
||||||
assert r2.status_code == 200, f"Retry trebuie sa treaca (dedup): {r2.text}"
|
assert r2.status_code == 200, f"Retry trebuie sa treaca (dedup): {r2.text}"
|
||||||
assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped"
|
assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped"
|
||||||
|
|
||||||
@@ -512,7 +511,7 @@ def test_dev_id1_neblocat(client):
|
|||||||
def test_flag_enforce_plans_false_sare_enforcement(client, monkeypatch):
|
def test_flag_enforce_plans_false_sare_enforcement(client, monkeypatch):
|
||||||
"""Kill-switch AUTOPASS_ENFORCE_PLANS=false sare toate gate-urile de plan.
|
"""Kill-switch AUTOPASS_ENFORCE_PLANS=false sare toate gate-urile de plan.
|
||||||
|
|
||||||
T5 PRD 5.17: flag de operare pentru debugging sau rollback rapid fara revert de cod.
|
Flag de operare pentru debugging sau rollback rapid fara revert de cod.
|
||||||
Chiar si cu free la 60/60, nu trebuie 422 cand flag-ul e oprit.
|
Chiar si cu free la 60/60, nu trebuie 422 cand flag-ul e oprit.
|
||||||
"""
|
"""
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Teste US-004 (PRD 5.6): audit cerere API per cont in jurnal."""
|
"""Teste audit cerere API per cont in jurnal."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
150
tests/test_auto_send_schema.py
Normal file
150
tests/test_auto_send_schema.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""Schema comutator Auto per cont + flag `held` pe submission.
|
||||||
|
|
||||||
|
Acopera: migrarea aditiva idempotenta (coloane + index partial pe DB vechi), helperii
|
||||||
|
`get_auto_send`/`set_auto_send`/`held_for_account` din app/accounts.py si default-ul 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fresh_conn(monkeypatch):
|
||||||
|
"""DB nou cu schema curenta (init_db)."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import get_connection, init_db
|
||||||
|
init_db()
|
||||||
|
c = get_connection()
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _old_db(path: str) -> sqlite3.Connection:
|
||||||
|
"""DB in forma PRE-5.19: accounts fara auto_send_enabled, submissions fara held."""
|
||||||
|
conn = sqlite3.connect(path, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, "
|
||||||
|
"cui TEXT)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE submissions (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||||
|
"idempotency_key TEXT NOT NULL UNIQUE, account_id INTEGER, status TEXT, "
|
||||||
|
"payload_json TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_old(path: str, monkeypatch) -> sqlite3.Connection:
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
conn = sqlite3.connect(path, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
from app.db import _migrate
|
||||||
|
_migrate(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrare_adauga_coloane_si_index(tmp_path, monkeypatch):
|
||||||
|
"""Pe DB vechi (fara coloane) migrarea adauga held, auto_send_enabled si indexul partial."""
|
||||||
|
path = str(tmp_path / "old.db")
|
||||||
|
_old_db(path).close()
|
||||||
|
conn = _migrate_old(path, monkeypatch)
|
||||||
|
|
||||||
|
sub = {r["name"] for r in conn.execute("PRAGMA table_info(submissions)").fetchall()}
|
||||||
|
assert "held" in sub
|
||||||
|
acc = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||||
|
assert "auto_send_enabled" in acc
|
||||||
|
|
||||||
|
idx = {r["name"] for r in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'"
|
||||||
|
).fetchall()}
|
||||||
|
assert "idx_submissions_held" in idx
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrare_idempotenta_a_doua_rulare(tmp_path, monkeypatch):
|
||||||
|
"""A doua rulare _migrate pe DB deja migrat nu arunca si nu dubleaza nimic."""
|
||||||
|
path = str(tmp_path / "old.db")
|
||||||
|
_old_db(path).close()
|
||||||
|
conn = _migrate_old(path, monkeypatch)
|
||||||
|
from app.db import _migrate
|
||||||
|
_migrate(conn) # nu arunca
|
||||||
|
acc = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||||
|
assert "auto_send_enabled" in acc
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrare_idempotenta_pe_db_fresh(fresh_conn):
|
||||||
|
"""_migrate re-rulat pe schema curenta (init_db) nu strica nimic."""
|
||||||
|
from app.db import _migrate
|
||||||
|
_migrate(fresh_conn)
|
||||||
|
sub = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(submissions)").fetchall()}
|
||||||
|
assert "held" in sub
|
||||||
|
|
||||||
|
|
||||||
|
def test_held_for_account_auto_on(fresh_conn):
|
||||||
|
from app.accounts import create_account, held_for_account, set_auto_send
|
||||||
|
aid = create_account(fresh_conn, "Auto ON")
|
||||||
|
set_auto_send(fresh_conn, aid, True)
|
||||||
|
assert held_for_account(fresh_conn, aid) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_held_for_account_auto_off(fresh_conn):
|
||||||
|
from app.accounts import create_account, held_for_account
|
||||||
|
aid = create_account(fresh_conn, "Auto OFF")
|
||||||
|
assert held_for_account(fresh_conn, aid) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_held_for_account_cont_inexistent(fresh_conn):
|
||||||
|
from app.accounts import held_for_account
|
||||||
|
assert held_for_account(fresh_conn, 99999) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_set_auto_send_round_trip(fresh_conn):
|
||||||
|
from app.accounts import create_account, get_auto_send, set_auto_send
|
||||||
|
aid = create_account(fresh_conn, "Round trip")
|
||||||
|
assert get_auto_send(fresh_conn, aid) is False
|
||||||
|
set_auto_send(fresh_conn, aid, True)
|
||||||
|
assert get_auto_send(fresh_conn, aid) is True
|
||||||
|
# Idempotent: acelasi set nu arunca si pastreaza valoarea.
|
||||||
|
set_auto_send(fresh_conn, aid, True)
|
||||||
|
assert get_auto_send(fresh_conn, aid) is True
|
||||||
|
set_auto_send(fresh_conn, aid, False)
|
||||||
|
assert get_auto_send(fresh_conn, aid) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_auto_send_cont_inexistent(fresh_conn):
|
||||||
|
from app.accounts import get_auto_send
|
||||||
|
assert get_auto_send(fresh_conn, 99999) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.no_auto_send_seed # verifica default-ul BRUT id=1==0; nu wrapa init_db (conftest)
|
||||||
|
def test_default_auto_send_zero(fresh_conn):
|
||||||
|
"""Cont nou (create_account) si contul id=1 pornesc pe auto_send_enabled=0 (Auto OFF)."""
|
||||||
|
from app.accounts import create_account
|
||||||
|
aid = create_account(fresh_conn, "Nou")
|
||||||
|
row = fresh_conn.execute(
|
||||||
|
"SELECT auto_send_enabled FROM accounts WHERE id=?", (aid,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["auto_send_enabled"] == 0
|
||||||
|
row1 = fresh_conn.execute(
|
||||||
|
"SELECT auto_send_enabled FROM accounts WHERE id=1"
|
||||||
|
).fetchone()
|
||||||
|
assert row1["auto_send_enabled"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_held_zile():
|
||||||
|
from app.config import Settings
|
||||||
|
s = Settings()
|
||||||
|
assert s.held_warn_days == 7
|
||||||
|
assert s.held_retention_days == 90
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Teste US-007 (PRD 3.6) actualizate dupa US-002 (PRD 5.11).
|
"""Teste comutator auto_send: macro autosend_toggle neutralizat (intoarce string gol).
|
||||||
|
|
||||||
US-002: macro autosend_toggle neutralizat (intoarce string gol).
|
|
||||||
Checkbox-ul name=auto_send a fost scos din UI. Coloanele DB raman.
|
Checkbox-ul name=auto_send a fost scos din UI. Coloanele DB raman.
|
||||||
Testele de UI verifica ABSENTA toggle-ului; testele de backend (stocare DB) raman.
|
Testele de UI verifica ABSENTA toggle-ului; testele de backend (stocare DB) raman.
|
||||||
"""
|
"""
|
||||||
@@ -97,24 +96,24 @@ def _macro_html(checked: bool = True, form_id: str = "") -> str:
|
|||||||
return str(mod.autosend_toggle(form_id=form_id, checked=checked))
|
return str(mod.autosend_toggle(form_id=form_id, checked=checked))
|
||||||
|
|
||||||
|
|
||||||
# --- markup: macro neutralizat dupa US-002 ---
|
# --- markup: macro neutralizat ---
|
||||||
|
|
||||||
def test_comutator_coada_prezent():
|
def test_comutator_coada_prezent():
|
||||||
"""US-002: macro autosend_toggle neutralizat -> output gol (fara checkbox)."""
|
"""macro autosend_toggle neutralizat -> output gol (fara checkbox)."""
|
||||||
html = _macro_html()
|
html = _macro_html()
|
||||||
assert 'name="auto_send"' not in html, "US-002: checkbox auto_send scos din UI"
|
assert 'name="auto_send"' not in html, "checkbox auto_send scos din UI"
|
||||||
assert html.strip() == "", f"macro neutralizat trebuie sa intoarca string gol, got: {html!r}"
|
assert html.strip() == "", f"macro neutralizat trebuie sa intoarca string gol, got: {html!r}"
|
||||||
|
|
||||||
|
|
||||||
def test_eticheta_scoped_pe_operatie():
|
def test_eticheta_scoped_pe_operatie():
|
||||||
"""US-002: macro neutralizat -> nicio eticheta scoped."""
|
"""macro neutralizat -> nicio eticheta scoped."""
|
||||||
html = _macro_html()
|
html = _macro_html()
|
||||||
assert "aceasta operatie" not in html
|
assert "aceasta operatie" not in html
|
||||||
assert html.strip() == ""
|
assert html.strip() == ""
|
||||||
|
|
||||||
|
|
||||||
def test_default_pune_automat():
|
def test_default_pune_automat():
|
||||||
"""US-002: macro neutralizat intoarce gol indiferent de parametrul checked."""
|
"""macro neutralizat intoarce gol indiferent de parametrul checked."""
|
||||||
html_default = _macro_html(checked=True)
|
html_default = _macro_html(checked=True)
|
||||||
assert html_default.strip() == ""
|
assert html_default.strip() == ""
|
||||||
html_off = _macro_html(checked=False)
|
html_off = _macro_html(checked=False)
|
||||||
@@ -154,7 +153,7 @@ def test_tine_pentru_verificare_mapeaza_auto_send_false(client):
|
|||||||
# --- prezent in AMBELE locuri (mapari tab + panou preview) ---
|
# --- prezent in AMBELE locuri (mapari tab + panou preview) ---
|
||||||
|
|
||||||
def test_comutator_in_tab_mapari(client):
|
def test_comutator_in_tab_mapari(client):
|
||||||
"""US-002: tabul Mapari nu mai contine checkbox auto_send (macro neutralizat)."""
|
"""Tabul Mapari nu mai contine checkbox auto_send (macro neutralizat)."""
|
||||||
from app.db import get_connection
|
from app.db import get_connection
|
||||||
import json
|
import json
|
||||||
acct = _create_account_user("tm@test.com")
|
acct = _create_account_user("tm@test.com")
|
||||||
@@ -172,12 +171,12 @@ def test_comutator_in_tab_mapari(client):
|
|||||||
_login(client, "tm@test.com")
|
_login(client, "tm@test.com")
|
||||||
resp = client.get("/?tab=mapari")
|
resp = client.get("/?tab=mapari")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert 'name="auto_send"' not in resp.text, "US-002: checkbox auto_send scos din UI"
|
assert 'name="auto_send"' not in resp.text, "checkbox auto_send scos din UI"
|
||||||
assert "In coada" not in resp.text, "US-002: coloana 'In coada' scoasa"
|
assert "In coada" not in resp.text, "coloana 'In coada' scoasa"
|
||||||
|
|
||||||
|
|
||||||
def test_comutator_in_panou_preview(client):
|
def test_comutator_in_panou_preview(client):
|
||||||
"""US-002: panoul de mapare din preview nu mai contine checkbox auto_send."""
|
"""Panoul de mapare din preview nu mai contine checkbox auto_send."""
|
||||||
_create_account_user("pp@test.com")
|
_create_account_user("pp@test.com")
|
||||||
_seed_nomenclator("R-FRANE")
|
_seed_nomenclator("R-FRANE")
|
||||||
_login(client, "pp@test.com")
|
_login(client, "pp@test.com")
|
||||||
@@ -203,5 +202,5 @@ def test_comutator_in_panou_preview(client):
|
|||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
|
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
|
||||||
assert 'name="auto_send"' not in r.text, "US-002: checkbox auto_send scos din preview"
|
assert 'name="auto_send"' not in r.text, "checkbox auto_send scos din preview"
|
||||||
assert "In coada automat" not in r.text, "US-002: eticheta 'In coada automat' scoasa"
|
assert "In coada automat" not in r.text, "eticheta 'In coada automat' scoasa"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user