Compare commits
12 Commits
0bc618763e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a9ad71cdd | ||
|
|
37172ae2f9 | ||
| 1091745789 | |||
|
|
cc8c712ed2 | ||
|
|
251666feb6 | ||
|
|
9b5006f26c | ||
|
|
e80a20c856 | ||
|
|
dc6c1db439 | ||
|
|
ec5ca5ba84 | ||
|
|
3ba35abed2 | ||
|
|
c2beede084 | ||
|
|
56ebe1f7cb |
@@ -40,6 +40,7 @@ AUTOPASS_LIVE_RAR=1 python3 -m pytest tests/test_live_rar.py -q # LIVE opt-in:
|
|||||||
python3 -m tools.apikey create --account 2 | list | rotate | revoke
|
python3 -m tools.apikey create --account 2 | list | rotate | revoke
|
||||||
|
|
||||||
docker compose up --build # deploy: api + worker + autoheal, acelasi image + volum SQLite
|
docker compose up --build # deploy: api + worker + autoheal, acelasi image + volum SQLite
|
||||||
|
# prod = Dokploy cu autodeploy la push; reguli in docs/deploy-dokploy.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Arhitectura
|
## Arhitectura
|
||||||
|
|||||||
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_...`.
|
||||||
|
|||||||
38
TODOS.md
38
TODOS.md
@@ -2,6 +2,44 @@
|
|||||||
|
|
||||||
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)
|
## Din /autoplan PRD 5.21 (2026-07-06)
|
||||||
|
|
||||||
- [ ] **CLI administrare cache embeddings (`python3 -m tools.embcache stats|clear|rebuild`)** — v1 foloseste
|
- [ ] **CLI administrare cache embeddings (`python3 -m tools.embcache stats|clear|rebuild`)** — v1 foloseste
|
||||||
|
|||||||
@@ -233,8 +233,10 @@ def _resolve_row_for_preview(
|
|||||||
"flags": all_flags,
|
"flags": all_flags,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Operatii excluse de la declarare: ies din prestatiile trimise (si din cheia
|
# Operatii excluse de la declarare: ies DOAR din payload-ul trimis la RAR (si din
|
||||||
# de idempotenta). Toate excluse -> rand 'excluded' (nu se trimite, nu blocheaza).
|
# 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)
|
declarabile, excluse = split_prestatii_excluse(resolved)
|
||||||
if excluse and not declarabile:
|
if excluse and not declarabile:
|
||||||
return {
|
return {
|
||||||
@@ -243,11 +245,10 @@ def _resolve_row_for_preview(
|
|||||||
"errors": [],
|
"errors": [],
|
||||||
"flags": all_flags,
|
"flags": all_flags,
|
||||||
}
|
}
|
||||||
if excluse:
|
|
||||||
mapped["prestatii"] = declarabile
|
|
||||||
|
|
||||||
# Validare continut
|
# Validare continut: doar partea declarabila conteaza (validate_prezentare
|
||||||
errors = validate_prezentare(mapped)
|
# 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.
|
||||||
@@ -852,7 +853,15 @@ def preview_import(
|
|||||||
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] = []
|
||||||
@@ -1204,16 +1213,19 @@ def commit_import(
|
|||||||
# 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, excluded_ops)
|
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
|
||||||
# Prestatiile excluse de la declarare NU pleaca la RAR (ca in preview);
|
# 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.
|
# rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip.
|
||||||
resolved, _excluse = split_prestatii_excluse(resolved)
|
if not declarabile:
|
||||||
if not resolved:
|
|
||||||
continue
|
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;
|
||||||
# cheia IDENTICA cu preview: _build_idempotency_key = canonicalize_row + build_key).
|
# cheia IDENTICA cu preview: _build_idempotency_key = canonicalize_row + build_key).
|
||||||
canon = canonicalize_row(mapped)
|
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"],
|
||||||
@@ -1223,13 +1235,13 @@ def commit_import(
|
|||||||
# 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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -62,7 +64,8 @@ def ping(
|
|||||||
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,
|
||||||
|
|||||||
@@ -33,22 +33,25 @@ from ...mapping import (
|
|||||||
classify_prezentare,
|
classify_prezentare,
|
||||||
load_excluded_ops,
|
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,
|
||||||
@@ -89,6 +92,16 @@ 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).
|
# Mesaj uman pentru un rand tinut manual (queued AND held=1).
|
||||||
# Reutilizeaza vocabularul existent AUTO_SEND_OPRIT (errors.py) — nu inventam al treilea
|
# 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.
|
# vocabular "auto_send". Onestitate: raspunsul NU trebuie sa arate un "queued" curat fals.
|
||||||
@@ -117,8 +130,16 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -185,6 +206,8 @@ def create_prezentari(
|
|||||||
# 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)
|
excluded_ops = load_excluded_ops(conn, acct)
|
||||||
@@ -288,6 +311,7 @@ def create_prezentari(
|
|||||||
# 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, excluded_ops)
|
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))
|
||||||
@@ -340,6 +364,7 @@ def create_prezentari(
|
|||||||
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
||||||
# (canonicalize + mapare op->cod + validare; fara gate auto_send).
|
# (canonicalize + mapare op->cod + validare; fara gate auto_send).
|
||||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
|
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))
|
||||||
@@ -356,6 +381,14 @@ def create_prezentari(
|
|||||||
# pe queued tinut manual (held=1) expune held=true + motiv.
|
# pe queued tinut manual (held=1) expune held=true + motiv.
|
||||||
results.append(_rezultat_enqueue(sub_id, cl, held=held_val, rar_env=env))
|
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).
|
||||||
dist: dict[str, int] = {}
|
dist: dict[str, int] = {}
|
||||||
@@ -376,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)
|
||||||
@@ -397,6 +444,7 @@ 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)
|
excluded_ops = load_excluded_ops(conn, acct)
|
||||||
@@ -433,6 +481,7 @@ 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, excluded_ops)
|
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
|
||||||
@@ -454,12 +503,35 @@ 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)
|
||||||
@@ -470,24 +542,49 @@ def list_prezentari(
|
|||||||
"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, held"
|
"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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -266,11 +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 > exclus de la declarare >
|
Precedenta (stricta): item-exclus EXPLICIT (`exclus=1` venit pe item, ex. din
|
||||||
mapare exacta `cod_op_service` in `mapping` > reguli text > nemapat. Un cod ales
|
editorul de chips) > `cod_prestatie` direct valid > exclus de la declarare
|
||||||
explicit pe rand (editor) bate regula de excludere; regula de excludere bate
|
(regula de cont) > mapare exacta `cod_op_service` in `mapping` > reguli text >
|
||||||
regulile text. Regulile text se incearca DOAR cand nu exista cod valid SI op
|
nemapat. Un item marcat explicit `exclus` de utilizator (editor) domina TOT —
|
||||||
nu e in `mapping`.
|
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
|
||||||
@@ -295,11 +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)
|
||||||
it.pop("exclus", 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.
|
||||||
@@ -966,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -127,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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -126,8 +149,11 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, Any]:
|
|||||||
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).
|
||||||
@@ -154,7 +180,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, Any]:
|
|||||||
"cod_rar": cod_rar or EMPTY,
|
"cod_rar": cod_rar or EMPTY,
|
||||||
# Lista ordonata, fara duplicate, a codurilor RAR din TOATE prestatiile
|
# Lista ordonata, fara duplicate, a codurilor RAR din TOATE prestatiile
|
||||||
# (goala cand nimic nu e mapat). Randul compact afiseaza primul + "+N".
|
# (goala cand nimic nu e mapat). Randul compact afiseaza primul + "+N".
|
||||||
"coduri_rar": _coduri_rar(data.get("prestatii")),
|
"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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" \\
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from .. import __version__
|
|||||||
from .. import errors as _errors
|
from .. import errors as _errors
|
||||||
from ..auth import rotate_api_key
|
from ..auth import rotate_api_key
|
||||||
from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS
|
from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS
|
||||||
from ..payload_view import prezentare_din_payload
|
from ..payload_view import iso_date_prefix as _iso_date_prefix, prezentare_din_payload
|
||||||
from ..web.csrf import get_csrf_token, verify_csrf
|
from ..web.csrf import get_csrf_token, verify_csrf
|
||||||
from .labels import (
|
from .labels import (
|
||||||
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||||
@@ -120,6 +120,7 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
|
|||||||
# Expune parse_erori si eticheta_env in toate template-urile
|
# Expune parse_erori si eticheta_env in toate template-urile
|
||||||
templates.env.globals["parse_erori"] = parse_erori
|
templates.env.globals["parse_erori"] = parse_erori
|
||||||
templates.env.globals["eticheta_env"] = eticheta_env
|
templates.env.globals["eticheta_env"] = eticheta_env
|
||||||
|
templates.env.globals["EXCLUDE_SENTINEL"] = EXCLUDE_SENTINEL
|
||||||
|
|
||||||
|
|
||||||
def _mediu_instanta() -> str:
|
def _mediu_instanta() -> str:
|
||||||
@@ -515,7 +516,7 @@ def _render_integrare(request: Request, conn, account_id: int) -> str:
|
|||||||
configurate pe cont), preia base_url real si genereaza snippet-uri multi-limbaj.
|
configurate pe cont), preia base_url real si genereaza snippet-uri multi-limbaj.
|
||||||
"""
|
"""
|
||||||
from ..mapping import account_or_default
|
from ..mapping import account_or_default
|
||||||
from .integrare_examples import exemple as _exemple
|
from .integrare_examples import exemple as _exemple, exemplu_json as _exemplu_json
|
||||||
|
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
row_creds = conn.execute(
|
row_creds = conn.execute(
|
||||||
@@ -537,6 +538,7 @@ def _render_integrare(request: Request, conn, account_id: int) -> str:
|
|||||||
"account_id": acct,
|
"account_id": acct,
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"exemple": ex,
|
"exemple": ex,
|
||||||
|
"exemplu_json": _exemplu_json(),
|
||||||
"are_cheie": are_cheie,
|
"are_cheie": are_cheie,
|
||||||
"are_creds": are_creds,
|
"are_creds": are_creds,
|
||||||
"csrf_token": csrf_token,
|
"csrf_token": csrf_token,
|
||||||
@@ -835,6 +837,32 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None, s
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Pagini statice publice cu antet/footer comune cu landing-ul.
|
||||||
|
_PAGINI_STATICE = {
|
||||||
|
"termeni": "termeni.html",
|
||||||
|
"confidentialitate": "confidentialitate.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/termeni", response_class=HTMLResponse)
|
||||||
|
@router.get("/confidentialitate", response_class=HTMLResponse)
|
||||||
|
def pagina_statica(request: Request) -> HTMLResponse:
|
||||||
|
nume = request.url.path.strip("/")
|
||||||
|
return templates.TemplateResponse(_PAGINI_STATICE[nume], {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documentatie-api", response_class=HTMLResponse)
|
||||||
|
def pagina_documentatie_api(request: Request) -> HTMLResponse:
|
||||||
|
"""Documentatie API publica (varianta scurta a tab-ului Integrare, fara login)."""
|
||||||
|
from .integrare_examples import exemplu_json
|
||||||
|
|
||||||
|
return templates.TemplateResponse("documentatie_api.html", {
|
||||||
|
"request": request,
|
||||||
|
"base_url": str(request.base_url).rstrip("/"),
|
||||||
|
"exemplu_json": exemplu_json(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/_fragments/acasa", response_class=HTMLResponse)
|
@router.get("/_fragments/acasa", response_class=HTMLResponse)
|
||||||
def fragment_acasa(request: Request) -> HTMLResponse:
|
def fragment_acasa(request: Request) -> HTMLResponse:
|
||||||
"""Fragment HTMX pentru tab-ul Acasa."""
|
"""Fragment HTMX pentru tab-ul Acasa."""
|
||||||
@@ -1072,25 +1100,6 @@ def fragment_trimiteri_versiune(request: Request) -> JSONResponse:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _iso_date_prefix(value: object) -> 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.
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
|
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
|
||||||
# scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "".
|
# scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "".
|
||||||
_STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping")
|
_STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping")
|
||||||
@@ -1336,8 +1345,10 @@ def _payload_form_values(payload_json) -> dict:
|
|||||||
def _prestatii_chips_from_payload(payload_json) -> list[dict]:
|
def _prestatii_chips_from_payload(payload_json) -> list[dict]:
|
||||||
"""Extrage lista de chips prestatii din payload_json pentru _form_editare.html.
|
"""Extrage lista de chips prestatii din payload_json pentru _form_editare.html.
|
||||||
|
|
||||||
Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire}.
|
Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire, exclus}.
|
||||||
Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''.
|
Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''.
|
||||||
|
`exclus` propaga adnotarea din payload (chip Nedeclarat la redeschidere);
|
||||||
|
default 0 pentru payload-uri vechi fara camp.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = json.loads(payload_json) if payload_json else {}
|
data = json.loads(payload_json) if payload_json else {}
|
||||||
@@ -1353,6 +1364,7 @@ def _prestatii_chips_from_payload(payload_json) -> list[dict]:
|
|||||||
"cod_prestatie": (item.get("cod_prestatie") or "").strip().upper(),
|
"cod_prestatie": (item.get("cod_prestatie") or "").strip().upper(),
|
||||||
"cod_op_service": (item.get("cod_op_service") or "").strip(),
|
"cod_op_service": (item.get("cod_op_service") or "").strip(),
|
||||||
"denumire": (item.get("denumire") or "").strip(),
|
"denumire": (item.get("denumire") or "").strip(),
|
||||||
|
"exclus": 1 if item.get("exclus") else 0,
|
||||||
})
|
})
|
||||||
return chips
|
return chips
|
||||||
|
|
||||||
@@ -1501,6 +1513,15 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
|||||||
ctx["prestatii_chips"] = prestatii_chips
|
ctx["prestatii_chips"] = prestatii_chips
|
||||||
ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips)
|
ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips)
|
||||||
ctx["form_chips_url"] = "/form-chips"
|
ctx["form_chips_url"] = "/form-chips"
|
||||||
|
# Tinta implicita + sugestii la randarea INITIALA (inainte de orice /form-chips) —
|
||||||
|
# helper partajat cu post_form_chips: cat exista operatii nemapate, EXISTA tinta.
|
||||||
|
# Fara conn nu se pot calcula sugestiile (enrich_suggestions).
|
||||||
|
ctx["chips_target_index"] = None
|
||||||
|
ctx["sugestii_tinta"] = []
|
||||||
|
if conn is not None:
|
||||||
|
ctx["chips_target_index"], ctx["sugestii_tinta"] = _chips_target_si_sugestii(
|
||||||
|
conn, prestatii_chips, nomenclator_rar
|
||||||
|
)
|
||||||
# submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html.
|
# submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html.
|
||||||
# Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste
|
# Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste
|
||||||
# → butonul nu apare (corect: /form-chips nu are scop de submission).
|
# → butonul nu apare (corect: /form-chips nu are scop de submission).
|
||||||
@@ -1656,6 +1677,41 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
|||||||
if isinstance(obs_val, str):
|
if isinstance(obs_val, str):
|
||||||
content["obs"] = obs_val.strip()
|
content["obs"] = obs_val.strip()
|
||||||
|
|
||||||
|
# Stare chips: editorul unificat trimite UN SINGUR hidden `chips_state`
|
||||||
|
# (JSON versionat, cu `exclus` per item — vezi _chips_state_from_form). Cand e
|
||||||
|
# prezent, INLOCUIESTE integral prestatiile, cu round-trip complet pe exclus.
|
||||||
|
# Cod necunoscut in nomenclator -> respins (invariant ORA-12899);
|
||||||
|
# sentinelul __NEDECLARAT__ nu e niciodata in nomenclator, deci pica in aceeasi
|
||||||
|
# verificare daca ajunge (fabricat) direct ca cod_prestatie in starea hidden.
|
||||||
|
_chips_state_raw = form.get("chips_state")
|
||||||
|
_are_chips_state = _chips_state_raw is not None and str(_chips_state_raw).strip() != ""
|
||||||
|
if _are_chips_state:
|
||||||
|
_chips_din_stare = _chips_from_state_json(str(_chips_state_raw))
|
||||||
|
if _chips_din_stare is None:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_trimitere_detaliu.html",
|
||||||
|
_detaliu_ctx(
|
||||||
|
request, row, conn=conn, account_id=account_id, error=True,
|
||||||
|
message="Stare chips invalida — reincarca formularul si reintroduce codurile.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
valid_codes_now = load_nomenclator_codes(conn) or set()
|
||||||
|
necunoscute = sorted(
|
||||||
|
{c["cod_prestatie"] for c in _chips_din_stare if c["cod_prestatie"]} - valid_codes_now
|
||||||
|
)
|
||||||
|
if necunoscute:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_trimitere_detaliu.html",
|
||||||
|
_detaliu_ctx(
|
||||||
|
request, row, conn=conn, account_id=account_id, error=True,
|
||||||
|
message=f"Cod RAR necunoscut in nomenclator: {', '.join(necunoscute)}. "
|
||||||
|
"Alege un cod valid din lista.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
content["prestatii"] = _chips_din_stare
|
||||||
|
else:
|
||||||
|
# Compat: formular vechi (peste deploy) cu listele paralele
|
||||||
|
# cod_prestatie/chip_op_service/chip_denumire + pickere ne-aprobate.
|
||||||
# Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii.
|
# Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii.
|
||||||
# form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din
|
# form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din
|
||||||
# prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite
|
# prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite
|
||||||
@@ -1733,19 +1789,23 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
|||||||
text_rules = load_text_rules(conn, account_id)
|
text_rules = load_text_rules(conn, account_id)
|
||||||
excluded_ops = load_excluded_ops(conn, account_id)
|
excluded_ops = load_excluded_ops(conn, account_id)
|
||||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
|
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
|
||||||
|
# Persistam TOATE itemii (inclusiv cei exclusi, adnotati exclus=1) — round-trip
|
||||||
|
# complet in editor (chip Nedeclarat reapare la redeschidere, x il readuce la
|
||||||
|
# warning). Excluderea din payload-ul RAR/cheia idempotentei se face DOAR local,
|
||||||
|
# mai jos, prin canon; content["prestatii"] NU se ingusteaza niciodata.
|
||||||
content["prestatii"] = resolved
|
content["prestatii"] = resolved
|
||||||
|
|
||||||
# telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
|
# telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
|
||||||
_emite_text_rule_hits(conn, account_id, row["id"], resolved)
|
_emite_text_rule_hits(conn, account_id, row["id"], resolved)
|
||||||
|
|
||||||
# Prestatiile excluse de la declarare ies din payload-ul trimis (ca in
|
# Prestatiile excluse de la declarare NU intra in payload-ul trimis la RAR nici
|
||||||
# reresolve_account); toate excluse -> needs_data cu motiv explicit mai jos.
|
# in cheia de idempotenta (ca in reresolve_account) — dar RAMAN in payload_json
|
||||||
|
# persistat mai sus. Toate excluse -> needs_data cu motiv explicit mai jos.
|
||||||
declarabile, excluse = split_prestatii_excluse(resolved)
|
declarabile, excluse = split_prestatii_excluse(resolved)
|
||||||
if not unmapped and excluse and declarabile:
|
|
||||||
content["prestatii"] = declarabile
|
|
||||||
|
|
||||||
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
|
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
|
||||||
canon = canonicalize_row(content)
|
# Foloseste DOAR partea declarabila pentru cheie (exclusii nu schimba hash-ul).
|
||||||
|
canon = canonicalize_row({**content, "prestatii": declarabile})
|
||||||
content.update({
|
content.update({
|
||||||
"vin": canon["vin"],
|
"vin": canon["vin"],
|
||||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||||
@@ -2392,6 +2452,71 @@ async def post_bulk_fix(request: Request) -> HTMLResponse:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _sugestii_select_pentru_tinta(conn, denumire: str | None, nomenclator: list[dict]) -> list[dict]:
|
||||||
|
"""Sugestii pentru optgroup "Sugestii" din selectul unic.
|
||||||
|
|
||||||
|
Refoloseste EXACT sursele din `_preview_import.html:89-121` (fara drum nou de cod):
|
||||||
|
`sugestie_principala` (GOLD/SILVER/embedding, `enrich_suggestions`) prima, apoi
|
||||||
|
top-3 fuzzy (`suggest_codes`), dedupliate, pastrand doar coduri care exista in
|
||||||
|
nomenclator. Fara memoizare — se recalculeaza la fiecare re-render.
|
||||||
|
|
||||||
|
Degradare gratioasa: motor de sugestii rece/dezactivat/exceptie -> lista goala
|
||||||
|
(selectul cade pe nomenclatorul complet, fara optgroup). `ensure_embeddings_corpus`
|
||||||
|
ruleaza cu `block=False` implicit — calea de request nu asteapta warmup-ul.
|
||||||
|
|
||||||
|
Fara denumire (tinta neidentificata) fuzzy match-ul ar intoarce primele coduri
|
||||||
|
din nomenclator ca sugestii irelevante — golim explicit.
|
||||||
|
"""
|
||||||
|
if not (denumire or "").strip():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
ensure_embeddings_corpus(conn, nomenclator)
|
||||||
|
by_cod = {n["cod_prestatie"]: n.get("nume_prestatie") for n in (nomenclator or [])}
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
enriched = enrich_suggestions(conn, denumire)
|
||||||
|
principala = enriched.get("sugestie_principala") if enriched else None
|
||||||
|
if principala and principala.get("cod_prestatie") in by_cod:
|
||||||
|
cod = principala["cod_prestatie"]
|
||||||
|
out.append({"cod_prestatie": cod, "nume_prestatie": by_cod.get(cod)})
|
||||||
|
seen.add(cod)
|
||||||
|
|
||||||
|
for s in suggest_codes(denumire, nomenclator, limit=3):
|
||||||
|
cod = s.get("cod_prestatie")
|
||||||
|
if cod and cod not in seen and cod in by_cod:
|
||||||
|
out.append({"cod_prestatie": cod, "nume_prestatie": by_cod.get(cod)})
|
||||||
|
seen.add(cod)
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return [] # degradare gratioasa: selectul ramane cu nomenclatorul complet
|
||||||
|
|
||||||
|
|
||||||
|
def _chips_target_si_sugestii(
|
||||||
|
conn, chips: list[dict], nomenclator: list[dict],
|
||||||
|
) -> tuple[int | None, list[dict]]:
|
||||||
|
"""Tinta implicita (prima operatie nemapata ne-exclusa, identificabila) + sugestii.
|
||||||
|
|
||||||
|
Sursa unica pentru `post_form_chips` SI randarea INITIALA a modalului (inainte
|
||||||
|
de orice /form-chips): cat exista operatii nemapate, EXISTA tinta. Fara acest
|
||||||
|
helper partajat, cele doua cai ar putea diverge.
|
||||||
|
|
||||||
|
Necesita `cod_op_service` truthy: template-ul randeaza chip-ul warning (tinta
|
||||||
|
vizibila) doar pentru operatii identificabile; un item raw fara cod_op_service
|
||||||
|
n-are ce sa arate ca tinta si ar ramane invizibil.
|
||||||
|
"""
|
||||||
|
target_index = next(
|
||||||
|
(i for i, c in enumerate(chips)
|
||||||
|
if not c.get("cod_prestatie") and not c.get("exclus") and c.get("cod_op_service")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
sugestii: list[dict] = []
|
||||||
|
if target_index is not None:
|
||||||
|
_den_tinta = chips[target_index].get("denumire") or chips[target_index].get("cod_op_service")
|
||||||
|
sugestii = _sugestii_select_pentru_tinta(conn, _den_tinta, nomenclator)
|
||||||
|
return target_index, sugestii
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
# Endpoint /form-chips — re-randare chips prestatii. Preia starea curenta din #
|
# Endpoint /form-chips — re-randare chips prestatii. Preia starea curenta din #
|
||||||
# form + actiunea (add/remove) si re-randeaza _chips_prestatii.html. Fara #
|
# form + actiunea (add/remove) si re-randeaza _chips_prestatii.html. Fara #
|
||||||
@@ -2403,37 +2528,25 @@ async def post_bulk_fix(request: Request) -> HTMLResponse:
|
|||||||
async def post_form_chips(request: Request) -> HTMLResponse:
|
async def post_form_chips(request: Request) -> HTMLResponse:
|
||||||
"""Re-randeaza sectiunea chips prestatii (HTMX server-driven).
|
"""Re-randeaza sectiunea chips prestatii (HTMX server-driven).
|
||||||
|
|
||||||
Primeste starea curenta a chip-urilor (3 liste paralele: cod_prestatie,
|
Stare unica: hidden `chips_state` (JSON versionat, vezi _chips_state_from_form).
|
||||||
chip_op_service, chip_denumire) + actiunea (add/remove) si returneaza
|
DOUA actiuni: add (leaga codul de operatia tinta prin chips_target_index, sau
|
||||||
_chips_prestatii.html actualizat. Fara scriere in DB (stateless mid-edit).
|
adauga cod liber fara tinta; sentinelul __NEDECLARAT__ + tinta marcheaza
|
||||||
Auth: sesiune activa; CSRF verificat.
|
exclus=1 pe item) si remove (chips_remove_index, index unic — acopera si fostul
|
||||||
|
remove_flat). Fara scriere in DB (stateless mid-edit). Auth: sesiune activa;
|
||||||
|
CSRF verificat.
|
||||||
"""
|
"""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
verify_csrf(request, str(form.get("csrf_token") or ""))
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
||||||
|
|
||||||
# Reconstruct current chips state from parallel hidden inputs (emise de _chips_prestatii.html).
|
chips, chips_error = _chips_state_from_form(form)
|
||||||
# Toate cele 3 liste sunt aceeasi lungime (emise index-by-index in template).
|
if chips is None:
|
||||||
cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")]
|
chips = []
|
||||||
op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")]
|
|
||||||
den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")]
|
|
||||||
|
|
||||||
# Aliniaza listele la lungimea maxima (defensive)
|
|
||||||
n = max(len(cod_list), len(op_list), len(den_list)) if (cod_list or op_list or den_list) else 0
|
|
||||||
chips: list[dict] = []
|
|
||||||
for i in range(n):
|
|
||||||
chips.append({
|
|
||||||
"cod_prestatie": cod_list[i] if i < len(cod_list) else "",
|
|
||||||
"cod_op_service": op_list[i] if i < len(op_list) else "",
|
|
||||||
"denumire": den_list[i] if i < len(den_list) else "",
|
|
||||||
})
|
|
||||||
|
|
||||||
action = str(form.get("chips_action") or "").strip()
|
action = str(form.get("chips_action") or "").strip()
|
||||||
# Orice adaugare are un efect vizibil: mesaj de refuz (chips_error) sau
|
# Orice adaugare are un efect vizibil: mesaj de refuz (chips_error) sau
|
||||||
# confirmare + chip evidentiat (chips_ok / chips_added_cod). Fara no-op silentios.
|
# confirmare + chip evidentiat (chips_ok / chips_added_index). Fara no-op silentios.
|
||||||
chips_error = ""
|
|
||||||
chips_ok = ""
|
chips_ok = ""
|
||||||
chips_added_cod = ""
|
chips_added_index: int | None = None
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|
||||||
@@ -2442,71 +2555,93 @@ async def post_form_chips(request: Request) -> HTMLResponse:
|
|||||||
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
||||||
).fetchone() is not None
|
).fetchone() is not None
|
||||||
|
|
||||||
try:
|
def _target_valid(idx: int) -> bool:
|
||||||
if action == "add":
|
return 0 <= idx < len(chips) and not chips[idx].get("cod_prestatie") and not chips[idx].get("exclus")
|
||||||
# Adauga cod la operatia specificata prin chips_add_op_index
|
|
||||||
try:
|
|
||||||
op_idx = int(str(form.get("chips_add_op_index") or 0))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
op_idx = 0
|
|
||||||
add_cod = str(form.get(f"chips_add_cod_{op_idx}") or "").strip().upper()
|
|
||||||
if not add_cod:
|
|
||||||
chips_error = "Selecteaza un cod RAR din lista inainte de a adauga."
|
|
||||||
elif not (0 <= op_idx < len(chips)):
|
|
||||||
chips_error = "Operatia nu mai exista in formular — reincarca editarea."
|
|
||||||
elif not _cod_valid(add_cod):
|
|
||||||
chips_error = f"Cod necunoscut in nomenclator: {add_cod}."
|
|
||||||
else:
|
|
||||||
chips[op_idx]["cod_prestatie"] = add_cod
|
|
||||||
chips_ok = f"{add_cod} adaugat."
|
|
||||||
chips_added_cod = add_cod
|
|
||||||
|
|
||||||
elif action in ("add_flat", "add_extra"):
|
|
||||||
# Adauga cod RAR liber (fara op_service): add_flat = mod plat,
|
|
||||||
# add_extra = mod operatii. Acelasi select (chips_add_cod_flat),
|
|
||||||
# aceeasi validare si acelasi dedup per-item (E4).
|
|
||||||
add_cod_flat = str(form.get("chips_add_cod_flat") or "").strip().upper()
|
|
||||||
if not add_cod_flat:
|
|
||||||
chips_error = "Selecteaza un cod RAR din lista inainte de a adauga."
|
|
||||||
elif not _cod_valid(add_cod_flat):
|
|
||||||
chips_error = f"Cod necunoscut in nomenclator: {add_cod_flat}."
|
|
||||||
else:
|
|
||||||
existing_pairs = {
|
|
||||||
(c.get("cod_op_service", ""), c.get("cod_prestatie", ""))
|
|
||||||
for c in chips
|
|
||||||
}
|
|
||||||
if ("", add_cod_flat) in existing_pairs:
|
|
||||||
chips_error = f"{add_cod_flat} este deja in lista — nu a fost adaugat inca o data."
|
|
||||||
else:
|
|
||||||
chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""})
|
|
||||||
chips_ok = f"{add_cod_flat} adaugat."
|
|
||||||
chips_added_cod = add_cod_flat
|
|
||||||
|
|
||||||
elif action == "remove":
|
|
||||||
# Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata)
|
|
||||||
try:
|
try:
|
||||||
remove_idx = int(str(form.get("chips_remove_index") or 0))
|
if not chips_error and action == "add":
|
||||||
|
raw_target = str(form.get("chips_target_index") or "").strip()
|
||||||
|
target_idx: int | None = None
|
||||||
|
if raw_target != "":
|
||||||
|
try:
|
||||||
|
target_idx = int(raw_target)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
remove_idx = 0
|
target_idx = -1 # index nenumeric -> forteaza "tinta invalida" mai jos
|
||||||
|
|
||||||
|
# Mai multe <select name="chips_pick"> pot coexista pe pagina (un picker
|
||||||
|
# per operatie nemapata + unul flat); doar cel interactionat are valoare.
|
||||||
|
pick = next(
|
||||||
|
(v.strip().upper() for v in form.getlist("chips_pick") if isinstance(v, str) and v.strip()),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not pick:
|
||||||
|
chips_error = "Selecteaza un cod RAR din lista inainte de a adauga."
|
||||||
|
elif pick == EXCLUDE_SENTINEL:
|
||||||
|
# "Nu se declara la RAR": cere OBLIGATORIU o tinta valida (operatie
|
||||||
|
# nemapata, ne-exclusa) — sentinelul nu are sens pe cod liber.
|
||||||
|
if target_idx is None or not _target_valid(target_idx):
|
||||||
|
chips_error = "\"Nu se declara la RAR\" cere o operatie tinta valida."
|
||||||
|
else:
|
||||||
|
chips[target_idx]["exclus"] = 1
|
||||||
|
chips[target_idx]["cod_prestatie"] = ""
|
||||||
|
op_nume = chips[target_idx].get("cod_op_service") or "Operatia"
|
||||||
|
chips_ok = f"{op_nume} nu se va declara la RAR."
|
||||||
|
chips_added_index = target_idx
|
||||||
|
elif not _cod_valid(pick):
|
||||||
|
chips_error = f"Cod necunoscut in nomenclator: {pick}."
|
||||||
|
elif target_idx is not None and not _target_valid(target_idx):
|
||||||
|
chips_error = "Operatia tinta nu mai exista sau e deja rezolvata — reincarca editarea."
|
||||||
|
elif target_idx is not None:
|
||||||
|
# Leaga codul de operatia tinta (E4 binding).
|
||||||
|
op_tinta = chips[target_idx].get("cod_op_service")
|
||||||
|
dedup = any(c.get("cod_op_service") == op_tinta and c.get("cod_prestatie") == pick for c in chips)
|
||||||
|
if dedup:
|
||||||
|
chips_error = f"{pick} este deja in lista pentru aceasta operatie."
|
||||||
|
else:
|
||||||
|
chips[target_idx]["cod_prestatie"] = pick
|
||||||
|
chips[target_idx]["exclus"] = 0
|
||||||
|
chips_ok = f"{pick} adaugat."
|
||||||
|
chips_added_index = target_idx
|
||||||
|
else:
|
||||||
|
# Fara tinta -> chip liber (fara operatie sursa).
|
||||||
|
dedup = any(not c.get("cod_op_service") and c.get("cod_prestatie") == pick for c in chips)
|
||||||
|
if dedup:
|
||||||
|
chips_error = f"{pick} este deja in lista — nu a fost adaugat inca o data."
|
||||||
|
else:
|
||||||
|
chips.append({"cod_prestatie": pick, "cod_op_service": "", "denumire": "", "exclus": 0})
|
||||||
|
chips_ok = f"{pick} adaugat."
|
||||||
|
chips_added_index = len(chips) - 1
|
||||||
|
|
||||||
|
elif not chips_error and action == "remove":
|
||||||
|
# Index unic (acopera si fostul remove_flat): chip legat de operatie ->
|
||||||
|
# codul se sterge (operatia redevine nemapata/warning, inclusiv Nedeclarat
|
||||||
|
# -> warning); chip liber -> iese complet din lista.
|
||||||
|
try:
|
||||||
|
remove_idx = int(str(form.get("chips_remove_index") or ""))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
remove_idx = -1
|
||||||
if 0 <= remove_idx < len(chips):
|
if 0 <= remove_idx < len(chips):
|
||||||
chips[remove_idx]["cod_prestatie"] = ""
|
item = chips[remove_idx]
|
||||||
|
if item.get("cod_op_service"):
|
||||||
|
item["cod_prestatie"] = ""
|
||||||
|
item["exclus"] = 0
|
||||||
|
else:
|
||||||
|
chips.pop(remove_idx)
|
||||||
|
|
||||||
elif action == "remove_flat":
|
|
||||||
# Sterge un chip plat dupa cod (in mod fara op_service)
|
|
||||||
remove_cod = str(form.get("chips_remove_cod") or "").strip().upper()
|
|
||||||
chips = [
|
|
||||||
c for c in chips
|
|
||||||
if not (not c.get("cod_op_service") and c.get("cod_prestatie") == remove_cod)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Compute has_r_odo dupa actiune
|
|
||||||
has_r_odo = _has_r_odo_chips(chips)
|
has_r_odo = _has_r_odo_chips(chips)
|
||||||
|
|
||||||
# Incarca nomenclatorul pentru picker
|
|
||||||
nomenclator_rar = load_nomenclator(conn)
|
nomenclator_rar = load_nomenclator(conn)
|
||||||
|
|
||||||
|
# Tinta implicita dupa actiune (prima operatie nemapata) + sugestii pentru ea
|
||||||
|
# (optgroup "Sugestii" din select) — helper partajat cu randarea initiala.
|
||||||
|
chips_target_index, sugestii_tinta = _chips_target_si_sugestii(conn, chips, nomenclator_rar)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# Ecou (nu re-derivat): permite butonului "salveaza ca regula" sa ramana
|
||||||
|
# disponibil dupa re-randari succesive via /form-chips.
|
||||||
|
chips_submission_id = str(form.get("chips_submission_id") or "").strip()
|
||||||
|
|
||||||
return templates.TemplateResponse("_chips_prestatii.html", {
|
return templates.TemplateResponse("_chips_prestatii.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"csrf_token": get_csrf_token(request),
|
"csrf_token": get_csrf_token(request),
|
||||||
@@ -2517,7 +2652,10 @@ async def post_form_chips(request: Request) -> HTMLResponse:
|
|||||||
"chips_section_id": "chips-section",
|
"chips_section_id": "chips-section",
|
||||||
"chips_error": chips_error,
|
"chips_error": chips_error,
|
||||||
"chips_ok": chips_ok,
|
"chips_ok": chips_ok,
|
||||||
"chips_added_cod": chips_added_cod,
|
"chips_added_index": chips_added_index,
|
||||||
|
"chips_target_index": chips_target_index,
|
||||||
|
"chips_submission_id": chips_submission_id,
|
||||||
|
"sugestii_tinta": sugestii_tinta,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -3235,7 +3373,12 @@ def _web_compute_preview(
|
|||||||
key: str | None = None
|
key: str | None = None
|
||||||
if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
||||||
try:
|
try:
|
||||||
key = _build_idempotency_key(account_id, info["resolved"], preview_env)
|
# Cheia foloseste DOAR partea declarabila (itemii exclusi nu intra in
|
||||||
|
# hash) — identica cu cea calculata la commit, altfel un rand cu
|
||||||
|
# prestatii excluse arata alt duplicat decat cel real.
|
||||||
|
decl, _exc = split_prestatii_excluse(info["resolved"].get("prestatii"))
|
||||||
|
key_resolved = {**info["resolved"], "prestatii": decl} if _exc else info["resolved"]
|
||||||
|
key = _build_idempotency_key(account_id, key_resolved, preview_env)
|
||||||
keys_for_lookup.append(key)
|
keys_for_lookup.append(key)
|
||||||
key_to_indices.setdefault(key, []).append(i)
|
key_to_indices.setdefault(key, []).append(i)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -3796,12 +3939,16 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) ->
|
|||||||
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
|
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
|
||||||
"cod_op_service": (p.get("cod_op_service") or "").strip(),
|
"cod_op_service": (p.get("cod_op_service") or "").strip(),
|
||||||
"denumire": (p.get("denumire") or "").strip(),
|
"denumire": (p.get("denumire") or "").strip(),
|
||||||
|
"exclus": 1 if p.get("exclus") else 0,
|
||||||
}
|
}
|
||||||
for p in (res.get("prestatii") or [])
|
for p in (res.get("prestatii") or [])
|
||||||
if isinstance(p, dict)
|
if isinstance(p, dict)
|
||||||
]
|
]
|
||||||
_preview_has_r_odo = _has_r_odo_chips(_preview_chips)
|
_preview_has_r_odo = _has_r_odo_chips(_preview_chips)
|
||||||
_preview_nomenclator = load_nomenclator(conn)
|
_preview_nomenclator = load_nomenclator(conn)
|
||||||
|
_preview_target_idx, _preview_sugestii = _chips_target_si_sugestii(
|
||||||
|
conn, _preview_chips, _preview_nomenclator
|
||||||
|
)
|
||||||
return templates.TemplateResponse("_editare_preview_modal.html", {
|
return templates.TemplateResponse("_editare_preview_modal.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"import_id": import_id,
|
"import_id": import_id,
|
||||||
@@ -3827,6 +3974,8 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) ->
|
|||||||
"obs_val": (res.get("obs") or "").strip(),
|
"obs_val": (res.get("obs") or "").strip(),
|
||||||
"nomenclator_rar": _preview_nomenclator,
|
"nomenclator_rar": _preview_nomenclator,
|
||||||
"form_chips_url": "/form-chips",
|
"form_chips_url": "/form-chips",
|
||||||
|
"chips_target_index": _preview_target_idx,
|
||||||
|
"sugestii_tinta": _preview_sugestii,
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -3859,25 +4008,77 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _chips_state_from_form(form) -> list[dict[str, str]] | None:
|
CHIPS_STATE_VERSION = 1
|
||||||
"""Reconstruieste starea chips prestatii din inputurile paralele ale formularului.
|
|
||||||
|
|
||||||
Aceeasi conventie ca /form-chips (_chips_prestatii.html): 3 liste paralele
|
|
||||||
cod_prestatie / chip_op_service / chip_denumire. Codurile alese in pickere dar
|
|
||||||
ne-aprobate prin '+' (chips_add_cod_{i}, chips_add_cod_flat) se aplica implicit,
|
|
||||||
ca in post_corectie_trimitere. Dedup pe perechea (op, cod).
|
|
||||||
|
|
||||||
Intoarce None cand formularul nu contine deloc stare de chips (form fara
|
def _parse_chip_exclus(raw) -> int:
|
||||||
sectiunea de prestatii) — apelantul lasa prestatiile neatinse.
|
"""Parsare stricta a flag-ului exclus: DOAR '1' -> 1, orice altceva -> 0."""
|
||||||
|
return 1 if str(raw).strip() == "1" else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _chips_state_to_json(chips: list[dict]) -> str:
|
||||||
|
"""Serializeaza chips (lista interna) in JSON versionat pentru hidden `chips_state`."""
|
||||||
|
return json.dumps({
|
||||||
|
"v": CHIPS_STATE_VERSION,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"cod": c.get("cod_prestatie") or "",
|
||||||
|
"op": c.get("cod_op_service") or "",
|
||||||
|
"den": c.get("denumire") or "",
|
||||||
|
"exclus": 1 if c.get("exclus") else 0,
|
||||||
|
}
|
||||||
|
for c in chips
|
||||||
|
],
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Expune serializarea chips_state in template (randare initiala, fara trecere prin
|
||||||
|
# post_form_chips) -- sursa unica de adevar pentru hidden `chips_state`.
|
||||||
|
templates.env.globals["chips_state_json"] = _chips_state_to_json
|
||||||
|
|
||||||
|
|
||||||
|
def _chips_from_state_json(raw: str) -> list[dict] | None:
|
||||||
|
"""Decodeaza hidden `chips_state` (JSON versionat) in lista interna de chips.
|
||||||
|
|
||||||
|
Intoarce None cand JSON-ul e invalid sau structura nu se potriveste
|
||||||
|
(fara cheie 'items' lista) — apelantul trateaza asta ca eroare vizibila,
|
||||||
|
NU ca stare goala (altfel o golire accidentala trece neobservata).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(parsed, dict) or not isinstance(parsed.get("items"), list):
|
||||||
|
return None
|
||||||
|
chips: list[dict] = []
|
||||||
|
for it in parsed["items"]:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
return None
|
||||||
|
chips.append({
|
||||||
|
"cod_prestatie": str(it.get("cod") or "").strip().upper(),
|
||||||
|
"cod_op_service": str(it.get("op") or "").strip(),
|
||||||
|
"denumire": str(it.get("den") or "").strip(),
|
||||||
|
"exclus": _parse_chip_exclus(it.get("exclus")),
|
||||||
|
})
|
||||||
|
return chips
|
||||||
|
|
||||||
|
|
||||||
|
def _chips_from_legacy_lists(form) -> list[dict]:
|
||||||
|
"""Compat tranzitorie: reconstruieste chips din listele paralele vechi.
|
||||||
|
|
||||||
|
cod_prestatie / chip_op_service / chip_denumire (+ chip_exclus optional,
|
||||||
|
parsat strict). Codurile alese in pickere dar ne-aprobate prin '+' (E.g.
|
||||||
|
chips_add_cod_{i}, chips_add_cod_flat) se aplica implicit — comportament
|
||||||
|
pastrat identic cu forma veche (form-uri deschise peste deploy). Dedup pe
|
||||||
|
perechea (op, cod). Se sterge in release-ul urmator.
|
||||||
"""
|
"""
|
||||||
flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
|
flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
|
||||||
if "cod_prestatie" not in form and "chip_op_service" not in form and not flat_picker:
|
|
||||||
return None
|
|
||||||
cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")]
|
cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")]
|
||||||
op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")]
|
op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")]
|
||||||
den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")]
|
den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")]
|
||||||
|
excl_list = [str(e) for e in form.getlist("chip_exclus")]
|
||||||
n = max(len(cod_list), len(op_list), len(den_list))
|
n = max(len(cod_list), len(op_list), len(den_list))
|
||||||
chips: list[dict[str, str]] = []
|
chips: list[dict] = []
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
cod = cod_list[i] if i < len(cod_list) else ""
|
cod = cod_list[i] if i < len(cod_list) else ""
|
||||||
if not cod:
|
if not cod:
|
||||||
@@ -3887,12 +4088,13 @@ def _chips_state_from_form(form) -> list[dict[str, str]] | None:
|
|||||||
"cod_prestatie": cod,
|
"cod_prestatie": cod,
|
||||||
"cod_op_service": op_list[i] if i < len(op_list) else "",
|
"cod_op_service": op_list[i] if i < len(op_list) else "",
|
||||||
"denumire": den_list[i] if i < len(den_list) else "",
|
"denumire": den_list[i] if i < len(den_list) else "",
|
||||||
|
"exclus": _parse_chip_exclus(excl_list[i]) if i < len(excl_list) else 0,
|
||||||
})
|
})
|
||||||
pairs = {(c["cod_op_service"], c["cod_prestatie"]) for c in chips}
|
pairs = {(c["cod_op_service"], c["cod_prestatie"]) for c in chips}
|
||||||
if flat_picker and ("", flat_picker) not in pairs:
|
if flat_picker and ("", flat_picker) not in pairs:
|
||||||
chips.append({"cod_prestatie": flat_picker, "cod_op_service": "", "denumire": ""})
|
chips.append({"cod_prestatie": flat_picker, "cod_op_service": "", "denumire": "", "exclus": 0})
|
||||||
seen: set = set()
|
seen: set = set()
|
||||||
out: list[dict[str, str]] = []
|
out: list[dict] = []
|
||||||
for c in chips:
|
for c in chips:
|
||||||
if not (c["cod_prestatie"] or c["cod_op_service"] or c["denumire"]):
|
if not (c["cod_prestatie"] or c["cod_op_service"] or c["denumire"]):
|
||||||
continue
|
continue
|
||||||
@@ -3904,6 +4106,33 @@ def _chips_state_from_form(form) -> list[dict[str, str]] | None:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _chips_state_from_form(form) -> tuple[list[dict] | None, str]:
|
||||||
|
"""Reconstruieste starea chips prestatii din formular.
|
||||||
|
|
||||||
|
Sursa unica: hidden `chips_state` (JSON versionat {"v":1,"items":[...]});
|
||||||
|
compat tranzitorie pe listele paralele vechi (cod_prestatie/chip_op_service/
|
||||||
|
chip_denumire) cand `chips_state` lipseste (form deschis peste deploy).
|
||||||
|
|
||||||
|
Intoarce (chips, error):
|
||||||
|
- error non-gol -> JSON invalid; apelantul arata chips_error si NU aplica
|
||||||
|
nicio schimbare (NU stare goala silentioasa).
|
||||||
|
- chips=None + error gol -> formularul nu are deloc sectiune de chips;
|
||||||
|
prestatiile raman neatinse.
|
||||||
|
- chips=[] + error gol -> golire intentionata (chips_state prezent, items:[]);
|
||||||
|
se persista la salvare.
|
||||||
|
"""
|
||||||
|
raw_state = form.get("chips_state")
|
||||||
|
if raw_state is not None and str(raw_state).strip() != "":
|
||||||
|
chips = _chips_from_state_json(str(raw_state))
|
||||||
|
if chips is None:
|
||||||
|
return None, "Stare chips invalida — reincarca formularul si reintroduce codurile."
|
||||||
|
return chips, ""
|
||||||
|
flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
|
||||||
|
if "cod_prestatie" not in form and "chip_op_service" not in form and not flat_picker:
|
||||||
|
return None, ""
|
||||||
|
return _chips_from_legacy_lists(form), ""
|
||||||
|
|
||||||
|
|
||||||
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
|
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
|
||||||
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
||||||
"""Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal.
|
"""Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal.
|
||||||
@@ -3924,9 +4153,55 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
|
|||||||
for camp in EDIT_FIELDS
|
for camp in EDIT_FIELDS
|
||||||
}
|
}
|
||||||
# Chips prestatii din modal: None = form fara sectiune de chips (neatins).
|
# Chips prestatii din modal: None = form fara sectiune de chips (neatins).
|
||||||
chips_state = _chips_state_from_form(form)
|
chips_state, chips_parse_error = _chips_state_from_form(form)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
|
if chips_parse_error:
|
||||||
|
# JSON chips_state corupt -> eroare vizibila, NU stare goala silentioasa;
|
||||||
|
# override NEATINS (nicio prestatie nu se schimba).
|
||||||
|
_nom_err = load_nomenclator(conn)
|
||||||
|
_cur_result, _cur_row = _preview_one_row(conn, import_id, account_id, row_index)
|
||||||
|
_cur_chips = (
|
||||||
|
(_cur_row.get("resolved") or {}).get("prestatii")
|
||||||
|
if _cur_row and not isinstance(_cur_result, str) else None
|
||||||
|
) or []
|
||||||
|
_cur_chips = [
|
||||||
|
{
|
||||||
|
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
|
||||||
|
"cod_op_service": (p.get("cod_op_service") or "").strip(),
|
||||||
|
"denumire": (p.get("denumire") or "").strip(),
|
||||||
|
"exclus": 1 if p.get("exclus") else 0,
|
||||||
|
}
|
||||||
|
for p in _cur_chips if isinstance(p, dict)
|
||||||
|
]
|
||||||
|
_cur_target_idx, _cur_sugestii = _chips_target_si_sugestii(conn, _cur_chips, _nom_err)
|
||||||
|
return templates.TemplateResponse("_editare_preview_modal.html", {
|
||||||
|
"request": request,
|
||||||
|
"import_id": import_id,
|
||||||
|
"row_index": row_index,
|
||||||
|
"csrf_token": get_csrf_token(request),
|
||||||
|
"vin": str(form.get("vin") or ""),
|
||||||
|
"stare_css": "",
|
||||||
|
"stare_eticheta": "",
|
||||||
|
"form_nr": str(form.get("nr_inmatriculare") or ""),
|
||||||
|
"form_vin": str(form.get("vin") or ""),
|
||||||
|
"form_data": str(form.get("data_prestatie") or ""),
|
||||||
|
"form_odo_final": str(form.get("odometru_final") or ""),
|
||||||
|
"form_odo_initial": str(form.get("odometru_initial") or ""),
|
||||||
|
"err_map": {},
|
||||||
|
"fix_map": {},
|
||||||
|
"vin_context": str(form.get("vin") or ""),
|
||||||
|
"btn_label": "Salveaza",
|
||||||
|
"message": chips_parse_error,
|
||||||
|
"chips_error": chips_parse_error,
|
||||||
|
"prestatii_chips": _cur_chips,
|
||||||
|
"has_r_odo": _has_r_odo_chips(_cur_chips),
|
||||||
|
"obs_val": str(form.get("obs") or "").strip(),
|
||||||
|
"nomenclator_rar": _nom_err,
|
||||||
|
"form_chips_url": "/form-chips",
|
||||||
|
"chips_target_index": _cur_target_idx,
|
||||||
|
"sugestii_tinta": _cur_sugestii,
|
||||||
|
})
|
||||||
# Invariant ORA-12899: orice cod trimis din form se valideaza fata de
|
# Invariant ORA-12899: orice cod trimis din form se valideaza fata de
|
||||||
# nomenclator INAINTE de persistare (RAR accepta NUMAI coduri valide).
|
# nomenclator INAINTE de persistare (RAR accepta NUMAI coduri valide).
|
||||||
if chips_state is not None:
|
if chips_state is not None:
|
||||||
@@ -3936,6 +4211,7 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
|
|||||||
)
|
)
|
||||||
if necunoscute:
|
if necunoscute:
|
||||||
_nom = load_nomenclator(conn)
|
_nom = load_nomenclator(conn)
|
||||||
|
_nec_target_idx, _nec_sugestii = _chips_target_si_sugestii(conn, chips_state, _nom)
|
||||||
return templates.TemplateResponse("_editare_preview_modal.html", {
|
return templates.TemplateResponse("_editare_preview_modal.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"import_id": import_id,
|
"import_id": import_id,
|
||||||
@@ -3960,6 +4236,8 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
|
|||||||
"obs_val": str(form.get("obs") or "").strip(),
|
"obs_val": str(form.get("obs") or "").strip(),
|
||||||
"nomenclator_rar": _nom,
|
"nomenclator_rar": _nom,
|
||||||
"form_chips_url": "/form-chips",
|
"form_chips_url": "/form-chips",
|
||||||
|
"chips_target_index": _nec_target_idx,
|
||||||
|
"sugestii_tinta": _nec_sugestii,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
|
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
|
||||||
@@ -3992,11 +4270,13 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
|
|||||||
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
|
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
|
||||||
"cod_op_service": (p.get("cod_op_service") or "").strip(),
|
"cod_op_service": (p.get("cod_op_service") or "").strip(),
|
||||||
"denumire": (p.get("denumire") or "").strip(),
|
"denumire": (p.get("denumire") or "").strip(),
|
||||||
|
"exclus": 1 if p.get("exclus") else 0,
|
||||||
}
|
}
|
||||||
for p in (res.get("prestatii") or [])
|
for p in (res.get("prestatii") or [])
|
||||||
if isinstance(p, dict)
|
if isinstance(p, dict)
|
||||||
]
|
]
|
||||||
_err_nomenclator = load_nomenclator(conn)
|
_err_nomenclator = load_nomenclator(conn)
|
||||||
|
_err_target_idx, _err_sugestii = _chips_target_si_sugestii(conn, _err_chips, _err_nomenclator)
|
||||||
return templates.TemplateResponse("_editare_preview_modal.html", {
|
return templates.TemplateResponse("_editare_preview_modal.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"import_id": import_id,
|
"import_id": import_id,
|
||||||
@@ -4021,6 +4301,8 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
|
|||||||
"obs_val": str(form.get("obs") or res.get("obs") or "").strip(),
|
"obs_val": str(form.get("obs") or res.get("obs") or "").strip(),
|
||||||
"nomenclator_rar": _err_nomenclator,
|
"nomenclator_rar": _err_nomenclator,
|
||||||
"form_chips_url": "/form-chips",
|
"form_chips_url": "/form-chips",
|
||||||
|
"chips_target_index": _err_target_idx,
|
||||||
|
"sugestii_tinta": _err_sugestii,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Succes: reincarca preview-ul complet + toast + inchide modal (vezi helper).
|
# Succes: reincarca preview-ul complet + toast + inchide modal (vezi helper).
|
||||||
@@ -4532,15 +4814,18 @@ async def web_confirma_import(
|
|||||||
# Rezolva prestatii
|
# Rezolva prestatii
|
||||||
prestatii = mapped.get("prestatii") or []
|
prestatii = mapped.get("prestatii") or []
|
||||||
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules, excluded_ops)
|
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules, excluded_ops)
|
||||||
# Prestatiile excluse NU pleaca la RAR (ca in preview); rand fara nicio
|
# Prestatiile excluse NU pleaca la RAR si nu intra in cheia de
|
||||||
# prestatie declarabila = 'excluded' in preview -> defensiv skip.
|
# idempotenta, dar RAMAN in payload_json persistat (round-trip in
|
||||||
resolved_p, _excluse_p = split_prestatii_excluse(resolved_p)
|
# editor) — ingustarea la declarabile se face doar local, pentru
|
||||||
if not resolved_p:
|
# canon/cheie, si la worker, la momentul trimiterii.
|
||||||
|
declarabile_p, _excluse_p = split_prestatii_excluse(resolved_p)
|
||||||
|
# rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip.
|
||||||
|
if not declarabile_p:
|
||||||
continue
|
continue
|
||||||
mapped["prestatii"] = resolved_p
|
mapped["prestatii"] = resolved_p
|
||||||
|
|
||||||
# Canonicalizare (cheia identica cu preview: canonicalize_row + build_key)
|
# Canonicalizare (cheia identica cu preview: canonicalize_row + build_key)
|
||||||
canon = canonicalize_row(mapped)
|
canon = canonicalize_row({**mapped, "prestatii": declarabile_p})
|
||||||
mapped.update({
|
mapped.update({
|
||||||
"vin": canon["vin"],
|
"vin": canon["vin"],
|
||||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||||
@@ -4556,7 +4841,7 @@ async def web_confirma_import(
|
|||||||
"odometru_final": mapped.get("odometru_final"),
|
"odometru_final": mapped.get("odometru_final"),
|
||||||
"prestatii": [
|
"prestatii": [
|
||||||
str(p.get("cod_prestatie") or p.get("cod_op_service") or "")
|
str(p.get("cod_prestatie") or p.get("cod_op_service") or "")
|
||||||
for p in resolved_p
|
for p in declarabile_p
|
||||||
],
|
],
|
||||||
}, sort_keys=True, ensure_ascii=False))
|
}, sort_keys=True, ensure_ascii=False))
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
{#
|
{#
|
||||||
_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)
|
||||||
|
chips_added_index — index-ul chip-ului tocmai adaugat (emfaza chip-nou)
|
||||||
|
chips_target_index — index-ul operatiei tinta curente (sau none)
|
||||||
|
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'
|
form_chips_url — URL pentru HTMX; default '/form-chips'
|
||||||
chips_section_id — ID div (default 'chips-section')
|
chips_section_id — ID div (default 'chips-section')
|
||||||
csrf_token — CSRF (trecut prin hx-include din form parinte)
|
csrf_token — CSRF (trecut prin hx-include din form parinte)
|
||||||
@@ -19,71 +33,92 @@
|
|||||||
{% 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 '' %}
|
||||||
{# 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 %}
|
||||||
{% if chip.cod_op_service %}
|
{% set _idx = loop.index0 %}
|
||||||
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
{% set _is_target = _target_idx is not none and _idx == _target_idx %}
|
||||||
{% set _nemapat = not chip.cod_prestatie %}
|
|
||||||
{% set _e_nou = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %}
|
{% if chip.cod_op_service and not chip.cod_prestatie and not chip.exclus %}
|
||||||
<div class="op-row {% if _nemapat %}op-row-warn{% endif %}" style="margin-bottom:6px;">
|
{# ===== Chip warning: operatie nemapata — click comuta tinta client-side ===== #}
|
||||||
<span class="op-row-name">
|
{% set _label = chip.cod_op_service %}
|
||||||
{{ chip.cod_op_service }}
|
{% set _label_scurt = (_label[:18] ~ '…') if _label | length > 18 else _label %}
|
||||||
{% if chip.denumire and chip.denumire != chip.cod_op_service %}
|
{% set _title = chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '') %}
|
||||||
<span class="muted" style="font-weight:400;font-size:11px;"> — {{ chip.denumire }}</span>
|
<button type="button"
|
||||||
{% endif %}
|
class="chip chip-op-warn {% if _is_target %}is-target{% endif %}"
|
||||||
{% if _nemapat %}
|
data-chip-idx="{{ _idx }}"
|
||||||
<span style="color:var(--warn);font-size:10px;font-weight:400;"> · lipsa cod</span>
|
data-op-nume="{{ chip.cod_op_service }}"
|
||||||
{% endif %}
|
title="{{ _title }}"
|
||||||
</span>
|
onclick="window.chipsSetTarget && window.chipsSetTarget(this, {{ _idx }})"
|
||||||
<span style="display:flex;align-items:center;gap:8px;">
|
aria-pressed="{{ 'true' if _is_target else 'false' }}"
|
||||||
{% if chip.cod_prestatie %}
|
aria-label="Operatia {{ chip.cod_op_service }} nu are cod RAR{% if _is_target %} (tinta curenta){% endif %}">
|
||||||
{# ===== Operatie mapata: chip cu × ===== #}
|
⚠ {{ _label_scurt }} · fara cod
|
||||||
<span class="chip {% if _is_warn %}chip-warn{% endif %} {% if _e_nou %}chip-nou{% endif %}"
|
</button>
|
||||||
aria-label="Prestatie {{ chip.cod_prestatie }} adaugata pentru {{ chip.cod_op_service }}">
|
|
||||||
{{ chip.cod_prestatie }}
|
{% 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-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","chips_remove_index":{{ loop.index0 }}}'
|
hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}'
|
||||||
aria-label="Sterge codul {{ chip.cod_prestatie }} pentru {{ chip.cod_op_service }}">
|
aria-label="Anuleaza Nedeclarat pentru {{ chip.cod_op_service or 'cod liber' }} (readuce avertismentul)">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</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>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
{# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
|
{# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
|
||||||
(in modalul de detaliu, nu la re-randarea stateless via /form-chips).
|
(modalul de detaliu, nu la re-randarea stateless via /form-chips fara ecou). #}
|
||||||
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 %}
|
{% 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"
|
<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;"
|
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-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
|
||||||
hx-disabled-elt="this"
|
hx-disabled-elt="this"
|
||||||
|
hx-indicator="#{{ _sec_id }}"
|
||||||
hx-include="closest form"
|
hx-include="closest form"
|
||||||
hx-target="#detaliu-modal-body"
|
hx-target="#detaliu-modal-body"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
@@ -91,143 +126,50 @@
|
|||||||
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
|
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
|
||||||
salveaza ca regula
|
salveaza ca regula
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% endif %}
|
||||||
{# ===== Operatie nemapata: picker galben cu "alege cod RAR" ===== #}
|
{% endif %}
|
||||||
<select name="chips_add_cod_{{ loop.index0 }}"
|
{% endfor %}
|
||||||
id="picker-op-{{ loop.index0 }}"
|
|
||||||
aria-label="Alege cod RAR pentru {{ chip.cod_op_service }}"
|
{# ===== Selectul unic: adauga instant la schimbare, placeholder numeste tinta ===== #}
|
||||||
style="min-width:160px;font-size:11px;height:26px;">
|
{% if nomenclator_rar %}
|
||||||
<option value="">— alege cod RAR —</option>
|
<select name="chips_pick"
|
||||||
|
id="chips-picker"
|
||||||
|
class="chips-picker"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-post="{{ _chips_url }}"
|
||||||
|
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 []) %}
|
{% 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-disabled-elt="this"
|
|
||||||
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 %}
|
|
||||||
|
|
||||||
{# ===== Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #}
|
|
||||||
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat).
|
|
||||||
Containerul .chips se randeaza DOAR cand exista chips extra — altfel ramanea
|
|
||||||
un chenar gol nefinisat sub randurile de operatie. #}
|
|
||||||
{% set _extra_chips = _chips | rejectattr('cod_op_service') | selectattr('cod_prestatie') | list %}
|
|
||||||
{% if _extra_chips %}
|
|
||||||
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;">
|
|
||||||
{% for chip in _extra_chips %}
|
|
||||||
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
|
||||||
{% set _e_nou_extra = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %}
|
|
||||||
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %} {% if _e_nou_extra %}chip-nou{% endif %}"
|
|
||||||
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}">
|
|
||||||
{{ chip.cod_prestatie }}
|
|
||||||
<button type="button" class="chip-del"
|
|
||||||
hx-post="{{ _chips_url }}"
|
|
||||||
hx-disabled-elt="this"
|
|
||||||
hx-include="closest form"
|
|
||||||
hx-target="#{{ _sec_id }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
|
|
||||||
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">×</button>
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if nomenclator_rar %}
|
|
||||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;">
|
|
||||||
<select name="chips_add_cod_flat"
|
|
||||||
aria-label="Adauga cod RAR suplimentar"
|
|
||||||
style="min-width:160px;font-size:11px;height:26px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
|
|
||||||
<option value="">+ Adauga alta operatie / cod RAR</option>
|
|
||||||
{% for n in nomenclator_rar %}
|
|
||||||
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="button"
|
|
||||||
class="add-code"
|
|
||||||
hx-post="{{ _chips_url }}"
|
|
||||||
hx-disabled-elt="this"
|
|
||||||
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 %}
|
{% else %}
|
||||||
{# T-D1/T-E5 (5.16): empty state in mod operatii 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;margin-top:4px;">
|
|
||||||
Nomenclator indisponibil — adaugarea de coduri suplimentare nu e posibila.
|
|
||||||
</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') %}
|
|
||||||
{% set _e_nou_flat = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %}
|
|
||||||
<span class="chip {% if _is_warn_flat %}chip-warn{% endif %} {% if _e_nou_flat %}chip-nou{% endif %}"
|
|
||||||
aria-label="Prestatie {{ chip.cod_prestatie }}">
|
|
||||||
{{ chip.cod_prestatie }}
|
|
||||||
<button type="button" class="chip-del"
|
|
||||||
hx-post="{{ _chips_url }}"
|
|
||||||
hx-disabled-elt="this"
|
|
||||||
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-disabled-elt="this"
|
|
||||||
hx-include="closest form"
|
|
||||||
hx-target="#{{ _sec_id }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-vals='{"chips_action":"add_flat"}'
|
|
||||||
aria-label="Adauga cod RAR selectat in lista">
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
{# T-D1/T-E5 (5.16): empty state in mod plat cand nomenclatorul lipseste #}
|
|
||||||
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;">
|
<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 %}
|
|
||||||
|
|
||||||
{# Rezultatul ultimei adaugari, in ambele moduri: refuz (rol=alert) sau confirmare.
|
{# Rezultatul ultimei adaugari: refuz (rol=alert) sau confirmare.
|
||||||
Clasa chips-extra-error pastrata pe eroare (selector stabil pentru teste/QA). #}
|
Clasa chips-extra-error pastrata pe eroare (selector stabil pentru teste/QA). #}
|
||||||
{% if chips_error is defined and chips_error %}
|
{% if chips_error is defined and chips_error %}
|
||||||
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
|
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
|
||||||
@@ -238,12 +180,40 @@
|
|||||||
{{ chips_ok }}
|
{{ chips_ok }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Hint discret fara chips (debut) #}
|
|
||||||
{% if not _chips %}
|
|
||||||
<div style="font-size:10px;color:var(--muted);padding:4px 0;">
|
|
||||||
Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
@@ -216,6 +216,41 @@
|
|||||||
</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) #}
|
{# Card: De ce nu ajunge la RAR? (onestitate canal API) #}
|
||||||
{# Un POST /v1/prezentari poate intoarce 200 dar randul sa NU plece la RAR. #}
|
{# Un POST /v1/prezentari poate intoarce 200 dar randul sa NU plece la RAR. #}
|
||||||
<div class="card" style="margin-bottom:16px;">
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
|||||||
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>
|
||||||
@@ -112,6 +112,7 @@
|
|||||||
celulele raman semnale compacte (bulina, litera). #}
|
celulele raman semnale compacte (bulina, litera). #}
|
||||||
<li class="trimiteri-head" aria-hidden="true">
|
<li class="trimiteri-head" aria-hidden="true">
|
||||||
<span></span>
|
<span></span>
|
||||||
|
<span>#</span>
|
||||||
<span>Vehicul</span>
|
<span>Vehicul</span>
|
||||||
<span>Cod RAR</span>
|
<span>Cod RAR</span>
|
||||||
<span>Operatie</span>
|
<span>Operatie</span>
|
||||||
@@ -144,6 +145,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Numarul trimiterii (id din baza) — aceeasi valoare ca `submission_id`
|
||||||
|
din raspunsul API si ca titlul modalului de detaliu/editare. #}
|
||||||
|
<span class="c-id">#{{ r.id }}</span>
|
||||||
|
|
||||||
{# Vehicul: placuta (identificator primar); fallback VIN scurt / 'fara numar'
|
{# Vehicul: placuta (identificator primar); fallback VIN scurt / 'fara numar'
|
||||||
(niciodata em-dash izolat). #}
|
(niciodata em-dash izolat). #}
|
||||||
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
|
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
{% 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 + motiv === #}
|
{# === Header — #id + pill stare + badge mediu RAR + motiv === #}
|
||||||
|
|||||||
@@ -758,7 +758,7 @@
|
|||||||
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
|
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
|
||||||
.trimiteri-head, .trimitere-slim {
|
.trimiteri-head, .trimitere-slim {
|
||||||
display:grid; align-items:center; column-gap:10px; row-gap:2px;
|
display:grid; align-items:center; column-gap:10px; row-gap:2px;
|
||||||
grid-template-columns:22px minmax(90px,150px) minmax(64px,auto) minmax(0,1fr) auto 46px 44px 84px; }
|
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);
|
.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;
|
font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase;
|
||||||
color:var(--muted); }
|
color:var(--muted); }
|
||||||
@@ -769,6 +769,10 @@
|
|||||||
.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; }
|
||||||
.trimitere-slim .c-check { display:flex; align-items:center; }
|
.trimitere-slim .c-check { display:flex; align-items:center; }
|
||||||
|
/* Numarul trimiterii: id-ul din baza, corelabil cu submission_id din API. */
|
||||||
|
.trimitere-slim .c-id { font-family:var(--font-mono); font-size:var(--fs-xs);
|
||||||
|
color:var(--muted); white-space:nowrap;
|
||||||
|
font-variant-numeric:tabular-nums; }
|
||||||
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-sm); font-weight:600; color:var(--ink);
|
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-sm); font-weight:600; color:var(--ink);
|
||||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||||
/* Pe desktop copiii .c-meta devin celule de grid (cod / operatie / data). */
|
/* Pe desktop copiii .c-meta devin celule de grid (cod / operatie / data). */
|
||||||
@@ -800,7 +804,7 @@
|
|||||||
.env-l-test { color:var(--muted); border:1px solid var(--line); }
|
.env-l-test { color:var(--muted); border:1px solid var(--line); }
|
||||||
.trimitere-slim .c-act { justify-self:end; }
|
.trimitere-slim .c-act { justify-self:end; }
|
||||||
/* Sub-linia de eroare: text brut, mono, un singur rand cu ellipsis (title = complet). */
|
/* Sub-linia de eroare: text brut, mono, un singur rand cu ellipsis (title = complet). */
|
||||||
.trimitere-slim .c-err { grid-column:2 / -1; min-width:0; font-family:var(--font-mono);
|
.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;
|
font-size:var(--fs-xs); color:var(--err); white-space:nowrap; overflow:hidden;
|
||||||
text-overflow:ellipsis; }
|
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.
|
||||||
@@ -828,31 +832,35 @@
|
|||||||
.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); }
|
||||||
/* Chip abia adaugat via /form-chips — confirmare vizibila a actiunii "+" */
|
/* Chip abia adaugat via /form-chips — confirmare vizibila a actiunii "+" */
|
||||||
.chip-nou { outline:2px solid color-mix(in srgb, var(--ok) 45%, transparent); outline-offset:1px; }
|
.chip-nou { outline:2px solid color-mix(in srgb, var(--ok) 45%, transparent); outline-offset:1px; }
|
||||||
/* .add-code — buton dashed pentru adaugare cod in chipbox */
|
/* Eticheta mono "COD" pe chip-ul de cod */
|
||||||
.add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent;
|
.chip-cod-label { font-size:9px; font-weight:800; opacity:.65; letter-spacing:.03em; }
|
||||||
border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line));
|
/* Chip warning operatie nemapata: dashed implicit; tinta activa = solid + inel —
|
||||||
border-radius:5px; color:var(--accent); font:500 10px var(--font-ui); cursor:pointer; }
|
singurul tratament "tare" din rand. */
|
||||||
.add-code:hover, .add-code:focus-visible { border-style:solid; }
|
.chip-op-warn { background:color-mix(in srgb, var(--warn) 16%, transparent); color:var(--warn);
|
||||||
/* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */
|
border:1px dashed var(--warn); cursor:pointer; font-weight:600; }
|
||||||
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
|
.chip-op-warn.is-target { border-style:solid; outline:2px solid color-mix(in srgb, var(--warn) 40%, transparent);
|
||||||
padding:8px 10px; border:1px solid var(--line); border-radius:6px;
|
outline-offset:1px; }
|
||||||
background:var(--card2); margin-bottom:8px; }
|
.chip-op-warn:focus-visible { outline:2px solid var(--warn); outline-offset:1px; }
|
||||||
/* Nume operatie emfatic (T-9 5.16): proeminent (bold) ca in mockup — e ancora
|
/* Chip Nedeclarat: vizibil (line-through), NU mut/invizibil — cea mai riscanta
|
||||||
vizuala a randului de mapare op<->cod. */
|
actiune legala (exclude de la declararea RAR) nu poate fi cea mai discreta. */
|
||||||
.op-row-name { font-size:var(--fs-sm); font-weight:700; color:var(--ink); }
|
.chip-nedeclarat { background:var(--card2); color:var(--muted); border:1px dashed var(--line);
|
||||||
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, 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),
|
/* Mobil: fara header, randul revine la 2 linii (placuta / cod·operatie·data),
|
||||||
cu actiune + litera mediu + bulina status in dreapta. Grid-areas peste
|
cu actiune + litera mediu + bulina status in dreapta. Grid-areas peste
|
||||||
aceleasi celule; .c-meta redevine linie flex cu separatoare "·". */
|
aceleasi celule; .c-meta redevine linie flex cu separatoare "·". */
|
||||||
@media (max-width:767px) {
|
@media (max-width:767px) {
|
||||||
.trimiteri-head { display:none; }
|
.trimiteri-head { display:none; }
|
||||||
.trimitere-slim { padding:12px 14px; column-gap:8px;
|
.trimitere-slim { padding:12px 14px; column-gap:8px;
|
||||||
grid-template-columns:22px minmax(0,1fr) auto auto auto;
|
grid-template-columns:22px auto minmax(0,1fr) auto auto auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"check veh act env status"
|
"check id veh act env status"
|
||||||
"check meta act env status"
|
"check meta meta act env status"
|
||||||
"err err err err err"; }
|
"err err err err err err"; }
|
||||||
.trimitere-slim .c-check { grid-area:check; }
|
.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 .slim-vin { grid-area:veh; font-size:var(--fs-md); }
|
||||||
.trimitere-slim .c-meta { grid-area:meta; display:flex; align-items:baseline;
|
.trimitere-slim .c-meta { grid-area:meta; display:flex; align-items:baseline;
|
||||||
gap:6px; min-width:0; margin-top:3px; }
|
gap:6px; min-width:0; margin-top:3px; }
|
||||||
|
|||||||
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" %}
|
||||||
|
|
||||||
/* 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>
|
||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
@@ -258,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"))
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
|
|||||||
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
|
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
|
||||||
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
|
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
|
||||||
|
|
||||||
**Ultima actualizare**: 2026-07-07 — **5.22 UI COMPACT TRIMITERI + FIX BUTON "+" EDITOR — LIVRAT** (PRD: [prd-5.22](prd/prd-5.22-ui-compact-trimiteri-reguli-fix-chips.md); mockup aprobat de utilizator inainte de executie). Trimiteri pe desktop = tabel grid cu header (Vehicul/Cod RAR/Operatie/Data/Status/Mediu), un rand per trimitere: status = bulina colorata cu tooltip (`.dot-stare`), mediu = litera `P` (fill --err, risc L.142) / `T` (contur), multi-cod = primul cod + badge `+N` cu toate codurile in title (`payload_view.coduri_rar` nou), sub-linie de eroare = textul BRUT (mesaj RAR, ex. ORA-12899) mono trunchiat doar pe error/needs_data (`labels.eroare_bruta` nou; needs_mapping exclus — "nemapat" e in coloana Cod RAR). Mobil (<=767px): fara header, rand pe 2 linii prin grid-areas, T/P + bulina in dreapta. Actiunile bulk ("Aplica cod"/"Sterge") mutate intr-o **bara contextuala** vizibila doar la selectie, cu contor si texte explicite ("Aplica acest cod la N randuri") + hint; JS delegat + sync la `htmx:afterSwap`. Mapari: descrierea sectiunii "Reguli automate (text)" inlocuita cu un exemplu concret formatat. **Fix bug "+" editor prestatii** — cauza-radacina gasita LA VERIFY E2E (nu in analiza statica): `hx-disabled-elt="find button"` de pe form-urile parinte se mosteneste pe butoanele chips → `find button` relativ la "+" = null → TypeError intern htmx 1.9.12 INAINTE de request (nicio cerere, nicio eroare) → fix `hx-disabled-elt="this"` explicit pe toate butoanele hx-post din `_chips_prestatii.html`; separat, no-op-urile silentioase server-side din `post_form_chips` (cod invalid pe add/add_flat, duplicat pe toate ramurile) au acum mesaje explicite (`chips_error`) si confirmare la succes (chip `.chip-nou` evidentiat + "X adaugat."). Verify E2E in browser pe desktop 1280 + mobil 390 (screenshots), bulk-fix aplicat live. Suita completa: **1622 passed, 1 skipped** (+26 teste noi). Observatii deferate: `bulk_message` e suprascris imediat de reload-ul `trimiteriChanged` (pre-existent); operatia trunchiata agresiv pe mobil pe randurile cu buton "Trimite".
|
**Ultima actualizare**: 2026-07-08 — **5.23 EDITOR PRESTATII UNIFICAT IN MODAL — LIVRAT** (PRD: [prd-5.23](prd/prd-5.23-editor-prestatii-unificat.md); executie echipa agenti Sonnet TDD in valuri, lead orchestreaza). Cele 3 controale suprapuse pentru asocierea codurilor RAR (rand-per-operatie + container chips libere + select "+ Adauga") inlocuite cu **UN singur rand de chips + UN singur select** care adauga instant la `change` (fara butoane "Adauga"/"+"). Starea chips = UN hidden `chips_state` JSON versionat (`{"v":1,"items":[{cod,op,den,exclus}]}`, `_chips_state_from_form` unic + fallback tranzitoriu pe listele vechi), `post_form_chips` redus la 2 actiuni (`add`/`remove`, validare semantica tinta). Selectul primeste optgroup "Sugestii" (fuzzy/k-NN pentru operatia tinta, aceleasi surse ca preview-ul, degradare gratioasa non-blocanta pe motor rece) + optiune "Nu se declara la RAR" (`__NEDECLARAT__` → chip `Nedeclarat` line-through, × readuce warning). Tinta implicita (prima operatie nemapata) evidentiata + placeholder-ul o numeste ("Cod pentru OP-X — alege"), comutare tinta client-side fara round-trip; helper partajat `_chips_target_si_sugestii` pe TOATE caile de randare (initiala + /form-chips + erori). `exclus` persistat prin salvare (round-trip complet): treapta noua de precedenta in `resolve_prestatii` (item-exclus explicit domina, `it.pop` inlocuit, test de regresie blocant pe precedenta), itemii exclusi RAMAN in `payload_json` adnotati. **CLOSE `/code-review high` (8 unghiuri) — 1 BLOCKER de siguranta prins + reparat**: itemii exclusi (cod null) ajungeau la RAR via worker (`build_rar_payload` fara filtru) → risc ORA-12899/FINALIZATA partial ireversibil; fix la chokepoint unic (worker `split_prestatii_excluse` inainte de `build_rar_payload`, acopera toate canalele) + filtru defensiv + teste care dovedesc; plus consistenta round-trip la import (`web_confirma_import`/`commit_import` pastreaza full, cheie=declarabile) + bug latent idempotenta preview↔commit reparat + curatare comentarii (conventie CLAUDE.md). VERIFY context curat PASS (E2E browser Playwright ambele fluxuri: import preview + Trimiteri corectie; regresia de aur RAR live NEPROBAT — cont QA fara creds). Suita completa: **1680 passed, 1 skipped** (+45 teste noi fata de 1635). Deferate in TODOS: cale legacy `/trimitere/{id}/mapeaza` (formular per-operatie separat), autocomplete in select, bulk-apply mapare pe randuri identice. Backend worker/masina stari NEATINS (exceptie: split la trimitere); fara migrare schema (`exclus` in `payload_json`).
|
||||||
|
|
||||||
|
> 2026-07-07 — **5.22 UI COMPACT TRIMITERI + FIX BUTON "+" EDITOR — LIVRAT** (PRD: [prd-5.22](prd/prd-5.22-ui-compact-trimiteri-reguli-fix-chips.md); mockup aprobat de utilizator inainte de executie). Trimiteri pe desktop = tabel grid cu header (Vehicul/Cod RAR/Operatie/Data/Status/Mediu), un rand per trimitere: status = bulina colorata cu tooltip (`.dot-stare`), mediu = litera `P` (fill --err, risc L.142) / `T` (contur), multi-cod = primul cod + badge `+N` cu toate codurile in title (`payload_view.coduri_rar` nou), sub-linie de eroare = textul BRUT (mesaj RAR, ex. ORA-12899) mono trunchiat doar pe error/needs_data (`labels.eroare_bruta` nou; needs_mapping exclus — "nemapat" e in coloana Cod RAR). Mobil (<=767px): fara header, rand pe 2 linii prin grid-areas, T/P + bulina in dreapta. Actiunile bulk ("Aplica cod"/"Sterge") mutate intr-o **bara contextuala** vizibila doar la selectie, cu contor si texte explicite ("Aplica acest cod la N randuri") + hint; JS delegat + sync la `htmx:afterSwap`. Mapari: descrierea sectiunii "Reguli automate (text)" inlocuita cu un exemplu concret formatat. **Fix bug "+" editor prestatii** — cauza-radacina gasita LA VERIFY E2E (nu in analiza statica): `hx-disabled-elt="find button"` de pe form-urile parinte se mosteneste pe butoanele chips → `find button` relativ la "+" = null → TypeError intern htmx 1.9.12 INAINTE de request (nicio cerere, nicio eroare) → fix `hx-disabled-elt="this"` explicit pe toate butoanele hx-post din `_chips_prestatii.html`; separat, no-op-urile silentioase server-side din `post_form_chips` (cod invalid pe add/add_flat, duplicat pe toate ramurile) au acum mesaje explicite (`chips_error`) si confirmare la succes (chip `.chip-nou` evidentiat + "X adaugat."). Verify E2E in browser pe desktop 1280 + mobil 390 (screenshots), bulk-fix aplicat live. Suita completa: **1622 passed, 1 skipped** (+26 teste noi). Observatii deferate: `bulk_message` e suprascris imediat de reload-ul `trimiteriChanged` (pre-existent); operatia trunchiata agresiv pe mobil pe randurile cu buton "Trimite".
|
||||||
|
|
||||||
> 2026-07-06 — **5.21 CACHE PERSISTENT EMBEDDINGS IN SQLITE — LIVRAT** (PRD: [prd-5.21](prd/prd-5.21-embedding-cache-sqlite.md); executie echipa de agenti Sonnet: cache-core T1+T2, wiring T3+T5, tests T4+T6, reviewer adversarial). Vectorii corpusului k-NN (17.181 exemple SILVER) persista in tabela noua `embedding_cache` (PK `(model, text_hash)`, blob float32 LE 384, `array('f')` end-to-end); la warmup se vectorizeaza DOAR miss-urile (hash sha256 pe `denumire_normalizata` din lista FILTRATA, identica cu ce intra in `index_corpus`), restarturile cu corpus neschimbat = `embed=0`. Modul nou `app/embedding_cache.py` (fara dependinte noi): `sync_corpus_vectors(conn, model, texts, embed_fn)` hash->load->embed(miss)->save; scrieri/stergeri in chunk-uri de 500 cu `BEGIN`/`COMMIT` EXPLICIT (conexiunile sunt autocommit — `conn.commit()` e no-op); validare dimensiune la scriere SI citire (blob corupt = miss re-vectorizat); purjare orfane + modele vechi DOAR dupa indexare reusita; toate erorile SQLite -> `log.warning` + fallback embed complet (degradare gratioasa, nimic propagat). `EmbeddingEngine.index_corpus(vectors=)` valideaza alinierea `len(vectors)==len(items)` + absenta None (mismatch -> fallback embed complet). Secventa protejata de `_embeddings_lock` (mapping.py); pe calea de request (`block=False`) acquire NE-blocant — daca warmup-ul tine lock-ul, return imediat (fix MAJOR din review: altfel primul request in fereastra de cold start ingheta ~1-2 min). Log observabilitate: `embeddings: warmup ok cache=N embed=M in Xs` (eticheta `corpus reindexat` pe calea de request). Semnatura corpusului (`_corpus_signature_silver`) = mecanism de decizie NESCHIMBAT; worker-ul nu atinge cache-ul. Review adversarial: 1 MAJOR + 2 MINOR + 1 NIT, toate reparate. Suita completa: **1596 passed, 1 skipped** (+31 teste noi: 15/15 cai din diagrama de acoperire, toate cu `AUTOPASS_EMBEDDINGS_ENABLED=1` explicit, backend mock). Crestere DB ~26-30MB acceptata; deferate in TODOS.md: CLI embcache, precompute la build, graceful reload, numpy la >50k randuri. **De verificat pe instanta reala (US-006)**: doua restarturi `./start.sh`, al doilea cu `embed=0` si sugestia "similaritate" in <30s.
|
> 2026-07-06 — **5.21 CACHE PERSISTENT EMBEDDINGS IN SQLITE — LIVRAT** (PRD: [prd-5.21](prd/prd-5.21-embedding-cache-sqlite.md); executie echipa de agenti Sonnet: cache-core T1+T2, wiring T3+T5, tests T4+T6, reviewer adversarial). Vectorii corpusului k-NN (17.181 exemple SILVER) persista in tabela noua `embedding_cache` (PK `(model, text_hash)`, blob float32 LE 384, `array('f')` end-to-end); la warmup se vectorizeaza DOAR miss-urile (hash sha256 pe `denumire_normalizata` din lista FILTRATA, identica cu ce intra in `index_corpus`), restarturile cu corpus neschimbat = `embed=0`. Modul nou `app/embedding_cache.py` (fara dependinte noi): `sync_corpus_vectors(conn, model, texts, embed_fn)` hash->load->embed(miss)->save; scrieri/stergeri in chunk-uri de 500 cu `BEGIN`/`COMMIT` EXPLICIT (conexiunile sunt autocommit — `conn.commit()` e no-op); validare dimensiune la scriere SI citire (blob corupt = miss re-vectorizat); purjare orfane + modele vechi DOAR dupa indexare reusita; toate erorile SQLite -> `log.warning` + fallback embed complet (degradare gratioasa, nimic propagat). `EmbeddingEngine.index_corpus(vectors=)` valideaza alinierea `len(vectors)==len(items)` + absenta None (mismatch -> fallback embed complet). Secventa protejata de `_embeddings_lock` (mapping.py); pe calea de request (`block=False`) acquire NE-blocant — daca warmup-ul tine lock-ul, return imediat (fix MAJOR din review: altfel primul request in fereastra de cold start ingheta ~1-2 min). Log observabilitate: `embeddings: warmup ok cache=N embed=M in Xs` (eticheta `corpus reindexat` pe calea de request). Semnatura corpusului (`_corpus_signature_silver`) = mecanism de decizie NESCHIMBAT; worker-ul nu atinge cache-ul. Review adversarial: 1 MAJOR + 2 MINOR + 1 NIT, toate reparate. Suita completa: **1596 passed, 1 skipped** (+31 teste noi: 15/15 cai din diagrama de acoperire, toate cu `AUTOPASS_EMBEDDINGS_ENABLED=1` explicit, backend mock). Crestere DB ~26-30MB acceptata; deferate in TODOS.md: CLI embcache, precompute la build, graceful reload, numpy la >50k randuri. **De verificat pe instanta reala (US-006)**: doua restarturi `./start.sh`, al doilea cu `embed=0` si sugestia "similaritate" in <30s.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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.
|
||||||
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
|
||||||
|
|
||||||
@@ -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):
|
||||||
@@ -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"},
|
||||||
|
|||||||
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"] == ""
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -479,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"
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,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_api_dedup_dupa_t9(client):
|
def test_api_dedup_dupa_t9(client):
|
||||||
|
|||||||
78
tests/test_chips_target_robustete.py
Normal file
78
tests/test_chips_target_robustete.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Robustete selectie tinta chips: doar operatii nemapate IDENTIFICABILE (cu
|
||||||
|
cod_op_service) pot deveni tinta. Un item fara cod_op_service (chip liber, fara
|
||||||
|
nicio operatie asociata) nu are cum sa fie afisat ca tinta in template (chip-ul
|
||||||
|
warning se randeaza doar cand cod_op_service e truthy) — daca ar fi ales tinta,
|
||||||
|
ar deveni invizibil: userul nu vede niciun highlight, dar selectul tot arata
|
||||||
|
sugestii/NEDECLARAT pentru un "cui" pe care nu-l poate vedea.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def conn(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import get_connection, init_db
|
||||||
|
init_db()
|
||||||
|
c = get_connection()
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_cod(conn, cod: str, nume: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||||
|
(cod, nume),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_fara_cod_op_service_nu_devine_tinta(conn):
|
||||||
|
"""Chip liber (fara cod_op_service) nemapat nu poate fi tinta — e invizibil in template."""
|
||||||
|
from app.web.routes import _chips_target_si_sugestii
|
||||||
|
_seed_cod(conn, "OE-1", "Schimb ulei")
|
||||||
|
from app.mapping import load_nomenclator
|
||||||
|
nomenclator = load_nomenclator(conn)
|
||||||
|
|
||||||
|
chips = [{"cod_prestatie": "", "cod_op_service": "", "denumire": "", "exclus": 0}]
|
||||||
|
target_index, sugestii = _chips_target_si_sugestii(conn, chips, nomenclator)
|
||||||
|
|
||||||
|
assert target_index is None
|
||||||
|
assert sugestii == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_operatie_identificabila_ramane_tinta(conn):
|
||||||
|
"""Regresie: o operatie nemapata cu cod_op_service ramane tinta normal."""
|
||||||
|
from app.web.routes import _chips_target_si_sugestii
|
||||||
|
_seed_cod(conn, "OE-1", "Schimb ulei motor")
|
||||||
|
from app.mapping import load_nomenclator
|
||||||
|
nomenclator = load_nomenclator(conn)
|
||||||
|
|
||||||
|
chips = [{"cod_prestatie": "", "cod_op_service": "Op-A", "denumire": "Schimb ulei motor", "exclus": 0}]
|
||||||
|
target_index, sugestii = _chips_target_si_sugestii(conn, chips, nomenclator)
|
||||||
|
|
||||||
|
assert target_index == 0
|
||||||
|
assert any(s.get("cod_prestatie") == "OE-1" for s in sugestii)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sugestii_pentru_tinta_fara_denumire_intoarce_gol():
|
||||||
|
"""Denumire (si cod_op_service) goala pe tinta -> lista goala, fara sugestii irelevante."""
|
||||||
|
from app.web.routes import _sugestii_select_pentru_tinta
|
||||||
|
|
||||||
|
class _FakeConn:
|
||||||
|
pass
|
||||||
|
|
||||||
|
nomenclator = [{"cod_prestatie": "OE-1", "nume_prestatie": "Schimb ulei"}]
|
||||||
|
out = _sugestii_select_pentru_tinta(_FakeConn(), "", nomenclator)
|
||||||
|
assert out == []
|
||||||
|
out_none = _sugestii_select_pentru_tinta(_FakeConn(), None, nomenclator)
|
||||||
|
assert out_none == []
|
||||||
@@ -37,7 +37,7 @@ def _body(password="corecta", **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": password}, "prezentari": [prez]}
|
return {"rar_credentials": {"email": "x@y.ro", "password": password}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
def _force_status(sid, status):
|
def _force_status(sid, status):
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ Un cod ales explicit pe rand bate regula de excludere.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -191,8 +193,72 @@ def test_rand_mixt_exclude_doar_operatia_exclusa(client):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
payload = json.loads(sub["payload_json"])
|
payload = json.loads(sub["payload_json"])
|
||||||
coduri = [p.get("cod_prestatie") for p in payload["prestatii"]]
|
# Round-trip: itemul exclus RAMANE in payload_json (persistat), adnotat exclus,
|
||||||
assert coduri == ["OE-2"], f"Operatia exclusa nu trebuie sa plece la RAR: {coduri}"
|
# dar fara cod_prestatie — nu pleaca niciodata la RAR (vezi build_rar_payload).
|
||||||
|
by_op = {p.get("cod_op_service"): p for p in payload["prestatii"] if p.get("cod_op_service")}
|
||||||
|
assert by_op["OP-ITP"]["exclus"] is True
|
||||||
|
assert not by_op["OP-ITP"].get("cod_prestatie")
|
||||||
|
coduri_declarabile = [p.get("cod_prestatie") for p in payload["prestatii"] if not p.get("exclus")]
|
||||||
|
assert coduri_declarabile == ["OE-2"], f"Operatia exclusa nu trebuie sa plece la RAR: {coduri_declarabile}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reupload_rand_cu_exclus_deja_comis_e_already_sent(client):
|
||||||
|
"""Cheia de idempotenta la commit (doar declarabile) e IDENTICA cu preview.
|
||||||
|
|
||||||
|
Re-incarcarea aceluiasi rand (dupa ce a fost deja comis cu un item exclus
|
||||||
|
adnotat in payload) trebuie recunoscuta drept duplicat — nu un rand nou.
|
||||||
|
"""
|
||||||
|
_seed()
|
||||||
|
rows = [{
|
||||||
|
"VIN": "WVWZZZ1KZAW000789",
|
||||||
|
"Nr": "B003TST",
|
||||||
|
"Data": "2026-06-12",
|
||||||
|
"KM": "90000",
|
||||||
|
"Operatie": "OP-ITP",
|
||||||
|
}]
|
||||||
|
iid = _upload_and_preview(client, rows=rows)
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
client.post(f"/_import/{iid}/mapare-operatii", data={
|
||||||
|
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
|
||||||
|
})
|
||||||
|
client.post(f"/_import/{iid}/rand/0/editeaza", data={
|
||||||
|
"cod_prestatie": ["", "OE-2"],
|
||||||
|
"chip_op_service": ["OP-ITP", ""],
|
||||||
|
"chip_denumire": ["", ""],
|
||||||
|
})
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||||
|
assert rc.status_code == 200, rc.text
|
||||||
|
|
||||||
|
# Re-incarca acelasi rand + acelasi cod ales pe editorul de rand. Maparea de
|
||||||
|
# coloane e deja memorata din primul upload -> raspunsul sare direct la preview
|
||||||
|
# (fara link mapare-coloane); extragem import_id din hx-get pe #import-section.
|
||||||
|
csv_data = _csv_bytes(rows)
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
r2 = client.post(
|
||||||
|
"/_import/upload",
|
||||||
|
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
|
||||||
|
data={"csrf_token": csrf},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
m2 = re.search(r"/_import/(\d+)/preview", r2.text)
|
||||||
|
assert m2, f"import_id negasit in raspunsul de reupload: {r2.text[:300]}"
|
||||||
|
iid2 = int(m2.group(1))
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
client.post(f"/_import/{iid2}/mapare-operatii", data={
|
||||||
|
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
|
||||||
|
})
|
||||||
|
client.post(f"/_import/{iid2}/rand/0/editeaza", data={
|
||||||
|
"cod_prestatie": ["", "OE-2"],
|
||||||
|
"chip_op_service": ["OP-ITP", ""],
|
||||||
|
"chip_denumire": ["", ""],
|
||||||
|
})
|
||||||
|
# already_sent se calculeaza doar la refresh-ul complet de preview (lookup pe
|
||||||
|
# tot batch-ul), nu la editarea unui singur rand — reincarcam preview-ul.
|
||||||
|
rp = client.get(f"/_import/{iid2}/preview")
|
||||||
|
assert rp.status_code == 200
|
||||||
|
assert "Deja trimis" in rp.text, \
|
||||||
|
"Randul re-incarcat trebuie recunoscut deja trimis (cheia de idempotenta neschimbata)"
|
||||||
|
|
||||||
|
|
||||||
def test_cod_explicit_pe_operatie_bate_regula_de_excludere(client):
|
def test_cod_explicit_pe_operatie_bate_regula_de_excludere(client):
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def _body(**over) -> dict:
|
|||||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
}
|
}
|
||||||
prez.update(over)
|
prez.update(over)
|
||||||
return {"prezentari": [prez]}
|
return {"prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.idempotency import build_key, canonicalize_row
|
from app.idempotency import build_key, canonicalize_row, normalize_nr_inmatriculare
|
||||||
|
|
||||||
|
|
||||||
def _canon():
|
def _canon():
|
||||||
@@ -26,3 +26,35 @@ def test_key_stabil_pe_env():
|
|||||||
assert build_key(1, canon, "prod") == build_key(1, canon, "prod")
|
assert build_key(1, canon, "prod") == build_key(1, canon, "prod")
|
||||||
# None si 1 colapseaza la aceeasi cheie (account_or_default), pe acelasi env
|
# None si 1 colapseaza la aceeasi cheie (account_or_default), pe acelasi env
|
||||||
assert build_key(None, canon, "test") == build_key(1, canon, "test")
|
assert build_key(None, canon, "test") == build_key(1, canon, "test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_nr_inmatriculare_elimina_spatii_cratime_puncte():
|
||||||
|
assert normalize_nr_inmatriculare("B 99 XYZ") == "B99XYZ"
|
||||||
|
assert normalize_nr_inmatriculare("B-99-XYZ") == "B99XYZ"
|
||||||
|
assert normalize_nr_inmatriculare("B.99.XYZ") == "B99XYZ"
|
||||||
|
assert normalize_nr_inmatriculare(" b99xyz ") == "B99XYZ"
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonicalize_row_normalizeaza_nr_inmatriculare():
|
||||||
|
raw = {"nr_inmatriculare": "B 99 XYZ"}
|
||||||
|
assert canonicalize_row(raw)["nr_inmatriculare"] == "B99XYZ"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_key_acelasi_pentru_formate_echivalente_nrinm():
|
||||||
|
""""B 99 XYZ" si "B99XYZ" trebuie sa produca aceeasi cheie de dedup."""
|
||||||
|
raw_cu_spatii = {
|
||||||
|
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B 99 XYZ",
|
||||||
|
"data_prestatie": "2026-01-10", "odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
|
}
|
||||||
|
raw_fara_spatii = {
|
||||||
|
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B99XYZ",
|
||||||
|
"data_prestatie": "2026-01-10", "odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
|
}
|
||||||
|
canon1 = canonicalize_row(raw_cu_spatii)
|
||||||
|
canon1["prestatii"] = raw_cu_spatii["prestatii"]
|
||||||
|
canon2 = canonicalize_row(raw_fara_spatii)
|
||||||
|
canon2["prestatii"] = raw_fara_spatii["prestatii"]
|
||||||
|
assert build_key(1, canon1, "test") == build_key(1, canon2, "test")
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ def _body(prestatii, **over):
|
|||||||
"prestatii": prestatii,
|
"prestatii": prestatii,
|
||||||
}
|
}
|
||||||
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_nomenclator_seed_la_boot(client):
|
def test_nomenclator_seed_la_boot(client):
|
||||||
|
|||||||
@@ -100,3 +100,35 @@ def test_normalizare_vin_nrinm_in_payload():
|
|||||||
|
|
||||||
def test_sistem_reparat_default_null():
|
def test_sistem_reparat_default_null():
|
||||||
assert build_rar_payload(_internal())["sistemReparat"] == "null"
|
assert build_rar_payload(_internal())["sistemReparat"] == "null"
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_exclus_nu_apare_in_payload():
|
||||||
|
"""Un item adnotat exclus=True nu produce niciodata {codPrestatie: null} la RAR."""
|
||||||
|
internal = {
|
||||||
|
"vin": "WVWZZZ1KZAW000123",
|
||||||
|
"nr_inmatriculare": "B999TST",
|
||||||
|
"data_prestatie": "2026-06-15",
|
||||||
|
"odometru_final": "123456",
|
||||||
|
"prestatii": [
|
||||||
|
{"cod_prestatie": "OE-1"},
|
||||||
|
{"cod_prestatie": None, "cod_op_service": "200", "denumire": "Spalare", "exclus": True},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
p = build_rar_payload(internal)
|
||||||
|
assert p["prestatii"] == [{"codPrestatie": "OE-1", "idPrezentare": None}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_fara_cod_prestatie_nu_apare_in_payload():
|
||||||
|
"""Defense-in-depth: orice item fara cod (chiar neadnotat exclus) e sarit, niciodata null."""
|
||||||
|
internal = {
|
||||||
|
"vin": "WVWZZZ1KZAW000123",
|
||||||
|
"nr_inmatriculare": "B999TST",
|
||||||
|
"data_prestatie": "2026-06-15",
|
||||||
|
"odometru_final": "123456",
|
||||||
|
"prestatii": [
|
||||||
|
{"cod_prestatie": "OE-1"},
|
||||||
|
{"cod_prestatie": None, "cod_op_service": "200"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
p = build_rar_payload(internal)
|
||||||
|
assert p["prestatii"] == [{"codPrestatie": "OE-1", "idPrezentare": None}]
|
||||||
|
|||||||
@@ -123,6 +123,28 @@ def test_cod_rar_gol_cand_nemapat():
|
|||||||
assert d3["cod_rar"] == EMPTY
|
assert d3["cod_rar"] == EMPTY
|
||||||
|
|
||||||
|
|
||||||
|
def test_cod_rar_din_toate_prestatiile_cand_prima_e_exclusa():
|
||||||
|
"""Prima prestatie exclusa (fara cod) nu ascunde codurile de pe itemii urmatori.
|
||||||
|
|
||||||
|
Rand importat editat: operatia originala ramane item 0 cu exclus=True si
|
||||||
|
cod_prestatie null, codurile adaugate sunt itemii 1..N.
|
||||||
|
"""
|
||||||
|
d = prezentare_din_payload({
|
||||||
|
"prestatii": [
|
||||||
|
{"cod_prestatie": None, "cod_op_service": "OP-ITP",
|
||||||
|
"denumire": "Inspectie Tehnica Periodica", "exclus": True},
|
||||||
|
{"cod_prestatie": "OE-2"},
|
||||||
|
{"cod_prestatie": "OE-4"},
|
||||||
|
{"cod_prestatie": "OE-8"},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
assert d["cod_rar"] == "OE-2"
|
||||||
|
assert d["coduri_rar"] == ["OE-2", "OE-4", "OE-8"]
|
||||||
|
# operatia si op. de service raman derivate din primul item (descrierea originala)
|
||||||
|
assert d["operatie"] == "Inspectie Tehnica Periodica"
|
||||||
|
assert d["op_service_cod"] == "OP-ITP"
|
||||||
|
|
||||||
|
|
||||||
def test_operatie_ramane_denumire_sau_op():
|
def test_operatie_ramane_denumire_sau_op():
|
||||||
"""operatie si cod raman neschimbate (compatibilitate cu apelantii existenti)."""
|
"""operatie si cod raman neschimbate (compatibilitate cu apelantii existenti)."""
|
||||||
# cu ambele coduri -> operatie=denumire, cod=cod_prestatie (neschimbat)
|
# cu ambele coduri -> operatie=denumire, cod=cod_prestatie (neschimbat)
|
||||||
|
|||||||
@@ -99,7 +99,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": "HUNTER2SECRET"}, "prezentari": [prez]}
|
return {"rar_credentials": {"email": "x@y.ro", "password": "HUNTER2SECRET"}, "prezentari": [prez], "raspuns": "complet"}
|
||||||
|
|
||||||
|
|
||||||
def test_422_does_not_echo_password(env):
|
def test_422_does_not_echo_password(env):
|
||||||
|
|||||||
@@ -63,10 +63,28 @@ def test_nrinmatriculare_prea_lung():
|
|||||||
|
|
||||||
|
|
||||||
def test_nrinmatriculare_caracter_special():
|
def test_nrinmatriculare_caracter_special():
|
||||||
errors = validate_prezentare(_base(nr_inmatriculare="B-99"))
|
errors = validate_prezentare(_base(nr_inmatriculare="B_99"))
|
||||||
assert "nr_inmatriculare" in _fields(errors)
|
assert "nr_inmatriculare" in _fields(errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nrinmatriculare_normalizeaza_spatii():
|
||||||
|
c = _base(nr_inmatriculare="B 99 XYZ")
|
||||||
|
assert c["nr_inmatriculare"] == "B99XYZ"
|
||||||
|
assert validate_prezentare(c) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nrinmatriculare_normalizeaza_cratime():
|
||||||
|
c = _base(nr_inmatriculare="B-99-XYZ")
|
||||||
|
assert c["nr_inmatriculare"] == "B99XYZ"
|
||||||
|
assert validate_prezentare(c) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nrinmatriculare_normalizeaza_puncte():
|
||||||
|
c = _base(nr_inmatriculare="B.99.XYZ")
|
||||||
|
assert c["nr_inmatriculare"] == "B99XYZ"
|
||||||
|
assert validate_prezentare(c) == []
|
||||||
|
|
||||||
|
|
||||||
def test_data_prea_veche():
|
def test_data_prea_veche():
|
||||||
errors = validate_prezentare(_base(data_prestatie="2024-11-30"))
|
errors = validate_prezentare(_base(data_prestatie="2024-11-30"))
|
||||||
assert "data_prestatie" in _fields(errors)
|
assert "data_prestatie" in _fields(errors)
|
||||||
@@ -135,7 +153,7 @@ def test_b64image_valid_ok():
|
|||||||
|
|
||||||
|
|
||||||
def test_erori_multiple_cumulate():
|
def test_erori_multiple_cumulate():
|
||||||
errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X-Y", data_prestatie="2024-01-01"))
|
errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X_Y", data_prestatie="2024-01-01"))
|
||||||
assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors)
|
assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors)
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +233,7 @@ def test_back_compat_field_message():
|
|||||||
"""Fiecare eroare are inca field + message (forma veche)."""
|
"""Fiecare eroare are inca field + message (forma veche)."""
|
||||||
errs = validate_prezentare(_base(
|
errs = validate_prezentare(_base(
|
||||||
vin="BAD",
|
vin="BAD",
|
||||||
nr_inmatriculare="X-Y",
|
nr_inmatriculare="X_Y",
|
||||||
data_prestatie="2024-01-01",
|
data_prestatie="2024-01-01",
|
||||||
odometru_final="abc",
|
odometru_final="abc",
|
||||||
prestatii=[],
|
prestatii=[],
|
||||||
@@ -232,7 +250,7 @@ def test_toate_codurile_in_catalog():
|
|||||||
|
|
||||||
cazuri = [
|
cazuri = [
|
||||||
_base(vin="WVWZZZ1OZAW000123"), # VIN_FORMAT
|
_base(vin="WVWZZZ1OZAW000123"), # VIN_FORMAT
|
||||||
_base(nr_inmatriculare="X-Y"), # NR_INMATRICULARE_FORMAT
|
_base(nr_inmatriculare="X_Y"), # NR_INMATRICULARE_FORMAT
|
||||||
_base(data_prestatie="15-06-2026"), # DATA_FORMAT
|
_base(data_prestatie="15-06-2026"), # DATA_FORMAT
|
||||||
_base(data_prestatie="2024-11-30"), # DATA_PREA_VECHE
|
_base(data_prestatie="2024-11-30"), # DATA_PREA_VECHE
|
||||||
_base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR
|
_base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR
|
||||||
|
|||||||
745
tests/test_web_chips.py
Normal file
745
tests/test_web_chips.py
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
"""US-001: stare chips extinsa (JSON versionat, D4) + actiuni add/remove unificate.
|
||||||
|
|
||||||
|
Contract nou /form-chips:
|
||||||
|
- Stare curenta: UN SINGUR hidden `chips_state` = JSON {"v":1,"items":[{"cod","op","den","exclus"}]}.
|
||||||
|
- chips_action=add + chips_pick (codul ales) + chips_target_index optional:
|
||||||
|
cu tinta -> leaga codul de operatia de la acel index; fara tinta -> chip liber.
|
||||||
|
chips_pick=__NEDECLARAT__ + tinta valida -> item exclus=1, fara cod_prestatie.
|
||||||
|
- chips_action=remove + chips_remove_index: chip legat de operatie -> revine warning
|
||||||
|
(cod_prestatie="", exclus=0); chip liber -> iese din lista.
|
||||||
|
- Actiunile vechi (add_flat/add_extra/remove_flat/chips_add_cod_{i}/chips_add_cod_flat
|
||||||
|
ca actiuni) sunt ELIMINATE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fixtures #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "chips.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.web import ratelimit
|
||||||
|
ratelimit._hits.clear()
|
||||||
|
from app.main import app
|
||||||
|
with TestClient(app, follow_redirects=False) as c:
|
||||||
|
yield c
|
||||||
|
ratelimit._hits.clear()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_account_user(email: str, password: str = "parolasecreta10"):
|
||||||
|
from app.accounts import create_account
|
||||||
|
from app.users import create_user
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
acct_id = create_account(conn, "Service", active=True)
|
||||||
|
create_user(conn, acct_id, email, password)
|
||||||
|
return acct_id
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, email: str, password: str = "parolasecreta10") -> None:
|
||||||
|
resp = client.get("/login")
|
||||||
|
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
|
||||||
|
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||||
|
assert m, "csrf_token nu gasit in login"
|
||||||
|
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||||
|
assert resp.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def _csrf(client) -> str:
|
||||||
|
resp = client.get("/?tab=coada")
|
||||||
|
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||||
|
assert m, "csrf_token nu gasit in dashboard"
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||||
|
(cod, denumire),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _state(items: list[dict]) -> str:
|
||||||
|
"""Construieste JSON-ul hidden chips_state din itemi scurti {cod,op,den,exclus}."""
|
||||||
|
return json.dumps({"v": 1, "items": items}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _login_seed(client, email: str) -> None:
|
||||||
|
_create_account_user(email)
|
||||||
|
_login(client, email)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# add cu tinta -> leaga codul de operatia respectiva #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_add_cu_tinta_leaga_de_operatie(client):
|
||||||
|
_login_seed(client, "add.tinta@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
state = _state([
|
||||||
|
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
|
||||||
|
])
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": state,
|
||||||
|
"chips_action": "add",
|
||||||
|
"chips_target_index": "0",
|
||||||
|
"chips_pick": "OE-1",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
html = resp.text
|
||||||
|
assert "OE-1" in html
|
||||||
|
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
|
||||||
|
assert m, f"hidden chips_state lipsa din raspuns: {html[:400]}"
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
assert payload["v"] == 1
|
||||||
|
items = payload["items"]
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["cod"] == "OE-1"
|
||||||
|
assert items[0]["op"] == "Op-A"
|
||||||
|
assert items[0]["exclus"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# add fara tinta -> chip liber #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_add_fara_tinta_adauga_cod_liber(client):
|
||||||
|
_login_seed(client, "add.liber@test.com")
|
||||||
|
_seed_cod("FRN1", "Sistem de franare")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": _state([]),
|
||||||
|
"chips_action": "add",
|
||||||
|
"chips_pick": "FRN1",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
assert m
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
items = payload["items"]
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["cod"] == "FRN1"
|
||||||
|
assert items[0]["op"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# add __NEDECLARAT__ cu tinta -> marcheaza exclus #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_add_nedeclarat_marcheaza_exclus(client):
|
||||||
|
_login_seed(client, "add.ned@test.com")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
state = _state([
|
||||||
|
{"cod": "", "op": "OP-ITP", "den": "Inspectie tehnica", "exclus": 0},
|
||||||
|
])
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": state,
|
||||||
|
"chips_action": "add",
|
||||||
|
"chips_target_index": "0",
|
||||||
|
"chips_pick": "__NEDECLARAT__",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
assert m
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
items = payload["items"]
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["exclus"] == 1
|
||||||
|
assert items[0]["cod"] == "", f"item exclus nu trebuie sa aiba cod_prestatie: {items[0]}"
|
||||||
|
assert items[0]["op"] == "OP-ITP"
|
||||||
|
assert "nu se va declara" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_nedeclarat_fara_tinta_da_eroare(client):
|
||||||
|
"""Sentinelul __NEDECLARAT__ fara tinta valida -> chips_error explicit, fara modificare."""
|
||||||
|
_login_seed(client, "add.ned.notinta@test.com")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": _state([]),
|
||||||
|
"chips_action": "add",
|
||||||
|
"chips_pick": "__NEDECLARAT__",
|
||||||
|
# fara chips_target_index
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
assert "chips-extra-error" in resp.text, (
|
||||||
|
f"Eroare vizibila lipsa pentru NEDECLARAT fara tinta: {resp.text[:400]}"
|
||||||
|
)
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
assert m
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
assert payload["items"] == [], "NEDECLARAT fara tinta nu trebuie sa modifice starea"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_nedeclarat_tinta_deja_mapata_da_eroare(client):
|
||||||
|
"""Tinta trebuie sa fie o operatie NEMAPATA — index spre operatie deja rezolvata -> eroare."""
|
||||||
|
_login_seed(client, "add.ned.mapata@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
state = _state([
|
||||||
|
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
|
||||||
|
])
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": state,
|
||||||
|
"chips_action": "add",
|
||||||
|
"chips_target_index": "0",
|
||||||
|
"chips_pick": "__NEDECLARAT__",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "chips-extra-error" in resp.text
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
assert payload["items"][0]["cod"] == "OE-1", "Tinta invalida nu trebuie sa strice starea existenta"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# remove -> readuce operatia nemapata (warning) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_remove_cod_readuce_operatia_nemapata(client):
|
||||||
|
_login_seed(client, "remove.readuce@test.com")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
state = _state([
|
||||||
|
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
|
||||||
|
])
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": state,
|
||||||
|
"chips_action": "remove",
|
||||||
|
"chips_remove_index": "0",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
assert m
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
items = payload["items"]
|
||||||
|
assert len(items) == 1, "Operatia trebuie sa RAMANA (doar codul dispare)"
|
||||||
|
assert items[0]["cod"] == ""
|
||||||
|
assert items[0]["op"] == "Op-A"
|
||||||
|
assert items[0]["exclus"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_pe_nedeclarat_readuce_warning(client):
|
||||||
|
"""x pe un chip Nedeclarat readuce operatia la warning (nu se sterge operatia)."""
|
||||||
|
_login_seed(client, "remove.nedeclarat@test.com")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
state = _state([
|
||||||
|
{"cod": "", "op": "OP-ITP", "den": "Inspectie", "exclus": 1},
|
||||||
|
])
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": state,
|
||||||
|
"chips_action": "remove",
|
||||||
|
"chips_remove_index": "0",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
items = payload["items"]
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["exclus"] == 0
|
||||||
|
assert items[0]["cod"] == ""
|
||||||
|
assert items[0]["op"] == "OP-ITP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_cod_liber_il_sterge_complet(client):
|
||||||
|
"""Chip liber (fara operatie sursa) e eliminat integral din lista la remove."""
|
||||||
|
_login_seed(client, "remove.liber@test.com")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
state = _state([
|
||||||
|
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
|
||||||
|
])
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": state,
|
||||||
|
"chips_action": "remove",
|
||||||
|
"chips_remove_index": "0",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
assert payload["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# dedup (operatie, cod) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_dedup_operatie_cod(client):
|
||||||
|
"""Acelasi cod liber nu se adauga de doua ori (pereche op='',cod deja existenta)."""
|
||||||
|
_login_seed(client, "dedup.op.cod@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
state = _state([
|
||||||
|
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
|
||||||
|
])
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": state,
|
||||||
|
"chips_action": "add",
|
||||||
|
"chips_pick": "OE-1",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "chips-extra-error" in resp.text or "deja in lista" in resp.text
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
assert len(payload["items"]) == 1, f"Dedup a esuat, itemi: {payload['items']}"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# actiuni vechi eliminate #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("actiune", ["add_flat", "add_extra", "remove_flat"])
|
||||||
|
def test_actiuni_vechi_eliminate_sunt_no_op(client, actiune):
|
||||||
|
"""Actiunile eliminate nu mai au ramura dedicata — cad prin fara sa schimbe starea."""
|
||||||
|
_login_seed(client, f"vechi.{actiune}@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
state = _state([{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}])
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": state,
|
||||||
|
"chips_action": actiune,
|
||||||
|
"chips_add_cod_flat": "FRN1",
|
||||||
|
"chips_remove_cod": "OE-1",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
assert payload["items"] == [{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# chips_state prezent mereu, inclusiv gol #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_chips_state_gol_ramane_prezent_in_raspuns(client):
|
||||||
|
_login_seed(client, "state.gol@test.com")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": _state([]),
|
||||||
|
"chips_action": "",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
assert m, "hidden chips_state trebuie sa fie mereu prezent, chiar cu lista goala"
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
assert payload == {"v": 1, "items": []}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# JSON invalid -> eroare vizibila, nu stare goala silentioasa #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_chips_state_json_invalid_da_eroare_vizibila(client):
|
||||||
|
_login_seed(client, "state.corupt@test.com")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": "{not valid json",
|
||||||
|
"chips_action": "",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "chips-extra-error" in resp.text or "invalid" in resp.text.lower(), (
|
||||||
|
f"JSON corupt trebuie sa dea eroare vizibila: {resp.text[:400]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# compat tranzitorie: chips_state lipsa -> fallback pe listele vechi #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_compat_fallback_pe_liste_vechi_cand_chips_state_lipseste(client):
|
||||||
|
_login_seed(client, "compat.fallback@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
resp = client.post("/form-chips", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
# FARA chips_state -> fallback pe listele vechi
|
||||||
|
"cod_prestatie": [""],
|
||||||
|
"chip_op_service": ["Op-A"],
|
||||||
|
"chip_denumire": ["Schimb ulei motor"],
|
||||||
|
"chips_action": "add",
|
||||||
|
"chips_target_index": "0",
|
||||||
|
"chips_pick": "OE-1",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
|
assert m
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
assert payload["items"][0]["cod"] == "OE-1"
|
||||||
|
assert payload["items"][0]["exclus"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== #
|
||||||
|
# US-002: template unificat -- un container .chips, un select, fara op-row #
|
||||||
|
# =========================================================================== #
|
||||||
|
|
||||||
|
|
||||||
|
def _post_chips(client, csrf, items, **extra):
|
||||||
|
data = {
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"chips_state": _state(items),
|
||||||
|
"chips_action": "",
|
||||||
|
}
|
||||||
|
data.update(extra)
|
||||||
|
return client.post("/form-chips", data=data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_un_singur_container_chips(client):
|
||||||
|
"""UN singur container `.chips` -- indiferent de mixul de tipuri de chip."""
|
||||||
|
_login_seed(client, "un.container@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
|
||||||
|
{"cod": "", "op": "Op-B", "den": "Vulcanizare", "exclus": 0},
|
||||||
|
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
|
||||||
|
{"cod": "", "op": "Op-C", "den": "ITP anual", "exclus": 1},
|
||||||
|
]
|
||||||
|
_seed_cod("FRN1", "Sistem de franare")
|
||||||
|
resp = _post_chips(client, csrf, items)
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
assert html.count('class="chips"') == 1, (
|
||||||
|
f"Trebuie sa existe UN SINGUR container .chips, gasit {html.count('class=\"chips\"')}: {html[:800]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_fara_op_row_si_fara_butoane_adauga(client):
|
||||||
|
"""Fara .op-row, fara butoane Adauga/+, fara actiunile vechi in markup."""
|
||||||
|
_login_seed(client, "fara.oprow@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
|
||||||
|
{"cod": "", "op": "Op-B", "den": "Vulcanizare", "exclus": 0},
|
||||||
|
]
|
||||||
|
resp = _post_chips(client, csrf, items)
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
assert "op-row" not in html
|
||||||
|
assert "Adauga" not in html
|
||||||
|
assert "add_flat" not in html
|
||||||
|
assert "chips_add_cod_" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_chip_warning_pentru_operatie_nemapata(client):
|
||||||
|
"""Operatie nemapata -> chip warning `warn OP-X . fara cod` cu title complet."""
|
||||||
|
_login_seed(client, "chip.warning@test.com")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"cod": "", "op": "OP-ITP", "den": "Inspectie tehnica periodica", "exclus": 0},
|
||||||
|
]
|
||||||
|
resp = _post_chips(client, csrf, items)
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
assert "OP-ITP" in html
|
||||||
|
assert "fara cod" in html
|
||||||
|
assert 'title="OP-ITP' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_are_hx_disabled_elt_this(client):
|
||||||
|
"""Selectul unic are hx-disabled-elt='this' EXPLICIT (capcana 5.22 -- nu mostenit de la form)."""
|
||||||
|
_login_seed(client, "select.disabled@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
resp = _post_chips(client, csrf, [])
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
m = re.search(r'<select[^>]*name="chips_pick"[^>]*>', html)
|
||||||
|
assert m, f"selectul chips_pick lipseste: {html[:600]}"
|
||||||
|
select_tag = m.group(0)
|
||||||
|
assert 'id="chips-picker"' in select_tag
|
||||||
|
assert 'hx-trigger="change"' in select_tag
|
||||||
|
assert 'hx-post="/form-chips"' in select_tag
|
||||||
|
assert 'hx-target="#chips-section"' in select_tag
|
||||||
|
assert 'hx-swap="outerHTML"' in select_tag
|
||||||
|
assert 'hx-include="closest form"' in select_tag
|
||||||
|
assert 'hx-disabled-elt="this"' in select_tag, (
|
||||||
|
f"hx-disabled-elt='this' lipseste de pe select (capcana 5.22): {select_tag}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mod_plat_fara_operatii_select_doar_coduri_libere(client):
|
||||||
|
"""Mod plat (zero operatii din import): doar chips libere + select placeholder generic."""
|
||||||
|
_login_seed(client, "mod.plat@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
|
||||||
|
]
|
||||||
|
resp = _post_chips(client, csrf, items)
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
assert "⚠" not in html, "Mod plat nu trebuie sa aiba niciun chip warning"
|
||||||
|
m = re.search(r'<select[^>]*name="chips_pick"[^>]*>\s*<option value="">([^<]*)</option>', html)
|
||||||
|
assert m, f"select cu optiune placeholder lipseste: {html[:600]}"
|
||||||
|
assert "adauga cod RAR" in m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_r_odo_pastrat_dupa_actiuni(client):
|
||||||
|
"""has_r_odo / data-has-r-odo + chip-warn pe R-ODO/I-ODO raman neschimbate."""
|
||||||
|
_login_seed(client, "r.odo@test.com")
|
||||||
|
_seed_cod("R-ODO", "Odometru initial")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"cod": "R-ODO", "op": "", "den": "", "exclus": 0},
|
||||||
|
]
|
||||||
|
resp = _post_chips(client, csrf, items)
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
assert 'data-has-r-odo="true"' in html
|
||||||
|
assert "chip-warn" in html
|
||||||
|
assert "R-ODO" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_chip_nedeclarat_are_x_functional(client):
|
||||||
|
"""Item exclus=1 -> chip Nedeclarat (line-through) cu buton de sters functional (remove -> warning)."""
|
||||||
|
_login_seed(client, "nedeclarat.x@test.com")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"cod": "", "op": "OP-ITP", "den": "Inspectie", "exclus": 1},
|
||||||
|
]
|
||||||
|
resp = _post_chips(client, csrf, items)
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
assert "Nedeclarat" in html
|
||||||
|
assert 'hx-vals=\'{"chips_action":"remove","chips_remove_index":0}\'' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_chips_submission_id_ecouat(client):
|
||||||
|
"""chips_submission_id trimis in form e ecouat ca hidden in raspunsul /form-chips."""
|
||||||
|
_login_seed(client, "sub.id.ecou@test.com")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
resp = _post_chips(client, csrf, [], chips_submission_id="4242")
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
assert re.search(r'name="chips_submission_id"\s+value="4242"', resp.text), (
|
||||||
|
f"chips_submission_id nu a fost ecouat: {resp.text[:600]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== #
|
||||||
|
# US-003: sugestii fuzzy/k-NN + optiunea "Nu se declara la RAR" in select #
|
||||||
|
# =========================================================================== #
|
||||||
|
|
||||||
|
|
||||||
|
def _extrage_select(html: str) -> str:
|
||||||
|
"""Extrage markup-ul complet al <select name="chips_pick">...</select>."""
|
||||||
|
m = re.search(r'<select[^>]*name="chips_pick".*?</select>', html, re.DOTALL)
|
||||||
|
assert m, f"select chips_pick lipseste din raspuns: {html[:600]}"
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_are_optgroup_sugestii_pt_tinta(client):
|
||||||
|
"""Cu tinta activa (operatie nemapata): optgroup 'Sugestii' + __NEDECLARAT__ + nomenclator."""
|
||||||
|
_login_seed(client, "sugestii.tinta@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei motor")
|
||||||
|
_seed_cod("FRN1", "Sistem de franare")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
|
||||||
|
]
|
||||||
|
resp = _post_chips(client, csrf, items)
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
select_html = _extrage_select(resp.text)
|
||||||
|
|
||||||
|
assert '<optgroup label="Sugestii">' in select_html, (
|
||||||
|
f"optgroup Sugestii lipseste cu tinta activa: {select_html[:600]}"
|
||||||
|
)
|
||||||
|
assert "OE-1" in select_html.split('<optgroup label="Sugestii">')[1].split("</optgroup>")[0], (
|
||||||
|
"sugestia fuzzy pentru 'Schimb ulei motor' trebuie sa contina OE-1"
|
||||||
|
)
|
||||||
|
assert "__NEDECLARAT__" in select_html
|
||||||
|
assert "Nu se declara la RAR" in select_html
|
||||||
|
# Nomenclatorul complet ramane dupa Sugestii/NEDECLARAT.
|
||||||
|
assert "FRN1" in select_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_are_nedeclarat_doar_cu_tinta(client):
|
||||||
|
"""Optiunea __NEDECLARAT__ apare DOAR cand exista o tinta (operatie nemapata) activa."""
|
||||||
|
_login_seed(client, "nedeclarat.doar.tinta@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
# Cu tinta (operatie nemapata) -> NEDECLARAT prezent.
|
||||||
|
resp_cu_tinta = _post_chips(client, csrf, [
|
||||||
|
{"cod": "", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
|
||||||
|
])
|
||||||
|
assert "__NEDECLARAT__" in _extrage_select(resp_cu_tinta.text)
|
||||||
|
|
||||||
|
# Fara tinta (doar cod liber, nicio operatie nemapata) -> NEDECLARAT absent.
|
||||||
|
resp_fara_tinta = _post_chips(client, csrf, [
|
||||||
|
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
|
||||||
|
])
|
||||||
|
assert "__NEDECLARAT__" not in _extrage_select(resp_fara_tinta.text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_fara_tinta_fara_sugestii(client):
|
||||||
|
"""Fara tinta: selectul listeaza DOAR nomenclatorul (fara optgroup Sugestii, fara NEDECLARAT)."""
|
||||||
|
_login_seed(client, "fara.tinta.fara.sugestii@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei motor")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
|
||||||
|
]
|
||||||
|
resp = _post_chips(client, csrf, items)
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
select_html = _extrage_select(resp.text)
|
||||||
|
|
||||||
|
assert "<optgroup" not in select_html
|
||||||
|
assert "__NEDECLARAT__" not in select_html
|
||||||
|
assert "OE-1" in select_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_sugestii_indisponibile_degradare_fara_optgroup(client, monkeypatch):
|
||||||
|
"""Motor de sugestii care arunca exceptie -> select DOAR cu nomenclatorul (fara optgroup),
|
||||||
|
fara eroare 500 si fara blocare (degradare gratioasa, GAP inchis din PRD 5.23)."""
|
||||||
|
_login_seed(client, "sugestii.indisponibile@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei motor")
|
||||||
|
csrf = _csrf(client)
|
||||||
|
|
||||||
|
from app.web import routes as _routes
|
||||||
|
|
||||||
|
def _boom(*args, **kwargs):
|
||||||
|
raise RuntimeError("motor de sugestii indisponibil (simulat)")
|
||||||
|
|
||||||
|
monkeypatch.setattr(_routes, "suggest_codes", _boom)
|
||||||
|
monkeypatch.setattr(_routes, "enrich_suggestions", _boom)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
|
||||||
|
]
|
||||||
|
resp = _post_chips(client, csrf, items)
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
select_html = _extrage_select(resp.text)
|
||||||
|
|
||||||
|
assert "<optgroup" not in select_html, (
|
||||||
|
f"optgroup nu trebuie sa apara cand motorul de sugestii arunca exceptie: {select_html[:600]}"
|
||||||
|
)
|
||||||
|
assert "OE-1" in select_html, "nomenclatorul complet trebuie sa ramana disponibil"
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== #
|
||||||
|
# FIX: tinta implicita + sugestii la RANDAREA INITIALA a modalului (fara #
|
||||||
|
# niciun round-trip /form-chips) — invariant PRD 5.23 §3.1. #
|
||||||
|
# =========================================================================== #
|
||||||
|
|
||||||
|
def _insert_submission(acct: int, *, status: str, payload: dict) -> int:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(f"k-{os.urandom(6).hex()}", acct, status, json.dumps(payload)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_randare_initiala_are_tinta_si_optgroup(client):
|
||||||
|
"""Deschiderea INITIALA a modalului de detaliu (needs_mapping, operatie nemapata),
|
||||||
|
FARA niciun /form-chips, trebuie sa aiba deja is-target pe chip-ul warning,
|
||||||
|
placeholder-ul D2 "Cod pentru OP-X — alege" si optgroup-ul Sugestii + NEDECLARAT."""
|
||||||
|
acct = _login_seed_ret(client, "randare.initiala@test.com")
|
||||||
|
_seed_cod("OE-1", "Schimb ulei motor")
|
||||||
|
_seed_cod("FRN1", "Sistem de franare")
|
||||||
|
|
||||||
|
op = "Schimb ulei motor"
|
||||||
|
sid = _insert_submission(acct, status="needs_mapping", payload={
|
||||||
|
"vin": "WVWZZZ1JZXW0SC001",
|
||||||
|
"nr_inmatriculare": "B100AAA",
|
||||||
|
"data_prestatie": "2026-06-10",
|
||||||
|
"odometru_final": "50000",
|
||||||
|
"prestatii": [{"cod_op_service": op, "denumire": op}],
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||||
|
assert resp.status_code == 200, resp.text[:400]
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
assert "is-target" in html, (
|
||||||
|
f"chip-ul warning al primei operatii nemapate trebuie sa fie tinta la deschiderea initiala: {html[:2000]}"
|
||||||
|
)
|
||||||
|
assert f"Cod pentru {op} — alege" in html, (
|
||||||
|
f"placeholder-ul D2 trebuie sa numeasca operatia tinta la deschiderea initiala: {html[:2000]}"
|
||||||
|
)
|
||||||
|
select_html = _extrage_select(html)
|
||||||
|
assert '<optgroup label="Sugestii">' in select_html, (
|
||||||
|
f"optgroup Sugestii trebuie sa apara la deschiderea initiala (fara /form-chips): {select_html[:600]}"
|
||||||
|
)
|
||||||
|
assert "OE-1" in select_html.split('<optgroup label="Sugestii">')[1].split("</optgroup>")[0], (
|
||||||
|
"sugestia fuzzy pentru 'Schimb ulei motor' trebuie sa contina OE-1 chiar la randarea initiala"
|
||||||
|
)
|
||||||
|
assert "__NEDECLARAT__" in select_html
|
||||||
|
|
||||||
|
|
||||||
|
def _login_seed_ret(client, email: str) -> int:
|
||||||
|
"""Ca `_login_seed`, dar intoarce account_id (necesar pentru inserarea directa a submission-ului)."""
|
||||||
|
acct = _create_account_user(email)
|
||||||
|
_login(client, email)
|
||||||
|
return acct
|
||||||
@@ -535,8 +535,8 @@ def test_picker_flat_arata_cod_si_denumire(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_adauga_cod_extra_in_mod_operatii(client):
|
def test_adauga_cod_extra_in_mod_operatii(client):
|
||||||
"""In mod operatii, actiunea add_extra adauga un cod RAR liber."""
|
"""In mod operatii, actiunea unificata 'add' fara tinta adauga un cod RAR liber."""
|
||||||
acct = _create_account_user("add.extra.ops@test.com")
|
_create_account_user("add.extra.ops@test.com")
|
||||||
_login(client, "add.extra.ops@test.com")
|
_login(client, "add.extra.ops@test.com")
|
||||||
_seed_cod("OE-1", "Schimb ulei motor")
|
_seed_cod("OE-1", "Schimb ulei motor")
|
||||||
_seed_cod("FRN1", "Sistem de franare")
|
_seed_cod("FRN1", "Sistem de franare")
|
||||||
@@ -547,11 +547,11 @@ def test_adauga_cod_extra_in_mod_operatii(client):
|
|||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
"cod_prestatie": ["OE-1"], # chip existent (op mapata)
|
"chips_state": json.dumps({"v": 1, "items": [
|
||||||
"chip_op_service": ["SchimbUlei"],
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0},
|
||||||
"chip_denumire": ["Schimb ulei motor"],
|
]}),
|
||||||
"chips_action": "add_extra",
|
"chips_action": "add",
|
||||||
"chips_add_cod_flat": "FRN1", # codul extra de adaugat
|
"chips_pick": "FRN1", # codul extra de adaugat, fara tinta -> chip liber
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200, resp.text[:300]
|
assert resp.status_code == 200, resp.text[:300]
|
||||||
@@ -601,32 +601,36 @@ def test_extra_cod_persistat_la_salvare(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_extra_cod_validat_nomenclator(client):
|
def test_extra_cod_validat_nomenclator(client):
|
||||||
"""add_extra respinge cod necunoscut in nomenclator (invariant ORA-12899)."""
|
"""'add' fara tinta respinge cod necunoscut in nomenclator (invariant ORA-12899)."""
|
||||||
acct = _create_account_user("extra.valid@test.com")
|
_create_account_user("extra.valid@test.com")
|
||||||
_login(client, "extra.valid@test.com")
|
_login(client, "extra.valid@test.com")
|
||||||
_seed_cod("OE-1", "Schimb ulei")
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
csrf = _csrf(client)
|
csrf = _csrf(client)
|
||||||
|
|
||||||
# add_extra cu cod INVALID (XX-99 nu e in nomenclator)
|
# add fara tinta cu cod INVALID (XX-99 nu e in nomenclator)
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
"cod_prestatie": ["OE-1"],
|
"chips_state": json.dumps({"v": 1, "items": [
|
||||||
"chip_op_service": ["SchimbUlei"],
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
|
||||||
"chip_denumire": ["Schimb ulei"],
|
]}),
|
||||||
"chips_action": "add_extra",
|
"chips_action": "add",
|
||||||
"chips_add_cod_flat": "XX-99", # cod necunoscut
|
"chips_pick": "XX-99", # cod necunoscut
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
# XX-99 NU trebuie sa apara ca chip valid (hidden input cu valoarea XX-99)
|
# XX-99 NU trebuie sa apara in starea persistata (chips_state)
|
||||||
import re as _re
|
import re as _re
|
||||||
hidden_xx99 = _re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="XX-99"', html)
|
m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
|
||||||
assert hidden_xx99 is None, (
|
assert m
|
||||||
f"Codul invalid XX-99 a fost adaugat ca chip! HTML: {html[:500]}"
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
coduri = [it["cod"] for it in payload["items"]]
|
||||||
|
assert "XX-99" not in coduri, (
|
||||||
|
f"Codul invalid XX-99 a fost adaugat ca chip! items={payload['items']}"
|
||||||
)
|
)
|
||||||
|
assert "chips-extra-error" in html
|
||||||
|
|
||||||
|
|
||||||
def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client):
|
def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client):
|
||||||
@@ -703,12 +707,12 @@ def test_salvare_fara_chip_explicit_nu_e_no_op(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_picker_by_index_op2_nu_op1(client):
|
def test_picker_by_index_op2_nu_op1(client):
|
||||||
"""Codul ales pe picker-ul op#2 aterizeaza pe op#2, NU pe op#1.
|
"""Codul ales pentru op#2 aterizeaza pe op#2, NU pe op#1.
|
||||||
|
|
||||||
Verifica alinierea by-index in modul operatii: chips_add_op_index=1 + chips_add_cod_1
|
Verifica alinierea by-index in modul operatii: chips_target_index=1 +
|
||||||
actualizeaza chips[1] (op#2), nu chips[0] (op#1).
|
chips_pick actualizeaza chips[1] (op#2), nu chips[0] (op#1).
|
||||||
"""
|
"""
|
||||||
acct = _create_account_user("byindex.op2@test.com")
|
_create_account_user("byindex.op2@test.com")
|
||||||
_login(client, "byindex.op2@test.com")
|
_login(client, "byindex.op2@test.com")
|
||||||
_seed_cod("OE-1", "Schimb ulei")
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
_seed_cod("FRN1", "Sistem de franare")
|
_seed_cod("FRN1", "Sistem de franare")
|
||||||
@@ -719,27 +723,25 @@ def test_picker_by_index_op2_nu_op1(client):
|
|||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
"cod_prestatie": ["OE-1", ""], # idx 0=OE-1 (mapata), idx 1="" (nemapata)
|
"chips_state": json.dumps({"v": 1, "items": [
|
||||||
"chip_op_service": ["Op-A", "Op-B"],
|
{"cod": "OE-1", "op": "Op-A", "den": "Prima", "exclus": 0},
|
||||||
"chip_denumire": ["Prima", "A doua"],
|
{"cod": "", "op": "Op-B", "den": "A doua", "exclus": 0},
|
||||||
|
]}),
|
||||||
"chips_action": "add",
|
"chips_action": "add",
|
||||||
"chips_add_op_index": "1", # adauga pe op#2 (idx=1)
|
"chips_target_index": "1", # adauga pe op#2 (idx=1)
|
||||||
"chips_add_cod_1": "FRN1", # picker-ul op#2 contine FRN1
|
"chips_pick": "FRN1",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200, resp.text[:300]
|
assert resp.status_code == 200, resp.text[:300]
|
||||||
html = resp.text
|
html = resp.text
|
||||||
|
|
||||||
import re as _re
|
import re as _re
|
||||||
hidden_vals = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="([^"]*)"', html)
|
m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
|
||||||
assert "OE-1" in hidden_vals, f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {hidden_vals}"
|
assert m
|
||||||
assert "FRN1" in hidden_vals, f"FRN1 nu a aterizat pe op#2: {hidden_vals}"
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
# By-index: OE-1 trebuie sa fie INAINTE de FRN1 (idx 0 < idx 1)
|
items = payload["items"]
|
||||||
oe1_pos = hidden_vals.index("OE-1") if "OE-1" in hidden_vals else -1
|
assert items[0]["cod"] == "OE-1", f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {items}"
|
||||||
frn1_pos = hidden_vals.index("FRN1") if "FRN1" in hidden_vals else -1
|
assert items[1]["cod"] == "FRN1", f"FRN1 nu a aterizat pe op#2 (idx=1): {items}"
|
||||||
assert oe1_pos < frn1_pos, (
|
|
||||||
f"FRN1 (op#2, idx=1) trebuie dupa OE-1 (op#1, idx=0) by-index: {hidden_vals}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_state_picker_nomenclator_gol(client):
|
def test_empty_state_picker_nomenclator_gol(client):
|
||||||
@@ -774,25 +776,25 @@ def test_empty_state_picker_nomenclator_gol(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_add_extra_semnal_vizibil_cod_invalid(client):
|
def test_add_extra_semnal_vizibil_cod_invalid(client):
|
||||||
"""add_extra cu cod invalid da semnal vizibil (nu esua silentios).
|
"""'add' fara tinta cu cod invalid da semnal vizibil (nu esua silentios).
|
||||||
|
|
||||||
div.chips-extra-error vizibil cand codul e invalid sau selectul e gol.
|
div.chips-extra-error vizibil cand codul e invalid sau selectul e gol.
|
||||||
"""
|
"""
|
||||||
acct = _create_account_user("extra.err.signal@test.com")
|
_create_account_user("extra.err.signal@test.com")
|
||||||
_login(client, "extra.err.signal@test.com")
|
_login(client, "extra.err.signal@test.com")
|
||||||
_seed_cod("OE-1", "Schimb ulei")
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
csrf = _csrf(client)
|
csrf = _csrf(client)
|
||||||
|
|
||||||
# add_extra cu cod necunoscut in nomenclator
|
# add fara tinta cu cod necunoscut in nomenclator
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
"cod_prestatie": ["OE-1"],
|
"chips_state": json.dumps({"v": 1, "items": [
|
||||||
"chip_op_service": ["SchimbUlei"],
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
|
||||||
"chip_denumire": ["Schimb ulei"],
|
]}),
|
||||||
"chips_action": "add_extra",
|
"chips_action": "add",
|
||||||
"chips_add_cod_flat": "XX-99", # cod inexistent
|
"chips_pick": "XX-99", # cod inexistent
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -802,7 +804,7 @@ def test_add_extra_semnal_vizibil_cod_invalid(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_add_per_op_cod_invalid_afiseaza_mesaj(client):
|
def test_add_per_op_cod_invalid_afiseaza_mesaj(client):
|
||||||
"""Actiunea 'add' (picker per operatie) cu cod invalid da semnal vizibil."""
|
"""Actiunea 'add' (picker per operatie, cu tinta) cu cod invalid da semnal vizibil."""
|
||||||
_create_account_user("add.op.err@test.com")
|
_create_account_user("add.op.err@test.com")
|
||||||
_login(client, "add.op.err@test.com")
|
_login(client, "add.op.err@test.com")
|
||||||
_seed_cod("OE-1", "Schimb ulei")
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
@@ -812,12 +814,12 @@ def test_add_per_op_cod_invalid_afiseaza_mesaj(client):
|
|||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
"cod_prestatie": [""],
|
"chips_state": json.dumps({"v": 1, "items": [
|
||||||
"chip_op_service": ["SchimbUlei"],
|
{"cod": "", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
|
||||||
"chip_denumire": ["Schimb ulei"],
|
]}),
|
||||||
"chips_action": "add",
|
"chips_action": "add",
|
||||||
"chips_add_op_index": "0",
|
"chips_target_index": "0",
|
||||||
"chips_add_cod_0": "XX-99", # cod inexistent
|
"chips_pick": "XX-99", # cod inexistent
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -837,21 +839,19 @@ def test_add_flat_cod_invalid_afiseaza_mesaj(client):
|
|||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
"cod_prestatie": ["OE-1"],
|
"chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}),
|
||||||
"chip_op_service": [""],
|
"chips_action": "add",
|
||||||
"chip_denumire": [""],
|
"chips_pick": "XX-99",
|
||||||
"chips_action": "add_flat",
|
|
||||||
"chips_add_cod_flat": "XX-99",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "chips-extra-error" in resp.text, (
|
assert "chips-extra-error" in resp.text, (
|
||||||
f"Semnal de eroare lipsa la add_flat cu cod invalid: {resp.text[:300]}"
|
f"Semnal de eroare lipsa la add cu cod invalid: {resp.text[:300]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_add_flat_duplicat_afiseaza_mesaj(client):
|
def test_add_flat_duplicat_afiseaza_mesaj(client):
|
||||||
"""add_flat cu un cod deja prezent NU dubleaza chip-ul si afiseaza mesaj."""
|
"""'add' fara tinta cu un cod deja prezent NU dubleaza chip-ul si afiseaza mesaj."""
|
||||||
_create_account_user("add.flat.dup@test.com")
|
_create_account_user("add.flat.dup@test.com")
|
||||||
_login(client, "add.flat.dup@test.com")
|
_login(client, "add.flat.dup@test.com")
|
||||||
_seed_cod("OE-1", "Schimb ulei")
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
@@ -861,24 +861,24 @@ def test_add_flat_duplicat_afiseaza_mesaj(client):
|
|||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
"cod_prestatie": ["OE-1"],
|
"chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}),
|
||||||
"chip_op_service": [""],
|
"chips_action": "add",
|
||||||
"chip_denumire": [""],
|
"chips_pick": "OE-1", # deja in lista
|
||||||
"chips_action": "add_flat",
|
|
||||||
"chips_add_cod_flat": "OE-1", # deja in lista
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
import re as _re
|
import re as _re
|
||||||
hidden = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', resp.text)
|
m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
||||||
assert len(hidden) == 1, f"Chip OE-1 duplicat: {len(hidden)} aparitii"
|
assert m
|
||||||
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
|
assert len(payload["items"]) == 1, f"Chip OE-1 duplicat: {payload['items']}"
|
||||||
assert "deja in lista" in resp.text, (
|
assert "deja in lista" in resp.text, (
|
||||||
f"Mesaj de duplicat lipsa la add_flat: {resp.text[:300]}"
|
f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_add_extra_duplicat_afiseaza_mesaj(client):
|
def test_add_extra_duplicat_afiseaza_mesaj(client):
|
||||||
"""add_extra cu chip extra identic existent afiseaza mesaj (nu no-op)."""
|
"""'add' fara tinta cu chip liber identic existent afiseaza mesaj (nu no-op)."""
|
||||||
_create_account_user("add.extra.dup@test.com")
|
_create_account_user("add.extra.dup@test.com")
|
||||||
_login(client, "add.extra.dup@test.com")
|
_login(client, "add.extra.dup@test.com")
|
||||||
_seed_cod("OE-1", "Schimb ulei")
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
@@ -889,16 +889,17 @@ def test_add_extra_duplicat_afiseaza_mesaj(client):
|
|||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
"cod_prestatie": ["OE-1", "FRN1"],
|
"chips_state": json.dumps({"v": 1, "items": [
|
||||||
"chip_op_service": ["SchimbUlei", ""],
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
|
||||||
"chip_denumire": ["Schimb ulei", ""],
|
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
|
||||||
"chips_action": "add_extra",
|
]}),
|
||||||
"chips_add_cod_flat": "FRN1", # chip extra identic deja existent
|
"chips_action": "add",
|
||||||
|
"chips_pick": "FRN1", # chip liber identic deja existent
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "deja in lista" in resp.text, (
|
assert "deja in lista" in resp.text, (
|
||||||
f"Mesaj de duplicat lipsa la add_extra: {resp.text[:300]}"
|
f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -914,11 +915,11 @@ def test_add_reusit_marcheaza_chip_nou(client):
|
|||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
"cod_prestatie": ["OE-1"],
|
"chips_state": json.dumps({"v": 1, "items": [
|
||||||
"chip_op_service": ["SchimbUlei"],
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
|
||||||
"chip_denumire": ["Schimb ulei"],
|
]}),
|
||||||
"chips_action": "add_extra",
|
"chips_action": "add",
|
||||||
"chips_add_cod_flat": "FRN1",
|
"chips_pick": "FRN1",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ def client(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_editabil_arata_select_cod_rar(client):
|
def test_editabil_arata_select_cod_rar(client):
|
||||||
"""needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='cod_prestatie'>."""
|
"""needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='chips_pick'>
|
||||||
|
(picker-ul unificat de cod RAR; starea chips traieste in hidden name='chips_state', PRD 5.23)."""
|
||||||
acct = _create_account_user("sel1@test.com")
|
acct = _create_account_user("sel1@test.com")
|
||||||
_ins_nomenclator("OE-1", "OE-2")
|
_ins_nomenclator("OE-1", "OE-2")
|
||||||
sid = _ins(acct, status="needs_data")
|
sid = _ins(acct, status="needs_data")
|
||||||
@@ -133,8 +134,11 @@ def test_editabil_arata_select_cod_rar(client):
|
|||||||
|
|
||||||
html = _detaliu(client, sid)
|
html = _detaliu(client, sid)
|
||||||
|
|
||||||
assert 'name="cod_prestatie"' in html, (
|
assert 'name="chips_pick"' in html, (
|
||||||
"Formularul de detaliu needs_data trebuie sa contina un select cu name='cod_prestatie'"
|
"Formularul de detaliu needs_data trebuie sa contina un select cu name='chips_pick'"
|
||||||
|
)
|
||||||
|
assert 'name="chips_state"' in html, (
|
||||||
|
"Starea chips trebuie persistata intr-un hidden name='chips_state'"
|
||||||
)
|
)
|
||||||
assert "<select" in html, (
|
assert "<select" in html, (
|
||||||
"Elementul <select> trebuie sa apara in detaliu pentru starea needs_data"
|
"Elementul <select> trebuie sa apara in detaliu pentru starea needs_data"
|
||||||
|
|||||||
421
tests/test_web_editare_rand.py
Normal file
421
tests/test_web_editare_rand.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
"""US-004: persistarea `exclus` prin salvare (import + Trimiteri), round-trip complet.
|
||||||
|
|
||||||
|
Acopera:
|
||||||
|
- resolve_prestatii: item-exclus EXPLICIT supravietuieste re-rezolvarii (treapta noua
|
||||||
|
de precedenta) FARA sa strice restul precedentei (IRON RULE, regresiune-cheie).
|
||||||
|
- Import (web_editeaza_rand / apply_row_override): `exclus` persistat in override_json,
|
||||||
|
chip Nedeclarat reapare la redeschiderea modalului, x il readuce la warning.
|
||||||
|
- Rand cu toate operatiile excluse -> stare preview 'excluded' ("Nedeclarat").
|
||||||
|
- Trimiteri (post_corectie_trimitere): acelasi round-trip pe chips_state; toate
|
||||||
|
excluse -> needs_data cu motiv explicit.
|
||||||
|
- Sentinelul __NEDECLARAT__ NU ajunge in payload daca apare direct ca cod_prestatie
|
||||||
|
in starea hidden (nu doar ales din select), pe AMBELE cai.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Test unitar (fara client): precedenta resolve_prestatii #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_exclus_supravietuieste_resolve_chiar_cu_mapare_pe_cont():
|
||||||
|
"""IRON RULE: item cu `exclus=1` explicit pe item RAMANE exclus dupa resolve_prestatii,
|
||||||
|
chiar daca operatia are (a) o mapare exacta pe cont SAU (b) un cod_prestatie deja
|
||||||
|
valid pe rand. Regresiune-cheie: restul precedentei (cod valid > exclus de cont >
|
||||||
|
mapare exacta > reguli text > nemapat) trebuie sa ramana IDENTICA pentru itemii
|
||||||
|
FARA `exclus` explicit — altfel stricam maparile existente.
|
||||||
|
"""
|
||||||
|
from app.mapping import resolve_prestatii
|
||||||
|
|
||||||
|
valid_codes = {"OE-1", "IG-1", "FRN1"}
|
||||||
|
mapping = {"Schimb-Ulei": "OE-1"} # mapare exacta pe cont pentru Schimb-Ulei
|
||||||
|
excluded_ops_cont = {"Filtru-Aer"} # regula de excludere PE CONT (alt mecanism)
|
||||||
|
|
||||||
|
prestatii = [
|
||||||
|
# (a) exclus explicit + mapare exacta pe cont exista -> exclus CASTIGA
|
||||||
|
{"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor", "exclus": 1},
|
||||||
|
# (b) exclus explicit + cod_prestatie deja valid pe rand -> exclus CASTIGA
|
||||||
|
{"cod_op_service": "Frana", "denumire": "Reparatie frana", "cod_prestatie": "FRN1", "exclus": 1},
|
||||||
|
# regresiune: FARA exclus explicit, mapare exacta trebuie sa functioneze ca inainte
|
||||||
|
{"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor 2"},
|
||||||
|
# regresiune: FARA exclus explicit, regula de excludere PE CONT trebuie sa functioneze
|
||||||
|
{"cod_op_service": "Filtru-Aer", "denumire": "Filtru aer"},
|
||||||
|
# regresiune: cod deja valid pe rand, FARA exclus -> pastrat ca atare (cod_valid)
|
||||||
|
{"cod_op_service": "", "denumire": "", "cod_prestatie": "IG-1"},
|
||||||
|
# regresiune: nemapat ramane nemapat
|
||||||
|
{"cod_op_service": "Necunoscut-Op", "denumire": "Operatie necunoscuta"},
|
||||||
|
]
|
||||||
|
|
||||||
|
resolved, unmapped = resolve_prestatii(
|
||||||
|
prestatii, mapping, valid_codes=valid_codes, text_rules=None, excluded_ops=excluded_ops_cont,
|
||||||
|
)
|
||||||
|
|
||||||
|
item_a = resolved[0]
|
||||||
|
assert item_a.get("exclus") is True, f"exclus explicit trebuia sa supravietuiasca: {item_a}"
|
||||||
|
assert not item_a.get("cod_prestatie"), f"item exclus explicit nu trebuie sa aiba cod_prestatie: {item_a}"
|
||||||
|
|
||||||
|
item_b = resolved[1]
|
||||||
|
assert item_b.get("exclus") is True, f"exclus explicit trebuia sa domine cod_prestatie valid: {item_b}"
|
||||||
|
assert not item_b.get("cod_prestatie"), f"cod_prestatie valid trebuia sters de exclus explicit: {item_b}"
|
||||||
|
|
||||||
|
# Regresiune: mapare exacta pe cont NESCHIMBATA pentru itemul fara exclus
|
||||||
|
item_c = resolved[2]
|
||||||
|
assert item_c.get("cod_prestatie") == "OE-1", f"mapare exacta pe cont stricata: {item_c}"
|
||||||
|
assert not item_c.get("exclus"), f"item fara exclus explicit nu trebuie marcat exclus: {item_c}"
|
||||||
|
|
||||||
|
# Regresiune: regula de excludere PE CONT (mecanism vechi) NESCHIMBATA
|
||||||
|
item_d = resolved[3]
|
||||||
|
assert item_d.get("exclus") is True, f"regula de excludere pe cont stricata: {item_d}"
|
||||||
|
assert not item_d.get("cod_prestatie")
|
||||||
|
|
||||||
|
# Regresiune: cod deja valid pastrat ca atare
|
||||||
|
item_e = resolved[4]
|
||||||
|
assert item_e.get("cod_prestatie") == "IG-1", f"cod valid pastrat stricat: {item_e}"
|
||||||
|
|
||||||
|
# Regresiune: nemapat ramane nemapat
|
||||||
|
assert len(unmapped) == 1
|
||||||
|
assert unmapped[0]["cod_op_service"] == "Necunoscut-Op"
|
||||||
|
|
||||||
|
|
||||||
|
def test_precedenta_neschimbata_pentru_itemi_fara_exclus():
|
||||||
|
"""Regresiune suplimentara: fara `exclus` explicit pe niciun item, comportamentul
|
||||||
|
lui resolve_prestatii e IDENTIC cu cel dinaintea treptei noi (cod valid > exclus
|
||||||
|
de cont > mapare exacta > reguli text > nemapat).
|
||||||
|
"""
|
||||||
|
from app.mapping import resolve_prestatii
|
||||||
|
|
||||||
|
valid_codes = {"OE-1"}
|
||||||
|
mapping = {"Op-Mapata": "OE-1"}
|
||||||
|
text_rules = [{"pattern": "frana", "cod_prestatie": "OE-1", "auto_send": 1, "priority": 1}]
|
||||||
|
excluded_ops = {"Op-Exclusa"}
|
||||||
|
|
||||||
|
prestatii = [
|
||||||
|
{"cod_op_service": "Op-Mapata", "denumire": "Operatie mapata"},
|
||||||
|
{"cod_op_service": "Op-Exclusa", "denumire": "Operatie exclusa"},
|
||||||
|
{"cod_op_service": "Op-Text", "denumire": "Reparatie frana fata"},
|
||||||
|
{"cod_op_service": "Op-Nemapat", "denumire": "Chestie noua"},
|
||||||
|
]
|
||||||
|
|
||||||
|
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
|
||||||
|
|
||||||
|
assert resolved[0]["cod_prestatie"] == "OE-1"
|
||||||
|
assert not resolved[0].get("exclus")
|
||||||
|
assert resolved[1]["exclus"] is True
|
||||||
|
assert not resolved[1].get("cod_prestatie")
|
||||||
|
assert resolved[2]["cod_prestatie"] == "OE-1" # via regula text
|
||||||
|
assert resolved[2].get("cod_sursa")
|
||||||
|
assert len(unmapped) == 1
|
||||||
|
assert unmapped[0]["cod_op_service"] == "Op-Nemapat"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fixtures + helpere HTTP (import + Trimiteri, fara auth — cont 1 implicit) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "editare_rand.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.crypto import reset_cache
|
||||||
|
reset_cache()
|
||||||
|
from app.main import app
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
get_settings.cache_clear()
|
||||||
|
reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||||
|
(cod, denumire),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_bytes(rows: list[dict]) -> bytes:
|
||||||
|
import csv as _csv
|
||||||
|
import io
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
return buf.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
_MAP_COLS = {
|
||||||
|
"VIN": "vin",
|
||||||
|
"Nr": "nr_inmatriculare",
|
||||||
|
"Data": "data_prestatie",
|
||||||
|
"KM": "odometru_final",
|
||||||
|
"Operatie": "operatie",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_and_preview(client: TestClient, rows: list[dict]) -> int:
|
||||||
|
import io as _io
|
||||||
|
csv_data = _csv_bytes(rows)
|
||||||
|
r = client.post(
|
||||||
|
"/_import/upload",
|
||||||
|
files={"file": ("test.csv", _io.BytesIO(csv_data), "text/csv")},
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
|
||||||
|
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
|
||||||
|
iid = int(m.group(1))
|
||||||
|
colnames = list(rows[0].keys())
|
||||||
|
canons = [_MAP_COLS[c] for c in colnames]
|
||||||
|
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
|
||||||
|
"colname": colnames,
|
||||||
|
"canon": canons,
|
||||||
|
"format_data": "YYYY-MM-DD",
|
||||||
|
})
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
return iid
|
||||||
|
|
||||||
|
|
||||||
|
def _chips_state_din_html(html: str) -> dict:
|
||||||
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
|
||||||
|
assert m, f"hidden chips_state lipsa din HTML: {html[:400]}"
|
||||||
|
return json.loads(m.group(1).replace(""", '"'))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Trimiteri: helpere submissions direct in DB (cont 1, fara auth) --------- #
|
||||||
|
|
||||||
|
def _insert_submission(*, status: str, payload: dict) -> int:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(f"k-{os.urandom(6).hex()}", 1, status, json.dumps(payload)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _submission_row(sid: int):
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _submission_payload(sid: int) -> dict:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
r = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone()
|
||||||
|
return json.loads(r["payload_json"])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_cu_ops(vin: str, ops: list[tuple[str, str]]) -> dict:
|
||||||
|
return {
|
||||||
|
"vin": vin,
|
||||||
|
"nr_inmatriculare": "B100AAA",
|
||||||
|
"data_prestatie": "2026-06-10",
|
||||||
|
"odometru_final": "50000",
|
||||||
|
"prestatii": [{"cod_op_service": op, "denumire": den} for op, den in ops],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Import: salvare + round-trip (web_editeaza_rand / apply_row_override) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_SAMPLE_ROW_1OP = [{
|
||||||
|
"VIN": "WVWZZZ1KZAW000999",
|
||||||
|
"Nr": "B009TST",
|
||||||
|
"Data": "2026-06-10",
|
||||||
|
"KM": "123456",
|
||||||
|
"Operatie": "Op-Nemapata",
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_salvare_pastreaza_exclus_in_override(client):
|
||||||
|
"""Salvarea unui chip Nedeclarat in modalul de editare (import) persista `exclus=1`
|
||||||
|
in override_json — la reincarcarea preview-ului randul ramane 'Nedeclarat'."""
|
||||||
|
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
|
||||||
|
|
||||||
|
chips_state = json.dumps({"v": 1, "items": [
|
||||||
|
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
|
||||||
|
]})
|
||||||
|
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
trigger_settle = r.headers.get("HX-Trigger-After-Settle", "")
|
||||||
|
assert "inchideModal" in trigger_settle, f"Salvarea trebuia sa reuseasca: {r.text[:400]}"
|
||||||
|
|
||||||
|
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
payload = _chips_state_din_html(r2.text)
|
||||||
|
item = payload["items"][0]
|
||||||
|
assert item.get("exclus") == 1, f"exclus nu a fost persistat: {payload}"
|
||||||
|
assert not item.get("cod"), f"item exclus nu trebuie sa aiba cod: {payload}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redeschidere_modal_arata_chip_nedeclarat_si_x_readuce_warning(client):
|
||||||
|
"""Round-trip complet: dupa salvare, redeschiderea modalului arata chipul
|
||||||
|
'Nedeclarat' (nu warning); actiunea x (/form-chips remove) il readuce la warning."""
|
||||||
|
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
|
||||||
|
|
||||||
|
chips_state = json.dumps({"v": 1, "items": [
|
||||||
|
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
|
||||||
|
]})
|
||||||
|
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
html = r2.text
|
||||||
|
assert "Nedeclarat" in html, f"Chipul Nedeclarat trebuia sa reapara la redeschidere: {html[:600]}"
|
||||||
|
assert "chip-nedeclarat" in html
|
||||||
|
|
||||||
|
# x pe chip Nedeclarat -> /form-chips remove index 0 -> revine la warning.
|
||||||
|
payload = _chips_state_din_html(html)
|
||||||
|
r3 = client.post("/form-chips", data={
|
||||||
|
"chips_state": json.dumps(payload),
|
||||||
|
"chips_action": "remove",
|
||||||
|
"chips_remove_index": "0",
|
||||||
|
})
|
||||||
|
assert r3.status_code == 200, r3.text
|
||||||
|
assert "Nedeclarat" not in r3.text, "Dupa x, chipul Nedeclarat nu mai trebuie sa apara"
|
||||||
|
payload3 = _chips_state_din_html(r3.text)
|
||||||
|
item3 = payload3["items"][0]
|
||||||
|
assert not item3.get("exclus"), f"x trebuia sa readuca operatia la warning (exclus=0): {payload3}"
|
||||||
|
assert not item3.get("cod"), f"operatia trebuia sa ramana fara cod dupa x: {payload3}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rand_toate_operatiile_excluse_devine_excluded(client):
|
||||||
|
"""Rand cu unica operatie exclusa -> stare preview 'excluded' ('Nedeclarat')."""
|
||||||
|
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
|
||||||
|
|
||||||
|
chips_state = json.dumps({"v": 1, "items": [
|
||||||
|
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
|
||||||
|
]})
|
||||||
|
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
trigger = json.loads(r.headers.get("HX-Trigger", "{}"))
|
||||||
|
stare = trigger.get("randSalvat", {}).get("stare", "")
|
||||||
|
assert "Nedeclarat" in stare, f"randSalvat.stare trebuia sa arate Nedeclarat, got: {stare!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Trimiteri: salvare + round-trip (post_corectie_trimitere) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_salvare_trimitere_cu_exclus(client):
|
||||||
|
"""/trimitere/{id}/corecteaza cu un item mapat + un item exclus -> queued,
|
||||||
|
payload_json pastreaza AMBELE (itemul exclus adnotat exclus=1, fara cod_prestatie)."""
|
||||||
|
_seed_cod("OE-1", "Schimb ulei")
|
||||||
|
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
|
||||||
|
"WVWZZZ1JZXW0EX001",
|
||||||
|
[("SchimbUlei", "Schimb ulei motor"), ("Filtru-Aer", "Filtru aer")],
|
||||||
|
))
|
||||||
|
|
||||||
|
chips_state = json.dumps({"v": 1, "items": [
|
||||||
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0},
|
||||||
|
{"cod": "", "op": "Filtru-Aer", "den": "Filtru aer", "exclus": 1},
|
||||||
|
]})
|
||||||
|
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
|
||||||
|
assert r.status_code == 200, r.text[:500]
|
||||||
|
|
||||||
|
row = _submission_row(sid)
|
||||||
|
assert row["status"] == "queued", f"status asteptat queued, got {row['status']}: {r.text[:500]}"
|
||||||
|
prestatii = _submission_payload(sid)["prestatii"]
|
||||||
|
assert len(prestatii) == 2, f"ambele itemi trebuiau pastrate in payload: {prestatii}"
|
||||||
|
by_op = {p.get("cod_op_service"): p for p in prestatii}
|
||||||
|
assert by_op["SchimbUlei"].get("cod_prestatie") == "OE-1"
|
||||||
|
assert by_op["Filtru-Aer"].get("exclus") is True, f"itemul exclus trebuia adnotat: {prestatii}"
|
||||||
|
assert not by_op["Filtru-Aer"].get("cod_prestatie"), f"itemul exclus nu trebuie sa aiba cod: {prestatii}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trimitere_toate_excluse_devine_needs_data(client):
|
||||||
|
"""Toate operatiile excluse -> needs_data cu motiv explicit (nu se trimite la RAR)."""
|
||||||
|
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
|
||||||
|
"WVWZZZ1JZXW0EX002",
|
||||||
|
[("Filtru-Aer", "Filtru aer")],
|
||||||
|
))
|
||||||
|
|
||||||
|
chips_state = json.dumps({"v": 1, "items": [
|
||||||
|
{"cod": "", "op": "Filtru-Aer", "den": "Filtru aer", "exclus": 1},
|
||||||
|
]})
|
||||||
|
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
|
||||||
|
assert r.status_code == 200, r.text[:500]
|
||||||
|
|
||||||
|
row = _submission_row(sid)
|
||||||
|
assert row["status"] == "needs_data", f"status asteptat needs_data, got {row['status']}"
|
||||||
|
assert "excluse" in (row["rar_error"] or "").lower()
|
||||||
|
prestatii = _submission_payload(sid)["prestatii"]
|
||||||
|
assert prestatii[0].get("exclus") is True, "itemul exclus trebuie pastrat in payload pt afisare motiv"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Sentinel __NEDECLARAT__ respins explicit daca apare ca cod_prestatie #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_sentinel_nedeclarat_nu_ajunge_in_payload_import(client):
|
||||||
|
"""Daca cineva fabrica direct chips_state cu cod=__NEDECLARAT__ (nu prin selectul
|
||||||
|
care seteaza exclus), salvarea trebuie respinsa — sentinelul nu e in nomenclator."""
|
||||||
|
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
|
||||||
|
|
||||||
|
chips_state = json.dumps({"v": 1, "items": [
|
||||||
|
{"cod": "__NEDECLARAT__", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 0},
|
||||||
|
]})
|
||||||
|
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "necunoscut" in r.text.lower(), f"Sentinelul trebuia respins ca cod necunoscut: {r.text[:400]}"
|
||||||
|
trigger_settle = r.headers.get("HX-Trigger-After-Settle", "")
|
||||||
|
assert "inchideModal" not in trigger_settle, "Salvarea NU trebuia sa reuseasca cu sentinelul in payload"
|
||||||
|
|
||||||
|
# Confirma ca sentinelul NU a ajuns in override (redeschiderea nu-l arata ca si cod).
|
||||||
|
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
|
||||||
|
payload2 = _chips_state_din_html(r2.text)
|
||||||
|
coduri = [it.get("cod") for it in payload2["items"]]
|
||||||
|
assert "__NEDECLARAT__" not in coduri, f"Sentinelul a ajuns in starea persistata: {payload2}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentinel_nedeclarat_nu_ajunge_in_payload_trimitere(client):
|
||||||
|
"""Aceeasi respingere pe calea Trimiteri (post_corectie_trimitere)."""
|
||||||
|
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
|
||||||
|
"WVWZZZ1JZXW0SN001",
|
||||||
|
[("Op-Test", "Operatie test")],
|
||||||
|
))
|
||||||
|
old_status = _submission_row(sid)["status"]
|
||||||
|
|
||||||
|
chips_state = json.dumps({"v": 1, "items": [
|
||||||
|
{"cod": "__NEDECLARAT__", "op": "Op-Test", "den": "Operatie test", "exclus": 0},
|
||||||
|
]})
|
||||||
|
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "necunoscut" in r.text.lower(), f"Sentinelul trebuia respins ca cod necunoscut: {r.text[:400]}"
|
||||||
|
|
||||||
|
row = _submission_row(sid)
|
||||||
|
assert row["status"] == old_status, f"Statusul nu trebuia sa se schimbe: {row['status']}"
|
||||||
|
prestatii = _submission_payload(sid)["prestatii"]
|
||||||
|
coduri = [p.get("cod_prestatie") for p in prestatii]
|
||||||
|
assert "__NEDECLARAT__" not in coduri, f"Sentinelul a ajuns in payload: {prestatii}"
|
||||||
@@ -212,7 +212,7 @@ def test_camp_observatii_prezent(client):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
def test_chips_multi_select_prestatii(client):
|
def test_chips_multi_select_prestatii(client):
|
||||||
"""Submission cu cod_prestatie setat afiseaza chip cu hidden input."""
|
"""Submission cu cod_prestatie setat afiseaza chip cu OE-1 in starea hidden chips_state."""
|
||||||
acct = _create_account_user("chips.test@test.com")
|
acct = _create_account_user("chips.test@test.com")
|
||||||
_login(client, "chips.test@test.com")
|
_login(client, "chips.test@test.com")
|
||||||
_seed_cod("OE-1", "Schimb ulei motor")
|
_seed_cod("OE-1", "Schimb ulei motor")
|
||||||
@@ -222,14 +222,13 @@ def test_chips_multi_select_prestatii(client):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
|
|
||||||
# Trebuie sa existe un input (de obicei hidden) cu name="cod_prestatie" si valoarea "OE-1"
|
# Starea chips traieste intr-UN SINGUR hidden JSON versionat (D4, PRD 5.23).
|
||||||
has_cod_prestatie_chip = bool(
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
|
||||||
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or
|
assert m, "Formularul trebuie sa contina hidden input name='chips_state'"
|
||||||
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html)
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
)
|
coduri = [it["cod"] for it in payload["items"]]
|
||||||
assert has_cod_prestatie_chip, (
|
assert "OE-1" in coduri, (
|
||||||
"Formularul trebuie sa contina un hidden input cu name='cod_prestatie' value='OE-1' "
|
f"chips_state trebuie sa contina codul OE-1 al prestatiei. items={payload['items']}"
|
||||||
"reprezentand chip-ul de prestatie."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -244,19 +243,19 @@ def test_adauga_sterge_chip(client):
|
|||||||
_seed_cod("OE-1", "Schimb ulei motor")
|
_seed_cod("OE-1", "Schimb ulei motor")
|
||||||
csrf = _csrf(client)
|
csrf = _csrf(client)
|
||||||
|
|
||||||
# POST /form-chips: adauga OE-1 la prima operatie (index 0)
|
# POST /form-chips: adauga OE-1 la prima operatie (index 0), stare curenta = un
|
||||||
|
# singur hidden chips_state (JSON versionat, D4).
|
||||||
|
state = json.dumps({"v": 1, "items": [
|
||||||
|
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
|
||||||
|
]})
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
# Starea curenta: o operatie fara cod
|
"chips_state": state,
|
||||||
"chip_op_service": ["Op-A"],
|
|
||||||
"chip_denumire": ["Schimb ulei motor"],
|
|
||||||
"cod_prestatie": [""], # nemaapat initial
|
|
||||||
# Actiunea
|
|
||||||
"chips_action": "add",
|
"chips_action": "add",
|
||||||
"chips_add_op_index": "0",
|
"chips_target_index": "0",
|
||||||
"chips_add_cod_0": "OE-1",
|
"chips_pick": "OE-1",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200, f"/form-chips a returnat {resp.status_code}: {resp.text[:400]}"
|
assert resp.status_code == 200, f"/form-chips a returnat {resp.status_code}: {resp.text[:400]}"
|
||||||
@@ -266,34 +265,31 @@ def test_adauga_sterge_chip(client):
|
|||||||
assert "OE-1" in html, (
|
assert "OE-1" in html, (
|
||||||
f"Dupa add, OE-1 trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}"
|
f"Dupa add, OE-1 trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}"
|
||||||
)
|
)
|
||||||
# Si hidden input cu valoarea OE-1
|
# Si in starea hidden chips_state
|
||||||
has_hidden = bool(
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
|
||||||
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or
|
assert m, f"hidden chips_state lipsa. html[:600]={html[:600]}"
|
||||||
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html)
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
)
|
assert payload["items"][0]["cod"] == "OE-1", (
|
||||||
assert has_hidden, (
|
f"Dupa add, chips_state trebuie sa contina OE-1. items={payload['items']}"
|
||||||
"Dupa add, trebuie sa existe un input cu name='cod_prestatie' value='OE-1' "
|
|
||||||
f"in raspunsul /form-chips. html[:600]={html[:600]}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_sterge_chip(client):
|
def test_sterge_chip(client):
|
||||||
"""POST /form-chips cu action=remove sterge chip-ul la indexul dat."""
|
"""POST /form-chips cu action=remove sterge codul chip-ului la indexul dat."""
|
||||||
acct = _create_account_user("form.chips.del@test.com")
|
acct = _create_account_user("form.chips.del@test.com")
|
||||||
_login(client, "form.chips.del@test.com")
|
_login(client, "form.chips.del@test.com")
|
||||||
_seed_cod("OE-1", "Schimb ulei motor")
|
_seed_cod("OE-1", "Schimb ulei motor")
|
||||||
csrf = _csrf(client)
|
csrf = _csrf(client)
|
||||||
|
|
||||||
# POST /form-chips: sterge chip-ul de la index 0 (OE-1 existent)
|
# POST /form-chips: sterge codul chip-ului de la index 0 (OE-1 existent, mapat pe Op-A)
|
||||||
|
state = json.dumps({"v": 1, "items": [
|
||||||
|
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
|
||||||
|
]})
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/form-chips",
|
"/form-chips",
|
||||||
data={
|
data={
|
||||||
"csrf_token": csrf,
|
"csrf_token": csrf,
|
||||||
# Starea curenta: OE-1 mapat pe Op-A
|
"chips_state": state,
|
||||||
"chip_op_service": ["Op-A"],
|
|
||||||
"chip_denumire": ["Schimb ulei motor"],
|
|
||||||
"cod_prestatie": ["OE-1"],
|
|
||||||
# Actiunea: sterge indexul 0
|
|
||||||
"chips_action": "remove",
|
"chips_action": "remove",
|
||||||
"chips_remove_index": "0",
|
"chips_remove_index": "0",
|
||||||
},
|
},
|
||||||
@@ -301,15 +297,15 @@ def test_sterge_chip(client):
|
|||||||
assert resp.status_code == 200, f"/form-chips remove a returnat {resp.status_code}: {resp.text[:400]}"
|
assert resp.status_code == 200, f"/form-chips remove a returnat {resp.status_code}: {resp.text[:400]}"
|
||||||
|
|
||||||
html = resp.text
|
html = resp.text
|
||||||
# Dupa remove, OE-1 nu mai apare ca chip (input hidden cu acea valoare)
|
# Dupa remove, OE-1 nu mai apare ca cod in starea chips (operatia RAMANE, nemapata)
|
||||||
has_oe1_chip = bool(
|
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
|
||||||
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or
|
assert m
|
||||||
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html)
|
payload = json.loads(m.group(1).replace(""", '"'))
|
||||||
)
|
coduri = [it["cod"] for it in payload["items"]]
|
||||||
assert not has_oe1_chip, (
|
assert "OE-1" not in coduri, (
|
||||||
"Dupa remove, OE-1 NU mai trebuie sa apara ca chip "
|
f"Dupa remove, OE-1 NU mai trebuie sa apara ca cod in chips_state. items={payload['items']}"
|
||||||
f"(hidden input cu cod_prestatie=OE-1). html[:500]={html[:500]}"
|
|
||||||
)
|
)
|
||||||
|
assert payload["items"][0]["op"] == "Op-A", "Operatia trebuie sa ramana (doar codul dispare)"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -437,11 +433,16 @@ def test_form_chips_reveal_r_odo(client):
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Test 8: Picker per operatie (E4 binding) -- format op-row #
|
# Test 8: Chip warning pentru operatie nemapata (control unificat, PRD 5.23) #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
def test_picker_per_operatie_in_form(client):
|
def test_picker_per_operatie_in_form(client):
|
||||||
"""Operatie nemapata (needs_mapping) -> formularul afiseaza picker pe operatie."""
|
"""Operatie nemapata (needs_mapping) -> formularul afiseaza chip warning + selectul unic.
|
||||||
|
|
||||||
|
PRD 5.23 US-002: controlul unificat elimina `.op-row` (rand per operatie cu
|
||||||
|
picker propriu); operatia nemapata devine un chip warning in containerul
|
||||||
|
unic `.chips`, iar selectul e unul singur (`chips_pick`, id `chips-picker`).
|
||||||
|
"""
|
||||||
acct = _create_account_user("picker.op@test.com")
|
acct = _create_account_user("picker.op@test.com")
|
||||||
_login(client, "picker.op@test.com")
|
_login(client, "picker.op@test.com")
|
||||||
# NU seed-uim nicio mapare -> operatia ramane nemapata
|
# NU seed-uim nicio mapare -> operatia ramane nemapata
|
||||||
@@ -455,17 +456,15 @@ def test_picker_per_operatie_in_form(client):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
|
|
||||||
# Operatia REVIZIE PERIODICA trebuie sa apara in form (op-row cu clasa specifica)
|
# Controlul unificat (PRD 5.23): fara .op-row, UN singur container .chips.
|
||||||
# clasa "op-row" din CSS base.html e adaugata NUMAI de chips_prestatii.html
|
assert "op-row" not in html, (
|
||||||
has_op_row = "op-row" in html
|
"op-row a fost eliminat de controlul unificat (PRD 5.23 US-002). "
|
||||||
assert has_op_row, (
|
|
||||||
"Formularul trebuie sa contina clasa 'op-row' "
|
|
||||||
"pentru picker-ul per-operatie. "
|
|
||||||
"Aceasta clasa e adaugata de _chips_prestatii.html. "
|
|
||||||
f"html[:600]={html[:600]}"
|
f"html[:600]={html[:600]}"
|
||||||
)
|
)
|
||||||
# Operatia REVIZIE PERIODICA trebuie sa apara in context op-row
|
assert 'class="chips"' in html
|
||||||
|
assert 'id="chips-picker"' in html
|
||||||
|
# Operatia REVIZIE PERIODICA trebuie sa apara ca chip warning
|
||||||
assert "REVIZIE PERIODICA" in html, (
|
assert "REVIZIE PERIODICA" in html, (
|
||||||
"Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare (op-row). "
|
"Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare "
|
||||||
f"html[:500]={html[:500]}"
|
f"(chip warning). html[:500]={html[:500]}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -280,8 +280,8 @@ def test_optional_nu_forteaza(client):
|
|||||||
def test_fara_select_vechi_redundant(client):
|
def test_fara_select_vechi_redundant(client):
|
||||||
"""Detaliu needs_data NU mai contine <select name='cod_prestatie'>.
|
"""Detaliu needs_data NU mai contine <select name='cod_prestatie'>.
|
||||||
|
|
||||||
Formularul editabil foloseste NUMAI chips (hidden inputs cod_prestatie),
|
Formularul editabil foloseste NUMAI chips (stare in hidden name='chips_state',
|
||||||
fara vechiul select dublu. Chips functioneaza ca singura sursa de cod_prestatie.
|
JSON versionat, PRD 5.23), fara vechiul select dublu.
|
||||||
|
|
||||||
Nota: <select name="cod_prestatie"> RAMANE in formularul /repune pentru starea error.
|
Nota: <select name="cod_prestatie"> RAMANE in formularul /repune pentru starea error.
|
||||||
"""
|
"""
|
||||||
@@ -302,15 +302,14 @@ def test_fara_select_vechi_redundant(client):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
|
|
||||||
# Chipurile trebuie sa fie prezente (hidden input cu name="cod_prestatie")
|
# Chipurile trebuie sa fie prezente (hidden input cu name="chips_state", JSON versionat)
|
||||||
has_chip_hidden = (
|
has_chip_hidden = re.search(r'<input[^>]+type=["\']hidden["\'][^>]+name=["\']chips_state["\']', html)
|
||||||
re.search(r'<input[^>]+type=["\']hidden["\'][^>]+name=["\']cod_prestatie["\']', html) or
|
|
||||||
re.search(r'<input[^>]+name=["\']cod_prestatie["\'][^>]+type=["\']hidden["\']', html)
|
|
||||||
)
|
|
||||||
assert has_chip_hidden, (
|
assert has_chip_hidden, (
|
||||||
"Chips: trebuie sa existe input hidden cu name='cod_prestatie' (din _chips_prestatii.html). "
|
"Chips: trebuie sa existe input hidden cu name='chips_state' (din _chips_prestatii.html). "
|
||||||
f"html[:600]={html[:600]}"
|
f"html[:600]={html[:600]}"
|
||||||
)
|
)
|
||||||
|
# Cod-ul OE-1 trebuie sa apara in stare (JSON), nu ca input separat
|
||||||
|
assert "OE-1" in html, f"OE-1 trebuie sa apara in chips_state. html[:600]={html[:600]}"
|
||||||
|
|
||||||
# Vechiul <select name="cod_prestatie"> NU trebuie sa existe in sectiunea editabila.
|
# Vechiul <select name="cod_prestatie"> NU trebuie sa existe in sectiunea editabila.
|
||||||
# (needs_data nu are formular /repune, deci niciun select cu name="cod_prestatie" legal)
|
# (needs_data nu are formular /repune, deci niciun select cu name="cod_prestatie" legal)
|
||||||
|
|||||||
@@ -791,12 +791,17 @@ def test_login_branded_nu_schelet(client):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def _citeste_landing() -> str:
|
def _citeste_landing() -> str:
|
||||||
"""Returneaza continutul landing.html (template static; variabilele Jinja2 nu
|
"""Returneaza landing.html + partial-urile comune (antet/footer/stil sunt
|
||||||
|
include-uri partajate cu paginile statice publice; variabilele Jinja2 nu
|
||||||
afecteaza copy-ul de limita/plan/buton verificat mai jos)."""
|
afecteaza copy-ul de limita/plan/buton verificat mai jos)."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
p = Path(__file__).parent.parent / "app" / "web" / "templates" / "landing.html"
|
tdir = Path(__file__).parent.parent / "app" / "web" / "templates"
|
||||||
assert p.exists(), f"landing.html negasit la {p}"
|
parts = []
|
||||||
return p.read_text(encoding="utf-8")
|
for nume in ("landing.html", "_lp_header.html", "_lp_footer.html", "_lp_style.html"):
|
||||||
|
p = tdir / nume
|
||||||
|
assert p.exists(), f"{nume} negasit la {p}"
|
||||||
|
parts.append(p.read_text(encoding="utf-8"))
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def test_landing_limita_60():
|
def test_landing_limita_60():
|
||||||
@@ -818,8 +823,10 @@ def test_landing_trial_pro_nu_premium():
|
|||||||
Verifica sectiunea PRICING (subtitle) si sectiunea AUTH (lista beneficii)."""
|
Verifica sectiunea PRICING (subtitle) si sectiunea AUTH (lista beneficii)."""
|
||||||
html = _citeste_landing()
|
html = _citeste_landing()
|
||||||
|
|
||||||
assert "Pro gratuit 30 de zile" in html, \
|
assert "30 de zile Pro gratuit" in html, \
|
||||||
"'Pro gratuit 30 de zile' lipseste din landing — verifica sectiunile PRICING + AUTH (5.17 T7)"
|
"'30 de zile Pro gratuit' lipseste din landing — verifica sectiunea TRIAL BENEFIT (5.17 T7)"
|
||||||
|
assert "Ai Pro gratuit primele 30 de zile, apoi pachetul ales" in html, \
|
||||||
|
"copy-ul trial din sectiunea AUTH lipseste din landing (5.17 T7)"
|
||||||
assert "Premium gratuit 30 de zile" not in html, \
|
assert "Premium gratuit 30 de zile" not in html, \
|
||||||
"'Premium gratuit 30 de zile' inca in landing — trial-ul e pe Pro, nu Premium (5.17 T7)"
|
"'Premium gratuit 30 de zile' inca in landing — trial-ul e pe Pro, nu Premium (5.17 T7)"
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,34 @@ def test_detaliu_trimitere(client):
|
|||||||
assert "99001" in html # nr prezentare RAR
|
assert "99001" in html # nr prezentare RAR
|
||||||
|
|
||||||
|
|
||||||
|
def test_detaliu_arata_toate_codurile_cand_prima_prestatie_e_exclusa(client):
|
||||||
|
"""Modalul arata codurile declarate cand operatia originala e exclusa (item 0).
|
||||||
|
|
||||||
|
Rand importat editat: item 0 = operatia originala cu exclus=True, fara cod;
|
||||||
|
codurile adaugate sunt itemii urmatori. Detaliul nu trebuie sa afiseze 'nemapat'.
|
||||||
|
"""
|
||||||
|
acct = _create_account_user("codmulti@test.com")
|
||||||
|
sid = _insert_submission(acct, "sent", id_prezentare=99002, payload={
|
||||||
|
"vin": "WVWZZZ3CZ9E123456",
|
||||||
|
"nr_inmatriculare": "TM789BC",
|
||||||
|
"data_prestatie": "2026-05-15",
|
||||||
|
"odometru_final": "82500",
|
||||||
|
"prestatii": [
|
||||||
|
{"cod_prestatie": None, "cod_op_service": "OP-ITP",
|
||||||
|
"denumire": "Inspectie Tehnica Periodica", "exclus": True},
|
||||||
|
{"cod_prestatie": "OE-2"},
|
||||||
|
{"cod_prestatie": "OE-4"},
|
||||||
|
{"cod_prestatie": "OE-8"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
_login(client, "codmulti@test.com")
|
||||||
|
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.text
|
||||||
|
assert "OE-2, OE-4, OE-8" in html
|
||||||
|
assert "nemapat" not in html
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tabel trimiteri — eticheta umana sub pill, fara chevron,
|
# Tabel trimiteri — eticheta umana sub pill, fara chevron,
|
||||||
# cod RAR simplu (fara prefix), randul declanseaza modalul. Teste de randare.
|
# cod RAR simplu (fara prefix), randul declanseaza modalul. Teste de randare.
|
||||||
|
|||||||
@@ -259,5 +259,5 @@ def test_mobil_pastreaza_doua_linii_cu_bulina_si_litera(client):
|
|||||||
html = client.get("/?tab=coada").text
|
html = client.get("/?tab=coada").text
|
||||||
assert re.search(r'\.trimiteri-head\s*\{\s*display\s*:\s*none', html), \
|
assert re.search(r'\.trimiteri-head\s*\{\s*display\s*:\s*none', html), \
|
||||||
"Headerul de coloane nu e ascuns pe mobil"
|
"Headerul de coloane nu e ascuns pe mobil"
|
||||||
m = re.search(r'grid-template-areas\s*:\s*"check veh\s+act env status"\s*"check meta act env status"', html)
|
m = re.search(r'grid-template-areas\s*:\s*"check id\s+veh\s+act env status"\s*"check meta meta act env status"', html)
|
||||||
assert m, "Grid-areas pe 2 linii (veh/meta) lipseste din CSS-ul mobil"
|
assert m, "Grid-areas pe 2 linii (id+veh/meta) lipseste din CSS-ul mobil"
|
||||||
|
|||||||
97
tests/test_worker_exclude_prestatii.py
Normal file
97
tests/test_worker_exclude_prestatii.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Prestatiile excluse de la declarare nu ajung niciodata la RAR.
|
||||||
|
|
||||||
|
content['prestatii'] pastreaza itemii exclusi (round-trip in editor), dar
|
||||||
|
payload-ul trimis efectiv la RAR (build_rar_payload, apelat din process_one)
|
||||||
|
trebuie sa contina DOAR partea declarabila. Un item exclus/fara cod nu produce
|
||||||
|
niciodata {codPrestatie: null} in payload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import get_connection, init_db
|
||||||
|
init_db()
|
||||||
|
conn = get_connection()
|
||||||
|
settings = get_settings()
|
||||||
|
yield conn, settings
|
||||||
|
conn.close()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
_CONTENT_CU_EXCLUS = {
|
||||||
|
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
|
||||||
|
"data_prestatie": "2026-06-15", "odometru_final": "123456",
|
||||||
|
"prestatii": [
|
||||||
|
{"cod_prestatie": "OE-1", "cod_op_service": "100"},
|
||||||
|
{"cod_prestatie": None, "cod_op_service": "200", "denumire": "Spalare", "exclus": True},
|
||||||
|
],
|
||||||
|
"sistem_reparat": "null",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _insert(conn, content):
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO submissions (idempotency_key, status, payload_json, retry_count) "
|
||||||
|
"VALUES (?, 'queued', ?, 0)",
|
||||||
|
(f"key-{os.urandom(4).hex()}", json.dumps(content)),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRarCapturePayload:
|
||||||
|
"""RAR mock care retine payload-ul trimis efectiv la postPrezentare."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sent_payload = None
|
||||||
|
|
||||||
|
def get_finalizate(self, token):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def post_prezentare(self, token, payload):
|
||||||
|
self.sent_payload = payload
|
||||||
|
return {"id": 999}
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_one_nu_trimite_item_exclus_la_rar(env):
|
||||||
|
from app.worker.__main__ import process_one
|
||||||
|
conn, settings = env
|
||||||
|
sid = _insert(conn, _CONTENT_CU_EXCLUS)
|
||||||
|
rar = FakeRarCapturePayload()
|
||||||
|
|
||||||
|
claimed = {"id": sid, "account_id": None, "content": _CONTENT_CU_EXCLUS}
|
||||||
|
status = process_one(conn, settings, rar, "tok", claimed)
|
||||||
|
|
||||||
|
assert status == "sent"
|
||||||
|
assert rar.sent_payload is not None
|
||||||
|
coduri = [p["codPrestatie"] for p in rar.sent_payload["prestatii"]]
|
||||||
|
assert None not in coduri
|
||||||
|
assert coduri == ["OE-1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_one_pastreaza_content_intact_in_db(env):
|
||||||
|
"""content['prestatii'] din DB nu se ingusteaza — doar payload-ul RAR."""
|
||||||
|
from app.worker.__main__ import process_one
|
||||||
|
conn, settings = env
|
||||||
|
sid = _insert(conn, _CONTENT_CU_EXCLUS)
|
||||||
|
rar = FakeRarCapturePayload()
|
||||||
|
|
||||||
|
claimed = {"id": sid, "account_id": None, "content": dict(_CONTENT_CU_EXCLUS)}
|
||||||
|
process_one(conn, settings, rar, "tok", claimed)
|
||||||
|
|
||||||
|
row = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone()
|
||||||
|
saved = json.loads(row["payload_json"])
|
||||||
|
# payload_json in DB nu e atins de process_one (doar payload-ul RAR e ingustat) —
|
||||||
|
# ramane cu ambele prestatii, inclusiv cea exclusa.
|
||||||
|
assert len(saved["prestatii"]) == 2
|
||||||
Reference in New Issue
Block a user