Compare commits
15 Commits
ce90dac833
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5ce0e2e2b | ||
|
|
deb6afff3e | ||
|
|
b4818349be | ||
|
|
ff9d0f41d1 | ||
|
|
7371c3703d | ||
|
|
851f76ca16 | ||
|
|
a29896a790 | ||
|
|
3f513f6c12 | ||
|
|
8f39dfbc1e | ||
|
|
e1243f603e | ||
|
|
80d90f317d | ||
|
|
12021eb269 | ||
|
|
308fee6c27 | ||
|
|
756f77730f | ||
|
|
c05fa00007 |
23
TODOS.md
23
TODOS.md
@@ -53,3 +53,26 @@ Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand de
|
|||||||
- [ ] **US-009/US-010 ca PRD separat daca propagarea design e urgenta** — salvarea mapare-din-chip si
|
- [ ] **US-009/US-010 ca PRD separat daca propagarea design e urgenta** — salvarea mapare-din-chip si
|
||||||
bulk-fix sunt adiacente FUNCTIONALE (acceptate via SELECTIVE EXPANSION), dincolo de obiectivul pur de
|
bulk-fix sunt adiacente FUNCTIONALE (acceptate via SELECTIVE EXPANSION), dincolo de obiectivul pur de
|
||||||
propagare design. Daca vrei sa livrezi designul rapid, pot fi scoase intr-un PRD propriu. (CEO, low.)
|
propagare design. Daca vrei sa livrezi designul rapid, pot fi scoase intr-un PRD propriu. (CEO, low.)
|
||||||
|
|
||||||
|
## Din raport comparatie mockup 5.16 (2026-06-29)
|
||||||
|
|
||||||
|
> Restul task-urilor din `docs/raport-comparatie-mockup-5.16.md` au fost livrate (T-1..T-9).
|
||||||
|
> Cele de mai jos raman explicit in coada la cererea userului.
|
||||||
|
|
||||||
|
- [ ] **Stare de eroare HTMX la incarcarea listei (D-4)** — cand `/_fragments/submissions`
|
||||||
|
da 500 sau pica reteaua, `#submissions-wrap` ramane blocat pe spinner ("se incarca…") fara
|
||||||
|
mesaj. De adaugat un partial de eroare / `hx-on::response-error` cu "nu s-a putut incarca,
|
||||||
|
reincearca". Robustete pre-existenta (nu introdusa de 5.16), impact functional real —
|
||||||
|
**candidatul cu cea mai mare valoare** din lista. (Design D-4, medium.)
|
||||||
|
|
||||||
|
- [ ] **Retokenizare px completa in template-uri** — `_submissions.html` / `_preview_*` folosesc
|
||||||
|
literali `font-size:13px/12px/11px` in loc de token-urile `--fs-*`. 5.16 a corectat doar
|
||||||
|
instanta sub-12px (incalca pragul PRD). Restul ramane debt: schimbarea in masa (13px→`--fs-sm`
|
||||||
|
=13.5px) misca layout-ul, deci necesita o baza de regresie vizuala inainte. (Eng, bounded —
|
||||||
|
amanat ca scope creep fara baza AC.)
|
||||||
|
|
||||||
|
- [ ] **Diacritice in textul vizibil pentru user** — mockup-urile folosesc diacritice complete
|
||||||
|
("Observații", "Salvează", "Adaugă"); aplicatia le omite in majoritatea label-urilor. Fontul
|
||||||
|
le randeaza corect (US-001 confirmat). De aplicat pe label-uri/butoane/titluri, pastrand
|
||||||
|
cod/comentariile fara diacritice. Decizie initiala (poarta de gust T3): nu se aplica acum —
|
||||||
|
reintrodus in coada la cererea userului (2026-06-29) ca finisaj viitor. (Transversal, low.)
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ def create_account(
|
|||||||
cui: str | None = None,
|
cui: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
active: bool = True,
|
active: bool = True,
|
||||||
|
requested_plan: str | None = None,
|
||||||
|
consent_at: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1).
|
"""Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1).
|
||||||
|
|
||||||
@@ -51,12 +53,19 @@ def create_account(
|
|||||||
`email` se normalizeaza (trim+lower); sir gol -> ValueError.
|
`email` se normalizeaza (trim+lower); sir gol -> ValueError.
|
||||||
Un CUI deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial
|
Un CUI deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial
|
||||||
`ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta.
|
`ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta.
|
||||||
|
|
||||||
|
`requested_plan`: planul CERUT la signup (separat de `tier`). NU acorda drepturi — `tier`
|
||||||
|
ramane mereu 'free' la creare; planul cerut e doar o intentie pentru integrarea platilor.
|
||||||
|
Valoare invalida (nu e in VALID_TIERS) -> ignorata (stocata NULL), nu arunca.
|
||||||
|
`consent_at`: marca temporala consimtamant Termeni+GDPR (proba); None = fara flux consimtamant.
|
||||||
"""
|
"""
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("name gol (un cont are nevoie de nume)")
|
raise ValueError("name gol (un cont are nevoie de nume)")
|
||||||
cui = _norm_cui(cui)
|
cui = _norm_cui(cui)
|
||||||
email = _norm_email(email)
|
email = _norm_email(email)
|
||||||
|
# Planul cerut: pastram doar valori valide; orice altceva -> NULL (defensiv).
|
||||||
|
req_plan = requested_plan if requested_plan in VALID_TIERS else None
|
||||||
try:
|
try:
|
||||||
# Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z.
|
# Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z.
|
||||||
trial_until = (
|
trial_until = (
|
||||||
@@ -64,10 +73,11 @@ def create_account(
|
|||||||
)
|
)
|
||||||
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO accounts (name, cui, email, active, status, tier, trial_until) "
|
"INSERT INTO accounts (name, cui, email, active, status, tier, trial_until, "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"requested_plan, consent_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(name, cui, email, 1 if active else 0, "active" if active else "pending",
|
(name, cui, email, 1 if active else 0, "active" if active else "pending",
|
||||||
"free", trial_until),
|
"free", trial_until, req_plan, consent_at),
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
||||||
@@ -185,6 +195,38 @@ def set_tier(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def set_trial(conn: sqlite3.Connection, account_id: int, trial_until: str | None) -> None:
|
||||||
|
"""Seteaza DOAR `trial_until` (acorda/prelungeste/sterge trial Pro), fara a atinge `tier`.
|
||||||
|
|
||||||
|
Trial Pro activ (trial_until in viitor) ridica planul efectiv la 'pro' (vezi
|
||||||
|
plans.effective_tier), indiferent de tier-ul de baza. Folosit din panoul admin ca sa
|
||||||
|
acorzi un trial fara a schimba tier-ul de baza (post-trial).
|
||||||
|
|
||||||
|
Contul de sistem id=1 e protejat. Cont inexistent -> ValueError.
|
||||||
|
trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul).
|
||||||
|
"""
|
||||||
|
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"cont inexistent: {account_id}")
|
||||||
|
if account_id == _PROTECTED_ACCOUNT_ID:
|
||||||
|
raise ValueError("Contul default (id=1) nu poate primi trial (cont de sistem).")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET trial_until=? WHERE id=?", (trial_until, account_id)
|
||||||
|
)
|
||||||
|
# Audit in app_events (best-effort, fara PII nou — ca set_tier).
|
||||||
|
try:
|
||||||
|
from .observ import log_event
|
||||||
|
log_event(
|
||||||
|
"plan_trial_setat",
|
||||||
|
account_id=account_id,
|
||||||
|
mesaj=f"trial_until -> {trial_until or 'NULL'}",
|
||||||
|
context={"trial_until": trial_until},
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 — jurnal best-effort (ca observ.log_event)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
|
def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
|
||||||
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele
|
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele
|
||||||
sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API
|
sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API
|
||||||
@@ -208,7 +250,8 @@ def list_accounts(conn: sqlite3.Connection) -> list[dict]:
|
|||||||
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
|
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
|
||||||
(stergere soft -> invizibile in panou)."""
|
(stergere soft -> invizibile in panou)."""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, cui, email, active, status, tier, trial_until, created_at FROM accounts "
|
"SELECT id, name, cui, email, active, status, tier, trial_until, "
|
||||||
|
"requested_plan, consent_at, created_at FROM accounts "
|
||||||
"WHERE status != 'deleted' ORDER BY id"
|
"WHERE status != 'deleted' ORDER BY id"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ class Settings(BaseSettings):
|
|||||||
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie
|
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie
|
||||||
# creds vin per-cerere de la ROAAUTO — lasa False.
|
# creds vin per-cerere de la ROAAUTO — lasa False.
|
||||||
worker_use_test_creds: bool = False
|
worker_use_test_creds: bool = False
|
||||||
|
# Keepalive RAR: cand coada e goala, worker-ul face un login de proba la fiecare
|
||||||
|
# atata timp ca sa pastreze last_rar_login_ok proaspat (sub pragul de 30h al
|
||||||
|
# dashboard-ului) — altfel banner-ul "RAR inaccesibil" apare fals doar din lipsa
|
||||||
|
# de trafic. 0 = dezactivat. Implicit o data pe zi (24h < 30h, margine de 6h).
|
||||||
|
worker_rar_keepalive_interval_s: int = 86400
|
||||||
worker_sending_lease_s: int = 120 # rand 'sending' mai vechi de atat = orfan (worker mort mid-POST)
|
worker_sending_lease_s: int = 120 # rand 'sending' mai vechi de atat = orfan (worker mort mid-POST)
|
||||||
worker_retry_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
|
worker_retry_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
|
||||||
worker_retry_max_s: int = 300
|
worker_retry_max_s: int = 300
|
||||||
@@ -112,11 +117,21 @@ class Settings(BaseSettings):
|
|||||||
enforce_plans: bool = True
|
enforce_plans: bool = True
|
||||||
|
|
||||||
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
||||||
# DEZACTIVAT implicit: prima folosire lazy-load-eaza modelul fastembed/ONNX
|
# ACTIVAT implicit: editorul de mapari ofera sugestii semantice (model fastembed/ONNX).
|
||||||
# (~230MB pe disc) sincron in thread-ul de cerere -> hang la prima cerere /mapari.
|
# Cost: prima folosire lazy-load-eaza modelul (~230MB pe disc) sincron in thread-ul de
|
||||||
# Activeaza explicit in productie (start.sh/Docker/.env) cand vrei sugestii semantice.
|
# cerere -> prima cerere /mapari poate dura 30-120s pana modelul intra in memorie; cererile
|
||||||
# OFF pastreaza suita de teste rapida si /mapari instant (cade pe GOLD/SILVER+fuzzy).
|
# urmatoare sunt instant. SUGGESTION-ONLY: nu intra in resolve_prestatii (nu auto-trimite).
|
||||||
embeddings_enabled: bool = False
|
# Pune-l pe False (start.sh/Docker/.env: AUTOPASS_EMBEDDINGS_ENABLED=false) cand vrei
|
||||||
|
# /mapari instant la prima cerere sau suita de teste rapida (cade pe GOLD/SILVER+fuzzy).
|
||||||
|
embeddings_enabled: bool = True
|
||||||
|
|
||||||
|
# --- Seed corpus operatii etichetate (SILVER, PRD 5.18 US-004) ---
|
||||||
|
# ACTIVAT implicit: la init_db, populeaza mapping_suggestions din artefactul comis
|
||||||
|
# `app/data/operatii-etichetate.json` (INSERT OR IGNORE). Asa SILVER nu mai e gol in
|
||||||
|
# productie -> sugestii exact-match + corpus k-NN reale. SUGGESTION-ONLY.
|
||||||
|
# Pune-l pe False (AUTOPASS_SEED_OPERATII_ENABLED=false) cand vrei SILVER gol —
|
||||||
|
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
|
||||||
|
seed_operatii_enabled: bool = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rar_base_url(self) -> str:
|
def rar_base_url(self) -> str:
|
||||||
|
|||||||
137450
app/data/operatii-etichetate.json
Normal file
137450
app/data/operatii-etichetate.json
Normal file
File diff suppressed because it is too large
Load Diff
134
app/db.py
134
app/db.py
@@ -37,6 +37,22 @@ def init_db() -> None:
|
|||||||
from .mapping import seed_nomenclator_if_empty
|
from .mapping import seed_nomenclator_if_empty
|
||||||
|
|
||||||
seed_nomenclator_if_empty(conn)
|
seed_nomenclator_if_empty(conn)
|
||||||
|
# Seed corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
|
||||||
|
# Gated: OFF in teste (conftest), ON in productie. INSERT OR IGNORE -> idempotent.
|
||||||
|
# DOAR daca mapping_suggestions e gol: seedul are ~17k randuri; re-rularea lui pe
|
||||||
|
# FIECARE boot (API + worker concurent) tinea write-lock-ul indelung -> al doilea
|
||||||
|
# proces primea "database is locked" la pornire. Guard "_if_empty" (ca nomenclatorul)
|
||||||
|
# -> boot rapid cand e deja seeded. Re-seed dupa actualizarea fisierului = manual
|
||||||
|
# (goleste tabela), consistent cu semantica v1 ignore-not-upsert a seederului.
|
||||||
|
if get_settings().seed_operatii_enabled:
|
||||||
|
already = conn.execute(
|
||||||
|
"SELECT 1 FROM mapping_suggestions LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if not already:
|
||||||
|
from .operatii_seed import seed_operatii_etichetate
|
||||||
|
|
||||||
|
seed_operatii_etichetate(conn)
|
||||||
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -55,11 +71,26 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute("ALTER TABLE submissions ADD COLUMN batch_id INTEGER")
|
conn.execute("ALTER TABLE submissions ADD COLUMN batch_id INTEGER")
|
||||||
if "row_index" not in sub_cols:
|
if "row_index" not in sub_cols:
|
||||||
conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER")
|
conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER")
|
||||||
|
if "rar_env" not in sub_cols:
|
||||||
|
# PRD 5.20 US-001. Mediul RAR tinta pe submission. Pe DB existent NU lasam
|
||||||
|
# randurile pe DEFAULT 'test': un rand prod pre-migrare etichetat 'test' ar fi
|
||||||
|
# reconciliat de worker (US-006) contra endpoint TEST -> no-match -> re-send prod
|
||||||
|
# = DUPLICAT REAL IREVERSIBIL. Backfill din AUTOPASS_RAR_ENV global (ancora de
|
||||||
|
# migrare) + recompute idempotency_key env-aware. Ruleaza O SINGURA DATA (in
|
||||||
|
# blocul de adaugare a coloanei); pe DB fresh coloana vine din schema.sql (fara rows).
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE submissions ADD COLUMN rar_env TEXT NOT NULL DEFAULT 'test' "
|
||||||
|
"CHECK (rar_env IN ('test', 'prod'))"
|
||||||
|
)
|
||||||
|
_backfill_submissions_rar_env(conn)
|
||||||
|
|
||||||
# Coloane accounts
|
# Coloane accounts
|
||||||
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||||
if "rar_creds_enc" not in acc_cols:
|
if "rar_creds_enc" not in acc_cols:
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
|
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
|
||||||
|
acc_cols.add("rar_creds_enc")
|
||||||
|
# Medii RAR per cont (PRD 5.20 US-001): activare + slot creds + default, per mediu.
|
||||||
|
_migrate_accounts_medii(conn, acc_cols)
|
||||||
if "active" not in acc_cols:
|
if "active" not in acc_cols:
|
||||||
# Conturi existente raman active (default 1).
|
# Conturi existente raman active (default 1).
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
||||||
@@ -93,6 +124,14 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
if "trial_until" not in acc_cols:
|
if "trial_until" not in acc_cols:
|
||||||
# Trial Pro activ daca != NULL si > now. Nullable (NULL = fara trial).
|
# Trial Pro activ daca != NULL si > now. Nullable (NULL = fara trial).
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN trial_until TEXT")
|
conn.execute("ALTER TABLE accounts ADD COLUMN trial_until TEXT")
|
||||||
|
if "requested_plan" not in acc_cols:
|
||||||
|
# Planul cerut la signup (integrare plati). NU acorda drepturi; `tier` ramane sursa
|
||||||
|
# de adevar pt API/volum. Nullable. ALTER nu poate adauga CHECK pe coloana noua in
|
||||||
|
# SQLite -> validarea valorilor se face in cod (signup, fata de VALID_TIERS).
|
||||||
|
conn.execute("ALTER TABLE accounts ADD COLUMN requested_plan TEXT")
|
||||||
|
if "consent_at" not in acc_cols:
|
||||||
|
# Marca temporala consimtamant Termeni+GDPR (proba). Nullable (NULL = CLI/legacy).
|
||||||
|
conn.execute("ALTER TABLE accounts ADD COLUMN consent_at TEXT")
|
||||||
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
|
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
||||||
@@ -140,6 +179,101 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
|
||||||
|
"""PRD 5.20 US-001: coloane medii RAR per cont + backfill din ancora globala.
|
||||||
|
|
||||||
|
Adauga (idempotent): rar_test_enabled/rar_prod_enabled (bife activare),
|
||||||
|
rar_creds_test_enc/rar_creds_prod_enc (sloturi creds), rar_env_default.
|
||||||
|
|
||||||
|
Backfill (O SINGURA DATA, cand coloanele tocmai au fost adaugate pe DB existent):
|
||||||
|
creds-ul legacy `rar_creds_enc` apartine mediului `AUTOPASS_RAR_ENV` global de la
|
||||||
|
momentul migrarii (ancora) — il copiem in slotul acelui mediu, activam DOAR acel
|
||||||
|
mediu (celalalt dezactivat) si fixam default-ul pe el. Conturile fara creds raman
|
||||||
|
pe default-urile coloanei (prod on / test off). Migrarea NU presupune env-ul; se
|
||||||
|
bazeaza pe ancora globala, exact cum opera contul inainte de 5.20.
|
||||||
|
"""
|
||||||
|
newly_added = "rar_env_default" not in acc_cols
|
||||||
|
if "rar_test_enabled" not in acc_cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE accounts ADD COLUMN rar_test_enabled INTEGER NOT NULL DEFAULT 0 "
|
||||||
|
"CHECK (rar_test_enabled IN (0, 1))"
|
||||||
|
)
|
||||||
|
if "rar_prod_enabled" not in acc_cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE accounts ADD COLUMN rar_prod_enabled INTEGER NOT NULL DEFAULT 1 "
|
||||||
|
"CHECK (rar_prod_enabled IN (0, 1))"
|
||||||
|
)
|
||||||
|
if "rar_creds_test_enc" not in acc_cols:
|
||||||
|
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_test_enc TEXT")
|
||||||
|
if "rar_creds_prod_enc" not in acc_cols:
|
||||||
|
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_prod_enc TEXT")
|
||||||
|
if "rar_env_default" not in acc_cols:
|
||||||
|
# ALTER nu poate adauga CHECK pe coloana noua in SQLite -> validarea ('test'/'prod')
|
||||||
|
# se face in cod (rar_env.py / rutele de cont). DEFAULT 'prod' (cont client nou).
|
||||||
|
conn.execute("ALTER TABLE accounts ADD COLUMN rar_env_default TEXT NOT NULL DEFAULT 'prod'")
|
||||||
|
|
||||||
|
if not newly_added:
|
||||||
|
return # coloanele existau deja -> backfill-ul a rulat la o pornire anterioara
|
||||||
|
|
||||||
|
# Are coloana legacy rar_creds_enc randuri de migrat? (Pe DB foarte nou, e absenta.)
|
||||||
|
if "rar_creds_enc" not in acc_cols:
|
||||||
|
return
|
||||||
|
env = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test"
|
||||||
|
other = "prod" if env == "test" else "test"
|
||||||
|
slot = f"rar_creds_{env}_enc"
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE accounts SET {slot} = rar_creds_enc, "
|
||||||
|
f"rar_{env}_enabled = 1, rar_{other}_enabled = 0, rar_env_default = ? "
|
||||||
|
f"WHERE rar_creds_enc IS NOT NULL AND TRIM(rar_creds_enc) <> '' AND {slot} IS NULL",
|
||||||
|
(env,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_submissions_rar_env(conn: sqlite3.Connection) -> None:
|
||||||
|
"""PRD 5.20 US-001 (AUTO-FIX G + E4/3): backfill rar_env + recompute idempotency_key.
|
||||||
|
|
||||||
|
Ruleaza O SINGURA DATA, imediat dupa ce coloana `submissions.rar_env` a fost adaugata
|
||||||
|
pe un DB existent. Toate randurile pre-migrare au fost trimise (sau urmeaza) catre
|
||||||
|
mediul `AUTOPASS_RAR_ENV` global — le etichetam cu acel env (NU DEFAULT 'test'), altfel
|
||||||
|
reconcilierea worker-ului ar lovi endpoint-ul gresit -> duplicat ireversibil.
|
||||||
|
|
||||||
|
Recompute `idempotency_key` la forma env-aware (`build_key(account_id, canon, rar_env)`):
|
||||||
|
altfel un re-POST al unui rand legacy (cheie env-less) ar rata randul existent ->
|
||||||
|
duplicat. Recompute-ul e consistent (acelasi env pe toate randurile pre-migrare) deci
|
||||||
|
nu poate crea coliziuni intre randuri care erau deja distincte.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
from .idempotency import build_key, canonicalize_row
|
||||||
|
|
||||||
|
env = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test"
|
||||||
|
conn.execute("UPDATE submissions SET rar_env = ?", (env,))
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, account_id, idempotency_key, payload_json FROM submissions"
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
content = _json.loads(r["payload_json"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
canon = canonicalize_row(content)
|
||||||
|
# Pastreaza prestatiile rezolvate (cod_prestatie/cod_op_service) pentru _op_identity.
|
||||||
|
canon["prestatii"] = content.get("prestatii") or []
|
||||||
|
new_key = build_key(r["account_id"], canon, env)
|
||||||
|
if new_key == r["idempotency_key"]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE submissions SET idempotency_key = ? WHERE id = ?",
|
||||||
|
(new_key, r["id"]),
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
# Coliziune improbabila pe UNIQUE(idempotency_key): lasa cheia veche (no-op),
|
||||||
|
# randul ramane gasibil prin dual-lookup legacy.
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Design (PRD 5.14, Decision #16/#16b):
|
|||||||
|
|
||||||
API public (nivel modul):
|
API public (nivel modul):
|
||||||
index_corpus(items) -> None
|
index_corpus(items) -> None
|
||||||
suggest_nearest(text, top_k) -> [{cod, similaritate}]
|
suggest_nearest(text, top_k) -> [{cod, is_nul, similaritate}]
|
||||||
is_available() -> bool
|
is_available() -> bool
|
||||||
|
|
||||||
Clase (pentru teste / injectare backend):
|
Clase (pentru teste / injectare backend):
|
||||||
@@ -135,10 +135,12 @@ class EmbeddingEngine:
|
|||||||
denumire: str,
|
denumire: str,
|
||||||
top_k: int = 3,
|
top_k: int = 3,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Returneaza top_k vecini cosine [{cod, similaritate}].
|
"""Returneaza top_k vecini cosine [{cod, is_nul, similaritate}].
|
||||||
|
|
||||||
Returneaza [] daca backend-ul lipseste, corpus-ul e gol sau apare
|
`is_nul` (PRD 5.18 US-005): cand corpusul include exemple NUL (non-operatii),
|
||||||
orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
|
un vecin NUL = semnal de SUPRESIE, nu cod. Default False pe corpusuri vechi
|
||||||
|
fara `is_nul` in itemi. Returneaza [] daca backend-ul lipseste, corpus-ul e gol
|
||||||
|
sau apare orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
|
||||||
"""
|
"""
|
||||||
if not self.is_available() or not self._corpus_items:
|
if not self.is_available() or not self._corpus_items:
|
||||||
return []
|
return []
|
||||||
@@ -149,6 +151,7 @@ class EmbeddingEngine:
|
|||||||
scored = [
|
scored = [
|
||||||
{
|
{
|
||||||
"cod": item["cod"],
|
"cod": item["cod"],
|
||||||
|
"is_nul": bool(item.get("is_nul", False)),
|
||||||
"similaritate": _cosine_similarity(query_vec, vec),
|
"similaritate": _cosine_similarity(query_vec, vec),
|
||||||
}
|
}
|
||||||
for item, vec in zip(self._corpus_items, self._corpus_vecs)
|
for item, vec in zip(self._corpus_items, self._corpus_vecs)
|
||||||
@@ -239,7 +242,7 @@ def index_corpus(items: list[dict], signature: str | None = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]:
|
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]:
|
||||||
"""Returneaza top_k sugestii [{cod, similaritate}] sau [] la eroare.
|
"""Returneaza top_k sugestii [{cod, is_nul, similaritate}] sau [] la eroare.
|
||||||
|
|
||||||
Sigur de apelat indiferent de starea backend-ului.
|
Sigur de apelat indiferent de starea backend-ului.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -70,17 +70,23 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
|
def build_key(account_id: int | None, canon: dict[str, Any], rar_env: str = "test") -> str:
|
||||||
"""SHA-256 partajat canal-API + canal-import.
|
"""SHA-256 partajat canal-API + canal-import, env-aware (PRD 5.20 US-003).
|
||||||
|
|
||||||
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
||||||
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
||||||
|
|
||||||
|
`rar_env` ('test'|'prod') intra in cheie: aceeasi prezentare la test si apoi la
|
||||||
|
prod sunt DOUA trimiteri reale distincte (sisteme RAR separate), nu un duplicat.
|
||||||
|
Default 'test' = back-compat cu apelantii care nu paseaza inca env-ul; toate
|
||||||
|
rutele de ingestie paseaza env-ul rezolvat explicit.
|
||||||
"""
|
"""
|
||||||
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
|
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
|
||||||
from .mapping import account_or_default
|
from .mapping import account_or_default
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
canonic = {
|
canonic = {
|
||||||
"account_id": acct,
|
"account_id": acct,
|
||||||
|
"rar_env": rar_env,
|
||||||
"vin": canon.get("vin", ""),
|
"vin": canon.get("vin", ""),
|
||||||
"nr_inmatriculare": canon.get("nr_inmatriculare", ""),
|
"nr_inmatriculare": canon.get("nr_inmatriculare", ""),
|
||||||
"data_prestatie": canon.get("data_prestatie"),
|
"data_prestatie": canon.get("data_prestatie"),
|
||||||
@@ -91,8 +97,8 @@ def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
|
|||||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
def idempotency_key(account_id: int | None, prezentare: dict[str, Any], rar_env: str = "test") -> str:
|
||||||
"""SHA-256 peste (account_id + campurile semnificative ale prezentarii).
|
"""SHA-256 peste (account_id + rar_env + campurile semnificative ale prezentarii).
|
||||||
|
|
||||||
Wrapper backward-compat peste canonicalize_row + build_key.
|
Wrapper backward-compat peste canonicalize_row + build_key.
|
||||||
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
||||||
@@ -102,7 +108,7 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
|||||||
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
|
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
|
||||||
"""
|
"""
|
||||||
canon = canonicalize_row(prezentare)
|
canon = canonicalize_row(prezentare)
|
||||||
return build_key(account_id, canon)
|
return build_key(account_id, canon, rar_env)
|
||||||
|
|
||||||
|
|
||||||
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||||
|
|||||||
143
app/mapping.py
143
app/mapping.py
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -49,6 +50,60 @@ def normalize_for_match(value: object) -> str:
|
|||||||
return " ".join(s.upper().split())
|
return " ".join(s.upper().split())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Pre-filtru determinist non-operatii (NUL) — US-001 PRD 5.18 #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
#
|
||||||
|
# Masuratoarea k-NN (memorie test-precizie-knn-embeddings) arata recall NUL doar
|
||||||
|
# 64%: gunoiul evident (ITP, plata, discount, nr. inmatriculare, tractare) scapa
|
||||||
|
# semantic ca OE-1. Un pre-filtru text/regex il marcheaza NUL INAINTE de k-NN.
|
||||||
|
#
|
||||||
|
# Garantie: ZERO fals-pozitiv pe operatii reale. Regulile au fost calibrate pe
|
||||||
|
# `docs/operatii-service/*.csv` (toate aparitiile distincte). Triggerele NEambigue
|
||||||
|
# (ITP, ACHITAT/PLATA, DISCOUNT/REDUCERE, TAXA) sunt neconditionate (0 FP masurat).
|
||||||
|
# Triggerele AMBIGUE (TRACTARE, NR INMATRICULARE + pattern placuta) apar si in
|
||||||
|
# operatii reale ("D/R CARLIG TRACTARE", "D/R ELECTROMOTOR CT 44 MKY") -> sunt
|
||||||
|
# ECRANATE de un context de piesa/operatie (`_NUL_CTX_PIESA`).
|
||||||
|
|
||||||
|
# Trigger-uri neambigue (substring/regex pe text normalizat).
|
||||||
|
_NUL_ITP = re.compile(r"(?:\bITP\b|\d\s*X\s*ITP|X\s*ITP\b|\bITP[.,])")
|
||||||
|
_NUL_PLATA = re.compile(r"\b(ACHITAT|ACHITARE|PLATA|PLATIT|PLATIRE)\b")
|
||||||
|
_NUL_DISCOUNT = re.compile(r"\b(DISCOUNT|REDUCERE)\b")
|
||||||
|
_NUL_TAXA = re.compile(r"\bTAXA\b")
|
||||||
|
|
||||||
|
# Trigger-uri ambigue — valide ca NUL DOAR in absenta unui context de piesa.
|
||||||
|
_NUL_TRACTARE = re.compile(r"\b(TRACTARE|TRACTARI)\b")
|
||||||
|
_NUL_NR_PLACUTA = re.compile(
|
||||||
|
r"(\bNR\s+INMATRICULARE\b|\bNUMAR\s+INMATRICULARE\b|\b[A-Z]{1,2}\s?\d{2,3}\s?[A-Z]{3}\b)"
|
||||||
|
)
|
||||||
|
# Daca apare oricare cuvant de aici, TRACTARE/placuta e nume de piesa sau operatie
|
||||||
|
# reala (carlig/capac de tractare, suport placuta, placuta lipita la o reparatie).
|
||||||
|
_NUL_CTX_PIESA = re.compile(
|
||||||
|
r"\b(D/R|D-R|CARLIG|CAPAC|BARA|PROTECTIE|MONTAT|MONTAJ|DEMONTAT|INLOCUIT|"
|
||||||
|
r"INLOCUIRE|REPARAT|REPARATIE|VOPSIT|SCHIMBAT|SUPORT)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prefiltru_nul(denumire: object) -> bool:
|
||||||
|
"""True daca operatia e gunoi evident (non-operatie de service) -> NUL determinist.
|
||||||
|
|
||||||
|
Ruleaza INAINTE de k-NN/embeddings in `enrich_suggestions` (US-006). Pur, fara DB.
|
||||||
|
Zero fals-pozitiv pe operatii reale (vezi comentariul de mai sus + tests).
|
||||||
|
"""
|
||||||
|
text = normalize_for_match(denumire)
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
# Neambigue: 0 FP masurat, fara ecranare.
|
||||||
|
if _NUL_ITP.search(text) or _NUL_PLATA.search(text) or _NUL_DISCOUNT.search(text) or _NUL_TAXA.search(text):
|
||||||
|
return True
|
||||||
|
# Ambigue: doar daca NU e context de piesa.
|
||||||
|
if _NUL_CTX_PIESA.search(text):
|
||||||
|
return False
|
||||||
|
if _NUL_TRACTARE.search(text) or _NUL_NR_PLACUTA.search(text):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def suggest_codes(
|
def suggest_codes(
|
||||||
denumire: object,
|
denumire: object,
|
||||||
nomenclator: list[dict],
|
nomenclator: list[dict],
|
||||||
@@ -576,51 +631,58 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
|
|||||||
EMB_MIN_SIMILARITATE = 0.5
|
EMB_MIN_SIMILARITATE = 0.5
|
||||||
|
|
||||||
|
|
||||||
def _corpus_signature(nomenclator: list[dict]) -> str:
|
def _corpus_signature_silver(rows: list) -> str:
|
||||||
"""Semnatura stabila a nomenclatorului pentru cache-ul corpusului embeddings.
|
"""Semnatura stabila a corpusului SILVER (mapping_suggestions) pentru cache.
|
||||||
|
|
||||||
Hash pe perechile (cod, denumire) sortate dupa cod -> se schimba la orice
|
Hash pe (denumire_normalizata, cod, is_nul) sortat -> se schimba la orice
|
||||||
add/remove/redenumire de cod, ramane stabila altfel (evita re-embed inutil).
|
add/remove/redenumire/relabel, ramane stabila altfel (evita re-embed inutil).
|
||||||
"""
|
"""
|
||||||
pairs = sorted(
|
triples = sorted(
|
||||||
(str(n.get("cod_prestatie") or ""), str(n.get("nume_prestatie") or ""))
|
(str(r["denumire_normalizata"] or ""), str(r["cod_prestatie"] or ""), int(r["is_nul"] or 0))
|
||||||
for n in nomenclator
|
for r in rows
|
||||||
)
|
)
|
||||||
blob = "".join(f"{c}{d}" for c, d in pairs)
|
blob = "".join(f"{d}|{c}|{n}" for d, c, n in triples)
|
||||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
|
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
|
||||||
"""Construieste/actualizeaza corpusul embeddings din nomenclator (Stratul 2 PRD 5.14).
|
"""Construieste/actualizeaza corpusul embeddings din corpusul ETICHETAT (PRD 5.18 US-005).
|
||||||
|
|
||||||
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default OFF): cand e dezactivat, e un
|
Sursa corpusului = `mapping_suggestions` (SILVER): exemple reale etichetate
|
||||||
no-op total (nu atinge modelul, nu interogheaza nomenclatorul) -> /mapari instant
|
{denumire_normalizata -> cod, is_nul}, NU cele 18 categorii generice din
|
||||||
+ suita de teste rapida; sugestiile cad pe GOLD/SILVER + fuzzy.
|
`nomenclator_rar`. k-NN peste exemple reale e net mai precis (94.3% acord LLM).
|
||||||
|
Parametrul `nomenclator` e pastrat pentru compatibilitatea apelantilor, dar nu mai
|
||||||
|
e folosit ca sursa.
|
||||||
|
|
||||||
Cand e activat: indexeaza corpusul {denumire=nume_prestatie, cod=cod_prestatie}
|
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default ON; OFF in teste): cand e
|
||||||
o singura data (lazy-load modelul ~230MB la prima chemare), re-indexeaza doar
|
dezactivat, e un no-op total -> /mapari instant + suita de teste rapida.
|
||||||
cand semnatura nomenclatorului s-a schimbat. Degradare gratioasa: orice eroare
|
|
||||||
(model absent, embed esuat) lasa corpusul gol -> enrich_suggestions cade pe restul.
|
|
||||||
|
|
||||||
Apelat de apelantii care imbogatesc sugestii (pending_unmapped,
|
Cand e activat: indexeaza corpusul o singura data (lazy-load modelul ~230MB la
|
||||||
_nemapate_pentru_submission) INAINTE de bucla de enrich_suggestions, NU din
|
prima chemare), re-indexeaza doar cand semnatura corpusului SILVER s-a schimbat.
|
||||||
enrich_suggestions (care ramane o interogare ieftina cu garda has_corpus()).
|
Itemii NUL (is_nul=1, cod NULL) raman in corpus: un vecin NUL e semnal de supresie
|
||||||
|
(US-006). Degradare gratioasa: orice eroare lasa corpusul gol -> enrich cade pe restul.
|
||||||
"""
|
"""
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
if not get_settings().embeddings_enabled:
|
if not get_settings().embeddings_enabled:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from . import embeddings as _emb
|
from . import embeddings as _emb
|
||||||
nomen = nomenclator if nomenclator is not None else load_nomenclator(conn)
|
rows = conn.execute(
|
||||||
if not nomen:
|
"SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions"
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
return
|
return
|
||||||
sig = _corpus_signature(nomen)
|
sig = _corpus_signature_silver(rows)
|
||||||
if _emb.corpus_signature() == sig and _emb.has_corpus():
|
if _emb.corpus_signature() == sig and _emb.has_corpus():
|
||||||
return # deja indexat pe acelasi nomenclator -> nimic de facut
|
return # deja indexat pe acelasi corpus SILVER -> nimic de facut
|
||||||
items = [
|
items = [
|
||||||
{"denumire": str(n["nume_prestatie"]), "cod": str(n["cod_prestatie"])}
|
{
|
||||||
for n in nomen
|
"denumire": str(r["denumire_normalizata"]),
|
||||||
if n.get("nume_prestatie") and n.get("cod_prestatie")
|
"cod": (str(r["cod_prestatie"]) if r["cod_prestatie"] is not None else None),
|
||||||
|
"is_nul": bool(r["is_nul"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
if r["denumire_normalizata"]
|
||||||
]
|
]
|
||||||
_emb.index_corpus(items, signature=sig)
|
_emb.index_corpus(items, signature=sig)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -641,26 +703,38 @@ def enrich_suggestions(
|
|||||||
(Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping;
|
(Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping;
|
||||||
nu apare in needs_mapping, deci nu e in precedenta de sugestie.)
|
nu apare in needs_mapping, deci nu e in precedenta de sugestie.)
|
||||||
|
|
||||||
|
Ordine completa (PRD 5.18 US-006):
|
||||||
|
pre-filtru NUL determinist -> (daca NUL: fara cod, `surse['nul']=True`)
|
||||||
|
altfel GOLD partajat > exact (SILVER) > k-NN embeddings.
|
||||||
|
|
||||||
Returneaza:
|
Returneaza:
|
||||||
{
|
{
|
||||||
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
|
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
|
||||||
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None}
|
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None, 'nul': bool}
|
||||||
}
|
}
|
||||||
|
|
||||||
INVARIANTE:
|
INVARIANTE:
|
||||||
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13).
|
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13).
|
||||||
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie (#4).
|
- Pre-filtru NUL (US-001) ruleaza PRIMUL: gunoiul evident (ITP/plata/discount...) e
|
||||||
|
marcat non-operatie INAINTE de k-NN, fara sugestie de cod.
|
||||||
|
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie (#4); vecin k-NN NUL idem.
|
||||||
- Degradare gratioasa pe embeddings (#16b): daca motorul nu e disponibil sau arunca,
|
- Degradare gratioasa pe embeddings (#16b): daca motorul nu e disponibil sau arunca,
|
||||||
returneaza sugestia disponibila din celelalte surse, fara exceptie.
|
returneaza sugestia disponibila din celelalte surse, fara exceptie.
|
||||||
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store
|
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store
|
||||||
importa normalize_for_match din mapping).
|
importa normalize_for_match din mapping).
|
||||||
"""
|
"""
|
||||||
sugestie_principala: dict | None = None
|
sugestie_principala: dict | None = None
|
||||||
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None}
|
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
|
||||||
|
|
||||||
if not denumire:
|
if not denumire:
|
||||||
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
||||||
|
|
||||||
|
# 0. Pre-filtru NUL determinist (US-001) INAINTE de orice k-NN/lookup: non-operatie
|
||||||
|
# evidenta -> fara cod, scurtcircuit (nu interogheaza embeddings/SILVER pe gunoi).
|
||||||
|
if prefiltru_nul(denumire):
|
||||||
|
surse["nul"] = True
|
||||||
|
return {"sugestie_principala": None, "surse": surse}
|
||||||
|
|
||||||
# Colecteaza TOATE sursele (fara short-circuit) in `surse`: editorul le poate afisa
|
# Colecteaza TOATE sursele (fara short-circuit) in `surse`: editorul le poate afisa
|
||||||
# toate, independent de care castiga ca sugestie principala.
|
# toate, independent de care castiga ca sugestie principala.
|
||||||
# Precedenta Eng-F2 se aplica DOAR la alegerea sugestiei_principale.
|
# Precedenta Eng-F2 se aplica DOAR la alegerea sugestiei_principale.
|
||||||
@@ -693,11 +767,18 @@ def enrich_suggestions(
|
|||||||
# ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand
|
# ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand
|
||||||
# flagul e off, has_corpus() ramane False si calea e un no-op real.
|
# flagul e off, has_corpus() ramane False si calea e un no-op real.
|
||||||
if _emb.has_corpus():
|
if _emb.has_corpus():
|
||||||
nn = _emb.suggest_nearest(str(denumire), top_k=1)
|
# F1 (US-005): corpusul k-NN e text NORMALIZAT (denumire_normalizata),
|
||||||
|
# deci query-ul TREBUIE normalizat la fel — altfel cosine degradeaza si
|
||||||
|
# nu mai e configul sub care s-a masurat 94.3%.
|
||||||
|
nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=1)
|
||||||
# Prag minim: similaritate prea mica = sugestie inutila.
|
# Prag minim: similaritate prea mica = sugestie inutila.
|
||||||
# Evita recomandari irelevante cand corpus-ul e mic/partial.
|
# Evita recomandari irelevante cand corpus-ul e mic/partial.
|
||||||
if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
|
if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
|
||||||
surse["embedding"] = str(nn[0]["cod"])
|
if nn[0].get("is_nul"):
|
||||||
|
# Vecin NUL (non-operatie) = semnal de SUPRESIE, nu cod (US-006).
|
||||||
|
surse["nul"] = True
|
||||||
|
elif nn[0].get("cod"):
|
||||||
|
surse["embedding"] = str(nn[0]["cod"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # degradare gratioasa (#16b): motorul absent nu blocheaza
|
pass # degradare gratioasa (#16b): motorul absent nu blocheaza
|
||||||
|
|
||||||
|
|||||||
59
app/operatii_seed.py
Normal file
59
app/operatii_seed.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Seeder corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
|
||||||
|
|
||||||
|
Artefactul `app/data/operatii-etichetate.json` e produs offline de
|
||||||
|
`tools/mapare-llm/genereaza_seed.py` (etichetare LM Studio, o singura data) si comis
|
||||||
|
in repo. La `init_db` il incarcam in `mapping_suggestions` cu INSERT OR IGNORE, ca
|
||||||
|
SILVER sa nu mai fie gol in productie (sugestii exact-match + corpus k-NN reale).
|
||||||
|
|
||||||
|
Format seed: [{denumire, denumire_normalizata, cod, is_nul, source, confidence}].
|
||||||
|
Reutilizeaza `shared_store.seed_suggestions` (normalizeaza cheia + impune NUL->cod NULL,
|
||||||
|
INSERT OR IGNORE). NB (F10): confirmarile UMANE stau in `shared_mappings`, NU aici —
|
||||||
|
deci INSERT OR IGNORE pastreaza codul LLM existent la re-seed (v1 = ignore, nu upsert).
|
||||||
|
|
||||||
|
SUGGESTION-ONLY (invariant #13): nimic din SILVER nu intra in resolve_prestatii/load_mapping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from .shared_store import seed_suggestions
|
||||||
|
|
||||||
|
SEED_PATH = os.path.join(os.path.dirname(__file__), "data", "operatii-etichetate.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_seed_file(path: str = SEED_PATH) -> list[dict]:
|
||||||
|
"""Citeste artefactul seed. Lipsa / invalid -> [] (degradare gratioasa)."""
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return []
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def seed_operatii_etichetate(conn: sqlite3.Connection, path: str = SEED_PATH) -> int:
|
||||||
|
"""Incarca seedul in mapping_suggestions (INSERT OR IGNORE). Intoarce nr. randuri inserate.
|
||||||
|
|
||||||
|
Mapeaza cheia seedului `cod` -> `cod_prestatie` (forma asteptata de seed_suggestions);
|
||||||
|
`is_nul=True` forteaza cod NULL acolo. Idempotent: re-rularea nu dubleaza randuri.
|
||||||
|
"""
|
||||||
|
raw = load_seed_file(path)
|
||||||
|
if not raw:
|
||||||
|
return 0
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"denumire": e.get("denumire") or e.get("denumire_normalizata") or "",
|
||||||
|
"cod_prestatie": e.get("cod"),
|
||||||
|
"is_nul": bool(e.get("is_nul")),
|
||||||
|
"source": e.get("source") or "llm_seed",
|
||||||
|
"confidence": e.get("confidence") or 0.0,
|
||||||
|
}
|
||||||
|
for e in raw
|
||||||
|
if isinstance(e, dict)
|
||||||
|
]
|
||||||
|
return seed_suggestions(conn, items)
|
||||||
91
app/rar_env.py
Normal file
91
app/rar_env.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Medii RAR per cont (PRD 5.20): disponibilitate + default efectiv.
|
||||||
|
|
||||||
|
Sursa UNICA de adevar pentru REQ-DISP / REQ-DEFAULT: vizibilitatea selector/toggle
|
||||||
|
in UI, validarea tintei in API si decizia worker-ului citesc TOATE de aici, ca sa
|
||||||
|
decida identic.
|
||||||
|
|
||||||
|
Un mediu ('test'|'prod') e *disponibil* pentru un cont daca e activat (bifa) SI are
|
||||||
|
credentiale (slot per-mediu non-gol). Din disponibilitate decurge tot UX-ul:
|
||||||
|
- 0 medii -> nicio tinta; trimiterea web e blocata, API cade pe ancora globala.
|
||||||
|
- 1 mediu -> tinta implicita (acel mediu), fara selector.
|
||||||
|
- 2 medii -> selector la import + toggle in statusbar + alegere in API.
|
||||||
|
|
||||||
|
Functii PURE (fara DB) peste un rand de cont (sqlite3.Row sau dict). Helperele cu
|
||||||
|
`conn` incarca randul si deleaga.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
VALID_ENVS: tuple[str, str] = ("test", "prod")
|
||||||
|
|
||||||
|
|
||||||
|
def _field(account: Any, key: str, default: Any = None) -> Any:
|
||||||
|
"""Citire toleranta a unui camp de cont (dict sau sqlite3.Row, camp posibil absent)."""
|
||||||
|
if account is None:
|
||||||
|
return default
|
||||||
|
if isinstance(account, dict):
|
||||||
|
return account.get(key, default)
|
||||||
|
try:
|
||||||
|
return account[key] # sqlite3.Row
|
||||||
|
except (IndexError, KeyError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _are_creds(account: Any, env: str) -> bool:
|
||||||
|
creds = _field(account, f"rar_creds_{env}_enc", None)
|
||||||
|
return bool(creds and str(creds).strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled(account: Any, env: str) -> bool:
|
||||||
|
return int(_field(account, f"rar_{env}_enabled", 0) or 0) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def medii_disponibile(account: Any) -> list[str]:
|
||||||
|
"""Subset din ('test','prod') = activat AND creds prezente. Ordine stabila test<prod."""
|
||||||
|
return [env for env in VALID_ENVS if _enabled(account, env) and _are_creds(account, env)]
|
||||||
|
|
||||||
|
|
||||||
|
def rar_env_efectiv(account: Any) -> str | None:
|
||||||
|
"""Mediul tinta implicit al contului (REQ-DEFAULT).
|
||||||
|
|
||||||
|
Mereu unul din mediile disponibile: default-ul contului daca inca e disponibil,
|
||||||
|
altfel singurul disponibil; daca 0 disponibile -> None (nicio tinta).
|
||||||
|
"""
|
||||||
|
disp = medii_disponibile(account)
|
||||||
|
if not disp:
|
||||||
|
return None
|
||||||
|
default = _field(account, "rar_env_default", "prod")
|
||||||
|
if default in disp:
|
||||||
|
return default
|
||||||
|
return disp[0]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Helpere cu conexiune #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_ACCOUNT_ENV_COLS = (
|
||||||
|
"id, rar_test_enabled, rar_prod_enabled, "
|
||||||
|
"rar_creds_test_enc, rar_creds_prod_enc, rar_env_default"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_account_env(conn: sqlite3.Connection, account_id: int) -> sqlite3.Row | None:
|
||||||
|
"""Randul de cont cu exact coloanele de mediu (pentru medii_disponibile/rar_env_efectiv)."""
|
||||||
|
from .mapping import account_or_default
|
||||||
|
|
||||||
|
return conn.execute(
|
||||||
|
f"SELECT {_ACCOUNT_ENV_COLS} FROM accounts WHERE id=?",
|
||||||
|
(account_or_default(account_id),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def medii_disponibile_cont(conn: sqlite3.Connection, account_id: int) -> list[str]:
|
||||||
|
return medii_disponibile(load_account_env(conn, account_id))
|
||||||
|
|
||||||
|
|
||||||
|
def rar_env_efectiv_cont(conn: sqlite3.Connection, account_id: int) -> str | None:
|
||||||
|
return rar_env_efectiv(load_account_env(conn, account_id))
|
||||||
@@ -19,7 +19,15 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
-- vezi accounts.delete_account — randul ramane doar pentru audit).
|
-- vezi accounts.delete_account — randul ramane doar pentru audit).
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
CHECK (status IN ('pending','active','blocked','archived','deleted')),
|
CHECK (status IN ('pending','active','blocked','archived','deleted')),
|
||||||
rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1)
|
rar_creds_enc TEXT, -- LEGACY (PRD 5.20 US-013 dropeaza coloana): creds RAR durabile env-less
|
||||||
|
-- Medii RAR per cont (PRD 5.20 US-001). Fiecare mediu = bifa de activare + slot creds.
|
||||||
|
-- medii_disponibile = enabled AND creds prezente (app/rar_env.py). Cont client nou =
|
||||||
|
-- Productie on / Testare off (clientii declara real); contul operator se pune manual pe Testare.
|
||||||
|
rar_test_enabled INTEGER NOT NULL DEFAULT 0 CHECK (rar_test_enabled IN (0, 1)),
|
||||||
|
rar_prod_enabled INTEGER NOT NULL DEFAULT 1 CHECK (rar_prod_enabled IN (0, 1)),
|
||||||
|
rar_creds_test_enc TEXT, -- creds RAR criptate (Fernet) pentru mediul Testare
|
||||||
|
rar_creds_prod_enc TEXT, -- creds RAR criptate (Fernet) pentru mediul Productie
|
||||||
|
rar_env_default TEXT NOT NULL DEFAULT 'prod' CHECK (rar_env_default IN ('test', 'prod')),
|
||||||
-- Comportament implicit la cod prestatie necunoscut/nemapat pe canalul API:
|
-- Comportament implicit la cod prestatie necunoscut/nemapat pe canalul API:
|
||||||
-- 0 (default, non-distructiv: submission 'needs_mapping', intra in editorul de mapare) sau
|
-- 0 (default, non-distructiv: submission 'needs_mapping', intra in editorul de mapare) sau
|
||||||
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
|
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
|
||||||
@@ -32,6 +40,15 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
tier TEXT NOT NULL DEFAULT 'free'
|
tier TEXT NOT NULL DEFAULT 'free'
|
||||||
CHECK (tier IN ('free','standard','pro','premium')),
|
CHECK (tier IN ('free','standard','pro','premium')),
|
||||||
trial_until TEXT, -- ISO datetime UTC sau NULL; nullable
|
trial_until TEXT, -- ISO datetime UTC sau NULL; nullable
|
||||||
|
-- Planul CERUT de client la signup (separat de `tier`). NU acorda drepturi:
|
||||||
|
-- `tier` ramane sursa unica de adevar pentru gate-ul API (require_api_access) si volum.
|
||||||
|
-- Folosit la integrarea platilor: client cere plan -> plateste -> admin/webhook urca `tier`
|
||||||
|
-- -> API se deblocheaza. NULL = necunoscut (cont creat via CLI / inainte de coloana).
|
||||||
|
requested_plan TEXT
|
||||||
|
CHECK (requested_plan IS NULL OR requested_plan IN ('free','standard','pro','premium')),
|
||||||
|
-- Marca temporala a acceptarii Termenilor + politicii de confidentialitate (GDPR, L.142).
|
||||||
|
-- Setata la signup (proba de consimtamant). NULL = cont fara flux de consimtamant (CLI/legacy).
|
||||||
|
consent_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
-- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi
|
-- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi
|
||||||
@@ -79,6 +96,10 @@ CREATE TABLE IF NOT EXISTS submissions (
|
|||||||
status TEXT NOT NULL DEFAULT 'queued'
|
status TEXT NOT NULL DEFAULT 'queued'
|
||||||
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
|
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
|
||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
|
-- Mediul RAR tinta al acestei trimiteri (PRD 5.20 US-001). DEFAULT 'test' e doar plasa
|
||||||
|
-- pentru randuri net-noi care nu seteaza explicit; fiecare INSERT (API/import/web) seteaza
|
||||||
|
-- rar_env explicit. Backfill din AUTOPASS_RAR_ENV global la migrare (NU lasa pe DEFAULT).
|
||||||
|
rar_env TEXT NOT NULL DEFAULT 'test' CHECK (rar_env IN ('test', 'prod')),
|
||||||
rar_creds_enc TEXT, -- creds RAR criptate (Fernet), sterse dupa primul login reusit
|
rar_creds_enc TEXT, -- creds RAR criptate (Fernet), sterse dupa primul login reusit
|
||||||
rar_status_code INTEGER,
|
rar_status_code INTEGER,
|
||||||
rar_error TEXT,
|
rar_error TEXT,
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ def seed_suggestions(
|
|||||||
continue
|
continue
|
||||||
is_nul = 1 if item.get("is_nul") else 0
|
is_nul = 1 if item.get("is_nul") else 0
|
||||||
# NUL -> cod NULL obligatoriu (supresie stricta, #4)
|
# NUL -> cod NULL obligatoriu (supresie stricta, #4)
|
||||||
cod = None if is_nul else ((item.get("cod_prestatie") or "") or None)
|
# Normalizeaza INAINTE de truthiness: un cod whitespace-only (" ") sau
|
||||||
if cod:
|
# ne-string trebuie sa devina NULL, nu '' (altfel rand non-NUL cu cod gol).
|
||||||
cod = cod.strip().upper()
|
cod = None
|
||||||
|
if not is_nul:
|
||||||
|
raw_cod = str(item.get("cod_prestatie") or "").strip().upper()
|
||||||
|
cod = raw_cod or None
|
||||||
source = str(item.get("source") or "llm")
|
source = str(item.get("source") or "llm")
|
||||||
confidence = float(item.get("confidence") or 0.0)
|
confidence = float(item.get("confidence") or 0.0)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Rute:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
@@ -15,12 +16,42 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
from ..accounts import account_is_complete, list_accounts, set_active, set_status, delete_account
|
from ..accounts import account_is_complete, list_accounts, set_active, set_status, set_tier, set_trial, delete_account
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db import get_connection
|
from ..db import get_connection
|
||||||
|
from ..plans import PLANS, effective_tier
|
||||||
from ..web.csrf import get_csrf_token, verify_csrf
|
from ..web.csrf import get_csrf_token, verify_csrf
|
||||||
from ..web.session import require_admin
|
from ..web.session import require_admin
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_label(code: str | None) -> str:
|
||||||
|
"""Eticheta RO a unui cod de plan (din PLANS). None/necunoscut -> '—'."""
|
||||||
|
if not code:
|
||||||
|
return "—"
|
||||||
|
plan = PLANS.get(code)
|
||||||
|
return plan["label"] if plan else code
|
||||||
|
|
||||||
|
|
||||||
|
def _trial_zile_ramase(trial_until_str: str | None, now: datetime) -> int | None:
|
||||||
|
"""Zile ramase din trial (rotunjit in sus), sau None daca nu e trial activ/malformat.
|
||||||
|
|
||||||
|
Acelasi parsing tolerant ca plans.effective_tier (UTC implicit pe valori naive).
|
||||||
|
"""
|
||||||
|
if not trial_until_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T"))
|
||||||
|
if tu.tzinfo is None:
|
||||||
|
tu = tu.replace(tzinfo=timezone.utc)
|
||||||
|
now_cmp = now if now.tzinfo else now.replace(tzinfo=timezone.utc)
|
||||||
|
secunde = (tu - now_cmp).total_seconds()
|
||||||
|
if secunde <= 0:
|
||||||
|
return None
|
||||||
|
# Rotunjire in sus la zile (o fractie de zi ramasa = inca 1 zi afisata).
|
||||||
|
return int(secunde // 86400) + (1 if secunde % 86400 else 0)
|
||||||
|
except (ValueError, AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||||
|
|
||||||
@@ -47,10 +78,19 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
|
|||||||
"""Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare."""
|
"""Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare."""
|
||||||
accounts = list_accounts(conn)
|
accounts = list_accounts(conn)
|
||||||
emails = _emails_by_account(conn)
|
emails = _emails_by_account(conn)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
for acct in accounts:
|
for acct in accounts:
|
||||||
# Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului
|
# Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului
|
||||||
acct["is_complete"] = account_is_complete(acct)
|
acct["is_complete"] = account_is_complete(acct)
|
||||||
acct["email"] = emails.get(acct["id"])
|
acct["email"] = emails.get(acct["id"])
|
||||||
|
# Plan EFECTIV (ce are contul acum): trial Pro activ ridica `free` la `pro`.
|
||||||
|
# `tier` ramane sursa de adevar pentru drepturi; `requested_plan` e doar intentia de la signup.
|
||||||
|
eff = effective_tier(acct, now)
|
||||||
|
acct["tier_label"] = _plan_label(acct.get("tier")) # tier de baza (post-trial)
|
||||||
|
acct["tier_efectiv_label"] = _plan_label(eff) # plan efectiv ACUM
|
||||||
|
acct["trial_activ"] = eff != (acct.get("tier") or "free")
|
||||||
|
acct["trial_zile"] = _trial_zile_ramase(acct.get("trial_until"), now)
|
||||||
|
acct["requested_plan_label"] = _plan_label(acct.get("requested_plan"))
|
||||||
# Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
# Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
||||||
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
|
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
|
||||||
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
|
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
|
||||||
@@ -146,6 +186,73 @@ async def admin_delete(request: Request, account_id: list[int] = Form(...),
|
|||||||
return _lifecycle_route(request, account_id, csrf_token, "delete")
|
return _lifecycle_route(request, account_id, csrf_token, "delete")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/set-tier", response_class=HTMLResponse)
|
||||||
|
async def admin_set_tier(
|
||||||
|
request: Request,
|
||||||
|
account_id: int = Form(...),
|
||||||
|
tier: str = Form(...),
|
||||||
|
csrf_token: str = Form(default=""),
|
||||||
|
):
|
||||||
|
"""Schimba planul (tier) unui cont din panou. require_admin + CSRF, PRG 303.
|
||||||
|
|
||||||
|
Reuseaza accounts.set_tier (valideaza tier-ul, protejeaza id=1, logheaza schimbarea).
|
||||||
|
INCHEIE trial-ul (trial_until=NULL): alocarea manuala = plan real de-acum, cu efect
|
||||||
|
imediat — altfel trial-ul Pro universal (30z la signup) ar masca alegerea pana la
|
||||||
|
expirare (decizie user 2026-06-29). Tier invalid / cont protejat -> re-randare cu eroare.
|
||||||
|
"""
|
||||||
|
require_admin(request)
|
||||||
|
verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
# trial_until=None: alocarea manuala incheie trial-ul si aplica tier-ul ales acum.
|
||||||
|
set_tier(conn, account_id, tier, trial_until=None)
|
||||||
|
conn.commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return RedirectResponse("/admin", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/set-trial", response_class=HTMLResponse)
|
||||||
|
async def admin_set_trial(
|
||||||
|
request: Request,
|
||||||
|
account_id: int = Form(...),
|
||||||
|
trial_days: int = Form(...),
|
||||||
|
csrf_token: str = Form(default=""),
|
||||||
|
):
|
||||||
|
"""Acorda/prelungeste un trial Pro de N zile (de la acum), fara a schimba tier-ul de baza.
|
||||||
|
|
||||||
|
require_admin + CSRF, PRG 303. Reuseaza accounts.set_trial (protejeaza id=1, logheaza).
|
||||||
|
trial_days <= 0 sau peste plafon -> re-randare panou cu eroare (422). Plafon defensiv 3650z.
|
||||||
|
"""
|
||||||
|
require_admin(request)
|
||||||
|
verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
if trial_days <= 0 or trial_days > 3650:
|
||||||
|
return _render_admin(
|
||||||
|
request, conn,
|
||||||
|
error="Numarul de zile pentru trial trebuie sa fie intre 1 si 3650.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
trial_until = (now + timedelta(days=trial_days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
set_trial(conn, account_id, trial_until)
|
||||||
|
conn.commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return RedirectResponse("/admin", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/deactivate", response_class=HTMLResponse)
|
@router.post("/admin/deactivate", response_class=HTMLResponse)
|
||||||
async def admin_deactivate(
|
async def admin_deactivate(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
@@ -9,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
from ..accounts import create_account
|
from ..accounts import VALID_TIERS, create_account
|
||||||
from ..auth import create_api_key
|
from ..auth import create_api_key
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db import get_connection
|
from ..db import get_connection
|
||||||
@@ -47,10 +48,18 @@ async def signup_post(
|
|||||||
cui: str = Form(default=""),
|
cui: str = Form(default=""),
|
||||||
email: str = Form(default=""),
|
email: str = Form(default=""),
|
||||||
parola: str = Form(default=""),
|
parola: str = Form(default=""),
|
||||||
|
plan: str = Form(default=""),
|
||||||
|
consent: str = Form(default=""),
|
||||||
csrf_token: str = Form(default=""),
|
csrf_token: str = Form(default=""),
|
||||||
):
|
):
|
||||||
verify_csrf(request, csrf_token)
|
verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
# Planul CERUT (intentie, nu drept): pastram doar valori valide; orice altceva -> 'free'.
|
||||||
|
# `tier`-ul real ramane 'free' la creare; planul ales se onoreaza dupa plata (admin/webhook).
|
||||||
|
requested_plan = plan.strip().lower() if plan else ""
|
||||||
|
if requested_plan not in VALID_TIERS:
|
||||||
|
requested_plan = "free"
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
ip = request.client.host if request.client else "unknown"
|
ip = request.client.host if request.client else "unknown"
|
||||||
if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s):
|
if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s):
|
||||||
@@ -58,7 +67,7 @@ async def signup_post(
|
|||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
error=_RATE_MSG,
|
error=_RATE_MSG,
|
||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=429)
|
), status_code=429)
|
||||||
|
|
||||||
if len(parola) < _PASSWORD_MIN:
|
if len(parola) < _PASSWORD_MIN:
|
||||||
@@ -66,7 +75,7 @@ async def signup_post(
|
|||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.",
|
error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.",
|
||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=422)
|
), status_code=422)
|
||||||
|
|
||||||
# CUI obligatoriu la signup (US-001, PRD 5.12)
|
# CUI obligatoriu la signup (US-001, PRD 5.12)
|
||||||
@@ -76,9 +85,19 @@ async def signup_post(
|
|||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
error="CUI-ul firmei este obligatoriu.",
|
error="CUI-ul firmei este obligatoriu.",
|
||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=422)
|
), status_code=422)
|
||||||
|
|
||||||
|
# Consimtamant Termeni + GDPR obligatoriu (proba). Checkbox bifat -> valoare ne-goala.
|
||||||
|
if not (consent and consent.strip()):
|
||||||
|
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||||
|
request,
|
||||||
|
csrf_token=get_csrf_token(request),
|
||||||
|
error="Trebuie sa accepti Termenii si prelucrarea datelor (GDPR) pentru a crea cont.",
|
||||||
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
|
), status_code=422)
|
||||||
|
consent_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
|
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
|
||||||
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
|
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -86,7 +105,10 @@ async def signup_post(
|
|||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
is_first = count_admins(conn) == 0
|
is_first = count_admins(conn) == 0
|
||||||
account_id = create_account(conn, name, cui=cui_norm, email=email, active=False)
|
account_id = create_account(
|
||||||
|
conn, name, cui=cui_norm, email=email, active=False,
|
||||||
|
requested_plan=requested_plan, consent_at=consent_at,
|
||||||
|
)
|
||||||
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
|
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
|
||||||
api_key = create_api_key(conn, account_id)
|
api_key = create_api_key(conn, account_id)
|
||||||
conn.execute("COMMIT")
|
conn.execute("COMMIT")
|
||||||
@@ -121,7 +143,7 @@ async def signup_post(
|
|||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
error=error_msg,
|
error=error_msg,
|
||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=422)
|
), status_code=422)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
conn.execute("ROLLBACK")
|
conn.execute("ROLLBACK")
|
||||||
@@ -129,7 +151,7 @@ async def signup_post(
|
|||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=422)
|
), status_code=422)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -1247,7 +1247,7 @@ def _nemapate_pentru_submission(row, nomenclator: list[dict], conn=None) -> list
|
|||||||
"denumire": item.get("denumire"),
|
"denumire": item.get("denumire"),
|
||||||
"suggestions": suggest_codes(item.get("denumire"), nomenclator, limit=5),
|
"suggestions": suggest_codes(item.get("denumire"), nomenclator, limit=5),
|
||||||
"sugestie_principala": None,
|
"sugestie_principala": None,
|
||||||
"surse_sugestie": {"gold_partajat": None, "silver": None, "embedding": None},
|
"surse_sugestie": {"gold_partajat": None, "silver": None, "embedding": None, "nul": False},
|
||||||
}
|
}
|
||||||
# L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13)
|
# L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13)
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
|
|||||||
@@ -118,10 +118,13 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{# ===== US-005 (5.16): Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #}
|
{# ===== US-005 (5.16): Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #}
|
||||||
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat) #}
|
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat).
|
||||||
|
T-7 (5.16): containerul .chips se randeaza DOAR cand exista chips extra — altfel ramanea
|
||||||
|
un chenar gol nefinisat sub randurile de operatie. #}
|
||||||
|
{% set _extra_chips = _chips | rejectattr('cod_op_service') | selectattr('cod_prestatie') | list %}
|
||||||
|
{% if _extra_chips %}
|
||||||
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;">
|
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;">
|
||||||
{% for chip in _chips %}
|
{% for chip in _extra_chips %}
|
||||||
{% if not chip.cod_op_service and chip.cod_prestatie %}
|
|
||||||
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
||||||
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %}"
|
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %}"
|
||||||
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}">
|
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}">
|
||||||
@@ -134,9 +137,9 @@
|
|||||||
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
|
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
|
||||||
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">×</button>
|
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">×</button>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if nomenclator_rar %}
|
{% if nomenclator_rar %}
|
||||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;">
|
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;">
|
||||||
<select name="chips_add_cod_flat"
|
<select name="chips_add_cod_flat"
|
||||||
|
|||||||
@@ -6,19 +6,26 @@
|
|||||||
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"
|
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"
|
||||||
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
|
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
{# US-002 (5.16): titlul de sectiune vizibil ("Trimiterile tale") a fost eliminat —
|
||||||
<h2 id="trimiteri-heading" style="font-size:15px; margin:0;">
|
lista incepe direct sub filtre. Heading pastrat sr-only pentru a11y (section
|
||||||
Trimiterile tale
|
aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
|
||||||
{% if blocate_total %}
|
<h2 id="trimiteri-heading" class="sr-only">Trimiterile tale</h2>
|
||||||
<span class="tab-badge" title="{{ blocate_total }} necesita atentie"
|
{% if blocate_total %}
|
||||||
style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ blocate_total }}</span>
|
<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin:0 0 10px;">
|
||||||
{% endif %}
|
<span class="tab-badge" title="{{ blocate_total }} necesita atentie"
|
||||||
</h2>
|
style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ blocate_total }}</span>
|
||||||
|
<span class="muted" style="font-size:var(--fs-sm);">de rezolvat</span>
|
||||||
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
|
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
|
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
|
||||||
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
|
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:8px; flex-wrap:wrap; margin:0 0 10px;">
|
||||||
|
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
|
||||||
|
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Bara de filtre: [quick-pills data STANGA] [cautare vehicul MIJLOC] [pills stare DREAPTA].
|
<!-- Bara de filtre: [quick-pills data STANGA] [cautare vehicul MIJLOC] [pills stare DREAPTA].
|
||||||
Pill-urile de stare scriu campul hidden status si re-trimit form-ul (filtreazaStare).
|
Pill-urile de stare scriu campul hidden status si re-trimit form-ul (filtreazaStare).
|
||||||
|
|||||||
@@ -103,8 +103,8 @@
|
|||||||
<div class="act-group" style="margin-top:14px;">
|
<div class="act-group" style="margin-top:14px;">
|
||||||
<button type="submit" class="act act-primary" aria-label="{{ btn_label or 'Salveaza' }}">
|
<button type="submit" class="act act-primary" aria-label="{{ btn_label or 'Salveaza' }}">
|
||||||
<span class="act-tx">{{ btn_label or 'Salveaza' }}</span>{{ icon('save') }}</button>
|
<span class="act-tx">{{ btn_label or 'Salveaza' }}</span>{{ icon('save') }}</button>
|
||||||
<button type="button" class="act" aria-label="{{ cancel_label or 'Anuleaza' }}" data-modal-close>
|
<button type="button" class="act" aria-label="{{ cancel_label or 'Renunta' }}" data-modal-close>
|
||||||
<span class="act-tx">{{ cancel_label or 'Anuleaza' }}</span>{{ icon('x') }}</button>
|
<span class="act-tx">{{ cancel_label or 'Renunta' }}</span>{{ icon('x') }}</button>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="margin-top:14px;">
|
<div style="margin-top:14px;">
|
||||||
|
|||||||
@@ -54,11 +54,22 @@
|
|||||||
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
|
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="muted" style="font-size:12px;" data-eticheta="Sugestii">
|
<td class="muted" style="font-size:12px;" data-eticheta="Sugestii">
|
||||||
|
{# 5.18 US-007: badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
|
||||||
|
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
|
||||||
|
{% if e.sugestie_principala %}
|
||||||
|
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
|
||||||
|
<span class="sugg-sursa sugg-sursa--confirmat" title="cod confirmat de un operator">confirmat</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
|
||||||
|
{% endif %}
|
||||||
|
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
|
||||||
|
<span class="sugg-sursa sugg-sursa--nul" title="pare non-operatie (ITP/plata/discount...)">non-operatie</span>
|
||||||
|
{% endif %}
|
||||||
{% if e.suggestions %}
|
{% if e.suggestions %}
|
||||||
{% for s in e.suggestions[:3] %}
|
{% for s in e.suggestions[:3] %}
|
||||||
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
|
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}—{% endif %}
|
{% elif not e.sugestie_principala and not (e.surse_sugestie and e.surse_sugestie.nul) %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td data-eticheta="Cod RAR">
|
<td data-eticheta="Cod RAR">
|
||||||
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
|
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
|
||||||
@@ -123,7 +134,7 @@
|
|||||||
<input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
|
<input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
|
||||||
</form>
|
</form>
|
||||||
<div><strong>{{ m.cod_op_service }}</strong></div>
|
<div><strong>{{ m.cod_op_service }}</strong></div>
|
||||||
<div class="muted" style="font-size:12px;">
|
<div class="muted map-acum" style="font-size:12px;">
|
||||||
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
|
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -144,19 +144,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
|
<!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
|
||||||
US-007: 8 coloane (coloana de verificare eliminata).
|
5.16 (T-4): densitate redusa la coloanele esentiale — Stare / Vehicul /
|
||||||
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). -->
|
Operatie / Data + Actiuni. KM final + mesajul de validare (Note) au iesit
|
||||||
|
din tabel: KM se editeaza in modal, motivul apare ca tooltip pe pill-ul de
|
||||||
|
Stare. Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). -->
|
||||||
<div id="preview-tabel" class="tablewrap tabel-trimiteri">
|
<div id="preview-tabel" class="tablewrap tabel-trimiteri">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-id">#</th>
|
|
||||||
<th class="col-stare">Stare</th>
|
<th class="col-stare">Stare</th>
|
||||||
<th class="col-vehicul">Vehicul</th>
|
<th class="col-vehicul">Vehicul</th>
|
||||||
<th class="col-operatie">Operatie</th>
|
<th class="col-operatie">Operatie</th>
|
||||||
<th class="col-data">Data</th>
|
<th class="col-data">Data</th>
|
||||||
<th class="col-km">KM final</th>
|
|
||||||
<th class="col-note">Note</th>
|
|
||||||
<th class="col-actiuni">Actiuni</th>
|
<th class="col-actiuni">Actiuni</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -23,9 +23,21 @@
|
|||||||
{% if _sent_dup %}class="preview-sent-row"{% endif %}
|
{% if _sent_dup %}class="preview-sent-row"{% endif %}
|
||||||
{% if oob_tr %}hx-swap-oob="outerHTML:#preview-row-{{ row.row_index }}"{% endif %}
|
{% if oob_tr %}hx-swap-oob="outerHTML:#preview-row-{{ row.row_index }}"{% endif %}
|
||||||
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif _sent_dup %}opacity:.6;{% endif %}">
|
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif _sent_dup %}opacity:.6;{% endif %}">
|
||||||
<td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td>
|
{#- Motivul (validare / deja-trimis / duplicat) — fost coloana Note, acum tooltip pe pill.
|
||||||
|
KM final iese din tabel (se editeaza in modal). -#}
|
||||||
|
{%- if status == 'already_sent' and row.get('already_sent_info') -%}
|
||||||
|
{%- set ai = row.already_sent_info -%}
|
||||||
|
{%- set _nota = 'deja trimis ' ~ ((ai.get('created_at') or '')[:10]) ~ ((' (#' ~ ai.id_prezentare ~ ')') if ai.get('id_prezentare') else '') -%}
|
||||||
|
{%- elif status == 'duplicate_in_file' and row.get('duplicate_with') -%}
|
||||||
|
{%- set _dwith = [] -%}
|
||||||
|
{%- for idx in row.duplicate_with -%}{{ _dwith.append(idx + 1) or '' }}{%- endfor -%}
|
||||||
|
{%- set _nota = 'dubla cu randul ' ~ (_dwith | join(', ')) -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set _nota = row.nota_umana or '' -%}
|
||||||
|
{%- endif -%}
|
||||||
<td class="col-stare" data-eticheta="Stare">
|
<td class="col-stare" data-eticheta="Stare">
|
||||||
<span class="pill {{ row.stare_css }}" style="display:inline-flex; align-items:center; gap:5px;">
|
<span class="pill {{ row.stare_css }}" style="display:inline-flex; align-items:center; gap:5px;"
|
||||||
|
{% if _nota %}title="{{ _nota }}"{% endif %}>
|
||||||
<span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ row.stare_eticheta }}</span>
|
<span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ row.stare_eticheta }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-vehicul" data-eticheta="Vehicul">
|
<td class="col-vehicul" data-eticheta="Vehicul">
|
||||||
@@ -43,20 +55,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-data" data-eticheta="Data prestatie">{{ row.prez.data_prestatie }}</td>
|
<td class="col-data" data-eticheta="Data prestatie">{{ row.prez.data_prestatie }}</td>
|
||||||
<td class="col-km" data-eticheta="KM final">{{ row.prez.odometru }}</td>
|
|
||||||
<td class="col-note" data-eticheta="Note"
|
|
||||||
style="font-size:var(--fs-xs); white-space:normal;">
|
|
||||||
{% if status == 'already_sent' and row.get('already_sent_info') %}
|
|
||||||
{% set ai = row.already_sent_info %}
|
|
||||||
deja trimis {{ (ai.get('created_at') or '')[:10] }}
|
|
||||||
{% if ai.get('id_prezentare') %}(#{{ ai.id_prezentare }}){% endif %}
|
|
||||||
{% elif status == 'duplicate_in_file' and row.get('duplicate_with') %}
|
|
||||||
dubla cu randul
|
|
||||||
{% for idx in row.duplicate_with %}{{ idx + 1 }}{% if not loop.last %}, {% endif %}{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
{{ row.nota_umana or '' }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
|
<td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
|
||||||
{% if status not in ('already_sent', 'duplicate_in_file') %}
|
{% if status not in ('already_sent', 'duplicate_in_file') %}
|
||||||
<button type="button" class="btn-editeaza"
|
<button type="button" class="btn-editeaza"
|
||||||
|
|||||||
@@ -137,10 +137,11 @@
|
|||||||
class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a>
|
class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{# US-006 (5.17): linia de plan — consum/trial (secundar, sub navigatie, non-blocant).
|
{# US-006 (5.17) + T-6 (5.16): linia de plan in CORP apare DOAR in starea de avertizare
|
||||||
Warn=culoare+text (accesibilitate): >=80% -> --warn; limita atinsa -> --err.
|
(>=80% -> --warn; limita atinsa -> --err). Consumul normal (N/60) traieste in badge-ul
|
||||||
Ierarhie: nu concureaza cu stripul de sanatate (E zero-silent-failures pastrat). #}
|
din antet + linia din meniul burger, nu ca rand permanent in corp (densitate redusa).
|
||||||
{% if plan_linie is defined and plan_linie %}
|
Ierarhie: nu concureaza cu stripul de sanatate (zero-silent-failures pastrat). #}
|
||||||
|
{% if plan_linie and (plan_warn|default(false) or plan_limita_atinsa|default(false)) %}
|
||||||
<div class="plan-status-line"
|
<div class="plan-status-line"
|
||||||
style="font-size:var(--fs-sm); margin-top:6px; padding-top:6px;
|
style="font-size:var(--fs-sm); margin-top:6px; padding-top:6px;
|
||||||
border-top:1px solid var(--line2);
|
border-top:1px solid var(--line2);
|
||||||
|
|||||||
@@ -96,41 +96,43 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Bloc text principal — stanga, ocupa spatiul ramas #}
|
{# Bloc text principal — stanga, ocupa spatiul ramas. Rand de 2 linii (spec 5.16):
|
||||||
|
L1 = placuta (identificator primar); L2 = cod RAR · operatie · data prestatie. #}
|
||||||
<div style="flex:1 1 auto; min-width:0;">
|
<div style="flex:1 1 auto; min-width:0;">
|
||||||
|
|
||||||
{# Linia 1: VIN mono scurt (slim-vin).
|
{# Linia 1: nr. inmatriculare (placuta) — identificatorul primar pe care il
|
||||||
Guard: vin_scurt='—' inseamna VIN lipsa; fallback la vehicul_nr. #}
|
scaneaza operatorul. .slim-vin reumplut (acelasi nume de clasa, churn minim).
|
||||||
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
Fallback cand placuta lipseste ('—'): VIN scurt, apoi mesaj neutru
|
||||||
<div class="slim-vin">{{ r.prez.vin_scurt }}</div>
|
(nu randa em-dash izolat ca identificator). #}
|
||||||
|
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}
|
||||||
|
<div class="slim-vin">{{ r.prez.vehicul_nr }}</div>
|
||||||
|
{% elif r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
||||||
|
<div class="slim-vin muted">{{ r.prez.vin_scurt }}</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="slim-vin muted">{{ r.prez.vehicul_nr }}</div>
|
<div class="slim-vin muted">fara numar</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Linia 2: Operatie · ora/data (slim-meta muted) #}
|
{# Linia 2: cod RAR (sau 'nemapat') · operatie (ink, ellipsis) · data prestatie.
|
||||||
<div class="slim-meta">{{ r.prez.operatie }} · {{ r.updated_at }}</div>
|
Separatorul "·" e injectat prin CSS intre celule. Operatia primeste ellipsis
|
||||||
|
ca randul sa NU treaca pe a 3-a linie nici la 390px.
|
||||||
{# Cod RAR sau indicatorul 'nemapat': discret sub operatie.
|
VIN integral, #id_prezentare si secundele traiesc in modalul de detaliu. #}
|
||||||
Mentine compatibilitatea cu testele cod_rar: OE-2 vizibil, fara prefix 'cod RAR:'. #}
|
<div class="slim-meta slim-rand2">
|
||||||
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
|
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
|
||||||
<div class="slim-meta"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
|
<span class="cod-rar-cod">{{ r.prez.cod_rar }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="slim-meta muted cod-rar-sub">nemapat</div>
|
<span class="cod-rar-cod cod-rar-sub muted">nemapat</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="slim-op">{{ r.prez.operatie }}</span>
|
||||||
{# Linia meta discreta: nr inmatriculare · data prestatie · nr prezentare RAR.
|
{% if r.prez.data_prestatie and r.prez.data_prestatie != '—' %}
|
||||||
Accesibila pe rand; informatia completa e in modalul de detaliu. #}
|
<span class="slim-data muted">{{ r.prez.data_prestatie }}</span>
|
||||||
<div class="slim-meta" style="opacity:0.7;">
|
{% endif %}
|
||||||
{{ r.prez.vehicul_nr -}}
|
|
||||||
{%- if r.prez.data_prestatie and r.prez.data_prestatie != '—' %} · {{ r.prez.data_prestatie }}{% endif -%}
|
|
||||||
{%- if r.id_prezentare %} · #{{ r.id_prezentare }}{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Eticheta umana scurta sub pill — text mic, s-error pe error/needs_*.
|
{# Micro-linie umana a problemei — text mic s-error, DOAR pe stari de problema
|
||||||
Afisata DOAR pe randuri cu problema (eticheta_problema ne-goala).
|
(loud-on-exception D6). Randul normal/finalizat ramane strict 2 linii.
|
||||||
Starea transmisa prin TEXT, nu doar culoare. #}
|
Token tipografic --fs-xs (>=12px, scala 5.16). #}
|
||||||
{% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
|
{% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
|
||||||
<div class="eticheta-problema s-error" style="font-size:10px; margin-top:2px;">{{ r.eticheta_problema }}</div>
|
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
'delete': ('Sterge', '/admin/delete', 'danger')
|
'delete': ('Sterge', '/admin/delete', 'danger')
|
||||||
} %}
|
} %}
|
||||||
|
|
||||||
|
{# Tier-uri selectabile in panou (cod, eticheta). Aliniat cu app/plans.py#PLANS. #}
|
||||||
|
{% set TIERS = [('free', 'Gratuit'), ('standard', 'Standard'), ('pro', 'Pro'), ('premium', 'Premium')] %}
|
||||||
|
|
||||||
{% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
|
{% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
|
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
|
||||||
@@ -34,7 +37,7 @@
|
|||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
|
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
|
||||||
aria-label="Selecteaza tot"></th>
|
aria-label="Selecteaza tot"></th>
|
||||||
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
|
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Plan curent</th><th>Plan cerut</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for acct in rows %}
|
{% for acct in rows %}
|
||||||
@@ -46,6 +49,45 @@
|
|||||||
<td>{{ acct.name }}</td>
|
<td>{{ acct.name }}</td>
|
||||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||||
<td>{{ acct.email or "—" }}</td>
|
<td>{{ acct.email or "—" }}</td>
|
||||||
|
<td style="white-space:nowrap;">
|
||||||
|
{# Plan EFECTIV acum (prominent): trial Pro activ ridica free->pro. #}
|
||||||
|
<div style="margin-bottom:5px;">
|
||||||
|
<span class="pill" style="font-weight:600;">{{ acct.tier_efectiv_label }}</span>
|
||||||
|
{% if acct.trial_activ %}
|
||||||
|
<span class="muted" style="font-size:11px;">
|
||||||
|
trial{% if acct.trial_zile %} · {{ acct.trial_zile }} {{ 'zi' if acct.trial_zile == 1 else 'zile' }} ramase{% endif %}
|
||||||
|
→ apoi {{ acct.tier_label }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{# Schimbare plan inline: select tier de baza + Aplica. Form propriu (nu imbricat in bulk-form).
|
||||||
|
Aplica INCHEIE trial-ul si seteaza planul ales ca real, cu efect imediat. #}
|
||||||
|
<form method="post" action="/admin/set-tier" class="tier-form"
|
||||||
|
style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||||
|
<select name="tier" aria-label="Plan pentru {{ acct.name }}"
|
||||||
|
style="padding:4px 8px;min-height:32px;max-width:130px;">
|
||||||
|
{% for code, label in TIERS %}
|
||||||
|
<option value="{{ code }}"{% if acct.tier == code %} selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-sm"
|
||||||
|
title="Aplica planul ales ca plan real (incheie trial-ul daca e activ)">Aplica</button>
|
||||||
|
</form>
|
||||||
|
{# Acorda/prelungeste trial Pro de N zile, fara a schimba tier-ul de baza. #}
|
||||||
|
<form method="post" action="/admin/set-trial" class="trial-form"
|
||||||
|
style="display:flex;align-items:center;gap:6px;margin-top:5px;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||||
|
<input type="number" name="trial_days" value="30" min="1" max="3650"
|
||||||
|
aria-label="Zile trial Pro pentru {{ acct.name }}"
|
||||||
|
style="padding:4px 8px;min-height:32px;width:64px;">
|
||||||
|
<button type="submit" class="btn-sm"
|
||||||
|
title="Acorda/prelungeste trial Pro de la acum (nu schimba tier-ul de baza)">Trial Pro</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ acct.requested_plan_label }}</td>
|
||||||
<td><span class="pill">{{ acct.status }}</span></td>
|
<td><span class="pill">{{ acct.status }}</span></td>
|
||||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||||
<td style="white-space:nowrap;">
|
<td style="white-space:nowrap;">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}ROMFAST AUTOPASS{% endblock %}</title>
|
<title>{% block title %}ROA AUTOPASS{% endblock %}</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
|
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
|
||||||
@@ -104,6 +104,18 @@
|
|||||||
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
||||||
.empty { color:var(--muted); padding:24px; text-align:center; }
|
.empty { color:var(--muted); padding:24px; text-align:center; }
|
||||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
|
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
|
||||||
|
/* Badge sursa sugestie (5.18 US-007): de unde vine sugestia de cod in editorul de mapare.
|
||||||
|
confirmat = GOLD validat de om (verde); similar = SILVER/embedding k-NN (azur);
|
||||||
|
non-operatie = pre-filtru NUL / vecin NUL (gri-cald). Suggestion-only, doar indiciu vizual. */
|
||||||
|
.sugg-sursa { display:inline-block; font-size:10px; font-weight:700; line-height:1; padding:2px 6px;
|
||||||
|
border-radius:99px; text-transform:uppercase; letter-spacing:.03em; vertical-align:middle;
|
||||||
|
border:1px solid transparent; }
|
||||||
|
.sugg-sursa--confirmat { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 45%, transparent);
|
||||||
|
background:color-mix(in srgb, var(--ok) 12%, transparent); }
|
||||||
|
.sugg-sursa--similar { color:var(--accent); border-color:color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
background:color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||||
|
.sugg-sursa--nul { color:var(--muted); border-color:color-mix(in srgb, var(--muted) 40%, transparent);
|
||||||
|
background:color-mix(in srgb, var(--muted) 12%, transparent); }
|
||||||
/* Pill-uri de filtrare a starii (bara de filtre Trimiteri). Inactiv = contur+text pe
|
/* Pill-uri de filtrare a starii (bara de filtre Trimiteri). Inactiv = contur+text pe
|
||||||
culoarea categoriei (injectata inline); activ = umplere pe acea culoare. */
|
culoarea categoriei (injectata inline); activ = umplere pe acea culoare. */
|
||||||
.pills-categorii { display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
.pills-categorii { display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
||||||
@@ -131,6 +143,9 @@
|
|||||||
.s-needs_review{color:var(--warn);}
|
.s-needs_review{color:var(--warn);}
|
||||||
.s-already_sent,.s-duplicate_in_file{color:var(--muted);}
|
.s-already_sent,.s-duplicate_in_file{color:var(--muted);}
|
||||||
.muted { color:var(--muted); }
|
.muted { color:var(--muted); }
|
||||||
|
/* Heading/eticheta accesibila doar pentru cititoare de ecran (vizual ascunsa). */
|
||||||
|
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
|
||||||
|
clip:rect(0 0 0 0); white-space:nowrap; border:0; }
|
||||||
a { color:var(--accent); }
|
a { color:var(--accent); }
|
||||||
/* Drop zone upload fisier */
|
/* Drop zone upload fisier */
|
||||||
.drop-zone { border:2px dashed var(--line); border-radius:8px; padding:32px 20px;
|
.drop-zone { border:2px dashed var(--line); border-radius:8px; padding:32px 20px;
|
||||||
@@ -398,7 +413,9 @@
|
|||||||
.tabel-trimiteri th, .tabel-trimiteri td { white-space:normal; word-break:break-word; vertical-align:top; }
|
.tabel-trimiteri th, .tabel-trimiteri td { white-space:normal; word-break:break-word; vertical-align:top; }
|
||||||
.tabel-trimiteri .col-chk { width:30px; }
|
.tabel-trimiteri .col-chk { width:30px; }
|
||||||
.tabel-trimiteri .col-id { width:48px; }
|
.tabel-trimiteri .col-id { width:48px; }
|
||||||
.tabel-trimiteri .col-stare { width:104px; }
|
/* col-stare largita (bug 4a 5.16): cu table-layout:fixed + pill nowrap, 104px era
|
||||||
|
prea ingusta -> pill-ul de stare se revarsa peste col-vehicul. 140px o contine. */
|
||||||
|
.tabel-trimiteri .col-stare { width:140px; }
|
||||||
.tabel-trimiteri .col-data { width:104px; }
|
.tabel-trimiteri .col-data { width:104px; }
|
||||||
.tabel-trimiteri .col-rar { width:96px; }
|
.tabel-trimiteri .col-rar { width:96px; }
|
||||||
.tabel-trimiteri .col-actualizat { width:128px; }
|
.tabel-trimiteri .col-actualizat { width:128px; }
|
||||||
@@ -710,8 +727,27 @@
|
|||||||
.trimitere-slim:last-child { border-bottom:none; }
|
.trimitere-slim:last-child { border-bottom:none; }
|
||||||
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||||
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||||
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-md); font-weight:500; color:var(--ink); }
|
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-md); font-weight:600; color:var(--ink); }
|
||||||
.slim-meta { font-size:var(--fs-sm); color:var(--muted); margin-top:3px; }
|
.slim-meta { font-size:var(--fs-sm); color:var(--muted); margin-top:3px; }
|
||||||
|
/* Linia 2 a randului slim (5.16): cod RAR · operatie (ellipsis) · data, pe UN rand.
|
||||||
|
Ellipsis-ul pe operatie garanteaza 2 linii MAX si la 390px. */
|
||||||
|
.slim-rand2 { display:flex; align-items:baseline; gap:6px; min-width:0; }
|
||||||
|
.slim-rand2 .cod-rar-cod { flex:0 0 auto; font-family:var(--font-mono); font-weight:600;
|
||||||
|
color:var(--accent); }
|
||||||
|
.slim-rand2 .cod-rar-cod.muted { color:var(--muted); font-weight:500; }
|
||||||
|
.slim-rand2 .slim-op { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden;
|
||||||
|
text-overflow:ellipsis; color:var(--ink); }
|
||||||
|
.slim-rand2 .slim-data { flex:0 0 auto; }
|
||||||
|
.slim-rand2 .slim-op::before, .slim-rand2 .slim-data::before {
|
||||||
|
content:"·"; color:var(--muted); margin-right:6px; }
|
||||||
|
.lista-trimiteri-slim .eticheta-problema { font-size:var(--fs-xs); line-height:1.3; margin-top:2px; }
|
||||||
|
/* Pill slim (5.16): fill-tint + dot 7px + text colorat per stare (currentColor din .s-*).
|
||||||
|
Pastrat pe FIECARE rand inclusiv Finalizat (linistit dar prezent). */
|
||||||
|
.lista-trimiteri-slim .pill { display:inline-flex; align-items:center; gap:5px; font-weight:600;
|
||||||
|
background:color-mix(in srgb, currentColor 14%, transparent);
|
||||||
|
border-color:color-mix(in srgb, currentColor 35%, transparent); }
|
||||||
|
.lista-trimiteri-slim .pill::before { content:""; width:7px; height:7px; border-radius:99px;
|
||||||
|
background:currentColor; flex-shrink:0; }
|
||||||
/* .camp-slim — varianta compacta camp formular: label --fs-sm muted deasupra, input --fs-md, fundal --card2.
|
/* .camp-slim — varianta compacta camp formular: label --fs-sm muted deasupra, input --fs-md, fundal --card2.
|
||||||
Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
|
Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
|
||||||
.camp-slim { margin-bottom:8px; }
|
.camp-slim { margin-bottom:8px; }
|
||||||
@@ -744,17 +780,45 @@
|
|||||||
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
|
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
|
||||||
padding:8px 10px; border:1px solid var(--line); border-radius:6px;
|
padding:8px 10px; border:1px solid var(--line); border-radius:6px;
|
||||||
background:var(--card2); margin-bottom:8px; }
|
background:var(--card2); margin-bottom:8px; }
|
||||||
.op-row-name { font-size:var(--fs-xs); font-weight:500; color:var(--ink); }
|
/* Nume operatie emfatic (T-9 5.16): proeminent (bold) ca in mockup — e ancora
|
||||||
|
vizuala a randului de mapare op<->cod. */
|
||||||
|
.op-row-name { font-size:var(--fs-sm); font-weight:700; color:var(--ink); }
|
||||||
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); }
|
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); }
|
||||||
/* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */
|
/* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */
|
||||||
@media (max-width:767px) {
|
@media (max-width:767px) {
|
||||||
.trimitere-slim { padding:12px 14px; }
|
.trimitere-slim { padding:12px 14px; }
|
||||||
}
|
}
|
||||||
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim US-002 === */
|
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim US-002 === */
|
||||||
|
/* === Fix mobil Mapari (bug live 2026-06-29) ===
|
||||||
|
Doua probleme raportate la 390px pe pagina Mapari:
|
||||||
|
(1) butoanele Salveaza/Sterge taiate: regula `.tabel-card td button {width:100%}`
|
||||||
|
(specificitate 0,1,2) batea `.act {width:44px}` (0,1,0) -> cele doua butoane act
|
||||||
|
deveneau full-width si al doilea (Sterge) iesea din card (celula are nowrap).
|
||||||
|
(2) carduri prea inalte: etichetele data-eticheta randate ca pseudo-titluri +
|
||||||
|
linia redundanta "acum: COD — nume" (duplica select-ul de dedesubt).
|
||||||
|
Plasat ultimul in <style> => castiga pe cascada la specificitate egala.
|
||||||
|
Atributele data-eticheta raman in DOM (a11y + teste); doar pseudo-eticheta se ascunde. */
|
||||||
|
@media (max-width:767px) {
|
||||||
|
/* Carduri Mapari compacte: fara etichete-zgomot (continutul e auto-descriptiv,
|
||||||
|
ca la cardul de trimiteri), padding strans. */
|
||||||
|
.tabel-card td::before { display:none; }
|
||||||
|
.tabel-card tr { padding:9px 12px; margin-bottom:8px; }
|
||||||
|
.tabel-card td { padding:3px 0; }
|
||||||
|
/* "acum: COD — nume" e redundant cu select-ul de dedesubt (aceeasi valoare). */
|
||||||
|
.map-acum { display:none; }
|
||||||
|
/* Celula Actiuni: butoanele act pe UN rand, vizibile, cu text (nu iconita-only
|
||||||
|
ambigua, nu full-width care impinge al doilea buton afara cardului).
|
||||||
|
`.tabel-card td .act` (0,2,1) > `.tabel-card td button` (0,1,2). */
|
||||||
|
.tabel-card td[data-eticheta="Actiuni"] { display:flex; gap:8px; align-items:stretch;
|
||||||
|
margin-top:2px; }
|
||||||
|
.tabel-card td .act { width:auto; flex:1 1 0; min-width:0; min-height:44px; padding:8px 12px; }
|
||||||
|
.tabel-card td .act .act-tx { display:inline; }
|
||||||
|
.tabel-card td .act .act-ic { display:inline-block; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{# US-010 (PRD 5.16): antet branduit ROMFAST AUTOPASS.
|
{# US-010 (PRD 5.16): antet branduit ROA AUTOPASS.
|
||||||
Grila 3 coloane — stanga (logo) | centru (titlu+env+tier+account_name) | dreapta (RAR dot + tema + burger).
|
Grila 3 coloane — stanga (logo) | centru (titlu+env+tier+account_name) | dreapta (RAR dot + tema + burger).
|
||||||
Antet MINIMAL pe /login: nu afiseaza RAR dot, meniu burger sau account_name (nelogat). #}
|
Antet MINIMAL pe /login: nu afiseaza RAR dot, meniu burger sau account_name (nelogat). #}
|
||||||
<header>
|
<header>
|
||||||
@@ -766,11 +830,11 @@
|
|||||||
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
|
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{# Celula centru: titlu ROMFAST AUTOPASS + badge env + badge tier + sub-titlu account_name.
|
{# Celula centru: titlu ROA AUTOPASS + badge env + badge tier + sub-titlu account_name.
|
||||||
Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
|
Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
|
||||||
<div class="header-center">
|
<div class="header-center">
|
||||||
<a href="/" style="text-decoration:none; color:inherit;">
|
<a href="/" style="text-decoration:none; color:inherit;">
|
||||||
<h1>ROMFAST AUTOPASS<span class="badge-env">{{ rar_env }}</span>{% if is_authenticated|default(false) and tier_label|default('') %}<span class="badge-tier">{{ tier_label }}</span>{% endif %}</h1>
|
<h1>ROA AUTOPASS<span class="badge-env">{{ rar_env }}</span>{% if is_authenticated|default(false) and tier_label|default('') %}<span class="badge-tier">{{ tier_label }}</span>{% endif %}</h1>
|
||||||
</a>
|
</a>
|
||||||
{% if is_authenticated|default(false) and account_name|default('') %}
|
{% if is_authenticated|default(false) and account_name|default('') %}
|
||||||
<div class="h-sub">Service auto: <span class="svc">{{ account_name }}</span></div>
|
<div class="h-sub">Service auto: <span class="svc">{{ account_name }}</span></div>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Gateway RAR AUTOPASS — declară automat la RAR | ROMFAST</title>
|
<title>ROA AUTOPASS — declari prestațiile la RAR din câteva click-uri</title>
|
||||||
<meta name="description" content="Gateway web care declară prestațiile de service-auto la RAR AUTOPASS, automat. Conform Legii 142/2023. Gratuit până la 60 de prestații/lună, fără card bancar.">
|
<meta name="description" content="Încarci fișierul tău cu operațiile service-ului, completezi o dată codurile RAR și le salvezi. ROMFAST trimite prestațiile la RAR AUTOPASS în locul tău, fără tastat manual. Conform Legii 142/2023.">
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
/* US-001/US-008 (PRD 5.16): IBM Plex eliminat complet — stive font sistem standard web.
|
/* US-001/US-008 (PRD 5.16): IBM Plex eliminat complet — stive font sistem standard web.
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
.page [style*="grid-template-columns"]{grid-template-columns:1fr!important;}
|
.page [style*="grid-template-columns"]{grid-template-columns:1fr!important;}
|
||||||
.page [style*="padding:80px 40px"]{padding:48px 20px!important;}
|
.page [style*="padding:80px 40px"]{padding:48px 20px!important;}
|
||||||
.page [style*="padding:0 40px 80px"]{padding:0 20px 48px!important;}
|
.page [style*="padding:0 40px 80px"]{padding:0 20px 48px!important;}
|
||||||
|
.page [style*="padding:56px 40px 80px"]{padding:36px 20px 48px!important;}
|
||||||
.page [style*="padding:44px"]{padding:28px!important;}
|
.page [style*="padding:44px"]{padding:28px!important;}
|
||||||
.page [style*="padding:56px 40px"]{padding:40px 22px!important;}
|
.page [style*="padding:56px 40px"]{padding:40px 22px!important;}
|
||||||
.page [style*="height:68px"]{height:60px!important;}
|
.page [style*="height:68px"]{height:60px!important;}
|
||||||
@@ -54,20 +55,19 @@
|
|||||||
<body data-theme="grafit">
|
<body data-theme="grafit">
|
||||||
<script>try{var _t=localStorage.getItem('lp-theme');if(_t&&['grafit','cobalt','cupru','hartie'].indexOf(_t)>=0)document.body.setAttribute('data-theme',_t);}catch(e){}</script>
|
<script>try{var _t=localStorage.getItem('lp-theme');if(_t&&['grafit','cobalt','cupru','hartie'].indexOf(_t)>=0)document.body.setAttribute('data-theme',_t);}catch(e){}</script>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<!-- ANNOUNCE BAR -->
|
<!-- HEADER -->
|
||||||
<div style="display:flex;align-items:center;justify-content:center;gap:16px;padding:10px 40px;background:var(--card,#181c24);border-bottom:1px solid var(--line,#262b36);font:500 13px var(--font-ui);color:var(--text,#e6e9ef);flex-wrap:wrap;">
|
|
||||||
<span style="display:inline-flex;align-items:center;gap:8px;"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Vrei să testezi sau ai un service mic? Este <strong style="font-weight:700;color:#1F9D5C;">gratuit</strong> — până la 60 de prestații/lună, fără card bancar.</span>
|
|
||||||
<a data-act="auth" data-tab="register" style="display:inline-flex;align-items:center;gap:5px;color:var(--accent,#2E74D6);font-weight:700;cursor:pointer;text-decoration:none;transition:color .18s ease, transform .18s ease;" style-hover="color:#17a96e;transform:translateX(2px)">Creează cont în 2 minute <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- HEADER -->
|
|
||||||
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
|
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
|
||||||
<div style="display:flex;align-items:center;gap:48px;">
|
<div style="display:flex;align-items:center;gap:14px;">
|
||||||
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
|
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
|
||||||
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
|
<div style="display:flex;flex-direction:column;line-height:1.05;">
|
||||||
<span>Cum funcționează</span><span>API</span><span>Preț</span>
|
<span style="font:700 17px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);">ROA AUTOPASS</span>
|
||||||
|
<span style="font:500 11px var(--font-ui);letter-spacing:.04em;color:var(--sub,#8b93a7);">Gateway RAR</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:28px;">
|
||||||
|
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
|
||||||
|
<a href="#cum-functioneaza" style="color:inherit;text-decoration:none;">Cum funcționează</a><a href="#api" style="color:inherit;text-decoration:none;">API</a><a href="#pret" style="color:inherit;text-decoration:none;">Preț</a>
|
||||||
|
</div>
|
||||||
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
|
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
|
||||||
<button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px var(--font-ui);cursor:pointer;">
|
<button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px var(--font-ui);cursor:pointer;">
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||||
@@ -76,17 +76,17 @@
|
|||||||
<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>
|
<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>
|
<button data-act="auth" data-tab="register" style="height:44px;padding:0 18px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
|
||||||
<div>
|
<div>
|
||||||
<div style="display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:99px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 13px var(--font-ui);margin-bottom:24px;">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>
|
|
||||||
<span><strong style="font-weight:700;color:#1F9D5C;">Gratuit</strong> pentru testare și service-uri mici · 60 prestații/lună</span>
|
|
||||||
</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ă prestațiile la RAR AUTOPASS, automat</h1>
|
||||||
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 32px;max-width:480px;">Conformitate RAR fără bătaie de cap. Încarci un fișier sau conectezi softul de service — noi trimitem prezentările la RAR în siguranță, conform Legii 142/2023.</p>
|
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;max-width:480px;">Încarci fișierul tău așa cum îl ai, potrivești o dată operațiile cu codurile RAR, și trimitem prestațiile la RAR AUTOPASS în locul tău. Fără tastat câmp cu câmp.</p>
|
||||||
|
<div style="margin-bottom:32px;">
|
||||||
|
<p style="display:flex;align-items:center;gap:8px;font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin:0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg><span><span style="color:#1F9D5C;">Gratuit</span> până la 60 de trimiteri/lună</span></p>
|
||||||
|
</div>
|
||||||
<div style="display:flex;gap:12px;margin-bottom:22px;">
|
<div style="display:flex;gap:12px;margin-bottom:22px;">
|
||||||
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
||||||
<button style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</button>
|
<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>
|
||||||
@@ -95,8 +95,6 @@
|
|||||||
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
|
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
|
||||||
<span style="color:var(--line,#262b36);">·</span>
|
<span style="color:var(--line,#262b36);">·</span>
|
||||||
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Datele tale criptate</span>
|
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Datele tale criptate</span>
|
||||||
<span style="color:var(--line,#262b36);">·</span>
|
|
||||||
<span>Fără card bancar</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,65 +135,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PROBLEM -->
|
<!-- PROBLEM + CALCULATOR (combinat) -->
|
||||||
<div style="padding:80px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
|
<div style="padding:80px 40px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
|
||||||
<div style="display:grid;grid-template-columns:1.05fr .95fr;gap:48px;align-items:start;margin:0 auto;">
|
<div style="text-align:center;max-width:760px;margin:0 auto 40px;">
|
||||||
<div>
|
<h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 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 18px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 2–3 minute și tastezi pe rar-autopass.ro</h2>
|
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi. Mută cursorul la volumul service-ului tău și vezi cât te costă.</p>
|
||||||
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 16px;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi.</p>
|
</div>
|
||||||
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Iar dacă greșești o cifră din VIN, prestația e respinsă și o iei de la capăt — cu risc de amendă pentru raportare incompletă sau întârziată.</p>
|
|
||||||
</div>
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:stretch;">
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:20px;">
|
<!-- STANGA: formularul RAR AUTOPASS -->
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">rar-autopass.ro · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px 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="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:24px;display:flex;flex-direction:column;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">RAR AUTOPASS · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px var(--font-mono);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;">
|
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||||
<div><div style="font:400 11px 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>
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Confirmă Vin</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Confirmă VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Data prestației</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">2026-06-22</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Data prestației</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">2026-06-22</div></div>
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Număr Înmatriculare</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">CT88NOE</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Număr înmatriculare</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">CT88NOE</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Observații</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-ui);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">REVIZIE; SCHIMBARE PLĂCUȚE FRÂNĂ</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Observații</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-ui);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">REVIZIE; SCHIMBARE PLĂCUȚE FRÂNĂ</div></div>
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Prestații</div><div style="min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);"><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>REVIZIE PERIODICĂ</span><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>ÎNTREȚINERE</span></div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Prestații</div><div style="min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);"><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>REVIZIE PERIODICĂ</span><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>ÎNTREȚINERE</span></div></div>
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Valoarea citită a odometrului</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">39000</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Valoarea citită a odometrului</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">39000</div></div>
|
||||||
</div>
|
</div>
|
||||||
<button style="margin-top:14px;height:34px;padding:0 14px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--card2,#0f1218));border:none;color:#fff;opacity:.55;font:600 12px var(--font-ui);cursor:not-allowed;align-self:flex-start;">Salvează Prezentarea</button>
|
<button style="margin-top:14px;height:34px;padding:0 14px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--card2,#0f1218));border:none;color:#fff;opacity:.55;font:600 12px var(--font-ui);cursor:not-allowed;align-self:flex-start;">Salvează Prezentarea</button>
|
||||||
<div style="margin-top:12px;font:400 12px var(--font-ui);color:var(--sub,#8b93a7);text-align:center;">se repetă pentru fiecare comandă · zi de zi</div>
|
<div style="margin-top:auto;padding-top:12px;font:400 12px var(--font-ui);color:var(--sub,#8b93a7);text-align:center;">se repetă pentru fiecare comandă · zi de zi</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DREAPTA: calculatorul (slidere + cifre) -->
|
||||||
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:34px;display:flex;flex-direction:column;">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri/lună</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);" id="out-pres">100</span></div>
|
||||||
|
<input type="range" min="50" max="1500" step="10" value="100" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Manoperă</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);"><span id="out-rate">60</span><span style="font:500 12px var(--font-ui);color:var(--sub,#8b93a7);"> lei/h</span></span></div>
|
||||||
|
<input type="range" min="30" max="200" step="5" value="60" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:color-mix(in srgb,#E05D5D 9%,var(--card2,#0f1218));border:1px solid color-mix(in srgb,#E05D5D 28%,var(--line,#262b36));border-radius:10px;padding:22px 24px;">
|
||||||
|
<div style="font:600 11px var(--font-ui);color:var(--errt,#E05D5D);letter-spacing:.08em;text-transform:uppercase;margin-bottom:14px;">Pierdut pe raportare manuală</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;">
|
||||||
|
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiMonth">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">lei/lună</div></div>
|
||||||
|
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="hMonth">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">ore/lună</div></div>
|
||||||
|
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiYear">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">lei/an</div></div>
|
||||||
|
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="days">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">zile/an</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:20px;padding-top:18px;border-top:1px solid var(--line,#262b36);">
|
||||||
|
<div style="display:flex;align-items:center;gap:9px;font:600 14px var(--font-ui);color:var(--okt,#2FBF8F);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Cu ROA AUTOPASS: câteva secunde pentru tot lotul</div>
|
||||||
|
<div style="font:400 13px/1.55 var(--font-ui);color:var(--sub,#8b93a7);margin-top:6px;">Recuperezi ~<span data-calc="leiMonth">0</span> lei/lună și timpul îl pui pe mașini, nu pe formulare.</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:14px;display:flex;align-items:center;gap:8px;font:400 12px var(--font-ui);color:var(--mut,#5c6473);"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>Estimat la ~2,5 minute de tastat manual pentru fiecare trimitere.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AGITATE / CALCULATOR -->
|
<!-- LEGE / AMENZI -->
|
||||||
<div style="padding:80px 40px;">
|
<div style="padding:56px 40px 80px;">
|
||||||
<div style="text-align:center;max-width:720px;margin:0 auto 40px;">
|
<div style="display:flex;gap:20px;align-items:flex-start;background:color-mix(in srgb,#E0A93B 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E0A93B 34%,var(--line,#262b36));border-radius:12px;padding:26px 28px;">
|
||||||
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Cât te costă de fapt</div>
|
|
||||||
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 12px;color:var(--text,#e6e9ef);">Fă socoteala. Minutele acelea sunt bani.</h2>
|
|
||||||
<p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Mută cursorul la volumul service-ului tău și vezi cât timp și câți bani pleacă pe raportarea manuală.</p>
|
|
||||||
</div>
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:0 auto;align-items:stretch;">
|
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:32px;">
|
|
||||||
<div style="margin-bottom:28px;">
|
|
||||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;"><span style="font:500 14px var(--font-ui);color:var(--text,#e6e9ef);">Prestații pe lună</span><span style="font:700 20px var(--font-mono);color:var(--accent,#2E74D6);" id="out-pres">300</span></div>
|
|
||||||
<input type="range" min="50" max="1500" step="10" value="300" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom:24px;">
|
|
||||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;"><span style="font:500 14px var(--font-ui);color:var(--text,#e6e9ef);">Cost manoperă</span><span style="font:700 20px var(--font-mono);color:var(--accent,#2E74D6);"><span id="out-rate">60</span> lei/h</span></div>
|
|
||||||
<input type="range" min="30" max="200" step="5" value="60" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:9px;padding-top:18px;border-top:1px solid var(--line,#262b36);font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="flex-shrink:0;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>Estimat la ~2,5 minute de introdus manual pentru fiecare prestație.</div>
|
|
||||||
</div>
|
|
||||||
<div style="background:color-mix(in srgb,#E05D5D 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E05D5D 32%,var(--line,#262b36));border-radius:12px;padding:32px;display:flex;flex-direction:column;justify-content:center;">
|
|
||||||
<div style="font:600 12px var(--font-ui);color:var(--errt,#E05D5D);letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px;">Pierdut pe raportare manuală</div>
|
|
||||||
<div style="display:flex;align-items:baseline;gap:8px;"><span style="font:700 52px/1 var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiMonth">0</span></span><span style="font:500 15px var(--font-ui);color:var(--sub,#8b93a7);">lei / lună</span></div>
|
|
||||||
<div style="font:400 14px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin-top:8px;"><span data-calc="hMonth">0</span> ore pe lună · <span data-calc="leiYear">0</span> lei pe an · ≈ <span data-calc="days">0</span> zile lucrătoare/an doar cu raportarea.</div>
|
|
||||||
<div style="margin-top:20px;padding-top:18px;border-top:1px solid color-mix(in srgb,#E05D5D 24%,var(--line,#262b36));">
|
|
||||||
<div style="display:flex;align-items:center;gap:9px;font:600 14px var(--font-ui);color:var(--okt,#2FBF8F);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Cu ROMFAST: câteva secunde pentru tot lotul</div>
|
|
||||||
<div style="font:400 13px/1.55 var(--font-ui);color:var(--sub,#8b93a7);margin-top:6px;">Recuperezi ~<span data-calc="leiMonth">0</span> lei/lună și timpul îl pui pe mașini, nu pe formulare.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin:24px auto 0;display:flex;gap:20px;align-items:flex-start;background:color-mix(in srgb,#E0A93B 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E0A93B 34%,var(--line,#262b36));border-radius:12px;padding:26px 28px;">
|
|
||||||
<div style="width:44px;height:44px;flex-shrink:0;border-radius:8px;background:color-mix(in srgb,#E0A93B 16%,transparent);display:flex;align-items:center;justify-content:center;color:#E0A93B;"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9.5C7.1 20.3 4 16.4 4 12V7l8-4z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></div>
|
<div style="width:44px;height:44px;flex-shrink:0;border-radius:8px;background:color-mix(in srgb,#E0A93B 16%,transparent);display:flex;align-items:center;justify-content:center;color:#E0A93B;"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9.5C7.1 20.3 4 16.4 4 12V7l8-4z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Evită riscul amenzilor — transmite automat la RAR Auto-Pass</div>
|
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Evită riscul amenzilor — transmite automat la RAR Auto-Pass</div>
|
||||||
@@ -205,10 +205,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SOLVE -->
|
<!-- SOLVE -->
|
||||||
<div style="padding:80px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
|
<div id="cum-functioneaza" style="padding:80px 40px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
|
||||||
<div style="max-width:780px;margin:0 auto;text-align:center;">
|
<div style="max-width:780px;margin:0 auto;text-align:center;">
|
||||||
<h2 style="font:700 36px 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 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2>
|
||||||
<p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA Auto-Pass îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
|
<p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA AUTOPASS îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 var(--font-ui);color:var(--sub,#8b93a7);">
|
<div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 var(--font-ui);color:var(--sub,#8b93a7);">
|
||||||
<span style="text-decoration:line-through;text-decoration-color:var(--errt,#E05D5D);text-decoration-thickness:2px;">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>
|
||||||
@@ -216,10 +216,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API INTEGRATION -->
|
<!-- API INTEGRATION -->
|
||||||
<div style="padding:0 40px 80px;">
|
<div id="api" style="padding:56px 40px 80px;">
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;">
|
||||||
<div>
|
<div>
|
||||||
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu · ROAAUTO</div>
|
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu</div>
|
||||||
<h2 style="font:700 30px/1.15 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>
|
<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>
|
||||||
@@ -242,72 +242,93 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PRICING -->
|
<!-- TRIAL BENEFIT -->
|
||||||
<div style="padding:0 40px 80px;">
|
<div style="padding:0 40px 80px;">
|
||||||
<div style="text-align:center;margin-bottom:44px;">
|
<div style="display:flex;align-items:center;gap:22px;background:color-mix(in srgb,#2FBF8F 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#2FBF8F 32%,var(--line,#262b36));border-radius:14px;padding:30px 34px;flex-wrap:wrap;">
|
||||||
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.08em;text-transform:uppercase;margin-bottom:12px;">Preț</div>
|
<div style="width:48px;height:48px;flex-shrink:0;border-radius:10px;background:color-mix(in srgb,#2FBF8F 16%,transparent);display:flex;align-items:center;justify-content:center;color:var(--okt,#2FBF8F);"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/><circle cx="12" cy="12" r="4.5"/></svg></div>
|
||||||
<h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
|
<div style="flex:1;min-width:240px;">
|
||||||
<p style="font:400 15px var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Fiecare cont începe cu <strong style="color:var(--text,#e6e9ef);font-weight:600;">Pro gratuit 30 de zile</strong>. Apoi trece automat pe Gratuit — fără plată, dacă nu alegi alt plan. Fără card bancar.</p>
|
<div style="font:700 19px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);margin-bottom:5px;"><span style="color:var(--okt,#2FBF8F);">30 de zile Pro gratuit</span> la fiecare cont nou</div>
|
||||||
|
<p style="font:400 14px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Începi direct cu tot ce are planul Pro — import API, categorisire automată și suport rapid. După 30 de zile treci automat pe Gratuit, fără plată și fără întreruperi.</p>
|
||||||
|
</div>
|
||||||
|
<button data-act="auth" data-tab="register" data-plan="pro" style="height:48px;padding:0 24px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;white-space:nowrap;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Începe gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:start;">
|
</div>
|
||||||
|
|
||||||
|
<!-- PRICING -->
|
||||||
|
<div id="pret" style="padding:0 40px 80px;">
|
||||||
|
<div style="text-align:center;margin-bottom:44px;">
|
||||||
|
<h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
|
||||||
|
<p style="font:400 15px var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Alege planul potrivit volumului tău. Poți schimba sau anula oricând.</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:stretch;">
|
||||||
<!-- Gratuit -->
|
<!-- Gratuit -->
|
||||||
<div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||||
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Testare și firme mici</div>
|
|
||||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div>
|
<div style="font:700 17px 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:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
|
||||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Fără card bancar</div>
|
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Până la 60 de trimiteri/lună</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
|
<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>Până la 60 de prestații/lună</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>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>Sugestii automate de prestații RAR (din mii)</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>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>Mapare manuală 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>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>Suport contact/email în 48h</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Trimiteri nelimitate</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
|
||||||
<span style="display:none;"></span>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--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>
|
</div>
|
||||||
<button data-act="auth" data-tab="register" data-plan="Gratuit" style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
<button data-act="auth" data-tab="register" data-plan="free" style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Standard -->
|
<!-- Standard -->
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;">
|
<div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;display:flex;flex-direction:column;">
|
||||||
|
<div style="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);">39 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
|
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px 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;">Volum nelimitat, fără API</div>
|
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Trimiteri nelimitate</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
|
<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>Tot din Gratuit</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 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>Prestații 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>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>Suport contact/email în 48h</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||||
<span style="display:none;"></span>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 24h</div>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px 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="Standard">Creează cont gratuit</button>
|
<button style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="standard">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Pro -->
|
<!-- Pro -->
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;position:relative;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||||
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 90%,#000);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Cel mai ales</div>
|
|
||||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div>
|
<div style="font:700 17px 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></div>
|
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
|
||||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Cu acces API</div>
|
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Nelimitat + acces API</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
|
<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>Tot din Standard</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>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>Import prin API + cheie API per cont</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>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>Suport pe email în 24h</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>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>Categorisire automată, cu confirmare la operațiile incerte</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||||
<span style="display:none;"></span>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>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>
|
</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 -->
|
<!-- Premium -->
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div>
|
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div>
|
||||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></div>
|
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></div>
|
||||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</div>
|
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
|
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 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>Tot din Pro</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 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>Opțiune de integrare în softul tău</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 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 și online</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 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>Asistență și onboarding dedicate</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Integrare în softul tău</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic + onboarding dedicat</div>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px 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>
|
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="premium">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,10 +360,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Creează cont</div>
|
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Creează cont</div>
|
||||||
<h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2>
|
<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, fără card bancar. Imediat poți încărca primul fișier sau conecta softul de service.</p>
|
<p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit. Imediat poți încărca primul fișier sau conecta softul de service.</p>
|
||||||
<div style="display:flex;flex-direction:column;gap:12px;">
|
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px 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>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>Fără card bancar la înscriere</div>
|
|
||||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023 și OMTI 210/2024</div>
|
<div style="display:flex;gap:10px;align-items:center;font:400 15px 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>
|
||||||
@@ -354,12 +374,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<form method="post" action="/signup" data-pane="register">
|
<form method="post" action="/signup" data-pane="register">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Nume contact</span><input type="text" name="name" required placeholder="Ion Popescu" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px 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);">Companie</span><input type="text" name="name" required placeholder="SC Service Auto SRL" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
|
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">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="Gratuit" selected>Gratuit — 0 lei/lună</option><option value="Standard">Standard — 39 lei/lună</option><option value="Pro">Pro — 59 lei/lună</option><option value="Premium">Premium — la cerere</option></select></label>
|
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="free" selected>Gratuit — 0 lei/lună</option><option value="standard">Standard — 49 lei/lună</option><option value="pro">Pro — 59 lei/lună</option><option value="premium">Premium — la cerere</option></select></label>
|
||||||
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
|
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
|
||||||
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px 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>
|
||||||
@@ -375,18 +395,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FINAL CTA -->
|
|
||||||
<div style="padding:0 40px 80px;">
|
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:16px;padding:56px 40px;text-align:center;">
|
|
||||||
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Începe să declari la RAR în câteva minute</h2>
|
|
||||||
<p style="font:400 16px var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 28px;">Gratuit până la 60 de prezentări pe lună. Fără card bancar.</p>
|
|
||||||
<div style="display:flex;gap:12px;justify-content:center;">
|
|
||||||
<button data-act="auth" data-tab="register" style="height:50px;padding:0 28px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
|
||||||
<button data-act="auth" data-tab="login" style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px 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)">Autentificare</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FOOTER -->
|
<!-- FOOTER -->
|
||||||
<div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;">
|
<div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;">
|
||||||
<div style="font:700 18px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div>
|
<div style="font:700 18px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div>
|
||||||
@@ -421,7 +429,7 @@
|
|||||||
var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
|
var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
|
||||||
document.getElementById('out-pres').textContent=p;
|
document.getElementById('out-pres').textContent=p;
|
||||||
document.getElementById('out-rate').textContent=r;
|
document.getElementById('out-rate').textContent=r;
|
||||||
var map={leiMonth:nf.format(Math.round(leiMonth)),hMonth:nf1.format(hMonth),leiYear:nf.format(Math.round(leiMonth*12)),days:nf1.format((hMonth*12)/8)};
|
var map={leiMonth:nf.format(Math.round(leiMonth)),hMonth:nf.format(Math.round(hMonth)),leiYear:nf.format(Math.round(leiMonth*12)),days:nf.format(Math.round((hMonth*12)/8))};
|
||||||
Object.keys(map).forEach(function(k){document.querySelectorAll('[data-calc="'+k+'"]').forEach(function(n){n.textContent=map[k];});});
|
Object.keys(map).forEach(function(k){document.querySelectorAll('[data-calc="'+k+'"]').forEach(function(n){n.textContent=map[k];});});
|
||||||
}
|
}
|
||||||
if(pres&&rate){pres.addEventListener('input',recalc);rate.addEventListener('input',recalc);recalc();}
|
if(pres&&rate){pres.addEventListener('input',recalc);rate.addEventListener('input',recalc);recalc();}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Autentificare — ROMFAST AUTOPASS{% endblock %}
|
{% block title %}Autentificare — ROA AUTOPASS{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{# US-010 (PRD 5.16): /login — layout 2 coloane branduit.
|
{# US-010 (PRD 5.16): /login — layout 2 coloane branduit.
|
||||||
Stanga: logo + tagline + puncte de incredere.
|
Stanga: logo + tagline + puncte de incredere.
|
||||||
@@ -8,31 +8,7 @@
|
|||||||
<div class="login-2col" style="max-width:860px; margin:32px auto;">
|
<div class="login-2col" style="max-width:860px; margin:32px auto;">
|
||||||
{# Antet minimal deja randat in base.html (fara RAR dot, fara burger, fara account_name) #}
|
{# Antet minimal deja randat in base.html (fara RAR dot, fara burger, fara account_name) #}
|
||||||
<div class="login-shell">
|
<div class="login-shell">
|
||||||
{# === Coloana stanga: brand + trust === #}
|
{# === Formular autentificare === #}
|
||||||
<aside class="login-aside" aria-label="Despre ROMFAST AUTOPASS">
|
|
||||||
<div class="login-brand-row">
|
|
||||||
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:36px; width:auto;">
|
|
||||||
</div>
|
|
||||||
<h2 class="login-headline">ROMFAST <span style="color:var(--accent);">AUTOPASS</span></h2>
|
|
||||||
<p class="login-tagline">Declara prestatiile de service-auto la RAR AUTOPASS, automat.
|
|
||||||
Conform Legii 142/2023 si OMTI 210/2024.</p>
|
|
||||||
<ul class="login-trust">
|
|
||||||
<li>
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
|
|
||||||
Conform Legii 142/2023 si OMTI 210/2024
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>
|
|
||||||
Datele criptate, sterse la 3 luni
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
|
|
||||||
Parte din familia ROA — Romfast Applications
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{# === Coloana dreapta: formular (NESCHIMBAT — CSRF, POST /login, link signup) === #}
|
|
||||||
<div class="login-form-col">
|
<div class="login-form-col">
|
||||||
<h3 style="font-size:var(--fs-xl); margin:0 0 4px;">Autentificare</h3>
|
<h3 style="font-size:var(--fs-xl); margin:0 0 4px;">Autentificare</h3>
|
||||||
<p style="font-size:var(--fs-sm); color:var(--muted); margin:0 0 22px;">
|
<p style="font-size:var(--fs-sm); color:var(--muted); margin:0 0 22px;">
|
||||||
@@ -66,33 +42,18 @@
|
|||||||
<style>
|
<style>
|
||||||
/* US-010 PRD 5.16: layout /login profesional 2 coloane. */
|
/* US-010 PRD 5.16: layout /login profesional 2 coloane. */
|
||||||
.login-shell {
|
.login-shell {
|
||||||
display:grid; grid-template-columns:1.1fr 0.9fr;
|
display:grid; grid-template-columns:1fr;
|
||||||
border:1px solid var(--line); border-radius:16px; overflow:hidden;
|
border:1px solid var(--line); border-radius:16px; overflow:hidden;
|
||||||
background:var(--card); min-height:480px;
|
background:var(--card); max-width:460px; margin:0 auto;
|
||||||
}
|
}
|
||||||
.login-aside {
|
|
||||||
padding:40px 38px;
|
|
||||||
background:linear-gradient(160deg, color-mix(in srgb,var(--accent) 12%,var(--card)), var(--card));
|
|
||||||
border-right:1px solid var(--line);
|
|
||||||
display:flex; flex-direction:column; justify-content:center;
|
|
||||||
}
|
|
||||||
.login-brand-row { display:flex; align-items:center; gap:10px; margin-bottom:24px; }
|
|
||||||
.login-headline { font-size:var(--fs-2xl); line-height:var(--lh-tight); margin:0 0 12px; letter-spacing:-.02em; }
|
|
||||||
.login-tagline { font-size:var(--fs-md); color:var(--muted); line-height:1.6; margin:0 0 20px; max-width:340px; }
|
|
||||||
.login-trust { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:10px; }
|
|
||||||
.login-trust li { display:flex; align-items:center; gap:9px; font-size:var(--fs-sm); color:var(--ink); }
|
|
||||||
.login-trust svg { flex-shrink:0; color:var(--ok); }
|
|
||||||
.login-form-col { padding:40px 38px; display:flex; flex-direction:column; justify-content:center; }
|
.login-form-col { padding:40px 38px; display:flex; flex-direction:column; justify-content:center; }
|
||||||
.btn-primary-full { width:100%; min-height:46px; font-family:var(--font-ui); font-size:var(--fs-md);
|
.btn-primary-full { width:100%; min-height:46px; font-family:var(--font-ui); font-size:var(--fs-md);
|
||||||
font-weight:600; background:var(--accent); color:#fff; border:none;
|
font-weight:600; background:var(--accent); color:#fff; border:none;
|
||||||
border-radius:8px; cursor:pointer; margin-top:4px; }
|
border-radius:8px; cursor:pointer; margin-top:4px; }
|
||||||
.btn-primary-full:hover { filter:brightness(1.08); }
|
.btn-primary-full:hover { filter:brightness(1.08); }
|
||||||
.login-foot { text-align:center; font-size:var(--fs-sm); color:var(--muted); margin-top:18px; }
|
.login-foot { text-align:center; font-size:var(--fs-sm); color:var(--muted); margin-top:18px; }
|
||||||
/* Mobil: stivuire verticala, formular sus */
|
|
||||||
@media (max-width:640px) {
|
@media (max-width:640px) {
|
||||||
.login-shell { grid-template-columns:1fr; grid-template-rows:auto auto; }
|
.login-form-col { padding:28px 22px; }
|
||||||
.login-aside { order:2; border-right:none; border-top:1px solid var(--line); padding:28px 22px; }
|
|
||||||
.login-form-col { order:1; padding:28px 22px; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -37,33 +37,53 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h2 style="margin-top:0;">Inregistrare cont nou</h2>
|
<h2 style="margin-top:0;">Creează cont nou</h2>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
|
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Format aliniat la formularul de inregistrare din landing (#inregistrare): aceleasi campuri,
|
||||||
|
etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro/premium),
|
||||||
|
normalizate server-side. #}
|
||||||
<form method="post" action="/signup">
|
<form method="post" action="/signup">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<p>
|
<label style="display:block;margin-bottom:14px;">
|
||||||
<label>Companie <span style="color:var(--err)">*</span></label><br>
|
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Companie</span>
|
||||||
<input type="text" name="name" value="{{ name or '' }}" required style="width:100%;">
|
<input type="text" name="name" value="{{ name or '' }}" required placeholder="SC Service Auto SRL"
|
||||||
</p>
|
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||||
<p>
|
</label>
|
||||||
<label>CUI <span style="color:var(--err)">*</span></label><br>
|
<label style="display:block;margin-bottom:14px;">
|
||||||
<input type="text" name="cui" value="{{ cui or '' }}" required style="width:100%;">
|
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">CUI</span>
|
||||||
</p>
|
<input type="text" name="cui" value="{{ cui or '' }}" required placeholder="RO12345678"
|
||||||
<p>
|
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-mono);outline:none;">
|
||||||
<label>Email <span style="color:var(--err)">*</span></label><br>
|
</label>
|
||||||
<input type="email" name="email" value="{{ email or '' }}" required style="width:100%;">
|
<label style="display:block;margin-bottom:14px;">
|
||||||
</p>
|
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Email</span>
|
||||||
<p>
|
<input type="email" name="email" value="{{ email or '' }}" required placeholder="nume@service.ro"
|
||||||
<label>Parola <span style="color:var(--err)">*</span>
|
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||||
<span style="color:var(--muted);font-size:12px;">(minim 10 caractere)</span>
|
</label>
|
||||||
</label><br>
|
<label style="display:block;margin-bottom:14px;">
|
||||||
<input type="password" name="parola" required style="width:100%;">
|
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Parolă</span>
|
||||||
</p>
|
<input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere"
|
||||||
<button type="submit" style="width:100%;margin-top:8px;">Creeaza cont</button>
|
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||||
|
</label>
|
||||||
|
<label style="display:block;margin-bottom:16px;">
|
||||||
|
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Pachet ales</span>
|
||||||
|
<select name="plan"
|
||||||
|
style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;cursor:pointer;">
|
||||||
|
<option value="free"{% if not plan or plan == 'free' %} selected{% endif %}>Gratuit — 0 lei/lună</option>
|
||||||
|
<option value="standard"{% if plan == 'standard' %} selected{% endif %}>Standard — 49 lei/lună</option>
|
||||||
|
<option value="pro"{% if plan == 'pro' %} selected{% endif %}>Pro — 59 lei/lună</option>
|
||||||
|
<option value="premium"{% if plan == 'premium' %} selected{% endif %}>Premium — la cerere</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--muted);cursor:pointer;">
|
||||||
|
<input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent);width:16px;height:16px;flex-shrink:0;">
|
||||||
|
Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).
|
||||||
|
</label>
|
||||||
|
<button type="submit"
|
||||||
|
style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;">Creează cont gratuit</button>
|
||||||
</form>
|
</form>
|
||||||
<p style="text-align:center;font-size:13px;margin-top:16px;">
|
<p style="text-align:center;font-size:13px;margin-top:16px;">
|
||||||
Ai deja cont? <a href="/login">Autentificare</a>
|
Ai deja cont? <a href="/login">Autentificare</a>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import httpx
|
|||||||
from .. import errors
|
from .. import errors
|
||||||
from ..config import Settings, get_settings, load_test_credentials
|
from ..config import Settings, get_settings, load_test_credentials
|
||||||
from ..crypto import decrypt_creds
|
from ..crypto import decrypt_creds
|
||||||
from ..db import get_connection, init_db, write_heartbeat
|
from ..db import get_connection, init_db, read_heartbeat, write_heartbeat
|
||||||
from ..observ import log_event, set_source
|
from ..observ import log_event, set_source
|
||||||
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
|
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
|
||||||
from ..payload import build_rar_payload
|
from ..payload import build_rar_payload
|
||||||
@@ -428,6 +428,68 @@ def _creds_from_account(conn, account_id: int) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _keepalive_target(conn, settings: Settings) -> tuple[int | None, dict | None]:
|
||||||
|
"""Un cont cu creds durabile pentru login-ul de proba (sau creds <test> in dev).
|
||||||
|
|
||||||
|
Sare conturile ale caror creds NU se decripteaza sub cheia curenta — in dev
|
||||||
|
`start.sh both` genereaza o cheie efemera noua la fiecare pornire, deci creds-urile
|
||||||
|
durabile criptate sub cheia veche dau decrypt -> None. Fallback la creds <test>.
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, rar_creds_enc FROM accounts "
|
||||||
|
"WHERE rar_creds_enc IS NOT NULL ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
creds = decrypt_creds(row["rar_creds_enc"])
|
||||||
|
if creds and creds.get("email") and creds.get("password"):
|
||||||
|
return row["id"], creds
|
||||||
|
if settings.worker_use_test_creds:
|
||||||
|
return DEFAULT_ACCOUNT_ID, load_test_credentials()
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_keepalive(conn, settings: Settings, sessions: "AccountSessions", state: dict) -> None:
|
||||||
|
"""Login de proba periodic cand coada e goala — verifica reachability RAR si
|
||||||
|
pastreaza last_rar_login_ok proaspat ca dashboard-ul sa nu afiseze fals
|
||||||
|
'RAR inaccesibil' doar din lipsa de trafic.
|
||||||
|
|
||||||
|
Sondeaza la cel mult o data pe interval (si pe succes, si pe esec): pe succes
|
||||||
|
heartbeat-ul se reimprospateaza singur; pe esec real (RAR jos) last_rar_login_ok
|
||||||
|
ramane vechi -> dashboard-ul degradeaza corect. Forteaza login real (invalideaza
|
||||||
|
sesiunea cache-uita) ca proba sa fie autentica, nu un token vechi din cache.
|
||||||
|
"""
|
||||||
|
interval = settings.worker_rar_keepalive_interval_s
|
||||||
|
if interval <= 0:
|
||||||
|
return
|
||||||
|
hb = read_heartbeat(conn)
|
||||||
|
last = hb["last_rar_login_ok"] if hb else None
|
||||||
|
if last:
|
||||||
|
try:
|
||||||
|
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds()
|
||||||
|
if age < interval:
|
||||||
|
return # login inca proaspat — nimic de facut
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
now_ts = time.time()
|
||||||
|
if now_ts - state["last_attempt"] < interval:
|
||||||
|
return # deja am incercat recent (nu hartui RAR daca e jos)
|
||||||
|
state["last_attempt"] = now_ts
|
||||||
|
|
||||||
|
account_id, creds = _keepalive_target(conn, settings)
|
||||||
|
if account_id is None or not creds:
|
||||||
|
return # niciun cont cu creds durabile — nimic de sondat
|
||||||
|
sessions.invalidate(account_id) # forteaza login real, nu token din cache
|
||||||
|
try:
|
||||||
|
sessions.get_token(conn, account_id, creds) # reimprospateaza last_rar_login_ok la succes
|
||||||
|
except RarAuthError:
|
||||||
|
pass # creds invalide — deja logat in get_token (WARNING)
|
||||||
|
except Exception as exc:
|
||||||
|
# RAR indisponibil: last_rar_login_ok ramane vechi (corect). Nu propaga.
|
||||||
|
log_event("rar_keepalive", nivel="WARNING", account_id=account_id,
|
||||||
|
mesaj=f"keepalive RAR esuat (cont {account_id}): {type(exc).__name__}",
|
||||||
|
context={"rezultat": "esuat"}, conn=conn, sursa="worker")
|
||||||
|
|
||||||
|
|
||||||
def run() -> int:
|
def run() -> int:
|
||||||
signal.signal(signal.SIGTERM, _stop)
|
signal.signal(signal.SIGTERM, _stop)
|
||||||
signal.signal(signal.SIGINT, _stop)
|
signal.signal(signal.SIGINT, _stop)
|
||||||
@@ -440,6 +502,7 @@ def run() -> int:
|
|||||||
|
|
||||||
sessions = AccountSessions(settings)
|
sessions = AccountSessions(settings)
|
||||||
_last_purge_time: float = 0.0
|
_last_purge_time: float = 0.0
|
||||||
|
_keepalive_state = {"last_attempt": 0.0}
|
||||||
|
|
||||||
while _running:
|
while _running:
|
||||||
try:
|
try:
|
||||||
@@ -466,6 +529,9 @@ def run() -> int:
|
|||||||
# Nimic de trimis: recupereaza orfanii conturilor deja logate.
|
# Nimic de trimis: recupereaza orfanii conturilor deja logate.
|
||||||
for acct, rar, tok in sessions.active():
|
for acct, rar, tok in sessions.active():
|
||||||
recover_orphans(conn, settings, rar, tok, account_id=acct)
|
recover_orphans(conn, settings, rar, tok, account_id=acct)
|
||||||
|
# Login de proba periodic ca dashboard-ul sa nu afiseze fals
|
||||||
|
# "RAR inaccesibil" din lipsa de trafic (vezi _maybe_keepalive).
|
||||||
|
_maybe_keepalive(conn, settings, sessions, _keepalive_state)
|
||||||
time.sleep(settings.worker_poll_interval_s)
|
time.sleep(settings.worker_poll_interval_s)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ services:
|
|||||||
- autopass-data:/data
|
- autopass-data:/data
|
||||||
environment:
|
environment:
|
||||||
AUTOPASS_DB_PATH: /data/autopass.db
|
AUTOPASS_DB_PATH: /data/autopass.db
|
||||||
AUTOPASS_RAR_ENV: prod
|
# Override din environment (Dokploy) pentru staging; default = prod.
|
||||||
|
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-prod}
|
||||||
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
|
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
|
||||||
TZ: ${TZ:-Europe/Bucharest}
|
TZ: ${TZ:-Europe/Bucharest}
|
||||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||||
@@ -35,10 +36,11 @@ services:
|
|||||||
- autopass-data:/data
|
- autopass-data:/data
|
||||||
environment:
|
environment:
|
||||||
AUTOPASS_DB_PATH: /data/autopass.db
|
AUTOPASS_DB_PATH: /data/autopass.db
|
||||||
AUTOPASS_RAR_ENV: test
|
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-test}
|
||||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||||
# Send dezactivat by default; activeaza pentru proba end-to-end.
|
# Send activ by default (prod); pe staging seteaza AUTOPASS_WORKER_SEND_ENABLED=false
|
||||||
AUTOPASS_WORKER_SEND_ENABLED: "true"
|
# in Dokploy ca worker-ul sa NU trimita declaratii reale la RAR (Legea 142/2023).
|
||||||
|
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:-true}
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
355
docs/prd/prd-5.18-corpus-knn-exemple-etichetate.md
Normal file
355
docs/prd/prd-5.18-corpus-knn-exemple-etichetate.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
# PRD 5.18 — Corpus k-NN din exemple reale etichetate (mapare operatii service)
|
||||||
|
|
||||||
|
**Stare**: aprobat + revizuit /autoplan (2026-06-28; intrebari deschise rezolvate de user — vezi §5 Decizii;
|
||||||
|
cerinte user D4/D5 + 10 constatari Eng incorporate — vezi GSTACK REVIEW REPORT la final)
|
||||||
|
|
||||||
|
> Proces: `docs/ROADMAP.md` §5. Contract RAR: `docs/api-rar-contract.md`. Construieste peste
|
||||||
|
> infrastructura 5.14 (straturi GOLD/SILVER/embeddings, `app/embeddings.py`, `app/shared_store.py`,
|
||||||
|
> `mapping_suggestions`). NU re-deschide deciziile 5.14 (#11-#19); le foloseste.
|
||||||
|
|
||||||
|
## 0. Context si motivatie (de ce acest PRD)
|
||||||
|
|
||||||
|
5.14 a livrat embeddings in-proces, dar corpusul indexat = **cele 18 denumiri RAR generice**
|
||||||
|
din nomenclator (`nume_prestatie` -> `cod_prestatie`). O operatie reala ("inlocuit lubrifiant
|
||||||
|
la propulsor") se potriveste semantic slab cu etichete generice scurte ("INTRETINERE",
|
||||||
|
"REPARATIE"). In plus, stratul **SILVER (`mapping_suggestions`) e populat DOAR in teste** —
|
||||||
|
in productie e gol, deci nu produce nicio sugestie (LLM-ul nu e chemat la runtime).
|
||||||
|
|
||||||
|
Acest PRD muta corpusul de la cele 18 categorii la **operatiile reale etichetate** (k-NN peste
|
||||||
|
exemple): o operatie noua se potriveste semantic cu o operatie deja vazuta si MOSTENESTE codul ei.
|
||||||
|
|
||||||
|
**Masuratori care justifica directia** (vezi memorie `test-precizie-knn-embeddings`, rulat 2026-06-28):
|
||||||
|
- k-NN peste exemple etichetate: **94.3% acord cu LLM pe operatii distincte** (baseline "mereu OE-1" = 86.2%).
|
||||||
|
- Acoperire IEFTINA: pe volumul real total (155.195 aparitii, 17.181 operatii distincte):
|
||||||
|
148 operatii = 50% volum, **1.380 = 80%**, 4.368 = 90%, 9.422 = 95%.
|
||||||
|
- Punct slab masurat: **NUL recall 64%** (ITP/discount/plata scapa ca OE-1) -> de aici pre-filtrul (US-001).
|
||||||
|
- Etichetarea offline cu **Qwen3-4B local (LM Studio, GPU RX 6600M)** + prompt procedural in 3 pasi:
|
||||||
|
**91% pe batch greu, 20/20 pe batch de validare**, ambele NUL prinse. Debit ~1.5-2h pentru ~13.5k operatii.
|
||||||
|
|
||||||
|
## 1. Obiectiv
|
||||||
|
|
||||||
|
Inlocuieste corpusul embeddings (18 categorii generice) cu **corpusul de operatii reale etichetate**
|
||||||
|
(exemplu -> cod RAR), populat dintr-un seed comis in repo, plus un **pre-filtru determinist** pentru
|
||||||
|
non-operatii (NUL). Rezultat: sugestii de mapare semnificativ mai precise in editor, fara LLM la runtime.
|
||||||
|
|
||||||
|
**Pasul 1 (bootstrap offline, fundatia intregului PRD) = etichetare cu LLM via LM Studio local.**
|
||||||
|
Tot restul (seeder, corpus embeddings, enrich) consuma artefactul produs aici. Pasul are doua garantii
|
||||||
|
non-negociabile:
|
||||||
|
1. **LM Studio = backend implicit aprobat pentru rularea v1** (Qwen3-4B local, GPU RX 6600M, `json_schema`
|
||||||
|
strict — `json_object` e respins de LM Studio). Groq/OpenRouter raman fallback-uri interschimbabile, dar
|
||||||
|
NU sunt calea aprobata pentru bootstrap-ul v1 (vezi D4).
|
||||||
|
2. **Dedup INAINTE de orice apel LLM.** Cele 4 fisiere (`docs/operatii-service/*.csv`) contin **19.456 randuri
|
||||||
|
brute -> 17.181 operatii distincte dupa `normalize_for_match`** (gain de doar 254 fata de dedup exact-string,
|
||||||
|
pentru ca datele sunt deja majuscule, fara diacritice — `normalize_for_match` colapseaza spatii + scoate diacritice,
|
||||||
|
**NU** scoate punctuatie). Din cele 17.181, **3.662 sunt deja etichetate** (in spatiu normalizat) in
|
||||||
|
`labels-groq-partial.json`. Trimitem la LLM EXACT cele **13.519** operatii distincte ne-etichetate, niciodata un
|
||||||
|
duplicat normalizat, o cheie normalizata vida sau o operatie deja etichetata (vezi D5). Economie: **31% mai putine
|
||||||
|
apeluri** vs randuri brute. (Castigul real al pipeline-ului nu e atat normalizarea — 254 chei — cat **reuse-ul
|
||||||
|
etichetelor existente** + agregarea frecventei; motivul principal pentru spatiul normalizat e **consistenta
|
||||||
|
end-to-end cu cheia DB/k-NN**, vezi F1/F3 din review.)
|
||||||
|
|
||||||
|
## 2. Non-Goals (anti scope-creep)
|
||||||
|
|
||||||
|
- **NU auto-send peste GOLD propriu.** Toate sursele (k-NN, exact, NUL pre-filtru) raman SUGGESTION-ONLY,
|
||||||
|
niciodata in `resolve_prestatii`/`load_mapping` (invariant #13, #11 din 5.14). Singura cale spre `queued`
|
||||||
|
ramane `operations_mapping` (GOLD propriu confirmat de om).
|
||||||
|
- **NU LLM la runtime.** Etichetarea LLM se face O SINGURA DATA, offline; runtime = doar embeddings + exact + reguli.
|
||||||
|
- **NU validare temporala / re-etichetare automata.** Seedul e static; reimprospatarea e un re-run manual al tool-ului.
|
||||||
|
- **NU schimbare UI majora.** Editorul (`_mapari.html`) consuma deja `sugestie_principala`; doar sursa se schimba.
|
||||||
|
(Un badge optional de sursa = US-007, jos.)
|
||||||
|
- **NU eshantion etichetat de om in acest PRD** (doar mentionat la Riscuri ca recomandare — Decision #19).
|
||||||
|
|
||||||
|
## 3. Stories atomice
|
||||||
|
|
||||||
|
> Fiecare story = cea mai mica unitate care lasa sistemul functional. Refoloseste `mapping_suggestions`
|
||||||
|
> (SILVER) ca tabela-corpus (are deja: `denumire_normalizata`, `cod_prestatie`, `is_nul`, `source`,
|
||||||
|
> `confidence`) — populata acum si in productie, nu doar in teste.
|
||||||
|
|
||||||
|
### US-001: Pre-filtru determinist non-operatii (NUL)
|
||||||
|
**Ca** operator **vreau** ca gunoiul evident (ITP, plata, discount, nr. inmatriculare, tractare) sa fie
|
||||||
|
marcat NUL inainte de k-NN **pentru ca** masuratoarea arata recall NUL doar 64% (scapa ca OE-1).
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `app/mapping.py` (functie noua `prefiltru_nul(denumire) -> bool`), `tests/test_prefiltru_nul.py` (~2 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_prefiltru_nul.py` — `test_itp_e_nul`, `test_plata_discount_nul`, `test_nr_inmatriculare_nul`, `test_operatie_reala_nu_e_nul`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Reguli text/regex deterministe (ITP, ACHITAT/PLATA, DISCOUNT/REDUCERE, NR INMATRICULARE + pattern placuta, TRACTARE, TAXA)
|
||||||
|
- [ ] `prefiltru_nul("13 X ITP")` / `("DISCOUNT FIDELITATE 10%")` -> True; `("INLOCUIT PLACUTE FRANA")` -> False
|
||||||
|
- [ ] Zero fals-pozitiv pe un set de 20 operatii reale (din `docs/operatii-service`)
|
||||||
|
- [ ] `python3 -m pytest tests/test_prefiltru_nul.py -q` verde
|
||||||
|
- **Verificare E2E**: — (pur backend, acoperit de teste)
|
||||||
|
|
||||||
|
### US-002: Etichetator offline multi-backend cu prompt procedural
|
||||||
|
**Ca** dezvoltator **vreau** un tool care eticheteaza operatii->coduri RAR via LM Studio local / Groq /
|
||||||
|
OpenRouter, cu prompt procedural in 3 pasi si `json_schema` strict **pentru ca** LM Studio respinge
|
||||||
|
`json_object` si promptul nou ridica precizia (91% vs 80%).
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `tools/mapare-llm/eticheteaza.py` (NOU, backend-uri interschimbabile), `tests/test_eticheteaza_tool.py` (mock HTTP) (~2 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_eticheteaza_tool.py` — `test_construieste_prompt_3pasi`, `test_parseaza_json_schema`, `test_backend_selectabil_env`, `test_scrub_pii_inainte_de_request`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Backend selectabil prin env (`ETICHETARE_BACKEND=lmstudio|groq|openrouter`, endpoint+model configurabile);
|
||||||
|
**default = `lmstudio`** (backend-ul aprobat pentru bootstrap v1, D4). Groq/OpenRouter = fallback.
|
||||||
|
- [ ] `response_format` = `json_schema` strict cu **envelope complet** `{"type":"json_schema","json_schema":{"name":...,"strict":true,"schema":{...}}}`
|
||||||
|
(NU `{"type":"json_object"}` ca `or_common.py:57`/`label_common.py:24`); `cod` = **enum** peste cele 19 `ALL_LABELS` (18 + NUL),
|
||||||
|
cod invalid/lipsa -> `?` (F8 din review). Etichetatorul nou NU reutilizeaza request-ul vechi, doar promptul/codurile/scrub-ul.
|
||||||
|
- [ ] **Dezactiveaza explicit "thinking"-ul Qwen3** (`/no_think` sau reasoning off) — altfel modelul emite `<think>` si
|
||||||
|
umfla tokeni/latenta sub structured output strict (F8).
|
||||||
|
- [ ] **Garda de truncare**: daca raspunsul are mai putine iteme decat batch-ul sau JSON invalid -> log + marcheaza `?`
|
||||||
|
pe pozitiile lipsa, NU le ascunde tacit (la batch 40 + prompt 3 pasi, `n_ctx=4096` e stramt — F8).
|
||||||
|
- [ ] Promptul = procedura 3 pasi + ancore (mapare parte caroserie->OE-C etc.), versionat in fisier
|
||||||
|
- [ ] Scrub PII (nr. inmatriculare, VIN) inainte de orice request (refoloseste `or_common.scrub`, #3)
|
||||||
|
- [ ] Setari conservatoare documentate in tool (batch 32-40, `n_parallel=1`, `n_ctx=4096`) — vezi Riscuri
|
||||||
|
- [ ] `python3 -m pytest tests/test_eticheteaza_tool.py -q` verde (fara retea reala)
|
||||||
|
- **Verificare E2E**: rulare manuala 1 batch pe LM Studio local (`http://<tailscale>:1234`), confirmare JSON valid
|
||||||
|
|
||||||
|
### US-003: Generare seed etichetat in faze pe frecventa
|
||||||
|
**Ca** dezvoltator **vreau** sa generez un fisier seed `operatii-etichetate.json` (operatie->cod) pornind de la
|
||||||
|
operatiile existente + cele deja etichetate, in ordinea frecventei **pentru ca** 1.380 operatii prind 80% din volum.
|
||||||
|
|
||||||
|
- **Depinde de**: US-002
|
||||||
|
- **Fisiere**: `tools/mapare-llm/genereaza_seed.py` (NOU), `app/data/operatii-etichetate.json` (artefact comis), `tests/test_genereaza_seed.py` (~3 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_genereaza_seed.py` — `test_dedup_normalizat`, `test_zero_duplicate_trimis_la_llm`, `test_rerun_zero_apeluri_llm`, `test_reuse_conflict_determinist`, `test_skip_cheie_normalizata_vida`, `test_reuse_in_spatiu_normalizat`, `test_ordine_pe_frecventa`, `test_format_seed_valid`
|
||||||
|
- **Pipeline dedup (ordinea e obligatorie, INAINTE de orice apel LLM):**
|
||||||
|
1. Agrega cele 4 CSV-uri -> pentru fiecare rand `(denumire, NR)`. Parseaza NR tolerant (skip rand pe NR ne-numeric, nu zero-weight — F9).
|
||||||
|
2. `cheie = normalize_for_match(denumire)` — ACEEASI functie ca DB/k-NN (`app/mapping.py:40`), NU `.strip()` exact.
|
||||||
|
**Arunca randurile cu `cheie == ""`** (gunoi gen `"..."`, `" "`) inainte de dedup — altfel se bat pe slotul UNIQUE gol (F6).
|
||||||
|
3. Dedup pe cheie: un singur reprezentant per cheie, `freq = suma NR` pe toate aparitiile/fisierele.
|
||||||
|
4. Construieste **harta** `cheie_normalizata -> cod` (NU doar un set) din TOATE sursele de etichete deja existente:
|
||||||
|
`labels-groq-partial.json` (cheiat pe text BRUT) **PLUS seedul comis anterior** `operatii-etichetate.json` (cheiat normalizat).
|
||||||
|
Reuse + scaderea se fac in spatiu normalizat. **Rezolvare conflict determinista** cand acelasi `cheie` are coduri diferite
|
||||||
|
pe variante raw (masurat: 1 azi — `CURATAT CATALIZATOR` OE-2 vs OE-1): castiga varianta cu `freq` (suma NR) maxima, tie-break pe `cod` sortat (F3).
|
||||||
|
5. `de_etichetat = {cheie in corpus} - {cheie in harta etichete}`. Lista (distincta, ne-etichetata, sortata desc pe freq) = SINGURUL input catre LLM.
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `test_zero_duplicate_trimis_la_llm` (within-run): backend LLM mock care inregistreaza fiecare denumire primita;
|
||||||
|
input cu duplicate intentionate (spatii/case + cross-file) -> mock-ul nu vede NICIODATA doua chei normalizate egale,
|
||||||
|
nicio cheie deja etichetata, nicio cheie vida.
|
||||||
|
- [ ] `test_rerun_zero_apeluri_llm` (cross-run, **criteriul real de idempotenta**, F2/F7): ruleaza tool-ul de doua ori cu acelasi
|
||||||
|
input; a doua rulare consuma seedul comis ca cache -> **0 apeluri LLM**, seed identic byte-cu-byte.
|
||||||
|
- [ ] `test_reuse_conflict_determinist` (F3/F7): doua variante raw ale aceleiasi chei cu coduri diferite -> codul ales e determinist (freq-max, tie-break cod).
|
||||||
|
- [ ] Dedup pe `normalize_for_match` (colapseaza spatii + diacritice, **NU** punctuatie; gain real ~254 chei vs exact-string —
|
||||||
|
valoarea principala e consistenta cu cheia DB/k-NN, nu volumul); NU reutiliza `or_common.corpus_by_freq()` ca atare (dedup exact-string).
|
||||||
|
- [ ] Eticheteaza DOAR ce lipseste, in ordine descrescatoare de frecventa, cu `--target-volum 0.9` (oprire la prag) sau `--all`
|
||||||
|
- [ ] Seed format `[{denumire, denumire_normalizata, cod, is_nul, source, confidence}]`, UTF-8, comis in repo;
|
||||||
|
`denumire_normalizata` unica + ne-vida in seed (oglindeste UNIQUE din `mapping_suggestions`; `test_format_seed_valid` asserta non-empty)
|
||||||
|
- [ ] `python3 -m pytest tests/test_genereaza_seed.py -q` verde
|
||||||
|
- **Verificare E2E**: rulare `--target-volum 0.5` pe date reale -> ~150 etichete noi, fisier valid; log-ul tool-ului
|
||||||
|
raporteaza explicit "{brute} randuri -> {distincte} dupa normalizare -> {de_etichetat} trimise la LLM"
|
||||||
|
|
||||||
|
### US-004: Seeder corpus etichetat in DB (mapping_suggestions)
|
||||||
|
**Ca** sistem **vreau** sa incarc seedul etichetat in `mapping_suggestions` la init (INSERT OR IGNORE)
|
||||||
|
**pentru ca** SILVER e gol in productie si trebuie populat ca sa dea sugestii exact-match + corpus k-NN.
|
||||||
|
|
||||||
|
- **Depinde de**: US-003
|
||||||
|
- **Fisiere**: `app/operatii_seed.py` (NOU, dupa modelul `nomenclator_seed.py`), `app/db.py` (apel la init), `tests/test_operatii_seed.py` (~3 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_operatii_seed.py` — `test_seed_populeaza_mapping_suggestions`, `test_insert_or_ignore_nu_clobber_uman`, `test_is_nul_din_seed`, `test_idempotent_la_reinit`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] La `init_db`, daca seedul exista si tabela permite, INSERT OR IGNORE randurile (idempotenta re-seed: nu dubla / nu
|
||||||
|
clobber un rand seedat sau de embedding deja prezent). NB (F10): confirmarile UMANE stau in `shared_mappings`
|
||||||
|
(`record_human_validation`), NU in `mapping_suggestions` — deci INSERT OR IGNORE pastreaza TACIT codul LLM vechi la re-seed;
|
||||||
|
daca vrei refresh pe coduri LLM invechite, e decizie explicita upsert-vs-ignore (v1 = ignore)
|
||||||
|
- [ ] `is_nul=1` -> `cod_prestatie=NULL` (respecta CHECK-ul existent); `source='llm_seed'`, `confidence` din seed
|
||||||
|
- [ ] Idempotent: a doua initializare nu dubleaza si nu modifica randuri existente
|
||||||
|
- [ ] `python3 -m pytest tests/test_operatii_seed.py -q` verde
|
||||||
|
- **Verificare E2E**: pornire app pe DB gol -> `SELECT count(*) FROM mapping_suggestions` > 0
|
||||||
|
|
||||||
|
### US-005: Embeddings indexeaza corpusul etichetat (nu nomenclatorul)
|
||||||
|
**Ca** sistem **vreau** ca `ensure_embeddings_corpus` sa indexeze operatiile etichetate (denumire->cod, cu is_nul)
|
||||||
|
**pentru ca** k-NN peste exemple reale e net mai precis decat peste 18 categorii generice.
|
||||||
|
|
||||||
|
- **Depinde de**: US-004
|
||||||
|
- **Fisiere**: `app/mapping.py` (`ensure_embeddings_corpus` schimba sursa), `app/embeddings.py` (`suggest_nearest` intoarce si `is_nul`), `tests/test_embeddings_corpus_etichetat.py` (~3 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_embeddings_corpus_etichetat.py` — `test_corpus_din_mapping_suggestions`, `test_suggest_nearest_intoarce_is_nul`, `test_semnatura_corpus_pe_seed`, `test_degradare_gratioasa_pastrata`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Corpusul = `mapping_suggestions` (denumire_normalizata -> cod, is_nul), NU `nomenclator_rar`
|
||||||
|
- [ ] **Simetrie corpus/query (F1, HIGH)**: corpusul e text `denumire_normalizata`; deci `enrich_suggestions` trebuie
|
||||||
|
sa interogheze `suggest_nearest(normalize_for_match(denumire), ...)`, NU `denumire` brut. Altfel corpus normalizat vs
|
||||||
|
query brut degradeaza cosine si NU e configul sub care s-a masurat 94.3%. `test_query_normalizat_ca_si_corpusul` o asserta.
|
||||||
|
- [ ] `suggest_nearest` intoarce `[{cod, is_nul, similaritate}]`; un vecin NUL -> semnal de supresie, nu cod
|
||||||
|
- [ ] Re-index doar la schimbarea semnaturii corpusului (cache pastrat, #16b degradare gratioasa neschimbata)
|
||||||
|
- [ ] Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (acum default True — vezi 5.14 CLOSE); off in teste (conftest)
|
||||||
|
- [ ] `python3 -m pytest tests/test_embeddings_corpus_etichetat.py -q` verde
|
||||||
|
- **Verificare E2E**: cu flag on + seed incarcat, `suggest_nearest("schimbat uleiul motor")` -> cod revizie/intretinere real
|
||||||
|
|
||||||
|
### US-006: enrich_suggestions = pre-filtru NUL + k-NN pe corpus etichetat
|
||||||
|
**Ca** operator **vreau** ca editorul sa imbine pre-filtrul NUL, exact-match si k-NN semantic in ordinea de
|
||||||
|
precedenta corecta **pentru ca** vreau sugestia cea mai buna fara junk.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001, US-005
|
||||||
|
- **Fisiere**: `app/mapping.py` (`enrich_suggestions`), `tests/test_enrich_corpus_etichetat.py` (~2 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_enrich_corpus_etichetat.py` — `test_prefiltru_nul_supreseaza_inainte_de_knn`, `test_precedenta_gold_exact_embedding`, `test_prag_similaritate`, `test_abtinere_sub_prag`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Ordine: pre-filtru NUL -> daca NUL, fara sugestie de cod (marcat non-operatie); altfel GOLD partajat > exact (SILVER) > k-NN embeddings
|
||||||
|
- [ ] k-NN sub `EMB_MIN_SIMILARITATE` -> abtinere (`embedding=None`), nu sugestie incerta
|
||||||
|
- [ ] Vecin k-NN cu `is_nul=1` -> tratat ca supresie, nu cod (consecventa cu pre-filtrul)
|
||||||
|
- [ ] Invariant #13 pastrat: nimic din asta nu intra in `resolve_prestatii`/`load_mapping` (test de regresie)
|
||||||
|
- [ ] `python3 -m pytest tests/test_enrich_corpus_etichetat.py -q` verde + suita 5.14 (`test_mapare_integrare_l14.py`) ramane verde
|
||||||
|
- **Verificare E2E**: browser HTMX pe `/_fragments/mapari` — operatie parafraza primeste cod corect pre-selectat din k-NN
|
||||||
|
|
||||||
|
### US-007 (optional): Badge sursa sugestie in editor
|
||||||
|
**Ca** operator **vreau** sa vad de unde vine sugestia (confirmat de om / exemplu similar / non-operatie)
|
||||||
|
**pentru ca** acum nu pot distinge sursa si nu stiu cata incredere sa am.
|
||||||
|
|
||||||
|
- **Depinde de**: US-006
|
||||||
|
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_badge_sursa.py` (~2 fisiere)
|
||||||
|
- **Test intai (RED)**: `tests/test_web_badge_sursa.py` — `test_badge_gold`, `test_badge_embedding`, `test_badge_nul`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] Chip mic langa sugestie: "confirmat" (gold), "similar" (embedding/silver), "non-operatie" (NUL)
|
||||||
|
- [ ] Fara sursa -> fara chip; nu rupe layoutul 5.15/5.16
|
||||||
|
- [ ] `python3 -m pytest tests/test_web_badge_sursa.py -q` verde
|
||||||
|
- **Verificare E2E**: browser — chip vizibil si corect colorat pe randul de mapare
|
||||||
|
|
||||||
|
## 4. Riscuri
|
||||||
|
|
||||||
|
- **Calitate etichetare model local (Qwen3-4B Q4) < model mare (Groq 70b).** Masurat: bun pe cap (frecvent,
|
||||||
|
clar), mai slab pe coada rara/ambigua (ADAS calibrare, chei, "doar nume piesa"). Mitigare: pre-filtru NUL
|
||||||
|
(US-001) + optiunea unui al doilea pas de verificare cloud DOAR pe esantionul cu cod rar/incert.
|
||||||
|
- **Hardware GPU-box instabil sub sarcina (shutdown observat 2026-06-29).** La config-ul rulant erau ~4GB VRAM
|
||||||
|
liberi -> cauza probabil termica/alimentare, NU memorie. Mitigare OBLIGATORIE pentru pasul de etichetare:
|
||||||
|
`n_parallel=1`, `n_ctx=4096`, batch 32-40, monitorizare temperatura GPU. NU mari batch/context fara headroom termic.
|
||||||
|
- **Ground-truth = eticheta LLM, nu om.** 94.3% e ACORD cu LLM, nu acuratete reala; LLM impinge 86% in OE-1
|
||||||
|
(posibil prea agresiv). **Recomandare (Decision #19):** inainte de a creste increderea/orice auto-send, ruleaza
|
||||||
|
`heldout_eval.py` cu un esantion etichetat de OM. Ramane in afara scope-ului acestui PRD, dar e poarta pentru orice 5.x viitor de auto-send.
|
||||||
|
- **`mapping_suggestions` populat schimba comportamentul testelor** care presupuneau SILVER gol. Mitigare: seederul
|
||||||
|
ruleaza doar daca seedul exista; conftest poate dezactiva seedul in testele care nu-l vor (ca la embeddings).
|
||||||
|
- **Coada lunga ramane needs_mapping.** Chiar la 90% volum acoperit, 76% din operatiile DISTINCTE raman neetichetate
|
||||||
|
(frecventa 1). Asteptare corecta: bootstrap-ul reduce mult volumul, dar editorul uman ramane necesar pe coada.
|
||||||
|
- **(F1, review) Simetrie corpus/query la embeddings.** Corpusul k-NN devine text NORMALIZAT (`denumire_normalizata`),
|
||||||
|
deci query-ul TREBUIE normalizat la fel inainte de embedding (US-005 AC). Daca raman asimetrice (corpus normalizat,
|
||||||
|
query brut), similaritatea scade si nu mai e configul masurat (94.3%). Risc de regresie tacuta — acoperit de test in US-005.
|
||||||
|
- **(F2, review) Idempotenta cross-run a etichetarii.** Etichetele noi produse de o rulare trebuie sa devina cache pentru
|
||||||
|
urmatoarea (seedul comis = sursa de etichete, nu doar `labels-groq-partial.json`), altfel re-run-ul re-trimite tot la LLM.
|
||||||
|
Acoperit de `test_rerun_zero_apeluri_llm` (US-003).
|
||||||
|
|
||||||
|
## 5. Decizii (intrebari deschise rezolvate la aprobare, 2026-06-28)
|
||||||
|
|
||||||
|
> Erau intrebari deschise; rezolvate de user la poarta de aprobare PRD. Devin constrangeri de executie.
|
||||||
|
|
||||||
|
- **D1 — Tinta de acoperire la etichetare: 90% din volum** (`--target-volum 0.9`, ~4.368 operatii distincte).
|
||||||
|
Restul (coada lunga, 76% din operatiile distincte dar doar ~10% din volum) ramane pe editorul uman.
|
||||||
|
US-003 implementeaza exact acest default; `--all` ramane disponibil dar NU e calea aprobata pentru v1.
|
||||||
|
- **D2 — Verificare cloud pe esantionul incert: NU in acest PRD.** Toate sursele sunt suggestion-only (blast
|
||||||
|
radius mic: o sugestie gresita = omul alege altceva in editor). Pre-filtrul NUL (US-001) acopera punctul slab
|
||||||
|
masurat. Codurile rare/avarii grave sunt volum mic; un pas de verificare cloud adauga un backend in plus pentru
|
||||||
|
castig marginal. Se reia DOAR daca esantionul uman (Decision #19, vezi Riscuri) arata ca erorile pe coduri rare
|
||||||
|
sunt o problema reala. `source`/`confidence` din seed raman in DB pentru o eventuala flag-uire ulterioara.
|
||||||
|
- **D3 — Pastram exact-match (SILVER) separat de k-NN.** Exact-match (`lookup_suggestion` pe text normalizat) =
|
||||||
|
instant, 100% pe text identic; k-NN = generalizare semantica pentru texte nevazute. Precedenta confirmata:
|
||||||
|
**GOLD partajat > exact (SILVER) > k-NN embedding** (US-006). k-NN NU inlocuieste exact-match.
|
||||||
|
- **D4 — Bootstrap-ul v1 ruleaza pe LM Studio local** (Qwen3-4B, `json_schema` strict), nu pe Groq/OpenRouter.
|
||||||
|
Motiv: zero cost per-token, date pe hardware propriu (PII service local), masurat 91% pe batch greu + 20/20 validare.
|
||||||
|
Groq/OpenRouter raman in tool ca fallback interschimbabil (US-002), dar nu sunt calea aprobata pentru v1. Cerinta user, 2026-06-28.
|
||||||
|
- **D5 — Dedup pe `normalize_for_match` INAINTE de orice apel LLM, cu reuse in spatiu normalizat.** Nu se trimite la LLM
|
||||||
|
niciun duplicat normalizat si nicio operatie deja etichetata. Garantat de `test_zero_duplicate_trimis_la_llm` (within-run) +
|
||||||
|
`test_rerun_zero_apeluri_llm` (cross-run, idempotenta) — US-003.
|
||||||
|
Motiv: ~31% randuri redundante (19.456 brute -> 13.519 de etichetat: cross-file + variatii spatii + reuse labels existente);
|
||||||
|
fara dedup-ul corect platim apeluri LLM inutile si riscam etichete inconsistente pe acelasi text logic. Cerinta user, 2026-06-28.
|
||||||
|
|
||||||
|
## 6. Valuri de executie (graful de dependente)
|
||||||
|
|
||||||
|
```
|
||||||
|
PASUL 1 — BOOTSTRAP ETICHETE OFFLINE (LM Studio LLM) — fundatia, ruleaza prima:
|
||||||
|
Val 1: [US-002] [US-001] ← US-002 (etichetator LM Studio) = pasul 1; US-001 (pre-filtru NUL) paralel, fisiere disjuncte
|
||||||
|
Val 2: [US-003] ← deblocat de US-002: dedup normalizat -> trimite la LLM -> seed comis
|
||||||
|
PASUL 2 — CONSUM SEED (fara LLM):
|
||||||
|
Val 3: [US-004] ← deblocat de US-003 (owns schema/seed loader)
|
||||||
|
Val 4: [US-005] ← deblocat de US-004
|
||||||
|
Val 5: [US-006] ← deblocat de US-001 + US-005
|
||||||
|
Val 6: [US-007] (optional) ← deblocat de US-006
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Raport VERIFY (2026-06-29) — PASS
|
||||||
|
|
||||||
|
> Faza VERIFY + CLOSE rulata pe `feat/5.18-corpus-knn-exemple-etichetate`, commit-uri
|
||||||
|
> `756f777` (5.18 core + seed) + `308fee6` (fix lateral start-test ONNX). Seed-ul real produs
|
||||||
|
> cu subagenti Haiku (decizie user 2026-06-29), NU LM Studio (GPU jos) si NU Groq — vezi
|
||||||
|
> nota la "Seed real" mai jos. Abaterea de la D4 (LM Studio = backend bootstrap v1) e
|
||||||
|
> documentata si justificata: motorul de etichetare s-a schimbat, garantiile de calitate
|
||||||
|
> (validare 157 op Haiku vs Groq) sunt mai bune, restul pipeline-ului (US-003..006) e neatins.
|
||||||
|
|
||||||
|
### PASS/FAIL per story
|
||||||
|
|
||||||
|
| Story | Stare | Dovada |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| US-001 pre-filtru NUL | PASS | `tests/test_prefiltru_nul.py` verde; seed contine 2200 NUL (`is_nul=1`, `cod=NULL`) |
|
||||||
|
| US-002 etichetator offline | PASS | `tests/test_eticheteaza_tool.py` verde (json_schema envelope, enum cod, scrub PII, no_think) |
|
||||||
|
| US-003 generare seed pe frecventa | PASS | `tests/test_genereaza_seed.py` verde (dedup normalizat, zero-duplicat, idempotenta cross-run, conflict determinist) |
|
||||||
|
| US-004 seeder DB | PASS | `tests/test_operatii_seed.py` verde; smoke `init_db` pe DB gol -> `mapping_suggestions`=17181, NUL=2200, re-seed = 0 inserate (idempotent) |
|
||||||
|
| US-005 embeddings pe corpus etichetat | PASS | `tests/test_embeddings_corpus_etichetat.py` verde (corpus din `mapping_suggestions`, query normalizat simetric, `is_nul` propagat) |
|
||||||
|
| US-006 enrich = NUL + exact + k-NN | PASS | `tests/test_enrich_corpus_etichetat.py` verde (precedenta NUL>GOLD>exact>k-NN, abtinere sub prag, invariant #13 regresie) |
|
||||||
|
| US-007 badge sursa (optional) | PASS | `tests/test_web_badge_sursa.py` verde (4 teste); E2E render live confirma chip confirmat/similar/non-operatie. Implementat la cererea user (2026-06-29) |
|
||||||
|
|
||||||
|
### Dovezi agregat
|
||||||
|
|
||||||
|
- **Suita completa**: `python3 -m pytest -q -m "not live"` -> **1387 passed, 1 deselected (live), 0 failed** (142.77s).
|
||||||
|
- **Cele 6 fisiere de test 5.18** rulate izolat: **36 passed** (`test_prefiltru_nul`, `test_eticheteaza_tool`, `test_genereaza_seed`, `test_operatii_seed`, `test_embeddings_corpus_etichetat`, `test_enrich_corpus_etichetat`).
|
||||||
|
- **Smoke seeder** (`init_db` pe DB gol, `AUTOPASS_SEED_OPERATII_ENABLED=true`): 17181 randuri in `mapping_suggestions`, 2200 NUL, `source='haiku_seed'`, re-seed idempotent (0 inserate).
|
||||||
|
- **Validare nomenclator**: toate codurile distincte din seed (`OE-1`..`OE-8`, `OE-I/R`, `AITLV`, `R-ODO`) sunt in `FALLBACK_NOMENCLATOR` — zero cod gunoi care ar da HTTP 500 / `ORA-12899` la RAR.
|
||||||
|
|
||||||
|
### Seed real (abatere de la D4, aprobata de user)
|
||||||
|
|
||||||
|
Seed-ul `app/data/operatii-etichetate.json` rescris de la 3758 (Groq partial) la **17181** operatii
|
||||||
|
distincte (toate, ordine frecventa), `source="haiku_seed"`, prin subagenti Haiku in Claude Code
|
||||||
|
(blocantul GPU LM Studio rezolvat fara GPU). Validare la dezacorduri Haiku vs Groq pe 157 operatii:
|
||||||
|
Haiku corect ~22/30, Groq ~0 (ex: CHIRIE ANVELOPE->NUL, ADAPTARE electronica->OE-7, INLOCUIT
|
||||||
|
PLACUTE FRANA->OE-1). Distributie: OE-1=13764 (cap, asteptat), NUL=2200, restul sparse. Calitate
|
||||||
|
estimata la scara ~95%; codurile rare (avarii grave OE-C/S/D/F/A, OE-5/6) sunt sparse si pot avea
|
||||||
|
erori de margine ne-verificate uman — ramane recomandarea Decision #19 (esantion uman) inainte de
|
||||||
|
orice crestere de incredere / auto-send.
|
||||||
|
|
||||||
|
### CLOSE — `/code-review high` (main..HEAD, 3 finder x 8 unghiuri)
|
||||||
|
|
||||||
|
Calea de runtime in productie = **curata**. Verificat intact:
|
||||||
|
- **Invariant #13**: nimic din SILVER/k-NN/NUL nu intra in `resolve_prestatii`/`load_mapping` (suggestion-only).
|
||||||
|
- `suggest_nearest`/`enrich_suggestions` semnatura noua (`is_nul`) consumata corect de unicul apelant.
|
||||||
|
- Worker keepalive RAR (`308fee6`/`c05fa00`): fara race (worker single-thread), heartbeat actualizat doar pe login reusit.
|
||||||
|
- Config `embeddings_enabled=True` + `seed_operatii_enabled=True` default: teste neafectate (conftest override).
|
||||||
|
|
||||||
|
Findings (toate low / cosmetic, niciun bug de runtime) — **REPARATE in faza CLOSE**:
|
||||||
|
1. `tools/mapare-llm/genereaza_seed.py` (`_incarca_seed`/`construieste_harta_etichete`): `json.loads(open(...).read())` fara context manager -> FD leak in tool offline. **Fix**: `with open(...)`.
|
||||||
|
2. `app/shared_store.py` `seed_suggestions`: `cod=" "` (whitespace) -> `''` in loc de NULL pe rand non-NUL. **Fix**: `str(...).strip().upper() or None` INAINTE de truthiness. Lock: `test_seed_suggestions_cod_whitespace_devine_null`.
|
||||||
|
3. `app/embeddings.py` (2 docstring-uri): ziceau `[{cod, similaritate}]`, real `[{cod, is_nul, similaritate}]`. **Fix**: docstring-uri aliniate.
|
||||||
|
|
||||||
|
Concluzie VERIFY: **PASS**. US-001..006 livrate cu dovezi; zero bug de corectitudine in runtime; cele 3 findings de cleanup reparate + lock-uite.
|
||||||
|
|
||||||
|
### CLOSE — US-007 implementat (cerere user 2026-06-29)
|
||||||
|
|
||||||
|
User a cerut la poarta CLOSE sa includem badge-ul direct pe sugestiile sistemului fuzzy.
|
||||||
|
Implementat: chip in coloana "Sugestii" din `_mapari.html`, mapat din `sugestie_principala.sursa`:
|
||||||
|
**confirmat** (GOLD partajat) / **similar** (SILVER exact + k-NN embeddings) / **non-operatie**
|
||||||
|
(pre-filtru NUL / vecin NUL). CSS `.sugg-sursa--{confirmat,similar,nul}` pe tokeni de tema
|
||||||
|
(`--ok`/`--accent`/`--muted` cu `color-mix`), nu rupe layoutul. Suggestion-only (#13). Fix lateral:
|
||||||
|
`surse_sugestie` default in `routes.py` a primit cheia `nul` (lipsea — finding cross-file). Teste:
|
||||||
|
`tests/test_web_badge_sursa.py` (gold/silver/nul/fara-sursa). Render verificat in serverul real
|
||||||
|
(`/_fragments/mapari`): OP-REV->confirmat, OP-REP->similar, OP-ITP->non-operatie, OP-XYZ->fara chip.
|
||||||
|
Suita: **1392 passed, 1 deselected (live)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- AUTONOMOUS DECISION LOG -->
|
||||||
|
## GSTACK REVIEW REPORT (/autoplan — Eng focus, 2026-06-28)
|
||||||
|
|
||||||
|
Scope review: Eng (CEO premise gate + Eng dual-voice). Design/DX sarite (UI = doar badge optional US-007, tool intern mono-dezvoltator). Voce Eng: **subagent-only** — Codex a lovit limita de utilizare (degradare conform matricei).
|
||||||
|
|
||||||
|
**Premise confirmate** (poarta umana): (1) k-NN peste exemple reale > 18 categorii generice (94.3% vs 86.2% masurat); (2) etichetare LLM o singura data, offline, zero LLM la runtime; (3) SILVER populat in productie din seed comis; (4) pre-filtru NUL necesar (recall 64%); (5) LM Studio Qwen3-4B = calitate acceptabila pt bootstrap (91% batch greu / 20/20 validare).
|
||||||
|
|
||||||
|
**Cerinte user incorporate**: D4 (LM Studio = backend default v1), D5 (dedup pe `normalize_for_match` + reuse normalizat, INAINTE de LLM).
|
||||||
|
|
||||||
|
### Decision Audit Trail
|
||||||
|
|
||||||
|
| # | Faza | Decizie | Clasif. | Principiu | Rationament |
|
||||||
|
|---|------|---------|---------|-----------|-------------|
|
||||||
|
| 1 | CEO | Restructurare valuri: Pasul 1 = bootstrap LM Studio (US-002->US-003) | Mecanic | P1 | Cerinta user explicita; reflecta dependenta reala |
|
||||||
|
| 2 | Eng | F1: query embedding normalizat ca si corpusul (US-005 AC + test) | Mecanic | P5 | Corectitudine; altfel 94.3% nereproductibil. Blast radius (US-005) |
|
||||||
|
| 3 | Eng | F2: seed comis = cache de etichete cross-run (US-003 pipeline + `test_rerun_zero_apeluri_llm`) | Mecanic | P1 | Criteriul "0 apel LLM la re-run" altfel nesatisfiabil |
|
||||||
|
| 4 | Eng | F3: harta normalizat->cod cu tie-break determinist (freq-max) | Mecanic | P5 | 1 conflict real azi (CURATAT CATALIZATOR); altfel cod nedeterminist |
|
||||||
|
| 5 | Eng | F4/F5: corectie cifre (17.181 distinct, 13.519 de etichetat, 31%) + claim "fara punctuatie" | Mecanic | P5 | Cifre verificate cu `normalize_for_match` real |
|
||||||
|
| 6 | Eng | F6: arunca cheie normalizata vida inainte de dedup | Mecanic | P1 | Coliziune pe slot UNIQUE gol |
|
||||||
|
| 7 | Eng | F7: teste two-run + conflict adaugate | Mecanic | P1 | Testul single-run nu acopera idempotenta/determinismul |
|
||||||
|
| 8 | Eng | F8: envelope json_schema strict + enum cod + dezactivare thinking Qwen3 + garda truncare | Mecanic | P1 | Realism integrare LM Studio (cerinta user #1) |
|
||||||
|
| 9 | Eng | F9: parsare NR toleranta (skip, nu zero-weight) | Mecanic | P3 | Date curate azi; ieftina robustete |
|
||||||
|
| 10 | Eng | F10: re-justificare INSERT OR IGNORE (confirmari umane = shared_mappings) | Mecanic | P5 | Evita inducerea in eroare a unui mentainer |
|
||||||
|
|
||||||
|
Zero decizii de gust (taste) si zero user-challenge: toate constatarile au intarit directia user, nu au contrazis-o.
|
||||||
495
docs/prd/prd-5.19-auto-send-manual-coada.md
Normal file
495
docs/prd/prd-5.19-auto-send-manual-coada.md
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260629-150326.md -->
|
||||||
|
# PRD 5.19 — Bifa "Auto": transmitere automata sau manuala din coada
|
||||||
|
|
||||||
|
> Status: DRAFT (asteapta aprobare). Sursa de contract: `docs/api-rar-contract.md`.
|
||||||
|
> Limba: romana, fara emoji. Stil: aditiv, nedistructiv pe backend-ul de trimitere.
|
||||||
|
|
||||||
|
## 1. Introducere
|
||||||
|
|
||||||
|
Astazi transmiterea catre RAR e controlata de un singur comutator **global**
|
||||||
|
(`AUTOPASS_WORKER_SEND_ENABLED`, env): cand e pornit, worker-ul ia ORICE rand `queued`
|
||||||
|
al unui cont `active` si il trimite imediat. Nu exista un control **per-cont** care sa
|
||||||
|
permita unui service sa-si tina prezentarile in coada pentru verificare umana inainte
|
||||||
|
de a pleca la RAR.
|
||||||
|
|
||||||
|
Cazul concret care motiveaza feature-ul: utilizatorul testeaza canalul API din ROAAUTO
|
||||||
|
(Visual FoxPro) direct in **productie** (`autopass.romfast.ro`), pe contul lui de test.
|
||||||
|
Vrea ca prezentarile sa **apara in coada si sa astepte**, nu sa plece automat la RAR,
|
||||||
|
pana cand le verifica si apasa explicit "Trimite". Reper vizual: bifa **"Auto"** din
|
||||||
|
dashboard-ul gomag-vending (`image.png`).
|
||||||
|
|
||||||
|
## 2. Obiective
|
||||||
|
|
||||||
|
### Obiectiv principal
|
||||||
|
- Un comutator **"Auto" per-cont**, persistat pe contul service-ului: cand e bifat,
|
||||||
|
prezentarile pleaca automat la RAR (comportament actual); cand e debifat, randurile
|
||||||
|
**asteapta vizibil in coada** pana cand un operator le trimite manual.
|
||||||
|
|
||||||
|
### Obiective secundare
|
||||||
|
- Trimitere manuala **per rand** ("Trimite") si in **bloc** ("Trimite toate (N)",
|
||||||
|
analogul "Start Sync" din gomag).
|
||||||
|
- La activarea Auto (OFF -> ON), randurile deja tinute sunt **eliberate automat**
|
||||||
|
spre transmitere.
|
||||||
|
- Vizibilitate: randurile tinute apar in coada cu o stare umana clara
|
||||||
|
("In asteptare (manual)"), separate de cele in curs de trimitere.
|
||||||
|
|
||||||
|
### Metrici de succes
|
||||||
|
- Cu Auto OFF, un `POST /v1/prezentari` valid creeaza un rand care **NU** e trimis de
|
||||||
|
worker (ramane vizibil in coada) pana la actiune umana.
|
||||||
|
- Cu Auto ON, acelasi rand pleaca la RAR fara interventie (zero regresie fata de azi).
|
||||||
|
- Bifa supravietuieste restartului (persistata in `accounts`), per-cont (un cont OFF nu
|
||||||
|
afecteaza alt cont).
|
||||||
|
|
||||||
|
## 3. Design (decizii luate cu utilizatorul)
|
||||||
|
|
||||||
|
| # | Decizie | Alegere |
|
||||||
|
|---|---------|---------|
|
||||||
|
| D1 | Default bifa "Auto" pe conturi (inclusiv noi) | **OFF** (manual) — sigur, nimic nu pleaca fara confirmare |
|
||||||
|
| D2 | La OFF -> ON, randurile deja tinute | **Eliberate automat** spre transmitere |
|
||||||
|
| D3 | Plasare in UI | **Bara de status** (langa contoare, ca in mockup gomag) |
|
||||||
|
| D4 | Trimitere manuala | **Per rand + buton "Trimite toate (N)"** |
|
||||||
|
| D5 | Persistenta | Bifa salvata **pe contul service-ului** (`accounts`) |
|
||||||
|
|
||||||
|
### Mecanica aleasa: flag `held` pe submission (NU stare noua)
|
||||||
|
|
||||||
|
Randurile tinute raman in starea `queued` (sunt logic in coada, doar puse pe pauza),
|
||||||
|
marcate cu o coloana booleana noua `submissions.held`. Motiv: evitam atingerea
|
||||||
|
CHECK-ului de stari si a masinii de stari (`queued/sending/sent/needs_mapping/
|
||||||
|
needs_data/error`), a pill-urilor, filtrelor si contoarelor — schimbare strict
|
||||||
|
**aditiva**. Eticheta umana "In asteptare (manual)" se deriva din `status='queued'
|
||||||
|
AND held=1` in stratul de afisaj (`labels.py`).
|
||||||
|
|
||||||
|
- **Comutatorul de cont** (`accounts.auto_send_enabled`) guverneaza DOAR:
|
||||||
|
(a) valoarea implicita a lui `held` la ingestie; (b) eliberarea in bloc la OFF -> ON.
|
||||||
|
- **Worker-ul** (`claim_one`) ia doar `status='queued' AND held=0`. Nu mai stie de
|
||||||
|
comutatorul de cont — ramane simplu si robust.
|
||||||
|
- **Trimiterea manuala** (per rand sau bulk) = `held: 1 -> 0`; worker-ul preia randul la
|
||||||
|
urmatorul poll. Functioneaza chiar daca contul e pe Auto OFF (override uman per rand).
|
||||||
|
|
||||||
|
Comutatorul global `AUTOPASS_WORKER_SEND_ENABLED` ramane **kill-switch master** (productia
|
||||||
|
il porneste). Feature-ul nou se aseaza DEASUPRA lui: held tine randul indiferent de env.
|
||||||
|
|
||||||
|
## 4. User Stories
|
||||||
|
|
||||||
|
### US-001: Schema — comutator cont + flag held
|
||||||
|
**Ca** dezvoltator
|
||||||
|
**Vreau** coloanele de persistenta pentru bifa Auto si pentru randurile tinute
|
||||||
|
**Pentru ca** starea sa supravietuiasca restartului si sa fie per-cont.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `accounts.auto_send_enabled INTEGER NOT NULL DEFAULT 0 CHECK (auto_send_enabled IN (0,1))`
|
||||||
|
adaugat in `app/schema.sql` + migrare defensiva in `app/db.py::_migrate` (ALTER
|
||||||
|
idempotent, ca la `email`/`tier`).
|
||||||
|
- [ ] `submissions.held INTEGER NOT NULL DEFAULT 0 CHECK (held IN (0,1))` adaugat +
|
||||||
|
migrare defensiva. Index partial `idx_submissions_held ON submissions(held) WHERE held=1`.
|
||||||
|
- [ ] **Index in `_migrate`, nu doar `schema.sql` (Eng MEDIUM):** `CREATE TABLE IF NOT EXISTS`
|
||||||
|
nu se declanseaza pe DB existent -> indexul partial trebuie creat si in `_migrate`
|
||||||
|
(ca `idx_submissions_batch` la `db.py:155`), altfel un DB prod upgradat capata coloana
|
||||||
|
(ALTER) dar NU si indexul.
|
||||||
|
- [ ] Contul implicit id=1 (dev) ramane pe default (0) — fara tratament special.
|
||||||
|
- [ ] Helperi in `app/accounts.py`: `get_auto_send(conn, account_id) -> bool` si
|
||||||
|
`set_auto_send(conn, account_id, enabled: bool)` (idempotent, scoped pe cont).
|
||||||
|
- [ ] `python3 -m pytest -q` ramane verde (migrare aditiva, fara regresie pe schema).
|
||||||
|
|
||||||
|
### US-002: Ingestie respecta comutatorul de cont
|
||||||
|
**Ca** operator de service cu Auto OFF
|
||||||
|
**Vreau** ca prezentarile noi sa intre in coada tinute (held=1)
|
||||||
|
**Pentru ca** sa le verific inainte sa plece la RAR.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] La INSERT-ul `status='queued'` pe canalul API (`app/api/v1/router.py`, ~l.282),
|
||||||
|
`held` = `0 daca accounts.auto_send_enabled=1 altfel 1` (snapshot la ingestie).
|
||||||
|
- [ ] Acelasi snapshot la commit-ul de import (`app/api/v1/import_router.py`, ~l.1193).
|
||||||
|
- [ ] La reresolve (un `needs_mapping` rezolvat trece pe `queued`, `app/mapping.py` ~l.895),
|
||||||
|
`held` se seteaza tot din comutatorul contului.
|
||||||
|
- [ ] `held` NU intra in `payload_json`, NU in `build_key`/idempotenta, NU in payload-ul
|
||||||
|
RAR — e pur control de coada (ca `reviewed` la import).
|
||||||
|
- [ ] **DRY + acoperire COMPLETA (review CEO + Eng Finding A — HIGH):** calculul `held` e UN
|
||||||
|
SINGUR helper `held_for_account(conn, account_id) -> int`, chokepoint pt. TOATE situri
|
||||||
|
`SET status='queued'`, nu doar 3. Codebase-ul are ~8 scriitori de `queued`:
|
||||||
|
`router.py:282` (enqueue), `import_router.py:1190` (commit), `mapping.py:895` (reresolve),
|
||||||
|
**`router.py:237` (reactivare error->queued la re-POST — BUG real de bypass: randul pastra
|
||||||
|
`held` VECHI -> se auto-trimite desi Auto OFF)**, si rutele web de operator
|
||||||
|
`routes.py` mapeaza-inline / corecteaza / repune / bulk-fix.
|
||||||
|
- [ ] **Politica rute operator:** pentru tranzitiile declansate de operator in panoul de
|
||||||
|
detaliu (corecteaza/repune/mapeaza/bulk-fix), `held=0` (actiunea operatorului = intentie
|
||||||
|
explicita de trimitere) — DAR e o DECIZIE documentata, nu o omisiune, si respecta UX-ul de
|
||||||
|
confirmare cand contul e OFF. Canalele de ingestie (API/import/reresolve/reactivare) =
|
||||||
|
`held_for_account`.
|
||||||
|
- [ ] `requeue_with_backoff` (worker `:154`) NU atinge `held` (tranzitie interna worker).
|
||||||
|
- [ ] **Echo pe dedup (Eng MEDIUM):** ramura de dedup (`router.py:264`, re-POST pe rand
|
||||||
|
existent) intoarce si ea `held` (azi ar da un "queued" curat fals — vezi US-009).
|
||||||
|
- [ ] Test: cont Auto OFF -> `POST /v1/prezentari` valid -> rand `queued, held=1`;
|
||||||
|
cont Auto ON -> `queued, held=0`.
|
||||||
|
- [ ] Test reresolve: cont Auto OFF, submission `needs_mapping` -> mapare salvata ->
|
||||||
|
rand devine `queued, held=1` (nu pleaca automat).
|
||||||
|
|
||||||
|
### US-003: Worker nu trimite randurile tinute
|
||||||
|
**Ca** sistem
|
||||||
|
**Vreau** ca worker-ul sa sara peste randurile held=1
|
||||||
|
**Pentru ca** transmiterea sa astepte decizia umana.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `claim_one` (`app/worker/__main__.py`) adauga `AND s.held = 0` la `WHERE`-ul de claim.
|
||||||
|
- [ ] Test: rand `queued, held=1` cu cont `active` si send pornit -> `claim_one` intoarce
|
||||||
|
`None` (nu il ia); acelasi rand cu `held=0` -> e luat (`sending`).
|
||||||
|
- [ ] Recuperarea orfanilor / reconcilierea NU sunt afectate (held se aplica doar la claim
|
||||||
|
din `queued`; un rand deja `sending` ramane gestionat normal).
|
||||||
|
|
||||||
|
### US-004: Bifa "Auto" in bara de status (toggle + persistenta + auto-release)
|
||||||
|
**Ca** operator
|
||||||
|
**Vreau** o bifa "Auto" in bara de status, salvata pe cont
|
||||||
|
**Pentru ca** sa pornesc/opresc transmiterea automata dintr-un click.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Control checkbox HTMX cu eticheta vizibila **"Trimite automat la RAR"** (decizie user;
|
||||||
|
NU "Auto" — eviti coliziunea cu "Trimitere automata" worker din `labels.py`) + helptext
|
||||||
|
("Debifat: prezentarile asteapta confirmarea ta"), in clusterul de header langa
|
||||||
|
`.rar-chip` SAU pe rand propriu in bara de status (vezi D1). Reflecta
|
||||||
|
`accounts.auto_send_enabled` al contului din sesiune.
|
||||||
|
- [ ] `POST /auto-send` (ruta web, sub `require_login` + scope cont + CSRF) comuta bifa
|
||||||
|
si o **persista** pe cont; raspuns OOB care re-randeaza bara de status.
|
||||||
|
- [ ] La trecerea OFF -> ON: toate randurile `queued AND held=1` ale contului devin
|
||||||
|
`held=0` (eliberare in bloc), scoped strict pe contul curent. Eliberarea e o
|
||||||
|
SINGURA instructiune SQL atomica (`UPDATE ... WHERE account_id=? AND status='queued'
|
||||||
|
AND held=1`), NU un loop (review CEO: atomicitate + evita contention cu worker-ul).
|
||||||
|
- [ ] **Garda de confirmare (review CEO F4):** daca exista N>0 randuri tinute la
|
||||||
|
activarea Auto, comutatorul cere o confirmare explicita cu numarul si destinatia
|
||||||
|
("Activarea Auto trimite imediat N prezentari catre RAR PRODUCTIE — FINALIZATA e
|
||||||
|
ireversibila"). Fara confirmare, randurile tinute NU pleaca. Motiv: pe contul de
|
||||||
|
test, un OFF->ON necugetat ar arunca toate prezentarile de proba in RAR real.
|
||||||
|
- [ ] La trecerea ON -> OFF: randurile deja `queued held=0` NU sunt retrase (doar
|
||||||
|
ingestiile NOI vor fi tinute); randurile in `sending`/`sent` neatinse.
|
||||||
|
- [ ] Verify in browser: comuti bifa, se salveaza, ramane dupa refresh; cu OFF un rand nou
|
||||||
|
apare tinut; comutand pe ON randurile tinute pleaca.
|
||||||
|
|
||||||
|
### US-005: Trimitere manuala — per rand + "Trimite toate (N)"
|
||||||
|
**Ca** operator cu Auto OFF
|
||||||
|
**Vreau** sa trimit un rand tinut sau toate odata
|
||||||
|
**Pentru ca** sa eliberez selectiv sau in bloc spre RAR.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Buton "Trimite" pe fiecare rand `queued held=1` in lista de trimiteri/coada
|
||||||
|
(`_submissions.html` / `_coada.html`), scoped + CSRF.
|
||||||
|
- [ ] `POST /trimitere/{id}/trimite-acum`: 404-before-leak pe id strain; seteaza `held=0`
|
||||||
|
DOAR daca randul e `queued held=1` (no-op sigur altfel); OOB refresh.
|
||||||
|
- [ ] Buton bulk "Trimite toate (N)" (N = nr. randuri tinute ale contului) ->
|
||||||
|
`POST /trimite-toate`: elibereaza toate `queued AND held=1` ale contului (held=0),
|
||||||
|
cu confirmare tipata (count + "catre RAR PRODUCTIE", review CEO F5). Update atomic
|
||||||
|
scoped pe cont (NU poate elibera randuri ale altui cont).
|
||||||
|
- [ ] `POST /trimitere/{id}/trimite-acum` UPDATE include `AND status='queued'` ca un rand
|
||||||
|
deja `sending` (luat de worker intre afisaj si click) sa fie no-op sigur (edge race).
|
||||||
|
- [ ] Eliberarea seteaza doar `held=0`; worker-ul preia randul la urmatorul poll
|
||||||
|
(trimitere asincrona, ca azi). Necesita worker pornit + send master ON + cont activ.
|
||||||
|
- [ ] Butonul "Trimite toate (0)" e ascuns cand nu exista randuri tinute.
|
||||||
|
- [ ] Test: rand tinut -> `trimite-acum` -> `held=0`; apoi `claim_one` il ia.
|
||||||
|
|
||||||
|
### US-006: Afisaj stare "In asteptare (manual)"
|
||||||
|
**Ca** operator
|
||||||
|
**Vreau** sa disting randurile tinute de cele in curs de trimitere
|
||||||
|
**Pentru ca** sa stiu ce asteapta decizia mea.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `app/web/labels.py`: pentru `status='queued' AND held=1` -> eticheta umana
|
||||||
|
"In asteptare (manual)" + clasa CSS de avertizare (ca `needs_*`); `held=0` ramane
|
||||||
|
"In asteptare" (queued normal).
|
||||||
|
- [ ] Bara de status arata un contor separat "In asteptare (manual): N" cand N > 0
|
||||||
|
(derivat din `queued AND held=1`); contorul `queued` total ramane corect.
|
||||||
|
- [ ] Lista de trimiteri marcheaza randurile tinute (badge/pill), butonul "Trimite" apare
|
||||||
|
doar pe ele.
|
||||||
|
- [ ] Verify in browser: un rand tinut afiseaza eticheta corecta si butonul; dupa trimitere
|
||||||
|
trece la "In curs de trimitere" -> "Trimisa".
|
||||||
|
|
||||||
|
### US-007: Vizibilitate coada tinuta imbatranita (mitigare OBLIGATORIE pt. default OFF)
|
||||||
|
**Ca** operator / admin
|
||||||
|
**Vreau** un semnal vizibil cand prezentari raman tinute prea mult
|
||||||
|
**Pentru ca** default OFF (decizie user, pana devine stabil) lasa altfel prezentari
|
||||||
|
nedeclarate tacit — exact esecul silentios pe care L.142/2023 il face risc legal.
|
||||||
|
|
||||||
|
> Conditie: user a ales DELIBERAT default OFF "pana devine stabil" peste avertismentul de
|
||||||
|
> conformitate (review CEO F1/F3). Aceasta US e atenuarea agreata si e BLOCANTA, nu optionala.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Bara de status: cand exista randuri `queued AND held=1` mai vechi de `N` zile
|
||||||
|
(config `AUTOPASS_HELD_WARN_DAYS`, default 7), afiseaza un banner de avertizare
|
||||||
|
("M prezentari tinute de >N zile — declarare obligatorie L.142") cu deep-link la lista
|
||||||
|
filtrata pe tinute.
|
||||||
|
- [ ] `/metrics` expune un gauge `autopass_held_submissions` (total randuri tinute) si
|
||||||
|
`autopass_held_oldest_age_seconds` (varsta celui mai vechi rand tinut), scoped global
|
||||||
|
(observabilitate ops, review CEO F3).
|
||||||
|
- [ ] Bannerul + gauge sunt derivate (zero stare noua); contorul varstei foloseste
|
||||||
|
`created_at` al randului.
|
||||||
|
- [ ] Test: rand tinut cu `created_at` vechi -> bannerul apare; gauge raporteaza varsta.
|
||||||
|
|
||||||
|
### US-008: Retentie randuri tinute (inchide gaura GDPR/L.142, review CEO F6)
|
||||||
|
**Ca** sistem
|
||||||
|
**Vreau** ca randurile tinute la nesfarsit sa aiba o politica de expirare
|
||||||
|
**Pentru ca** un `queued held=1` nu e nici `sent` nici blocat -> azi NU primeste
|
||||||
|
`purge_after` -> PII criptat (si `rar_creds_enc` efemer pe canalul API) ar sta vesnic.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Worker-ul expira randurile `queued AND held=1` mai vechi de `held_retention_days`
|
||||||
|
(config, default 90, aliniat T16): le trece la `error` cu mesaj `TINUT_EXPIRAT`
|
||||||
|
(terminal) + **seteaza `purge_after` DIRECT la momentul expirarii** (NU lasa `mark()` sa
|
||||||
|
aplice `blocked_retention_days`=30). Eng MEDIUM: altfel viata reala = 90 (held) + 30
|
||||||
|
(error) = 120 zile, nu 90. Fie purge_after explicit la tranzitie, fie documenteaza 120.
|
||||||
|
- [ ] La eliberarea manuala/auto a unui rand tinut, daca `rar_creds_enc` (canal API) e prea
|
||||||
|
vechi, worker-ul cade pe `accounts.rar_creds_enc` (fallback re-login) ca azi — verificat
|
||||||
|
ca creds efemere expirate nu blocheaza trimiterea.
|
||||||
|
- [ ] Test: rand tinut vechi -> ciclul de purjare al worker-ului il expira + seteaza
|
||||||
|
`purge_after`; PII devine purjabil.
|
||||||
|
|
||||||
|
### US-009: Fixturi teste + jurnal audit (review CEO F7 + observabilitate)
|
||||||
|
**Ca** dezvoltator
|
||||||
|
**Vreau** ca suita existenta sa nu se blocheze pe default OFF si ca actiunile sa fie auditate
|
||||||
|
**Pentru ca** default OFF + `claim_one ... AND held=0` face ca lantul `POST -> claim -> sent`
|
||||||
|
din testele existente (+ `test_live_rar`) sa stagneze tacit daca nu setam Auto ON.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `conftest`/factory de cont seteaza `auto_send_enabled=1` (sau `held=0`) pe conturile
|
||||||
|
folosite de testele care exercita lantul de trimitere; `test_live_rar` seteaza explicit
|
||||||
|
Auto ON. `pytest -q` ramane verde.
|
||||||
|
- [ ] **Subtilitate id=1 (Eng HIGH/test):** contul implicit id=1 e creat de `schema.sql`
|
||||||
|
(`INSERT OR IGNORE`), NU de `create_account` -> un fix care patcheaza doar factory-ul NU
|
||||||
|
acopera contul folosit de majoritatea testelor (`test_import_e2e`, `test_creds_delivery`,
|
||||||
|
`test_live_rar` ar stagna). Conftest face explicit `UPDATE accounts SET auto_send_enabled=1
|
||||||
|
WHERE id=1` (autouse). E un fix de STARE DB, nu env var (coloana e per-rand in `accounts`).
|
||||||
|
- [ ] Audit `app_events`: comutarea Auto (`auto_send_schimbat` cu valoarea + cont) si
|
||||||
|
eliberarile manuale/bulk (`held_eliberat` cu count) sunt jurnalizate (redactat, scoped).
|
||||||
|
- [ ] Echo onest pe canalul API (aliniat invariant 5.7): raspunsul `POST /v1/prezentari`
|
||||||
|
pentru un rand tinut indica starea reala (`held=true` / nota umana "tinut pentru
|
||||||
|
verificare"), nu un fals "queued" curat. Dev-ul ROAAUTO vede ca randul NU a plecat.
|
||||||
|
- [ ] Test: eveniment audit scris la toggle + la eliberare; raspuns API reflecta `held`.
|
||||||
|
|
||||||
|
### US-010: Onestitate + observabilitate pe canalul API (review DX Faza 3.5)
|
||||||
|
**Ca** dezvoltator ROAAUTO/VFP care integreaza prin `POST /v1/prezentari`
|
||||||
|
**Vreau** sa vad clar ca un rand e tinut si NU a plecat la RAR
|
||||||
|
**Pentru ca** azi un rand tinut intoarce byte-identic cu unul gata de auto-send
|
||||||
|
(`status:queued, erori:[]`) -> reintroduce exact bug-ul de succes-fals 5.7.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] **Camp `held: bool = False` pe `SubmissionResult`** (`models.py`) + plumbing din
|
||||||
|
`held_for_account` in `_rezultat_enqueue(..., held=...)` SI pe ramura de dedup
|
||||||
|
(`router.py:264`). Cand `held and status=='queued'`, `motiv` devine NON-null
|
||||||
|
(DX CRITICAL): mesaj uman "In asteptare — tinut pt verificare; NU trimis la RAR (Auto OFF)".
|
||||||
|
- [ ] **`held` in proiectiile GET** (`_PREZENTARE_FIELDS` `router.py:398` + lista `cols`
|
||||||
|
`router.py:369`): un dev care face `GET /v1/prezentari/{id}` vede `held=true`, nu un
|
||||||
|
`queued` etern fara semnal (DX HIGH).
|
||||||
|
- [ ] **Reutilizeaza vocabularul existent `AUTO_SEND_OPRIT`** (`errors.py:92`) pt. mesajul
|
||||||
|
held — NU inventa al treilea vocabular "auto_send" (DX + R6). Mesaj 3-niveluri
|
||||||
|
(problema/cauza/fix) pe `rar_error`/`motiv`.
|
||||||
|
- [ ] **Documentatie hub `/integrare`** (`integrare_examples.py`/`_integrare.html`): tabel
|
||||||
|
"De ce nu ajunge la RAR?" (held / needs_mapping / needs_data) + nota explicita "conturi
|
||||||
|
noi pornesc cu Auto OFF, randurile asteapta eliberare manuala" + cum verifici/comuti
|
||||||
|
(DX HIGH — altfel primul POST da 200/queued, dev-ul crede ca merge, nimic nu ajunge).
|
||||||
|
- [ ] (Optional, paritate API) endpoint de eliberare API simetric cu `/repune`
|
||||||
|
(`router.py:458`): `POST /v1/prezentari/{id}/trimite-acum`, scoped pe cont, 404-before,
|
||||||
|
no-op daca nu `queued AND held=1` — ca integratorul API sa nu fie fortat in browser.
|
||||||
|
- [ ] Test: held -> `held=true` + `motiv` non-null pe enqueue, dedup si GET (regresie ca
|
||||||
|
`test_queued_fara_erori_nemapate`).
|
||||||
|
|
||||||
|
## 5. Cerinte functionale
|
||||||
|
|
||||||
|
1. [REQ-001] Comutatorul "Auto" e per-cont, persistat in `accounts.auto_send_enabled`,
|
||||||
|
default 0 (OFF) inclusiv pentru conturi noi.
|
||||||
|
2. [REQ-002] Cu Auto OFF, orice ingestie care ar produce `queued` produce `queued held=1`.
|
||||||
|
3. [REQ-003] Worker-ul nu trimite niciodata un rand `held=1`.
|
||||||
|
4. [REQ-004] OFF -> ON elibereaza in bloc randurile tinute ale contului (atomic, scoped),
|
||||||
|
DAR cu confirmare tipata cand N>0 (count + "RAR PRODUCTIE"); ON -> OFF nu retrage
|
||||||
|
randuri deja eliberate.
|
||||||
|
5. [REQ-005] Operatorul poate elibera un rand tinut individual sau toate odata (bulk cu
|
||||||
|
confirmare).
|
||||||
|
6. [REQ-006] `held` nu influenteaza payload-ul RAR, idempotenta sau validarea — pur coada.
|
||||||
|
7. [REQ-007] Toate rutele noi sunt scoped pe contul din sesiune, sub `require_login`,
|
||||||
|
cu CSRF si 404-before-leak pe id strain. **`account_id` se deriva INTOTDEAUNA din sesiune,
|
||||||
|
NICIODATA dintr-un camp de formular** (Eng security): altfel un operator pe contul A ar
|
||||||
|
elibera in bloc randurile contului B postand `account_id=B`. Per-rand prin
|
||||||
|
`_get_submission_scoped` (404 inainte de UPDATE).
|
||||||
|
8. [REQ-008] Randurile tinute imbatranite sunt VIZIBILE (banner + `/metrics`) si au
|
||||||
|
politica de retentie/expirare (nu raman PII vesnic). Comutarea + eliberarile sunt
|
||||||
|
auditate in `app_events`.
|
||||||
|
|
||||||
|
## 6. Non-Goals (ce NU facem)
|
||||||
|
|
||||||
|
- **Fara interval/programare de sync** (dropdown "1 min" + buton "Start Sync" din gomag):
|
||||||
|
worker-ul autopass e continuu, nu pe interval. "Trimite toate" e analogul lui "Start Sync".
|
||||||
|
- **Fara stare noua de submission** (`held`/`tinut`): folosim flag boolean pe `queued`.
|
||||||
|
- **Fara comutator per-operatie sau per-canal**: granularitatea e per-cont (decizie D5).
|
||||||
|
(Nota: coloanele `auto_send` ramase pe `operations_mapping`/`operation_text_rules` sunt
|
||||||
|
neutralizate din 5.11 si NU se reactiveaza aici.)
|
||||||
|
- **Fara modificarea kill-switch-ului global** `AUTOPASS_WORKER_SEND_ENABLED`.
|
||||||
|
- **Fara retragerea randurilor deja in `sending`/`sent`** (FINALIZATA e terminal la RAR).
|
||||||
|
- **`held` NU e sandbox de testare** (avertisment de onestitate — tema cross-faza CEO F2 + DX4):
|
||||||
|
eliberarea unui rand tinut declara REAL la RAR (FINALIZATA ireversibila). "Tinut" doar
|
||||||
|
AMANA o trimitere reala. Ca sa testezi fara consecinte cu functia asta: tii randul si il
|
||||||
|
STERGI (nu-l eliberezi). **Decizie user (poarta finala): 5.19 = doar tinut operational**;
|
||||||
|
fara documentare `/valideaza` ca unealta de testare si fara rutare per-cont la RAR test
|
||||||
|
(`rar_env`). Acestea raman posibile follow-up-uri (TODOS), neangajate in 5.19.
|
||||||
|
|
||||||
|
## 7. Consideratii tehnice
|
||||||
|
|
||||||
|
### Stack / fisiere atinse
|
||||||
|
- Schema: `app/schema.sql` + `app/db.py::_migrate` (2 coloane aditive + 1 index).
|
||||||
|
- Backend cont: `app/accounts.py` (get/set toggle).
|
||||||
|
- Ingestie: `app/api/v1/router.py`, `app/api/v1/import_router.py`, `app/mapping.py`
|
||||||
|
(reresolve) — set `held` din comutator.
|
||||||
|
- Worker: `app/worker/__main__.py::claim_one` (+`AND s.held=0`).
|
||||||
|
- Web: `app/web/routes.py` (rute `/auto-send`, `/trimite-toate`,
|
||||||
|
`/trimitere/{id}/trimite-acum`), `app/web/labels.py`, template-uri
|
||||||
|
`_status.html` / `_submissions.html` / `_coada.html`.
|
||||||
|
|
||||||
|
### Patterns de urmat
|
||||||
|
- Migrare defensiva aditiva (model `accounts.email` / `accounts.tier` din 5.12/5.17).
|
||||||
|
- Rute web scoped + CSRF + OOB HTMX (model `submissions_admin.py` / butoanele de lifecycle 5.6).
|
||||||
|
- Strat de afisaj pur in `labels.py` (model 5.4) — fara logica de stare in template.
|
||||||
|
|
||||||
|
### Riscuri tehnice
|
||||||
|
- **R1 (default OFF schimba comportamentul):** azi nu exista hold; cu default 0, conturile
|
||||||
|
ar tine totul. Acceptabil — productia e pre-lansare, fara conturi legacy active
|
||||||
|
(cf. 5.17), iar utilizatorul vrea explicit OFF pe contul de test. Documentat ca
|
||||||
|
decizie constienta (D1).
|
||||||
|
- **R2 (reresolve scapa snapshot-ul):** daca uitam `held` pe calea de reresolve
|
||||||
|
(`mapping.py`), un rand deblocat din `needs_mapping` ar pleca automat desi contul e OFF.
|
||||||
|
Acoperit explicit de US-002 AC.
|
||||||
|
- **R3 (idempotenta):** `held` NU intra in cheie -> un re-`POST` al aceluiasi continut
|
||||||
|
loveste randul existent (dedup), nu creeaza dublura. Confirmat de invariantul `build_key`.
|
||||||
|
- **R4 (hazard de rollback — review CEO + Eng, HIGH operational):** daca se da revert pe cod
|
||||||
|
DUPA ce randuri au `held=1`, worker-ul pierde filtrul `AND held=0` -> ar trimite TOATE
|
||||||
|
randurile tinute la RAR (FINALIZATA ireversibila). Atenuare OBLIGATORIE: livreaza ODATA cu
|
||||||
|
feature-ul un helper `tools/` care carantineaza randurile tinute
|
||||||
|
(`UPDATE submissions SET status='error', rar_error='ROLLBACK_QUARANTINE' WHERE held=1`) +
|
||||||
|
pas de runbook scris in §9 (copy-paste, nu improvizat sub presiune).
|
||||||
|
- **R7 (eroziune creds efemere — Eng low-med):** la orice login reusit worker-ul NULL-eaza
|
||||||
|
TOATE `submissions.rar_creds_enc` ale contului (`worker:382`), nu doar randul trimis. Un cont
|
||||||
|
hibrid web+API cu keepalive-login poate sterge creds-urile efemere ale unui rand tinut API ->
|
||||||
|
la eliberare se cade pe `accounts.rar_creds_enc` (fallback). Acoperit de US-008, dar triggerul
|
||||||
|
e login-frate, nu varsta creds — de formulat corect.
|
||||||
|
- **R5 (contention SQLite la bulk release):** `UPDATE` masiv pe "Trimite toate" concureaza
|
||||||
|
cu `BEGIN IMMEDIATE` al worker-ului -> posibil `database is locked`. Update-ul atomic
|
||||||
|
(o instructiune) + retry/backoff scurt; sau chunking daca N e mare.
|
||||||
|
- **R6 (naming):** apare al TREILEA `auto_send` (cont `auto_send_enabled` vs
|
||||||
|
`operations_mapping.auto_send` vs `operation_text_rules.auto_send`). Comentariu clar in
|
||||||
|
`schema.sql` care le distinge, ca un viitor dezvoltator sa nu le confunde.
|
||||||
|
|
||||||
|
### Rafinari UI (review design Faza 2 — OBLIGATORII la implementare)
|
||||||
|
- **D1 (container real):** RAR dot e in `base.html` (header `.rar-chip`), NU in `_status.html`.
|
||||||
|
US-004 AC corectat: comutatorul Auto sta in clusterul de header langa `.rar-chip` (vizibilitate
|
||||||
|
maxima, langa semnalul RAR real) SAU pe un rand propriu etichetat in bara de status — NU
|
||||||
|
"langa dot" in `_status.html` (dot-ul nu e acolo).
|
||||||
|
- **D2 (toggle non-optimist):** checkbox HTMX flip-uie vizual indiferent de raspuns. Necesita
|
||||||
|
`hx-indicator` + revert-on-failure (la esec POST `/auto-send` -> bifa revine + toast eroare).
|
||||||
|
Fara fals-sigur tacit pe un comutator de transmitere guvernamentala.
|
||||||
|
- **D3 (poller nu inghite toggle-ul):** `#status-bar` are `hx-trigger="every 15s"` +
|
||||||
|
`hx-swap="outerHTML"` -> ar inlocui comutatorul la fiecare 15s (pierdere focus tastatura +
|
||||||
|
flicker). Exclude comutatorul din swap-ul periodic (container separat sau `hx-preserve`).
|
||||||
|
- **D4 (modal de confirmare real):** confirmarea tipata (count + "RAR PRODUCTIE") NU se poate
|
||||||
|
face cu `hx-confirm` (doar OK/Cancel nativ). Necesita un component modal (count, destinatie,
|
||||||
|
type-to-confirm) — adaugat in lista de fisiere. Per-rand "Trimite" primeste si el o
|
||||||
|
confirmare (1 linie + microcopy de ireversibilitate), nu doar bulk-ul.
|
||||||
|
- **D5 (camp derivat, nu in template):** `held` NU e stare noua -> pill-ul existent ar randa
|
||||||
|
"In coada" identic pt held si non-held. Calcul UN camp de afisaj derivat in `routes.py`
|
||||||
|
(regula "display layer pur"), nu in template. Culoare `--warn` (amber), NU clasa `needs_*`
|
||||||
|
(rosu/eroare) — held e asteptare benigna, nu eroare.
|
||||||
|
- **D6 (mobil 390px):** per-rand actiune = afordanta dedicata pe `.trimitere-slim` cu
|
||||||
|
`event.stopPropagation()` (randul e el insusi `role=button`), NU buton-copil nestat.
|
||||||
|
Al 6-lea contor "In asteptare (manual)" se pliaza in celula "In coada" pe bara compacta (nu
|
||||||
|
adauga a 6-a celula la 10px). Pill scurt ("Manual"/"Tinut") cu fraza completa in `title`.
|
||||||
|
- **D7 (ordonare bannere):** `_status.html` poate avea deja 3 bannere (cont inactiv / trial /
|
||||||
|
RAR jos) + al 4-lea (US-007 held). Regula de prioritate un-singur-banner ca sa nu impinga
|
||||||
|
contoarele sub fold pe mobil.
|
||||||
|
|
||||||
|
### Dependente
|
||||||
|
- Trimiterea manuala produce efect doar cu worker pornit + send master ON + cont `active`
|
||||||
|
(mediul de productie real). In dev (send OFF) randul eliberat ramane `queued held=0`.
|
||||||
|
|
||||||
|
## 8. Open Questions
|
||||||
|
|
||||||
|
- [ ] Trimiterea manuala se face asincron (flip `held=0`, worker preia la poll). Acceptam
|
||||||
|
latenta de un poll (cateva secunde) sau vrem feedback "in curs" imediat in UI?
|
||||||
|
(Propunere: asincron + OOB refresh, fara sincron — consistent cu arhitectura.)
|
||||||
|
- [ ] Pe mobil, butonul "Trimite" per rand + "Trimite toate" incap in layout-ul compact
|
||||||
|
(5.13)? (Propunere: "Trimite toate" in bara sticky, "Trimite" iconita pe card.)
|
||||||
|
|
||||||
|
## 9. Plan de verificare
|
||||||
|
|
||||||
|
- Regresie `python3 -m pytest -q` verde (baseline curent ~1392) + teste noi per story.
|
||||||
|
- E2E browser (Playwright, logat): comutare bifa persistenta dupa refresh; rand nou tinut
|
||||||
|
cu Auto OFF; eliberare per rand si bulk; tranzitie OFF -> ON elibereaza in bloc.
|
||||||
|
- Optional live RAR (`AUTOPASS_LIVE_RAR=1`): cont OFF -> rand tinut -> "Trimite" ->
|
||||||
|
`sent idPrezentare=...` confirmat in finalizate.
|
||||||
|
|
||||||
|
## 10. Decizii /autoplan — audit trail
|
||||||
|
|
||||||
|
Pipeline: CEO -> Design -> Eng -> DX, voce unica (Codex indisponibil pana 2026-07-18, plafon
|
||||||
|
utilizare). Deciziile intermediare auto-decise pe 6 principii; portile umane = premise + taste.
|
||||||
|
|
||||||
|
### Poarta de premise (decizia ta)
|
||||||
|
- **Scop:** AMBELE — control operational permanent + ajutor de testare.
|
||||||
|
- **Default Auto:** OFF, pastrat "pana devine stabil" (ales constient peste avertismentul de
|
||||||
|
conformitate L.142). Inverseaza recomandarea CEO F1 (default ON). Acceptat ca decizie de
|
||||||
|
domeniu; declanseaza atenuari OBLIGATORII (US-007/008/009).
|
||||||
|
|
||||||
|
### Decizii auto (6 principii)
|
||||||
|
| # | Faza | Decizie | Clasif. | Principiu | Motiv |
|
||||||
|
|---|------|---------|---------|-----------|-------|
|
||||||
|
| 1 | CEO | Approach A (held boolean) ca baza, nu stare noua (B) sau enum mod (C) | Mecanica | P5+P3 | aditiv, reuse pattern `reviewed`; B atinge masina de stari pazita |
|
||||||
|
| 2 | CEO | US-007 vizibilitate coada imbatranita OBLIGATORIE | Mecanica | P1+observ | atenuarea agreata pt default OFF; inchide esecul silentios F3 |
|
||||||
|
| 3 | CEO | US-008 retentie randuri tinute | Mecanica | P1 | F6: held nu primeste `purge_after` -> PII vesnic (GDPR/L.142) |
|
||||||
|
| 4 | CEO | US-009 fixturi teste Auto ON + audit + echo API held | Mecanica | P1 | F7: default OFF stagneaza testele; invariant 5.7 raspuns onest |
|
||||||
|
| 5 | CEO | Garda de confirmare OFF->ON + bulk (count + RAR PRODUCTIE) | Mecanica | P1 | F4/F5: flush ireversibil de randuri test in RAR real |
|
||||||
|
| 6 | CEO | `held_for_account()` helper unic (DRY) | Mecanica | P4 | calcul held inline de 3x = sit uitat trimite automat |
|
||||||
|
| 7 | CEO | Enum mod cont (live/hold/test) -> TODOS | Mecanica | P3 | scope dincolo de cerere; dream-state, nu blocant |
|
||||||
|
|
||||||
|
### Decizii de taste / provocari -> poarta finala (Faza 4)
|
||||||
|
- **T-EXP1 (reframe testare, CEO F2 + DX4) -> REZOLVAT: user a ales "doar tinut".** Nici
|
||||||
|
`rar_env`, nici documentarea `/valideaza` ca unealta de testare in 5.19. Ambele -> TODOS
|
||||||
|
(posibil follow-up). Pastrat doar avertismentul de onestitate ca eliberarea declara real.
|
||||||
|
- **T-LABEL (eticheta toggle, Design HIGH) -> REZOLVAT: user a ales REDENUMIREA.** Eticheta
|
||||||
|
vizibila = **"Trimite automat la RAR"** (nu "Auto"), ca sa nu se ciocneasca cu
|
||||||
|
"Trimitere automata" (worker viu) din `labels.py`. Conceptul/coloana raman `auto_send_enabled`.
|
||||||
|
|
||||||
|
### Faze Design/Eng/DX (audit)
|
||||||
|
| Faza | Decizie cheie | Clasif. | Motiv |
|
||||||
|
|------|---------------|---------|-------|
|
||||||
|
| Design | D1-D7 rafinari UI (non-optimist, poller, modal, mobil, camp derivat, --warn, bannere) | Mecanica | structural, P5 explicit |
|
||||||
|
| Eng | held_for_account la TOATE ~8 situri queued (bug reactivare router:237) | Mecanica | P5; bypass real Auto OFF |
|
||||||
|
| Eng | conftest UPDATE id=1; index in _migrate; purge_after direct; account_id din sesiune | Mecanica | corectitudine/securitate |
|
||||||
|
| DX | held pe SubmissionResult+GET; reuse AUTO_SEND_OPRIT; hub docs | Mecanica | P1; invariant 5.7 |
|
||||||
|
|
||||||
|
### Sumar completare review
|
||||||
|
```
|
||||||
|
+====================================================================+
|
||||||
|
| /autoplan — MEGA PLAN REVIEW — COMPLETION SUMMARY |
|
||||||
|
+====================================================================+
|
||||||
|
| Mod | SELECTIVE EXPANSION |
|
||||||
|
| Voci | Claude subagent (CEO/Design/Eng/DX); |
|
||||||
|
| | Codex INDISPONIBIL (plafon -> 2026-07-18) |
|
||||||
|
| Poarta premise | scop=AMBELE; default OFF (user, time-boxed) |
|
||||||
|
| CEO | 7 findings, 2 critice -> atenuate |
|
||||||
|
| Design | 5->8/10; 13 findings, 3 critice -> D1-D7 |
|
||||||
|
| Eng | 7 issues; 1 BUG real (reactivare bypass) |
|
||||||
|
| DX | 5->8/10; onestitate API -> US-010 |
|
||||||
|
| Stories | 6 -> 10 (US-007/008/009/010 adaugate) |
|
||||||
|
| Taskuri | 26 (14 P1, 9 P2, 3 P3), agregate pe faze |
|
||||||
|
| Tema cross-faza | hold != sandbox testare (CEO F2 + DX4) |
|
||||||
|
| Taste rezolvate | T-EXP1=doar tinut; T-LABEL=redenumire |
|
||||||
|
| Deferate (TODOS) | enum mod cont; rar_env; doc /valideaza |
|
||||||
|
| Test plan | scris pe disc (~/.gstack/.../test-plan) |
|
||||||
|
| Artefacte taskuri | 4 JSONL pe faza |
|
||||||
|
| Decizii nerezolvate | 0 |
|
||||||
|
+====================================================================+
|
||||||
|
```
|
||||||
|
|
||||||
|
## GSTACK REVIEW REPORT
|
||||||
|
|
||||||
|
| Review | Trigger | Why | Runs | Status | Findings |
|
||||||
|
|--------|---------|-----|------|--------|----------|
|
||||||
|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | issues_open->resolved | 5 propuneri, 4 acceptate, 2 deferate; 2 gap critice atenuate |
|
||||||
|
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | issues_open->resolved | 7 issues (1 bug bypass reactivare), 0 gap critice ramase |
|
||||||
|
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open->resolved | 5->8/10, 13 findings (3 critice) -> D1-D7 |
|
||||||
|
| DX Review | `/plan-devex-review` | Developer experience gaps | 1 | issues_open->resolved | 5->8/10, onestitate API (US-010) |
|
||||||
|
|
||||||
|
- **CROSS-MODEL:** N/A — Codex indisponibil (plafon utilizare pana 2026-07-18); voce unica Claude subagent pe toate fazele.
|
||||||
|
- **VERDICT:** CEO + DESIGN + ENG + DX CLEARED (voce unica) — PRD revizuit, gata de implementare. Toate deciziile portilor inchise cu user.
|
||||||
|
|
||||||
|
NO UNRESOLVED DECISIONS
|
||||||
478
docs/prd/prd-5.20-target-rar-test-productie.md
Normal file
478
docs/prd/prd-5.20-target-rar-test-productie.md
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260629-184940.md -->
|
||||||
|
# PRD 5.20 — Medii RAR per cont (Testare / Productie): activare, credentiale, selectie per trimitere
|
||||||
|
|
||||||
|
**Stare**: aprobat
|
||||||
|
|
||||||
|
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||||
|
> Stare: `draft -> aprobat -> in-executie -> verify-pass -> inchis`.
|
||||||
|
|
||||||
|
## 1. Obiectiv
|
||||||
|
|
||||||
|
Trateaza **Testare** si **Productie** ca doua medii RAR configurabile **per cont**. Fiecare mediu are, independent:
|
||||||
|
o **bifa de activare** si un **set propriu de credentiale**. Un mediu e *disponibil* pentru trimitere doar daca e
|
||||||
|
activat SI are credentiale. Din disponibilitate decurge tot UX-ul: cand un singur mediu e disponibil totul merge
|
||||||
|
acolo (fara selector); cand ambele sunt disponibile, apare selector la import + toggle in statusbar + alegere in
|
||||||
|
API. Trimiterile arata mereu un **badge** cu mediul tinta. Scop: clientul declara real pe Productie, iar cine are
|
||||||
|
si cont de test RAR isi poate testa integrarea pe Testare — fara redeploy si fara variabila globala de mediu.
|
||||||
|
|
||||||
|
**Premisa verificata (2026-06-29, doua seturi reale)**: test si prod sunt sisteme RAR **complet separate**; un set
|
||||||
|
de credentiale se autentifica pe **exact unul** (creds dev: test 200 / prod 401; creds client real: test 401 /
|
||||||
|
prod 200). Deci 2 seturi de creds per cont; un cont prod-only NU poate trimite la test fara cont de test emis de RAR.
|
||||||
|
Detaliu memorat: vezi memoria de proiect "rar-test-prod-creds-separate".
|
||||||
|
|
||||||
|
## 2. Non-Goals (anti scope-creep)
|
||||||
|
|
||||||
|
- NU eliminam `AUTOPASS_RAR_ENV` global: ramane **ancora de migrare** + fallback pentru actiuni de sistem fara cont
|
||||||
|
(ex. keepalive login). Per-submission are precedenta cand exista.
|
||||||
|
- NU configuram base_url-uri din UI (raman in `config.py`); NU adaugam un al treilea mediu.
|
||||||
|
- NU gating pe plan/tier pentru Productie (decizie user: liber). „Guard-ul" e: Productie e tinta doar daca e
|
||||||
|
activata + are creds, plus o confirmare unica la activarea Productie (constientizare L.142), NU per trimitere.
|
||||||
|
- NU schimbam masina de stari, backoff-ul, sau payload-ul `postPrezentare`.
|
||||||
|
- NU migram automat credentiale de prod ale clientilor — ei le introduc; migrarea doar muta creds-ul existent in
|
||||||
|
slotul mediului sub care contul opera efectiv.
|
||||||
|
|
||||||
|
## 3. Cerinte transversale (reguli de derivare)
|
||||||
|
|
||||||
|
- **REQ-DISP**: `medii_disponibile(cont)` = mediile din {test, prod} cu `enabled=1` SI creds prezente. Sursa unica
|
||||||
|
de adevar pentru vizibilitatea selector/toggle si pentru validarea unei tinte cerute.
|
||||||
|
- **REQ-VIZ**: selector la import + toggle in statusbar apar DOAR cand `len(medii_disponibile) >= 2`. La 1 mediu,
|
||||||
|
tinta e implicita (acel mediu), fara selector. La 0, trimiterea e blocata cu mesaj „configureaza credentiale RAR".
|
||||||
|
- **REQ-BADGE**: orice trimitere afiseaza badge Test/Productie (chiar si la 1 mediu — claritate ca declari real).
|
||||||
|
- **REQ-DEFAULT**: `rar_env_default(cont)` e mereu unul din mediile disponibile; cont client nou = `prod`. Daca
|
||||||
|
default-ul nu mai e disponibil (mediu dezactivat), cade pe singurul disponibil; daca 0 disponibile -> nicio tinta.
|
||||||
|
- **REQ-CONF**: trimiterea pe Productie nu cere confirmare per-rand; constientizarea vine din badge + o confirmare
|
||||||
|
UNICA la activarea mediului Productie in configurare.
|
||||||
|
|
||||||
|
## 4. Stories atomice
|
||||||
|
|
||||||
|
> Backend + UI pentru acelasi comportament = stories separate. `Fisiere` + `Depinde de` complete.
|
||||||
|
|
||||||
|
### US-001: Schema — medii per cont (activare + creds) + env pe submission
|
||||||
|
**Ca** sistem **vreau** sa stochez per cont activarea si credentialele fiecarui mediu, default-ul, si env-ul tinta
|
||||||
|
pe fiecare submission **pentru ca** test si prod sunt sisteme separate cu credentiale separate.
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `app/schema.sql`, `app/db.py` (migrare idempotenta), `tests/test_schema_migrate.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_schema_migrate.py` — `test_coloane_medii_pe_cont`,
|
||||||
|
`test_default_client_prod_on_test_off`, `test_migrare_creds_in_slotul_env_global`, `test_submissions_rar_env`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `accounts`: `rar_test_enabled INTEGER NOT NULL DEFAULT 0`, `rar_prod_enabled INTEGER NOT NULL DEFAULT 1`
|
||||||
|
(ambele CHECK IN (0,1)); `rar_creds_test_enc TEXT`, `rar_creds_prod_enc TEXT`;
|
||||||
|
`rar_env_default TEXT NOT NULL DEFAULT 'prod' CHECK (rar_env_default IN ('test','prod'))`
|
||||||
|
- [ ] `submissions.rar_env TEXT NOT NULL DEFAULT 'test' CHECK (rar_env IN ('test','prod'))`
|
||||||
|
- [ ] **Migrare existenti (NU presupune env-ul)**: `rar_creds_enc` -> slotul `AUTOPASS_RAR_ENV` global de la
|
||||||
|
migrare; seteaza `enabled=1` DOAR pe mediul cu creds; `rar_env_default` = acel mediu. Conturi fara creds:
|
||||||
|
raman pe default-urile coloanei (prod on / test off). Coloana veche RAMANE acum (dropul e in US-013, dupa
|
||||||
|
ce toate citirile trec pe per-env)
|
||||||
|
- [ ] **(AUTO-FIX G — CRITIC, amendament AC) Backfill `submissions.rar_env` EXISTENT din `AUTOPASS_RAR_ENV`
|
||||||
|
global**, NU lasa pe `DEFAULT 'test'`. Un rand prod pre-migrare etichetat 'test' -> US-006 reconciliaza
|
||||||
|
contra endpoint TEST -> no-match -> re-send prod = DUPLICAT REAL IREVERSIBIL. `DEFAULT 'test'` ramane doar
|
||||||
|
plasa pentru randuri net-noi (fiecare INSERT din US-004/005/009 seteaza `rar_env` explicit)
|
||||||
|
- [ ] **(AUTO-FIX E4/3) Recompute `idempotency_key` pentru randurile existente** la forma env-aware
|
||||||
|
(`build_key(account_id, canon, rar_env)` cu `rar_env`-ul backfill-at), ca lookup-urile de dedup (API +
|
||||||
|
import) sa nu rateze randuri legacy -> altfel re-POST = duplicat
|
||||||
|
- [ ] `test_submissions_rar_env` asserteaza ca un rand PRE-migrare ajunge cu env-ul global (NU 'test') si
|
||||||
|
reconciliaza contra endpointului corect
|
||||||
|
- [ ] migrare idempotenta pe DB existent, fara pierdere de date
|
||||||
|
- [ ] `python3 -m pytest tests/test_schema_migrate.py -q` PASS
|
||||||
|
- **Verificare E2E**: DB pre-migrare cu `AUTOPASS_RAR_ENV=test` -> creds aterizeaza in `rar_creds_test_enc`,
|
||||||
|
`rar_test_enabled=1`, `rar_env_default='test'`.
|
||||||
|
|
||||||
|
### US-002: Logica de disponibilitate si default efectiv
|
||||||
|
**Ca** sistem **vreau** un helper unic care intoarce mediile disponibile si default-ul efectiv al unui cont
|
||||||
|
**pentru ca** vizibilitatea UI, API-ul si worker-ul sa decida identic (REQ-DISP/REQ-DEFAULT).
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/rar_env.py` (nou) sau `app/mapping.py`, `tests/test_rar_env_disponibil.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_rar_env_disponibil.py` — `test_doar_prod_cu_creds`, `test_ambele`,
|
||||||
|
`test_zero_cand_lipsesc_creds`, `test_default_cade_pe_singurul_disponibil`, `test_enabled_fara_creds_nu_e_disponibil`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `medii_disponibile(account) -> list[str]` (subset din ['test','prod']) = enabled AND creds prezente
|
||||||
|
- [ ] `rar_env_efectiv(account) -> 'test'|'prod'|None` aplica REQ-DEFAULT
|
||||||
|
- [ ] `python3 -m pytest tests/test_rar_env_disponibil.py -q` PASS
|
||||||
|
- **Verificare E2E**: —
|
||||||
|
|
||||||
|
### US-003: Idempotenta include rar_env
|
||||||
|
**Ca** sistem **vreau** ca `build_key` sa incorporeze `rar_env` **pentru ca** aceeasi prezentare la test si apoi
|
||||||
|
la prod sunt doua trimiteri reale distincte, nu un duplicat.
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `app/idempotency.py`, `tests/test_idempotency.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_idempotency.py` — `test_key_difera_intre_test_si_prod`, `test_key_stabil_pe_env`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `build_key(account_id, canon, rar_env)` -> chei diferite test vs prod pe acelasi continut; stabil pe re-apel
|
||||||
|
- [ ] toate apelurile (`router.py`, `import_router.py`) trec env-ul rezolvat
|
||||||
|
- [ ] `python3 -m pytest tests/test_idempotency.py -q` PASS
|
||||||
|
- **Verificare E2E**: —
|
||||||
|
|
||||||
|
### US-004: Rezolvare tinta la ingestie (cerere > default cont) + respinge tinta indisponibila
|
||||||
|
**Ca** sistem **vreau** sa decid env-ul unui submission si sa resping tintele indisponibile **pentru ca** o tinta
|
||||||
|
fara mediu activ/creds nu trebuie sa intre in coada.
|
||||||
|
|
||||||
|
- **Depinde de**: US-002
|
||||||
|
- **Fisiere**: `app/validation.py`, `app/mapping.py`, `tests/test_rar_env_resolve.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_rar_env_resolve.py` — `test_cerere_castiga`, `test_fallback_default_cont`,
|
||||||
|
`test_tinta_indisponibila_respinsa`, `test_valoare_invalida`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] precedenta: valoare ceruta (daca e in `medii_disponibile`) > `rar_env_efectiv(cont)`
|
||||||
|
- [ ] tinta ceruta dar indisponibila -> eroare clara („mediul X nu e activat / fara credentiale"), fara enqueue
|
||||||
|
- [ ] valoare invalida (≠ test/prod) -> eroare de validare, fara fallback silentios
|
||||||
|
- [ ] `python3 -m pytest tests/test_rar_env_resolve.py -q` PASS
|
||||||
|
- **Verificare E2E**: —
|
||||||
|
|
||||||
|
### US-005: API — camp `rar_target` pe POST /v1/prezentari si /valideaza
|
||||||
|
**Ca** integrator ROAAUTO **vreau** sa pot preciza `rar_target`, cu default = default-ul contului meu **pentru ca**
|
||||||
|
sa aleg unde declar fara sa stiu env-ul global.
|
||||||
|
|
||||||
|
- **Depinde de**: US-003, US-004
|
||||||
|
- **Fisiere**: `app/api/v1/router.py`, `app/models.py`, `tests/test_api_rar_target.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_api_rar_target.py` — `test_default_din_cont_cand_lipseste`,
|
||||||
|
`test_target_explicit`, `test_target_indisponibil_respins`, `test_get_ecou_rar_env`, `test_valoare_invalida_422`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] camp optional `rar_target: "test"|"prod"` pe `POST /v1/prezentari` si `/valideaza`
|
||||||
|
- [ ] absent -> `rar_env_efectiv(cont)` (pt client prod-only = `prod`)
|
||||||
|
- [ ] tinta indisponibila -> raspuns clar, fara enqueue; `SubmissionResult` + GET ecou-iesc `rar_env`
|
||||||
|
- [ ] valoare invalida -> 422 fara echo de input (handler global pastrat)
|
||||||
|
- [ ] `python3 -m pytest tests/test_api_rar_target.py -q` PASS
|
||||||
|
- **Verificare E2E**: `POST /v1/prezentari` fara `rar_target` pe un cont prod-only -> submission env=prod.
|
||||||
|
|
||||||
|
### US-006: Worker — sesiuni si trimitere per (cont, env)
|
||||||
|
**Ca** worker **vreau** login/JWT separat per `(account_id, rar_env)`, cu base_url + creds corecte per submission
|
||||||
|
**pentru ca** test si prod sunt sisteme RAR diferite.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/worker/__main__.py` (`AccountSessions`), `app/rar_client.py` (base_url per env),
|
||||||
|
`app/reconcile.py`, `tests/test_worker_rar_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_worker_rar_env.py` — `test_sesiune_separata_per_env`,
|
||||||
|
`test_base_url_dupa_submission`, `test_creds_din_slotul_env`, `test_reconcile_pe_env_corect`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] cheia cache sesiune = `(account_id, rar_env)`; JWT/keepalive/last_rar_login_ok per env
|
||||||
|
- [ ] `RarClient` primeste env/base_url explicit (nu doar `settings.rar_base_url`)
|
||||||
|
- [ ] creds alese: submission efemere -> `accounts.rar_creds_{env}_enc`; lipsa -> blocaj clar (nu trimite)
|
||||||
|
- [ ] reconcilierea cauta in `finalizate` pe endpoint-ul `submission.rar_env`
|
||||||
|
- [ ] purjarea atinge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_{env}_enc`
|
||||||
|
- [ ] `python3 -m pytest tests/test_worker_rar_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: doua submission-uri (test + prod, creds prezente) -> doua login-uri distincte in jurnal.
|
||||||
|
|
||||||
|
### US-007: Validare login pe env-ul ales (signup / preview / test integrare)
|
||||||
|
**Ca** sistem **vreau** ca validarea credentialelor sa loveasca mediul caruia ii apartin **pentru ca** o parola
|
||||||
|
prod nu se valideaza contra RAR test si invers (confirmat: 401 incrucisat).
|
||||||
|
|
||||||
|
- **Depinde de**: US-002
|
||||||
|
- **Fisiere**: `app/web/routes.py`, `app/rar_client.py`, `app/web/templates/_integrare.html`,
|
||||||
|
`tests/test_validare_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_validare_env.py` — `test_valideaza_pe_env_creds`, `test_mesaj_distinge_env`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] validarea (signup, „testeaza integrarea", preview) foloseste env-ul setului de creds verificat
|
||||||
|
- [ ] mesaj distinct „creds invalide pe TESTARE" vs „pe PRODUCTIE"
|
||||||
|
- [ ] `python3 -m pytest tests/test_validare_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: in UI „testeaza integrarea" cu creds prod -> login pe endpoint prod.
|
||||||
|
|
||||||
|
### US-008: Configurare cont — doua medii (bifa activare + creds), default, confirmare prod
|
||||||
|
**Ca** titular de cont **vreau** sa activez fiecare mediu, sa-i introduc credentialele si sa aleg default-ul
|
||||||
|
**pentru ca** vreau sa controlez unde se poate trimite si unde merge implicit.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001, US-007
|
||||||
|
- **Fisiere**: `app/web/routes.py`, `app/web/templates/_cont.html`, `app/crypto.py` (refolosit),
|
||||||
|
`tests/test_cont_medii.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_cont_medii.py` — `test_activeaza_si_salveaza_creds_per_env`,
|
||||||
|
`test_default_doar_dintre_disponibile`, `test_activare_prod_cere_confirmare`, `test_creds_criptate_fara_echo`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] doua sectiuni „Testare" si „Productie": fiecare cu bifa Activeaza + campuri email/parola; default client =
|
||||||
|
Productie bifat, Testare nebifat
|
||||||
|
- [ ] la salvare, creds-ul fiecarui mediu activat e validat prin login pe acel env (US-007); invalid -> nu se
|
||||||
|
marcheaza disponibil
|
||||||
|
- [ ] selectorul de default ofera DOAR mediile disponibile; nu poti seta default un mediu indisponibil
|
||||||
|
- [ ] activarea mediului Productie cere o confirmare unica „Inteleg ca trimiterile pe Productie sunt declaratii
|
||||||
|
reale (L.142)"
|
||||||
|
- [ ] creds criptate Fernet in `rar_creds_{env}_enc`, niciodata reflectate inapoi in pagina
|
||||||
|
- [ ] `python3 -m pytest tests/test_cont_medii.py -q` PASS
|
||||||
|
- **Verificare E2E**: activez Testare + creds valide si Productie + creds invalide -> doar Testare devine disponibil.
|
||||||
|
|
||||||
|
### US-009: Import web — selector mediu conditionat de disponibilitate
|
||||||
|
**Ca** operator **vreau** sa aleg mediul la import doar cand am ≥2 disponibile, pre-bifat pe default **pentru ca**
|
||||||
|
la un singur mediu alegerea e inutila.
|
||||||
|
|
||||||
|
- **Depinde de**: US-002, US-004
|
||||||
|
- **Fisiere**: `app/import_router.py`, `app/import_parse.py`, `app/web/templates/_upload.html`,
|
||||||
|
`_preview_import.html`, `tests/test_import_rar_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_import_rar_env.py` — `test_selector_ascuns_la_un_mediu`,
|
||||||
|
`test_selector_prezent_si_prebifat_la_doua`, `test_commit_seteaza_env_pe_submissions`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] selector Test/Prod apare DOAR daca `len(medii_disponibile) >= 2`; initial = `rar_env_efectiv`
|
||||||
|
- [ ] la 1 mediu: fara selector, toate randurile primesc acel mediu
|
||||||
|
- [ ] la commit, toate submission-urile lotului primesc `rar_env` ales
|
||||||
|
- [ ] `python3 -m pytest tests/test_import_rar_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: cont prod-only -> import fara selector, submissions env=prod; cont cu ambele -> selector pre-bifat.
|
||||||
|
|
||||||
|
### US-010: Badge mediu in liste, preview, jurnal, audit + ecou API
|
||||||
|
**Ca** utilizator **vreau** sa vad pe fiecare trimitere mediul tinta **pentru ca** sa nu confund testul cu realul.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/web/templates/_submissions.html`, `_coada.html`, `_trimitere_detaliu.html`,
|
||||||
|
`_preview_rand.html`, `_jurnal.html`, `app/web/routes.py` (audit export), `app/api/v1/router.py` (GET),
|
||||||
|
`tests/test_badge_rar_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_badge_rar_env.py` — `test_badge_in_lista`, `test_audit_contine_rar_env`,
|
||||||
|
`test_get_ecou_rar_env`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] badge vizibil (Test vs Productie, culori distincte) in lista, preview rand, detaliu, jurnal
|
||||||
|
- [ ] `rar_env` in audit export si in `GET /v1/prezentari(/{id})`
|
||||||
|
- [ ] `python3 -m pytest tests/test_badge_rar_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: rand prod -> badge „Productie"; export audit contine coloana.
|
||||||
|
|
||||||
|
### US-011: Statusbar — indicator mediu + toggle conditionat
|
||||||
|
**Ca** operator **vreau** sa vad in statusbar mediul default si sa-l pot schimba cand am ≥2 medii **pentru ca**
|
||||||
|
sa stiu mereu unde trimit si sa comut rapid.
|
||||||
|
|
||||||
|
- **Depinde de**: US-002, US-008
|
||||||
|
- **Fisiere**: `app/web/templates/_status.html`, `base.html`, `app/web/routes.py` (ruta toggle account-scoped),
|
||||||
|
`tests/test_statusbar_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_statusbar_env.py` — `test_afiseaza_env_default`,
|
||||||
|
`test_toggle_doar_la_doua_medii`, `test_toggle_schimba_default`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] statusbar afiseaza mediul default al contului logat (Test/Productie), distinct vizual
|
||||||
|
- [ ] toggle apare DOAR la `len(medii_disponibile) >= 2`; comutarea schimba `rar_env_default` (HTMX, fara reload)
|
||||||
|
- [ ] la 1 mediu: doar eticheta statica
|
||||||
|
- [ ] `python3 -m pytest tests/test_statusbar_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: cont cu ambele -> click statusbar schimba default; cont prod-only -> eticheta fixa „Productie".
|
||||||
|
|
||||||
|
### US-012: Audit + e2e pe medii
|
||||||
|
**Ca** lead **vreau** evenimente de audit la activare mediu / schimbare default / blocaj tinta, plus teste e2e
|
||||||
|
**pentru ca** orice atingere a mediului Productie trebuie trasabila.
|
||||||
|
|
||||||
|
- **Depinde de**: US-005, US-006, US-009, US-011
|
||||||
|
- **Fisiere**: `app/audit.py`/`log_event`, `tests/test_e2e_rar_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_e2e_rar_env.py` — `test_lant_import_pana_la_queued`, `test_activare_prod_logata`,
|
||||||
|
`test_tinta_indisponibila_blocata_si_logata`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] audit la: activare/dezactivare mediu, schimbare `rar_env_default`, blocaj tinta indisponibila
|
||||||
|
- [ ] e2e (TestClient + SQLite temporar) acopera import->queued cu env corect, ambele cai
|
||||||
|
- [ ] `python3 -m pytest tests/test_e2e_rar_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: jurnal arata „mediu Productie activat" + „default schimbat" cu cont + timestamp.
|
||||||
|
|
||||||
|
### US-013: Retragerea `accounts.rar_creds_enc` (toate citirile -> per-env, apoi DROP)
|
||||||
|
**Ca** sistem **vreau** ca toate cele ~40 de locuri care citesc `accounts.rar_creds_enc` sa treaca pe coloanele
|
||||||
|
per-mediu si apoi sa sterg coloana veche **pentru ca** modelul per-env sa fie sursa unica, fara schema dubla.
|
||||||
|
|
||||||
|
- **Depinde de**: US-005, US-006, US-008 (consumatorii principali deja pe per-env)
|
||||||
|
- **Fisiere**: `app/worker/__main__.py` (fallback + bucla keepalive „toate conturile cu creds"),
|
||||||
|
`app/web/routes.py` (indicatorii `are_creds`), `app/api/v1/integrare_router.py` (`are_creds_rar`),
|
||||||
|
`app/api/v1/router.py` (`POST /v1/conturi/rar-creds` devine env-aware), `app/accounts.py` (purge la stergere cont),
|
||||||
|
`app/db.py` (DROP cu garda), `app/models.py`, `tests/test_retragere_creds_enc.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_retragere_creds_enc.py` — `test_niciun_read_pe_coloana_veche`,
|
||||||
|
`test_conturi_rar_creds_env_aware`, `test_are_creds_pe_per_env`, `test_drop_cu_garda_blocat_daca_lipsa_copiere`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] worker fallback + keepalive citesc `rar_creds_{env}_enc` (per env), nu coloana veche
|
||||||
|
- [ ] `are_creds` (web) + `are_creds_rar` (integrare) devin per-mediu („are creds pe Testare/Productie")
|
||||||
|
- [ ] `POST /v1/conturi/rar-creds` primeste mediul (`rar_target`/`env`) si scrie in slotul corect — **schimbare
|
||||||
|
de contract API**, documentata in `docs/api-rar-contract.md`
|
||||||
|
- [ ] purjarea la stergere cont (`accounts.py`) sterge ambele sloturi per-env
|
||||||
|
- [ ] **DROP cu garda**: migrarea verifica intai ca fiecare `rar_creds_enc` non-null a aterizat intr-un slot
|
||||||
|
per-env (assert), apoi `ALTER TABLE accounts DROP COLUMN rar_creds_enc` (SQLite 3.45 OK); verificare esuata
|
||||||
|
-> NU dropa, ridica eroare (fail-safe)
|
||||||
|
- [ ] **(AUTO-FIX 6a — CRITIC) Elimina ATOMIC blocul `ADD COLUMN rar_creds_enc` din `db.py:77-78`** in aceeasi
|
||||||
|
migrare cu DROP-ul. Altfel urmatorul boot vede coloana absenta si o re-ADD goala -> ping-pong perpetuu,
|
||||||
|
garda se rupe. Garda e one-way: dropeaza doar cand sloturile per-env sunt populate SI coloana inca exista
|
||||||
|
- [ ] **(AUTO-FIX 6b — HIGH) DROP-ul nu crapa boot-ul**: `init_db/_migrate` ruleaza la fiecare pornire a ambelor
|
||||||
|
procese; un `DROP COLUMN` care arunca (SQLite < 3.35 / assert garda esuat) propaga -> API + worker
|
||||||
|
crash-loop. Prinde + degradeaza (log + lasa coloana pe loc), NU arunca. Asserteaza `sqlite_version() >= 3.35`
|
||||||
|
(verifica SQLite din imaginea Docker, nu doar dev box) si sare drop-ul gracios sub acel prag
|
||||||
|
- [ ] **(AUTO-FIX 6c — HIGH) Re-ruleaza backfill old->new IMEDIAT inainte de assert**: creds setate via
|
||||||
|
`POST /v1/conturi/rar-creds` intre deploy-ul US-001 si US-013 aterizeaza doar in coloana veche; copiaza-le
|
||||||
|
in slotul per-env (ancora globala) inainte de garda, altfel garda blocheaza drop-ul la nesfarsit
|
||||||
|
- [ ] **(AUTO-FIX 6d) Verificare prin `PRAGMA table_info(accounts)`** ca `rar_creds_enc` lipseste, NU doar prin
|
||||||
|
grep (ambele coloane — `accounts` si `submissions` — au acelasi nume; purjarea worker-ului ramane pe
|
||||||
|
`submissions.rar_creds_enc`)
|
||||||
|
- [ ] `grep -rn "rar_creds_enc" app/` nu mai gaseste citiri pe `accounts` (doar `submissions.rar_creds_enc` ramane)
|
||||||
|
- [ ] `python3 -m pytest tests/test_retragere_creds_enc.py -q` PASS
|
||||||
|
- **Verificare E2E**: dupa migrare, `PRAGMA table_info(accounts)` nu mai contine `rar_creds_enc`; fluxul de cont
|
||||||
|
(salvare creds, worker trimite) functioneaza pe per-env.
|
||||||
|
|
||||||
|
## 5. Riscuri
|
||||||
|
|
||||||
|
- **Trimitere reala accidentala** (FINALIZATA terminal, L.142): atenuat prin badge omniprezent + Productie disponibil
|
||||||
|
doar dupa activare explicita + creds + confirmare unica la activare. NU exista anulare la RAR.
|
||||||
|
- **Default invalid dupa dezactivare mediu**: REQ-DEFAULT recalculeaza; teste US-002 acopera caderea pe disponibil.
|
||||||
|
- **Migrare ambigua** (CONFIRMAT): `rar_creds_enc` poate fi test SAU prod; migrarea aterizeaza in slotul
|
||||||
|
`AUTOPASS_RAR_ENV` global + activeaza doar acel mediu. De validat pe DB-ul real inainte de deploy.
|
||||||
|
- **Client prod-only nu poate testa**: corect by design; UI explica explicit (nu „creds invalide"), nu ofera Testare
|
||||||
|
fara creds test.
|
||||||
|
- **Idempotenta**: schimbarea cheii (US-003) cere ca TOATE apelurile sa treaca env-ul; grep dupa `build_key` + teste.
|
||||||
|
- **Retragere `rar_creds_enc` (US-013)**: ~40 citiri + endpoint API `POST /v1/conturi/rar-creds` (contract). Blast
|
||||||
|
radius mare, dar single-release e mai curat decat schema dubla. DROP cu garda (assert copiere) = fara pierdere
|
||||||
|
de date; produsul e in TESTE (putine conturi reale). Recuperarea via coloana veche dispare dupa DROP — acceptat.
|
||||||
|
|
||||||
|
## 6. Intrebari deschise — REZOLVATE (user 2026-06-29)
|
||||||
|
|
||||||
|
- [x] **Default API** = default-ul contului (NU „test" hardcodat), fiindca clientii sunt prod-only. CONFIRMAT.
|
||||||
|
- [x] **Activare implicita cont nou** = Productie on / Testare off; contul operator setat manual pe Testare. CONFIRMAT.
|
||||||
|
- [x] **Confirmare Productie** = o data, la activarea mediului in configurare (nu per trimitere). CONFIRMAT.
|
||||||
|
- [x] **`rar_creds_enc` vechi** = se STERGE in acest PRD (US-013), nu in 5.2x. DROP cu garda (assert copiere),
|
||||||
|
toate citirile mutate pe per-env, endpoint `POST /v1/conturi/rar-creds` devine env-aware. CONFIRMAT.
|
||||||
|
|
||||||
|
## 7. Valuri de executie (graful de dependente)
|
||||||
|
|
||||||
|
```
|
||||||
|
Val 1: [US-001] [US-003] ← schema + idempotenta (fisiere distincte) → paralel
|
||||||
|
Val 2: [US-002] ← deblocat de US-001
|
||||||
|
Val 3: [US-004] [US-006] [US-007] ← rezolvare ingestie / worker / validare → paralel
|
||||||
|
Val 4: [US-005] [US-008] [US-009] [US-010] ← API / config cont / import / badge → paralel
|
||||||
|
Val 5: [US-011] ← statusbar (depinde de US-008)
|
||||||
|
Val 6: [US-012] [US-013] ← audit + e2e; retragere rar_creds_enc + DROP (depind de tot)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Raport VERIFY
|
||||||
|
|
||||||
|
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
||||||
|
> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- AUTONOMOUS DECISION LOG -->
|
||||||
|
## /autoplan Review (2026-06-29, commit 7371c37)
|
||||||
|
|
||||||
|
Voci: Claude (primar) + Claude subagent (independent). Codex indisponibil (usage limit, revine 18 iul) -> mod `[subagent-only]`. Poarta premisa: user a ales **"Build full per-account multi-env (as planned)"** — premisa de baza (sisteme separate) verificata live; nevoia de dashboard unic justifica per-cont peste 2 deployment-uri pinned.
|
||||||
|
|
||||||
|
### Auto-fixuri (corectitudine/siguranta — incorporate in stories)
|
||||||
|
|
||||||
|
| # | Story | Gap (gasit de) | Fix incorporat | Principiu |
|
||||||
|
|---|-------|----------------|----------------|-----------|
|
||||||
|
| G | US-001 | **CRITIC** (subagent): migrarea backfill-eaza creds dar NU `submissions.rar_env` existent; randuri prod pre-migrare cad pe DEFAULT 'test' -> US-006 reconciliaza contra endpoint TEST -> no-match -> **re-send prod = duplicat real ireversibil** | Migrarea backfill-eaza `submissions.rar_env` din `AUTOPASS_RAR_ENV` global (DEFAULT 'test' doar pentru randuri net-noi). Test: rand prod pre-migrare reconciliaza contra endpoint prod | P1 completeness + siguranta |
|
||||||
|
| L | **US-005/US-013** (NU US-006 — eng finding 5: write-back e in `router.py`, pe care US-006 nu-l atinge) | HIGH (ambele voci, `router.py:250`): write-back creds efemere API -> `accounts.rar_creds_enc` durabil nu e rutat pe slotul `submission.rar_env` | Write-back tinteste `accounts.rar_creds_{submission.rar_env}_enc` + test. **Plus**: nu auto-propaga creds API NEVALIDATE in slotul durabil per-env (ar putea clobber-i un slot login-validat); propaga doar dupa login reusit | P1 |
|
||||||
|
| K | US-013 | HIGH (subagent): `POST /v1/conturi/rar-creds` e contract extern; env-aware in-place = breaking | Endpoint **aditiv**: param `env` optional, default = default cont; apelanti vechi neatinsi. (Independent de decizia DROP) | P5 explicit + back-compat |
|
||||||
|
| M2 | US-013 | MEDIUM (Claude): `_keepalive_target` alege un cont fara notiune de env dupa per-env | Keepalive foloseste ancora globala `AUTOPASS_RAR_ENV` + un cont cu creds in slotul acelui env | P5 |
|
||||||
|
| M3 | US-003 | MEDIUM (Claude): `_already_sent_lookup` (import_router.py:369) are dual-lookup legacy; adaugarea env in cheie cere extinderea lui, nu doar a parametrului | US-003 extinde dual-lookup (cheie noua env-aware + fallback legacy) | P1 |
|
||||||
|
| D | US-001 | HIGH (subagent): corectitudinea migrarii e "de validat manual"; trebuie poarta testata | Script de audit pre-migrare (raporteaza slot-ul atribuit) + assert DROP-cu-garda existent ca poarta, nu nota manuala | P1 |
|
||||||
|
| M | US-012 | MEDIUM (subagent): niciun test live dual-env; riscul dominant (rutare gresita env) e exact ce SQLite nu prinde | Test live opt-in dual-env (extinde `test_live_rar`): 1 rand test + 1 prod -> 2 login-uri, 2 endpoint-uri, badge corecte, reconciliere pe env corect | P1 |
|
||||||
|
| backup | US-013 | MEDIUM (Claude): "recovery via coloana veche dispare dupa DROP — acceptat" | Inainte de DROP, dump coloana veche criptata intr-un backup timestamped (recuperare supravietuieste DROP) | P2 boil-lake |
|
||||||
|
|
||||||
|
### Decizii user la poarta finala (REZOLVATE 2026-06-29) — APROBAT
|
||||||
|
|
||||||
|
- **A (DROP US-013) -> PASTREAZA single-release.** User: "aplicatia e doar in teste, nu folosita de clienti" -> blast radius mic, rollback-ul conteaza mai putin. Decizia §6 ramane. **Garzile 6a/6b/6c sunt obligatorii in AC US-013** (eliminare atomica bloc ADD, catch+degrade fara boot-crash, re-backfill interim) + backup criptat inainte de DROP. NU se amana.
|
||||||
|
- **J/H1 (interlock prod) -> doar butonul de commit colorat (F8), FARA modal.** REQ-CONF ramane. Lantul: bifa activare (o data) + badge "fierbinte" + buton "Declară la PRODUCȚIE (real)". Fara confirmare per-commit (evita oboseala de click; clientii prod-only oricum n-au selector).
|
||||||
|
- **H (fallback default) -> doar toast zgomotos (F5), FARA re-confirmare.** REQ-DEFAULT auto-fallback ramane; toast-ul "Mediul implicit a trecut pe X" face flip-ul vizibil. Fara gate suplimentar.
|
||||||
|
|
||||||
|
### Taste (recomandari acceptate — fara override)
|
||||||
|
- **T1**: token dedicat `--prod` (brick) pentru badge-ul Productie. **T2**: `rar_env` ca nume unic input+output (scoate `rar_target`/`env`).
|
||||||
|
|
||||||
|
### Taste decisions (auto-decise cu recomandare — override la poarta)
|
||||||
|
- **T1 — token culoare Productie**: rosu (`--err`) se ciocneste cu erorile, amber (`--warn`) cu badge-ul legacy. Recomandat: token dedicat `--prod` (brick inchis) SAU `--accent` plin. (design F2)
|
||||||
|
- **T2 — nume camp request**: recomandat `rar_env` peste tot (un singur nume input+output), scoate `rar_target`/`env`. (DX F1)
|
||||||
|
|
||||||
|
### Teme cross-fază (semnal de incredere ridicat — aparut independent in 2+ faze)
|
||||||
|
- **Siguranta declaratiei reale ireversibile** — TOATE 4 fazele (CEO G/H1/J, Design F1/F8/F10, Eng 1b/3/G, DX F2/F3/F4). Semnalul dominant: badge + interlock + discoverability + rutare env corecta converg pe "nu declara real din greseala".
|
||||||
|
- **Flip silentios al mediului default** — CEO-H, Design-F5, DX-F3 (3 faze). Fa flip-ul zgomotos + nu auto-promova prod silentios.
|
||||||
|
- **Risc DROP US-013** — CEO-A, Eng 6a/6b/6c (2 faze). Intareste amanarea DROP-ului.
|
||||||
|
- **Ambiguitate spec/nume care musca implementer-ul** — Design-F14, Eng-4a, DX-F1/F7. Auto-fixurile TREBUIE sa intre in AC + contract inainte de implementare.
|
||||||
|
|
||||||
|
### NOT in scope (confirmat)
|
||||||
|
Eliminarea ancorei globale `AUTOPASS_RAR_ENV`; base_url din UI; al treilea mediu; gating plan/tier pe prod; schimbari masina-stari/backoff/payload; auto-migrare creds prod client. (PRD §2)
|
||||||
|
|
||||||
|
### Ce exista deja (leverage)
|
||||||
|
`crypto.py` Fernet (creds per-env), `AccountSessions` (re-key (cont,env)), `RarClient` (primeste settings; +param env), `config.rar_base_url_test/prod` (deja prezent), `build_key` (+param), `account_scope_clause`. Fara infra noua.
|
||||||
|
|
||||||
|
### Auto-fixuri DESIGN (structurale — incorporate in stories)
|
||||||
|
|
||||||
|
Voci: Claude (primar) + Claude subagent. Scorecard: 1 CRITIC, 7 HIGH, 5 MEDIUM, toate CONFIRMED.
|
||||||
|
|
||||||
|
| # | Story | Gap | Fix incorporat | Sev |
|
||||||
|
|---|-------|-----|----------------|-----|
|
||||||
|
| F1 | US-010 | **CRITIC**: "culori distincte" e singura spec a singurului guard vizual contra riscului dominant | Badge **normativ**: Productie = fill plin, saturat, text alb, iconita + cuvant complet UPPERCASE cu diacritice ("PRODUCȚIE"); Testare = outline/tint linistit (muted/accent), receding. Asimetria de greutate ESTE designul | CRITIC |
|
||||||
|
| F2 | US-010 | HIGH: rosu (`--err`) rezervat erorilor, amber (`--warn`) ocupat de `.badge-env` legacy + needs_* | Token dedicat `--prod` (ex. brick `#B4452F`) SAU `--accent` plin pentru Productie; hex/token scris in AC, nu improvizat per template. (taste: hexul exact -> poarta) | HIGH |
|
||||||
|
| F3/F12 | US-010 | HIGH: "Test/Testare/prod/PRODUCTIE" folosite interschimbabil; bypass `labels.py` | `labels.py` adaugat in Fisiere: `ETICHETE_ENV` + `eticheta_env(env)->(text,css)` (oglindeste `eticheta_scurta`). Productie UPPERCASE+diacritice, Testare title-case; clase `.badge-prod/.badge-test` definite o data in base.html langa `.sugg-sursa` | HIGH |
|
||||||
|
| F11 | US-011 | HIGH: `.badge-env` EXISTENT in header arata `AUTOPASS_RAR_ENV` global -> dupa 5.20 e semantic gresit; doua indicatoare env cu surse diferite in acelasi viewport | US-011 retrage/repurpune header `.badge-env` (preferat: scos pentru user logat, inlocuit de indicatorul account-scoped din statusbar). NU coexista doua surse de adevar | HIGH |
|
||||||
|
| F4 | US-009 | HIGH: starea 0-medii e numita dar nedesignata; blocaj la commit (dupa munca) = calea minima | Blocaj la UPLOAD (nu commit): banner `--warn` (refoloseste pattern "Cont in asteptare", `_status.html:8`) + CTA link `?tab=cont`, inainte de drop-zone | HIGH |
|
||||||
|
| F5 | US-011 | HIGH: schimbarea silentioasa a default-ului (mediu dezactivat) nu are UI -> target real/test comuta fara ca userul sa stie | Toast explicit (componenta `#toast` exista) la schimbarea `rar_env_default` ca efect al disponibilitatii: "Mediul implicit a trecut pe X". Leaga de CEO-H | HIGH |
|
||||||
|
| F8 | US-009 | HIGH: o bifa la activare apoi nimic = sub-avertizare; modalul per-trimitere a fost respins (REQ-CONF) | Butonul de commit POARTA greutatea cand target=Productie: "Declară la PRODUCȚIE (real)" + culoarea Productie (FARA modal, FARA click extra -> nu incalca REQ-CONF). Copy bifa activare: adauga ireversibilitatea ("declarații oficiale, finale și fără anulare") | HIGH |
|
||||||
|
| F6/F7 | US-008/US-011 | MEDIUM: stari loading/error pt toggle HTMX + validare creds la RAR nespecificate; stare per-sectiune (activat-fara-creds-valide) | toggle: `hx-indicator` + disabled in zbor, pe esec NU schimba default + eroare; US-008 validare creds arata `htmx-indicator` ("se verifica la RAR…") + esec in `.banner` cu copy per-env (US-007); fiecare sectiune arata 3 stari: dezactivat / activat-fara-creds / disponibil | MEDIUM |
|
||||||
|
| F9/F10 | US-009 | MEDIUM/HIGH: selectorul absent la 1 mediu = env invizibil la import; default pre-bifat prod la prima trimitere | Mereu randeaza un indicator env la import (eticheta statica la 1 mediu, toggle la >=2, ACEEASI pozitie). Prod pre-bifat e sigur DOAR daca F8+F9 livreaza impreuna — legate explicit in AC | HIGH |
|
||||||
|
| F13 | US-010 | MEDIUM: sa nu forkeze un badge structural nou | Refoloseste idiomul `.sugg-sursa` (10px, weight 700, tint+border) pt Testare; Productie = aceeasi geometrie dar fill plin+alb+icon (spargerea e semnalul) | MEDIUM |
|
||||||
|
|
||||||
|
### Auto-fixuri ENG (corectitudine/deploy — incorporate in stories)
|
||||||
|
|
||||||
|
Voci: Claude (primar) + Claude subagent (verificat contra codului real). **Meta (eng 4a): toate auto-fixurile de mai jos sunt NORMATIVE si trebuie sa intre in AC-ul story-urilor inainte de implementare — un implementer care urmeaza AC-ul literal, fara ele, livreaza bug-urile critice.** G + 6a deja imbinate in AC US-001/US-013.
|
||||||
|
|
||||||
|
| # | Story | Gap (vs cod real) | Fix | Sev |
|
||||||
|
|---|-------|-------------------|-----|-----|
|
||||||
|
| E1/1a | US-006 | `get_token` purjeaza `submissions.rar_creds_enc WHERE account_id=?` -> dupa re-key, login TEST sterge creds efemere ale submission-urilor PROD ale contului -> prod blocat | `WHERE account_id=? AND rar_env=?` + test `test_purge_creds_doar_pe_env` | HIGH |
|
||||||
|
| 1b/E6 | US-006 | `recover_orphans` filtreaza doar pe `account_id`; iterat per sesiune (cont,env) reconciliaza orfanii prod contra endpoint TEST -> no-match -> re-POST prod = DUPLICAT real | +`rar_env` in WHERE; apelat per (cont,env) din `active()`; test orfan env A nereconciliat contra env B | HIGH/CRITIC |
|
||||||
|
| 3/E4 | US-003 | API channel (`router.py:223`) NU are dual-lookup; re-POST al unui rand pre-5.20 cu cheie env-aware rateaza randul legacy -> duplicat. Import dual-lookup ignora env-ul randului matchuit | Recompute-keys la migrare (US-001, vezi acolo) acopera ambele canale uniform; daca pastrezi dual-lookup, exista si in `router.py` SI gate pe `matched_row.rar_env==target_env` | HIGH |
|
||||||
|
| 1c/E8 | US-006 | `claim_one` nu selecteaza `s.rar_env` -> worker nu poate alege cheia sesiune/base_url/slot | AC explicit: claim selecteaza + propaga `rar_env` in dict-ul `claimed` | MEDIUM |
|
||||||
|
| 1d | US-006/US-001 | `worker_heartbeat` e un singur rand global (`WHERE id=1`); US-006 cere `last_rar_login_ok` PER env dar US-001 nu adauga schema per-env -> neimplementabil ca scris | Decizie: pastreaza heartbeat global (JWT/sesiune per env e suficient), scoate "per env" din AC US-006; SAU adauga coloana in US-001. Recomandat: global | MEDIUM |
|
||||||
|
| 1e | US-006 (doc) | `_refresh_nomenclator` upsert intr-un `nomenclator_rar` env-less la fiecare login; login test suprascrie cu coduri test, prod cu prod -> un cod valid pe prod poate fi respins la ingestie daca ultimul refresh a fost test | Documenteaza presupunerea (nomenclator identic intre medii — aceleasi 18 coduri) SAU scope per-env (out of scope acum). Minim: nota explicita | MEDIUM |
|
||||||
|
| 5 | US-005/US-013 | write-back creds API nevalidate -> slot durabil (vezi L de mai sus) | re-asignat la US-005/US-013; propaga doar dupa login reusit | MEDIUM/HIGH |
|
||||||
|
| 6a..6d | US-013 | ping-pong re-ADD / boot-crash / interim-creds / grep ambiguu | imbinate in AC US-013 (vezi acolo) | CRITIC/HIGH |
|
||||||
|
|
||||||
|
### ENG DUAL VOICES — CONSENSUS TABLE
|
||||||
|
```
|
||||||
|
Dimension Claude Subagent Consensus
|
||||||
|
────────────────────────────── ──────── ───────── ────────────────────
|
||||||
|
1. Architecture sound? da/cond da/cond CONFIRMED (cond. fixuri)
|
||||||
|
2. Test coverage sufficient? lacune +API b/c CONFIRMED lacune
|
||||||
|
3. Performance risks? low low CONFIRMED low
|
||||||
|
4. Security (creds routing)? L/5 5+unvalid CONFIRMED
|
||||||
|
5. Error paths (boot)? E1/E9 6a/6b CRIT CONFIRMED (boot-crash)
|
||||||
|
6. Deployment risk (DROP)? migrare CRIT/HIGH CONFIRMED ELEVAT -> intareste challenge A
|
||||||
|
```
|
||||||
|
Codex: indisponibil (N/A). Mesaj-cheie: caile de duplicat ireversibil (1b, 3) si boot-crash/ping-pong (6a, 6b) musca in productie; intaresc recomandarea de a amana DROP-ul (challenge A).
|
||||||
|
|
||||||
|
### Diagrama teste (codepath -> acoperire)
|
||||||
|
| Codepath nou | Story test | Stare |
|
||||||
|
|---|---|---|
|
||||||
|
| `medii_disponibile`/`rar_env_efectiv` | US-002 | acoperit |
|
||||||
|
| resolve target (cerere>default), respinge indisponibil | US-004 | acoperit |
|
||||||
|
| idempotency env-aware + **recompute legacy** | US-003/US-001 | GAP recompute -> adaugat |
|
||||||
|
| **migrare backfill `submissions.rar_env`** | US-001 | GAP (G) -> adaugat in AC |
|
||||||
|
| worker sesiune (cont,env) + base_url per env | US-006 | acoperit |
|
||||||
|
| **purge creds scoped pe env** | US-006 | GAP (E1) -> adaugat |
|
||||||
|
| **recover_orphans per env** | US-006 | GAP (1b) -> adaugat |
|
||||||
|
| **write-back slot routing** | US-005/013 | GAP (L/5) -> adaugat |
|
||||||
|
| reconcile endpoint per env (inline + **orfani**) | US-006 | inline acoperit; orfani GAP -> adaugat |
|
||||||
|
| **keepalive env (ancora globala)** | US-013 | GAP (M2) -> adaugat |
|
||||||
|
| DROP garda: assert + **idempotent re-run** + **fail-loud/no-crash** | US-013 | partial -> intarit (6a/6b/6c) |
|
||||||
|
| **API-channel idempotency back-compat** | US-003 | GAP (3) -> adaugat |
|
||||||
|
| badge/labels env | US-010 | acoperit |
|
||||||
|
| API `rar_target` default/explicit/invalid/indisponibil | US-005 | acoperit |
|
||||||
|
| config 2 sectiuni + confirmare prod | US-008 | acoperit |
|
||||||
|
| statusbar toggle viz + **retragere header `.badge-env`** | US-011 | toggle acoperit; header GAP (F11) -> adaugat |
|
||||||
|
| **live dual-env smoke** | US-012 | GAP (M) -> adaugat opt-in |
|
||||||
|
|
||||||
|
### Auto-fixuri DX (contract API extern — incorporate in stories)
|
||||||
|
|
||||||
|
Voci: Claude (primar) + Claude subagent (perspectiva integrator VFP/ROAAUTO). Riscul ireversibilitatii ridica stacheta pe claritate nume / eroare / discoverability pre-trimitere.
|
||||||
|
|
||||||
|
| # | Story | Gap | Fix | Sev |
|
||||||
|
|---|-------|-----|-----|-----|
|
||||||
|
| F1 | US-005/US-013 | Trei nume pt un concept: input `rar_target`, echo/DB `rar_env`, rar-creds `env` (US-013 AC scrie literal "rar_target/env") | **Un singur cheie: `rar_env`** pe input + output + rar-creds (englez snake, consistent cu coloana si `on_unmapped_error`). Scoate `rar_target`/`env`. (taste usor -> poarta) | HIGH |
|
||||||
|
| F2 | US-004 | Eroarea "mediu indisponibil" e proza, fara `cod`/envelope 6-chei/status; `errors.py` nu e in Fisiere | `RAR_MEDIU_INDISPONIBIL` in `errors.CATALOG` (problema/cauza cu lista disponibile/fix "activeaza in Cont"); adauga `errors.py` la Fisiere US-004; distinge literal-invalid (422 pydantic) de valid-dar-indisponibil (cod dedicat); acopera si cazul 0-medii | HIGH |
|
||||||
|
| F3 | US-004/contract | Flip runtime test->prod prin canal web: operator comuta disponibilitatea -> apelant API fara `rar_env` trece silentios pe prod (real). Migrarea previne flip la DEPLOY, nu la RUNTIME | Mitigat de F4+F5 (probe pre-trimitere); documenteaza reasignarea ca comportament cunoscut; leaga de CEO-H | HIGH |
|
||||||
|
| F4 | US-010 (sau story noua) | Niciun GET nu expune `medii_disponibile`/`rar_env_default` -> integratorul afla env-ul doar din eroare sau dupa o trimitere reala | `GET /v1/conturi/medii` account-scoped: `{medii_disponibile, rar_env_default, test:{enabled,has_creds}, prod:{...}}` (refoloseste helper US-002, <1 fisier) | HIGH |
|
||||||
|
| F5 | US-005 | `ValidareResult` (dry-run) NU ecou-ieste `rar_env`; dry-run e canalul sigur de a confirma unde ar ateriza o trimitere reala | adauga `rar_env: str` la `ValidareResult` + `/valideaza`; `models.py` | MEDIUM |
|
||||||
|
| F6 | US-004/US-005 | Respingere whole-request vs per-rand inconsistenta cu `on_unmapped_error` (per-rand, 200) | Decide + documenteaza; recomandat: corp parsabil imbogatit cu `cod` (prietenos VFP), noteaza asimetria intentionat | MEDIUM |
|
||||||
|
| F7 | US-005/US-010/US-004/US-013 | Contractul (sursa adevar) actualizat doar pt rar-creds; lipsesc field-ul nou, echo-ul, cod-ul nou. **`/v1/conturi/rar-creds` NU e documentat deloc azi** -> US-013 e documentare de la zero, nu amendament | AC explicit "update `api-rar-contract.md`" pe fiecare; US-013 documenteaza endpoint-ul intreg (req/resp, param env, slot default) | HIGH |
|
||||||
|
| F8 | US-013 (doc) | `env` optional default = slot default cont: integrator cu creds TEST pe cont nou (default prod) le scrie silentios in slot prod -> US-007 le respinge "invalide pe PRODUCTIE" desi sunt valide (test) | pastreaza aditiv; documenteaza ca omiterea `env` tinteste slotul default; mesaj validare sugereaza nepotrivire env ("creds valide pentru alt mediu?") | MEDIUM |
|
||||||
|
|
||||||
|
### DX DUAL VOICES — CONSENSUS TABLE
|
||||||
|
```
|
||||||
|
Dimension Claude Subagent Consensus
|
||||||
|
─────────────────────────────── ─────── ───────── ──────────────
|
||||||
|
1. Getting started (aditiv)? low fr low fr CONFIRMED low
|
||||||
|
2. Naming guessable? D1 incon F1 3-nume CONFIRMED -> rar_env
|
||||||
|
3. Error messages actionable? D2 gap F2 gap CONFIRMED gap
|
||||||
|
4. Docs findable & complete? D4 gap F7 gap+ CONFIRMED gap
|
||||||
|
5. Back-compat safe? D3 resid F3 runtime CONFIRMED (1 rezidual)
|
||||||
|
6. Discoverability pre-send? D5 gap F4 gap CONFIRMED gap
|
||||||
|
```
|
||||||
|
Codex: indisponibil (N/A). DX scor initial: ~6/10 (model API solid + aditiv, dar nume inconsistent + eroare neimbogatita + zero discoverability + contract neactualizat). Tinta dupa fixuri: ~9/10.
|
||||||
|
|
||||||
|
### Jurnal integrator (condensat)
|
||||||
|
| Etapa | Azi (plan brut) | Dupa fixuri DX |
|
||||||
|
|---|---|---|
|
||||||
|
| Afla env-urile contului | doar din eroare / dupa trimitere reala | `GET /v1/conturi/medii` |
|
||||||
|
| Trimite | `rar_target` (nume #1) | `rar_env` (un nume) |
|
||||||
|
| Confirma tinta fara trimitere reala | imposibil (valideaza nu ecou-ieste) | `/valideaza` ecou-ieste `rar_env` |
|
||||||
|
| Eroare tinta indisponibila | proza, fara cod | `cod: RAR_MEDIU_INDISPONIBIL` + fix |
|
||||||
|
| Citeste rezultatul | `rar_env` (nume #2) | `rar_env` (acelasi) |
|
||||||
|
| Doc | contract fara field/endpoint | contract complet |
|
||||||
534
docs/raport-comparatie-mockup-5.16.md
Normal file
534
docs/raport-comparatie-mockup-5.16.md
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/feat-5.18-corpus-knn-exemple-etichetate-autoplan-restore-20260629-070833.md -->
|
||||||
|
# Raport comparatie UI real vs. mockup-uri (PRD 5.16 + 5.17)
|
||||||
|
|
||||||
|
**Data**: 2026-06-29
|
||||||
|
**Metoda**: comparatie in browser (Playwright, 1280px + 390px) intre aplicatia live
|
||||||
|
(`http://localhost:8010`, cont 2 "Romfast SRL", 34 trimiteri) si mockup-urile de
|
||||||
|
referinta din `docs/mockups/`. Pentru fiecare pagina/formular am pus fata in fata
|
||||||
|
implementarea reala si intentia de design, apoi am evaluat in spiritul PRD-urilor.
|
||||||
|
|
||||||
|
> Concluzie pe scurt: **antetul, /login, selectorul de tema, contoarele si modalele
|
||||||
|
> sunt conforme**. Abaterea dominanta este **densitatea informationala**: lista de
|
||||||
|
> trimiteri si tabelul de preview din import afiseaza mult mai multa informatie pe rand
|
||||||
|
> decat mockup-ul minimalist — exact observatia userului ("randurile foarte late").
|
||||||
|
> Plus un **bug de layout** (coliziune coloane in preview-ul de import) si cateva
|
||||||
|
> abateri minore de copy/stil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Lista de trimiteri — rand cu 4 linii in loc de 2 (PRIORITATE INALTA)
|
||||||
|
|
||||||
|
**Aceasta e problema semnalata de user.**
|
||||||
|
|
||||||
|
| | Mockup (`prd-5.16-dashboard.html`) | Real (`_submissions.html:100-139`) |
|
||||||
|
|---|---|---|
|
||||||
|
| Linii / rand | **2**: VIN + `operatie · ora` | **4**: VIN; `operatie · data+ora+secunde`; cod RAR; `nr · data · #id` |
|
||||||
|
| Pastila de stare | DOAR pe exceptii (In coada / De corectat / Trimis); finalizatele **nu au pastila** | **pe fiecare rand**, inclusiv "Finalizat" |
|
||||||
|
| Marca de timp | ora scurta (`09:42`) | datetime complet cu secunde (`27.06.2026 22:25:52`) |
|
||||||
|
| Inaltime efectiva | ~2 randuri text | ~2x mai mare; pe mobil un rand se desfasoara pe 5-6 linii |
|
||||||
|
|
||||||
|
Cauza in cod (`app/web/templates/_submissions.html`):
|
||||||
|
- **Linia 3** — codul RAR (`OE-8`) / "nemapat": liniile 113-119.
|
||||||
|
- **Linia 4** — `vehicul_nr · data_prestatie · #id_prezentare`: liniile 121-127.
|
||||||
|
- **Marca de timp** foloseste `r.updated_at` complet (data+ora+secunde): linia 111
|
||||||
|
(mockup-ul foloseste ora scurta).
|
||||||
|
- **Pastila mereu randata** cu `r.stare_scurt`: liniile 137-139 (mockup-ul ascunde
|
||||||
|
pastila pe starea implicita/finalizata — minimalism "linistit cand e ok, zgomotos
|
||||||
|
cand e exceptie", in spiritul D6/zero-silent-failures).
|
||||||
|
|
||||||
|
**Recomandari** (in ordinea impactului):
|
||||||
|
1. **Comprima la 2 linii pe starea normala**: pastreaza linia 1 (VIN) + linia 2
|
||||||
|
(`operatie · data`). Muta cod RAR, nr. inmatriculare si `#id_prezentare` in modalul
|
||||||
|
de detaliu (care le are deja — vezi sectiunea 5) sau intr-un al doilea rand afisat
|
||||||
|
doar la hover/expand. Informatia completa nu trebuie sa coabiteze pe rand cu lista.
|
||||||
|
2. **Ascunde pastila pe starea finalizata** (afiseaz-o doar pe `queued/sending/
|
||||||
|
needs_*/error`), exact ca mockup-ul. Finalizat = implicit linistit.
|
||||||
|
3. **Scurteaza marca de timp**: data fara secunde (`27.06.2026`) sau `data · ora`
|
||||||
|
fara secunde. Secundele sunt zgomot.
|
||||||
|
4. Daca cod RAR / nr. inmatriculare sunt considerate esentiale in lista, fa-le optional
|
||||||
|
(toggle "afiseaza detalii") in loc sa fie mereu prezente — implicit colapsat.
|
||||||
|
5. Minor: `eticheta-problema` are `font-size:10px` (`_submissions.html:133`) — sub
|
||||||
|
pragul de 12px din scala 5.16/US-002; recableaza pe `--fs-xs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Acasa — titlu de sectiune + toolbar mult mai greu decat mockup-ul (PRIORITATE MEDIE)
|
||||||
|
|
||||||
|
PRD 5.16/US-002 cere explicit: *"Se ELIMINA titlul de sectiune ... lista incepe direct
|
||||||
|
sub tab-uri/filtre"* si *"fara subtitlu de sectiune"*. In real:
|
||||||
|
|
||||||
|
- **Titlul "Trimiterile tale" (h2) + link-urile "export CSV: trimise | toate"** sunt inca
|
||||||
|
prezente ca antet de sectiune deasupra listei. Mockup-ul nu are titlu de sectiune —
|
||||||
|
lista porneste direct sub tab-uri.
|
||||||
|
- **Toolbar-ul de filtre e mult mai dens** decat mockup-ul. Mockup: 4 pastile simple de
|
||||||
|
stare (`Toate / In coada / Trimise / De corectat`). Real: pastile de timp
|
||||||
|
(`Azi / 7 zile / 30 zile / Custom`) + camp cautare `Vehicul (nr/VIN)` + butoane
|
||||||
|
`Filtreaza` + `Toate` + un AL DOILEA rand de actiuni bulk (`Cod RAR ... / Aplica cod
|
||||||
|
/ Sterge selectate`). Sunt functii reale, dar contrazic intentia minimalista.
|
||||||
|
|
||||||
|
**Recomandari**:
|
||||||
|
1. Elimina antetul "Trimiterile tale" (sau redu-l la un label discret); muta link-urile
|
||||||
|
de export CSV langa tab-uri sau in meniul de cont.
|
||||||
|
2. Pastreaza filtrele de timp + cautarea (sunt utile), dar **colapseaza randul de actiuni
|
||||||
|
bulk** (Cod RAR / Aplica cod / Sterge selectate) intr-un buton "Actiuni" care se
|
||||||
|
deschide doar cand exista selectie — azi ocupa un rand permanent.
|
||||||
|
3. Aliniaza pastilele de stare cu mockup-ul (stari, nu doar timp), eventual ambele
|
||||||
|
grupuri pe acelasi rand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Linia "Plan: Gratuit · 34/60 luna asta" reintroduce un meta-rand sub tab-uri (PRIORITATE MEDIE)
|
||||||
|
|
||||||
|
PRD 5.17/US-006 + 5.16 cer planul ca **badge in antet** (exista — "GRATUIT") si **linie
|
||||||
|
in meniul burger**, NU ca rand in corpul paginii. Real afiseaza consumul si ca **rand
|
||||||
|
standalone sub tab-uri**, pe FIECARE tab (Acasa, Mapari, Integrare). Asta:
|
||||||
|
- duplica informatia din antet, si
|
||||||
|
- recreeaza exact "meta-randul de sectiune" pe care 5.16/US-002 voia sa-l elimine.
|
||||||
|
|
||||||
|
**Recomandare**: muta `N/60 luna asta` in meniul burger / pagina Cont (cum cere PRD-ul);
|
||||||
|
pastreaza in antet doar badge-ul de plan. Daca avertizarea de consum (>=80%) trebuie sa
|
||||||
|
fie vizibila in corp, afiseaz-o **doar** in starea de avertizare, nu permanent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Import — preview pas 3: coliziune de coloane + tabel mai greu decat mockup-ul
|
||||||
|
|
||||||
|
### 4a. BUG layout — pastila STARE se suprapune peste coloana VEHICUL (PRIORITATE INALTA)
|
||||||
|
In tabelul de preview (pas Verifica), pastila de stare ("Date incomplete" / "Cod RAR
|
||||||
|
lipsa") se **suprapune vizual** peste textul din coloana VEHICUL (`CT88NOE` / `B123ABC`
|
||||||
|
apar lipite/sub pastila). Vizibil clar la 1280px. E un bug de latime de coloana / pastila
|
||||||
|
fara `white-space:nowrap` sau coloana STARE prea ingusta.
|
||||||
|
**Recomandare**: largeste coloana STARE / pune pastila pe `nowrap` cu min-width, sau
|
||||||
|
muta stare si vehicul pe coloane clar separate; testeaza la 1280 si 390.
|
||||||
|
|
||||||
|
### 4b. Densitate — tabel cu 8 coloane vs. 4 in mockup (PRIORITATE MEDIE)
|
||||||
|
Mockup pas 3 = 4 coloane (`VIN / OPERATIE / DATA / STARE` + link editeaza). Real = 8
|
||||||
|
coloane (`# / STARE / VEHICUL / OPERATIE / DATA / KM FINAL / NOTE / ACTIUNI`), cu coloana
|
||||||
|
NOTE care afiseaza inline mesaje de validare lungi ("VIN trebuie sa aiba exact 17
|
||||||
|
caractere..."). Aceeasi tendinta ca lista de trimiteri: prea multa informatie pe rand.
|
||||||
|
**Recomandare**: redu la coloanele esentiale (Stare / Vehicul / Operatie / Data +
|
||||||
|
Editeaza); muta KM si mesajul de validare in randul de editare (care le are deja) sau
|
||||||
|
intr-un tooltip pe pastila de stare.
|
||||||
|
|
||||||
|
### 4c. Pastilele de filtru sunt toate albastru-plin (par toate active) (PRIORITATE MICA)
|
||||||
|
`Toate (2) / Cod RAR lipsa (1) / Date incomplete (1)` sunt randate ca butoane albastru
|
||||||
|
plin — toate par selectate simultan. Mockup-ul foloseste pastile subtiri cu dot colorat,
|
||||||
|
doar cea activa accentuata.
|
||||||
|
**Recomandare**: stil outline + dot pentru filtrele inactive; plin doar pentru cel activ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Import — pas 1: dropzone compact vs. zona mare din mockup (PRIORITATE MICA)
|
||||||
|
|
||||||
|
Mockup pas 1 = zona mare cu chenar punctat, iconita upload centrata, "Trage fisierul
|
||||||
|
aici", buton "Alege fisier" + chips de format (`.xlsx .csv .xls`). Real = o bara
|
||||||
|
orizontala slim ("Importa: [Alege fisier] sau trage aici"). Bara compacta se potriveste
|
||||||
|
cu "import colapsat", deci e o abatere **acceptabila**; daca se doreste fidelitate cu
|
||||||
|
mockup-ul, zona se poate inalta cand `<details>` e deschis (chenar punctat + iconita).
|
||||||
|
Pozitiv: stepper-ul (4 pasi, cifre in cerc, pas finalizat = bifa verde) si saltul automat
|
||||||
|
peste pas 2 la format recunoscut sunt conforme si bune.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Formularul de editare (modal corectie / editare rand)
|
||||||
|
|
||||||
|
Comparatie cu modalul din mockup ("Corecteaza trimiterea / randul"):
|
||||||
|
|
||||||
|
- **Conform**: structura (VIN; Data + Nr. inmatriculare pe 2 coloane; Observatii;
|
||||||
|
"Prestatii — cod RAR pe fiecare operatie"; picker cu denumiri; "+ Adauga alta
|
||||||
|
operatie / cod RAR"). Bug-urile US-004..007 sunt rezolvate functional.
|
||||||
|
- **Anomalie (PRIORITATE MEDIE)**: intre randul de operatie si controlul "+ Adauga alta
|
||||||
|
operatie" apare un **chenar gol** (container de chips fara continut) — pare nefinisat /
|
||||||
|
neintentionat. De ascuns cand nu are chips.
|
||||||
|
- **Stil nume operatie (PRIORITATE MICA)**: mockup-ul afiseaza numele operatiei
|
||||||
|
**bold/uppercase, proeminent** ("SCHIMB PLACUTE FRANA — lipsa cod"); real il arata
|
||||||
|
in greutate normala, mic ("Schimb placute frana · lipsa cod"). Mai putin emfatic.
|
||||||
|
- **Copy butoane (PRIORITATE MICA)**: real "Salveaza / Anuleaza"; mockup + PRD/US-007
|
||||||
|
spun "Renunta" (si "Salveaza si retrimite" in modalul de detaliu). Aliniaza eticheta
|
||||||
|
"Anuleaza" -> "Renunta".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tema transversala — diacritice in textul vizibil (PRIORITATE MICA)
|
||||||
|
|
||||||
|
Mockup-urile (intentia de design) folosesc diacritice romanesti complete in textul catre
|
||||||
|
user ("Observatii" -> "Observații", "Salveaza" -> "Salvează", "Numar inmatriculare" ->
|
||||||
|
"Număr înmatriculare", "Adauga" -> "Adaugă", "In coada" -> "În coadă"). Aplicatia reala
|
||||||
|
omite diacriticele in majoritatea label-urilor. US-001 a confirmat ca fontul de sistem
|
||||||
|
randeaza corect diacriticele, iar landing-ul le foloseste deja — deci e o diferenta de
|
||||||
|
finisaj fata de mockup, nu o limitare tehnica.
|
||||||
|
**Recomandare**: aplica diacritice la **textul vizibil pentru user** (label-uri, butoane,
|
||||||
|
titluri), pastrand codul/comentariile fara diacritice ca azi. Optional (non-blocant);
|
||||||
|
de decis daca se urmareste fidelitate completa cu mockup-urile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Pagini fara mockup dedicat (judecate dupa design system) — CONFORME
|
||||||
|
|
||||||
|
- **Mapari** (`?tab=mapari`): carduri, tabele, fonturi uniforme — coerent cu sistemul.
|
||||||
|
Singura observatie: cardul gol "De rezolvat" cand nu exista needs_mapping (se poate
|
||||||
|
ascunde cand e gol).
|
||||||
|
- **Integrare** (`?tab=integrare`): tab-uri de limbaj (curl/Python/PHP/C#/Node/VFP),
|
||||||
|
blocuri de cod, carduri export + test cheie — curat si profesional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Ce este DEJA conform mockup-urilor (pentru context — fara actiune)
|
||||||
|
|
||||||
|
- **/login**: layout brandeit pe 2 coloane (panou ROMFAST + formular), badge mediu,
|
||||||
|
link signup — conform `prd-5.16-header-login-tema.html`.
|
||||||
|
- **Antet**: titlu "ROMFAST AUTOPASS" + badge mediu (TEST) + badge plan (GRATUIT) +
|
||||||
|
"Service auto: Romfast SRL" + pastila "RAR online" (dot verde) + meniu burger.
|
||||||
|
Conform US-010/003.
|
||||||
|
- **Selector tema**: pill cu iconita + eticheta ("Auto"), iconita-only pe mobil.
|
||||||
|
Conform US-011.
|
||||||
|
- **Contoare**: 5 carduri separate desktop (Total / Luna asta / Azi / In coada /
|
||||||
|
De corectat); bara compacta de cifre pe mobil. Conform US-002. (Minor: eticheta
|
||||||
|
"Total" vs mockup "Total trimise"; pe mobil "Erori" vs mockup "Corectat".)
|
||||||
|
- **Import colapsat pe Acasa** (`<details>` slim "+ Importa fisier"). Conform US-013.
|
||||||
|
- **Modal detaliu trimitere finalizata**: read-only, label-uri clare, "Detalii tehnice"
|
||||||
|
colapsabil — curat si conform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rezumat prioritati
|
||||||
|
|
||||||
|
| # | Constatare | Prioritate | Fisier principal |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Rand lista cu 4 linii + pastila mereu (rânduri late) | **INALTA** | `_submissions.html:110-139` |
|
||||||
|
| 4a | Coliziune pastila STARE / coloana VEHICUL in preview import | **INALTA** | `_preview_import.html` |
|
||||||
|
| 2 | Titlu sectiune "Trimiterile tale" + toolbar bulk permanent | MEDIE | `_acasa.html` / `_submissions.html` |
|
||||||
|
| 3 | "Plan: N/60" ca rand in corp (duplica antetul) | MEDIE | `_acasa.html` / context layout |
|
||||||
|
| 4b | Tabel preview cu 8 coloane vs 4 | MEDIE | `_preview_import.html` |
|
||||||
|
| 6 | Chenar gol de chips in formularul de editare | MEDIE | `_chips_prestatii.html` |
|
||||||
|
| 4c | Pastile de filtru toate albastru-plin | MICA | `_preview_import.html` |
|
||||||
|
| 5 | Dropzone import compact vs zona mare | MICA | `_upload.html` |
|
||||||
|
| 6 | Nume operatie ne-emfatic + copy "Anuleaza" vs "Renunta" | MICA | `_form_editare.html` / `_chips_prestatii.html` |
|
||||||
|
| 7 | Diacritice lipsa in textul vizibil | MICA | transversal |
|
||||||
|
|
||||||
|
**Cele doua corectii cu impact maxim**: (1) comprimarea randului de lista la 2 linii +
|
||||||
|
ascunderea pastilei pe finalizat, si (4a) bug-ul de coliziune din preview-ul de import.
|
||||||
|
Restul sunt finisaje de aliniere la spiritul minimalist al mockup-urilor.
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- ================= /autoplan REVIEW APPENDIX ================= -->
|
||||||
|
# /autoplan — Revizuire automata (CEO → Design → Eng)
|
||||||
|
|
||||||
|
> Tratam acest raport ca **plan**: cele 10 recomandari (sectiunile 1-7) sunt
|
||||||
|
> elementele de implementat. Scope UI: DA (Design conduce). Scope DX: NU
|
||||||
|
> (sectiunea 8 "Integrare" e marcata CONFORM, fara actiune pe suprafata API/CLI).
|
||||||
|
> Voci duale: Claude subagent + Codex per faza. Decizii intermediare auto-decise
|
||||||
|
> pe cele 6 principii; deciziile de gust merg la poarta finala.
|
||||||
|
|
||||||
|
## Faza 1 — CEO (Strategie & Scope)
|
||||||
|
|
||||||
|
### 0A. Provocarea premiselor
|
||||||
|
|
||||||
|
Planul (raportul) se sprijina pe 4 premise implicite:
|
||||||
|
|
||||||
|
- **P1 — Fidelitatea fata de mockup este tinta.** Mockup-urile reprezinta intentia
|
||||||
|
corecta de design; orice abatere a UI-ului real e un defect. *Status: in mare
|
||||||
|
valida, dar nu absoluta* — raportul insusi recunoaste ca UI-ul real a adaugat
|
||||||
|
FUNCTII pe care mockup-ul minimalist nu le are (cautare, filtre de timp, bulk-fix
|
||||||
|
cod RAR, cod RAR + #id_prezentare pe rand). Acele functii pot sa-si merite densitatea.
|
||||||
|
- **P2 — "Densitatea informationala" e problema centrala**, iar minimalismul ("linistit
|
||||||
|
cand e ok, zgomotos pe exceptie", D6/zero-silent-failures) e principiul corect.
|
||||||
|
*Status: validata de durere reala* — userul s-a plans explicit de "randurile foarte
|
||||||
|
late". Aici premisa e bine sustinuta.
|
||||||
|
- **P3 — Criteriile de acceptare PRD 5.16/5.17 sunt obligatorii** si UI-ul real a
|
||||||
|
derivat de la ele (titlu sectiune de eliminat `_coada.html:10`; plan ca badge nu rand
|
||||||
|
in corp `_status.html:140`; prag tipografic 12px incalcat de `font-size:10px`
|
||||||
|
`_submissions.html:133`). *Status: validata — sunt AC contractuale, nu preferinte.*
|
||||||
|
Acestea NU sunt decizii de gust; sunt conformare la PRD.
|
||||||
|
- **P4 — Mutarea informatiei de pe rand nu pierde nimic** fiindca e deja in modalul
|
||||||
|
de detaliu / randul de editare. *Status: tehnic adevarata* (verificat: modalul are
|
||||||
|
cod RAR/nr/#id; randul de editare are KM + mesaj validare), dar muta un cost de la
|
||||||
|
"vizibil la scanare" la "vizibil dupa click" — un compromis de UX, nu zero-cost.
|
||||||
|
|
||||||
|
**Premisa care merita judecata umana** (poarta de mai jos): pentru informatia scoasa
|
||||||
|
de pe rand (cod RAR, #id_prezentare, marca de timp completa) — o **ascundem in modal**
|
||||||
|
(minimalism strict, fidel mockup-ului) sau o **pastram in spatele unui toggle
|
||||||
|
compact/detaliat** (operatorul de service poate vrea sa scaneze cod RAR/#id fara click)?
|
||||||
|
Userul s-a plans de latime, NU neaparat ca informatia in sine e inutila.
|
||||||
|
|
||||||
|
### 0B. Harta de leverage (ce exista deja)
|
||||||
|
|
||||||
|
| Sub-problema | Cod existent reutilizat | Tip schimbare |
|
||||||
|
|---|---|---|
|
||||||
|
| Compresie rand lista | modal detaliu (`_fragments/trimitere/{id}`) are deja cod RAR/nr/#id | SCADERE (sterge L3/L4 din `_submissions.html`) |
|
||||||
|
| Pastila pe finalizat | `r.stare_css/stare_scurt` exista; conditie lipsa | conditie `{% if %}` in jurul liniei 138 |
|
||||||
|
| Prag tipografic 12px | sistemul de token-uri `--fs-xs:12px` exista deja in mockup/base | re-cablare literal `10px` → `--fs-xs` |
|
||||||
|
| KM + validare in preview | randul de editare le are deja | SCADERE coloane din `_preview_import.html` |
|
||||||
|
| Chenar gol chips | `_has_ops`/`_chips` deja calculate | conditie `{% if _chips %}` pe container |
|
||||||
|
|
||||||
|
Concluzie: planul e **dominant SCADERE + re-tokenizare**, putin cod nou, leverage mare.
|
||||||
|
|
||||||
|
### 0B-bis. Pattern de fond depistat (in afara raportului, in blast radius)
|
||||||
|
|
||||||
|
`_submissions.html` foloseste **literali px inline** peste tot (`font-size:13px`,
|
||||||
|
`12px`, `11px`, `10px` — liniile 18, 45, 54, 63, 133, 153, 182...) in loc de token-uri
|
||||||
|
`--fs-*`. Raportul a prins DOAR instanta de 10px (US-002). Cauza-radacina e ca scala
|
||||||
|
tipografica 5.16 nu e aplicata sistematic in template-urile de lista/preview. *Flag
|
||||||
|
pentru poarta finala: extindem fix-ul la re-tokenizarea template-urilor atinse, sau
|
||||||
|
doar instanta 10px?* (In blast radius, < 1 zi CC — candidat de auto-aprobat pe P2.)
|
||||||
|
|
||||||
|
### 0C. Dream-state delta
|
||||||
|
|
||||||
|
```
|
||||||
|
CURENT → ACEST PLAN → IDEAL 12 LUNI
|
||||||
|
UI real, dens, derivat de Aliniat la minimalismul Sistem de token-uri aplicat
|
||||||
|
la AC-urile PRD 5.16/5.17; mockup-ului; bug 4a rezolvat; uniform (zero literali px);
|
||||||
|
bug coliziune coloane; randuri 2 linii; tipo 12px+ teste de regresie design vs
|
||||||
|
literali px imprastiati. pe instantele semnalate. mockup (Playwright snapshot).
|
||||||
|
```
|
||||||
|
Delta ramasa dupa plan: re-tokenizarea completa + testele de regresie vizuala (defer).
|
||||||
|
|
||||||
|
### 0C-bis. Alternative de implementare
|
||||||
|
|
||||||
|
| # | Abordare | Efort (CC) | Risc | Pro / Contra |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A | Fix exact ca raportul (scade L3/L4 in modal, ascunde pastila, fix bug, polish) | ~30 min | mic | + fidel mockup, simplu / − operatorul pierde cod RAR/#id la scanare |
|
||||||
|
| B | Ca A, dar info de rand in spatele unui toggle compact/detaliat | ~60 min | mediu | + nu pierde info / − complexitate noua, contrazice "explicit over clever" (P5) |
|
||||||
|
| C | Ca A + re-tokenizare px→token in template-urile atinse | ~50 min | mic | + rezolva cauza-radacina P2 / − atinge mai multe linii |
|
||||||
|
|
||||||
|
Recomandare CEO: **A pentru structura** (P5 explicit, P1 completeness fata de mockup),
|
||||||
|
cu **C ca extindere in blast radius** (P2 boil-the-lake pe tipografie). B intra la poarta
|
||||||
|
finala ca decizie de gust (toggle vs. mutare-in-modal).
|
||||||
|
|
||||||
|
### 0D. Mod: **SELECTIVE EXPANSION**
|
||||||
|
Nucleu = sectiunile 1 + 4a (impact maxim, una e bug). Extindere selectiva in blast
|
||||||
|
radius = re-tokenizarea (0B-bis) + AC-urile PRD (2, 3). Restul (polish MICA) = inclus,
|
||||||
|
cost trivial.
|
||||||
|
|
||||||
|
### 0E. Interogare temporala
|
||||||
|
- **Ora 1**: bug 4a (coliziune `_preview_import.html`) + compresie rand `_submissions.html`
|
||||||
|
+ ascundere pastila finalizat. Astea ating durerea userului + singurul bug real.
|
||||||
|
- **Ora 6+**: sectiunile 2, 3 (conformare AC), chenarul gol chips (6), polish copy/stil,
|
||||||
|
diacritice (decizie separata).
|
||||||
|
|
||||||
|
### 0F. Confirmare mod
|
||||||
|
SELECTIVE EXPANSION confirmat: planul livreaza nucleul de impact + extinderile in blast
|
||||||
|
radius care isi platesc costul, defera testele de regresie vizuala.
|
||||||
|
|
||||||
|
### POARTA DE PREMISE — REZOLVATA (directiva user, 2026-06-29)
|
||||||
|
|
||||||
|
Userul a dat o directiva mai precisa decat oricare optiune A/B/C. **Spec guvernanta
|
||||||
|
pentru randul de lista:**
|
||||||
|
|
||||||
|
> **2 linii MAXIM** (inaltime minimalista, ca in mockup), dar randul CONTINE:
|
||||||
|
> **nr. inmatriculare · operatia RAR (cod) · operatia din service (denumire) · data**,
|
||||||
|
> plus **pill de stare (inclusiv "Finalizat")**.
|
||||||
|
|
||||||
|
Consecinte (override-uri fata de recomandarile raportului):
|
||||||
|
- **OVERRIDE rec. 1.1** (partial): cod RAR si operatia din service RAMAN pe rand, NU se
|
||||||
|
muta in modal. Doar VIN (ca identificator primar), #id_prezentare si secundele din
|
||||||
|
timestamp se scot. Identificatorul primar devine **nr. inmatriculare**, nu VIN.
|
||||||
|
- **OVERRIDE rec. 1.2**: pastila RAMANE pe finalizat (userul cere explicit "+ pill
|
||||||
|
finalizat"). NU se ascunde pe starea normala. (Raportul recomanda ascunderea — anulat.)
|
||||||
|
- **CONFIRMA rec. 1.3**: marca de timp scurta (data, fara secunde).
|
||||||
|
- **CONFIRMA rec. 1.4**: implicit 2 linii (fara toggle detaliat — userul nu vrea toggle).
|
||||||
|
|
||||||
|
Aceasta devine cerinta de design pentru Faza 2 (aranjarea celor 5 campuri in 2 linii).
|
||||||
|
Campuri necesare pe rand: `vehicul_nr`, `cod_rar`, `operatie` (denumire service), `data`,
|
||||||
|
`pill`. Campuri eliminate: `vin_scurt` (sau retrogradat), `#id_prezentare`, secunde.
|
||||||
|
|
||||||
|
> Nota proces: aceasta a fost singura poarta de judecata umana din Faza 1. Suprafata
|
||||||
|
> strategica (minimalism vs. densitate) a fost decisa de user; nu mai exista premisa
|
||||||
|
> deschisa de provocat. Vocile duale CEO sunt redundante pe aceasta suprafata si se
|
||||||
|
> consolideaza in Faza 3 (vezi nota de proportionalitate).
|
||||||
|
|
||||||
|
### Voci (proportionalitate)
|
||||||
|
- Codex: **INDISPONIBIL** (limita de utilizare atinsa, reset 18 iul) → tag `[subagent-only]`.
|
||||||
|
- Claude subagent Design + Claude subagent Eng: rulate la adancime completa, pe cod real
|
||||||
|
(template-uri + rute + teste), nu pe proza. Acestea sunt vocile substantiale.
|
||||||
|
|
||||||
|
## Faza 2 — Design (UI/UX)
|
||||||
|
|
||||||
|
### Aranjarea randului de 2 linii (livrabilul central)
|
||||||
|
|
||||||
|
Placuta-primul e corect: un operator identifica masina dupa nr. inmatriculare de pe
|
||||||
|
comanda, nu dupa VIN de 17 caractere. Layout propus (peste `.trimitere-slim` existent):
|
||||||
|
|
||||||
|
```
|
||||||
|
L1: B-123-ABC (placuta, --fs-md, weight 600, ink) ............ [ PILL dreapta ]
|
||||||
|
L2: OE-8 (cod RAR, mono/accent) · Schimb placute frana (operatie, ink, ellipsis) · 27.06.2026 (muted)
|
||||||
|
```
|
||||||
|
|
||||||
|
- L1 = `vehicul_nr` (stanga, `flex:1 1 auto; min-width:0`) + pill (dreapta, `flex:0 0 auto`).
|
||||||
|
- L2 = flex 3 celule: cod RAR (auto, primul — e identificatorul scanabil) · operatie
|
||||||
|
(`flex:1 1 auto; min-width:0; white-space:nowrap; text-overflow:ellipsis` — ellipsis-ul
|
||||||
|
pe operatie garanteaza ca randul NU trece pe a 3-a linie nici la 390px) · data (muted, ultima).
|
||||||
|
- Operatia ramane **ink, nu muted** (e al doilea cel mai citit camp dupa placuta).
|
||||||
|
- Ierarhie vizuala: placuta → pill → cod+operatie → data.
|
||||||
|
|
||||||
|
### CONSTATARI DESIGN dincolo de raport (corectii)
|
||||||
|
|
||||||
|
| # | Constatare | Sev | Fix |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D-1 | Linia `eticheta_problema` (L:129-134) e a **5-a linie** → strica "2 linii MAX" pe randurile de eroare | inalta | DECIZIE DE GUST (vezi poarta) — drop vs micro-linie doar pe eroare |
|
||||||
|
| D-2 | Pastilele **NU sunt conforme** (raportul sec.9 gresit): chip outline gri, fara dot/fill, doar culoare text. Cu pill permanent pe orice rand → zgomot gri permanent | medie-inalta | restileaza pill ca mockup: fill tint + dot 7px + text colorat (DECIZIE DE GUST) |
|
||||||
|
| D-3 | Bug 4a cauza-radacina: `table-layout:fixed` + `.col-stare width:104px` (base.html:401) + pill `nowrap` → overflow peste col-vehicul | inalta | widen `.col-stare`→~140px; reducerea 8→4 col NU rezolva bug-ul (curge in coloanele fluide, nu in col-stare fixa) |
|
||||||
|
| D-4 | Lipsa stare de eroare la incarcarea listei (HTMX `/_fragments/submissions` 500 → spinner blocat) | medie | adauga partial de eroare / `hx-on::response-error` (DEFER TODOS — pre-existent) |
|
||||||
|
| D-5 | Filtre 4c "toate albastru": raportul e **STALE** — codul are deja `background:transparent` + doar activ plin (`_preview_import.html:56-58,277`). Ramane doar diferenta stilistica (fara dot) | mica | NO-ACTION pe bug; eventual dot pe mockup (gust, optional) |
|
||||||
|
|
||||||
|
### Litmus design (consens)
|
||||||
|
```
|
||||||
|
DESIGN — voci: Claude-sub Codex Consens
|
||||||
|
1. Layout 2 linii fezabil/curat? DA N/A Confirmat (single voice)
|
||||||
|
2. Placuta-primul corect? DA N/A Confirmat
|
||||||
|
3. Bug 4a cauza reala identificata? DA N/A Confirmat
|
||||||
|
4. Pill conform mockup? NU N/A Flag (D-2)
|
||||||
|
5. Stari complete (loading/error/mobil)? partial N/A Gap (D-4 error state)
|
||||||
|
6. Polish: defect vs gust separat? DA N/A Confirmat (4c stale, 6 real)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Faza 3 — Eng (arhitectura, regresie)
|
||||||
|
|
||||||
|
### Arhitectura (grafic dependente)
|
||||||
|
```
|
||||||
|
_acasa.html ─include─ _coada.html ─include─ _submissions.html (LISTA: .lista-trimiteri-slim)
|
||||||
|
└─ titlu "Trimiterile tale" (h2, L:10) + export CSV ← scoate (PRD)
|
||||||
|
_preview_import.html (.tabel-trimiteri) ─include─ _preview_rand.html (pill inline-flex) ← bug 4a
|
||||||
|
_chips_prestatii.html (.chips operatii-mode, L:122) ← chenar gol
|
||||||
|
_status.html:140 rand plan N/60 in corp ← muta in burger/cont (PRD)
|
||||||
|
|
||||||
|
DATE: r.prez = prezentare_din_payload (payload_view.py:86) → vehicul_nr, cod_rar,
|
||||||
|
operatie, data_prestatie TOATE prezente. Schimbare = TEMPLATE-ONLY (fara rute).
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decizie semantica: marca de timp
|
||||||
|
`r.updated_at` (L:111) = `format_data_rar` care adauga MEREU `%H:%M:%S` (labels.py:158) →
|
||||||
|
sursa secundelor zgomotoase. **Auto-decis: foloseste `r.prez.data_prestatie`** (data
|
||||||
|
prestatiei declarate, deja date-only `2026-06-18`) — semantic e "data" pe care o cere
|
||||||
|
userul, langa celelalte campuri de prezentare. (Alternativa: helper `format_data_scurta`
|
||||||
|
%d.%m.%Y daca trebuie pastrat updated_at — respins ca redundant.)
|
||||||
|
|
||||||
|
### Eng consensus table
|
||||||
|
```
|
||||||
|
ENG — voci: Claude-sub Codex Consens
|
||||||
|
1. Arhitectura sunet (template-only)? DA N/A Confirmat
|
||||||
|
2. Acoperire teste suficienta? NU (3 rup) N/A Gap mapat (vezi test plan)
|
||||||
|
3. Riscuri performanta? nule N/A Confirmat (subtractiv)
|
||||||
|
4. Securitate? N/A N/A Fara suprafata noua
|
||||||
|
5. Cai de eroare tratate? partial N/A Gap: vehicul_nr=='—' + D-4
|
||||||
|
6. Risc deploy gestionabil? DA N/A Confirmat (4 teste de update)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regresie (artefact pe disc)
|
||||||
|
Test plan scris: `~/.gstack/projects/romfast-rar-autopass/feat-5.18-corpus-knn-test-plan-20260629-071500.md`
|
||||||
|
- **3 teste se strica HARD**: `test_vin_pe_rand_separat_sub_nr`, `test_rand_slim_vin_operatie_pill`,
|
||||||
|
`test_submissions_coloane_umane` (toate hard-codeaza VIN-primar / #id-pe-rand).
|
||||||
|
- **2 la risc**: depind de numele claselor → **pastreaza `slim-vin`/`slim-meta`** (reumple, nu redenumi).
|
||||||
|
- Invariant cod_rar ("OE-2 vizibil, fara prefix, nemapat") **pastrat** de spec.
|
||||||
|
|
||||||
|
### Registru moduri de esec
|
||||||
|
| Mod | Trigger | Tratare in plan | Gap? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Placuta lipsa | payload fara `vehicul_nr` → `'—'` | azi mascat de VIN-primar | **GAP — auto-include fallback** (nu randa em-dash singur) |
|
||||||
|
| cod_rar lipsa | nemapat | guard `!= '—'` → "nemapat" | OK (pastrat) |
|
||||||
|
| operatie lunga la 390px | denumire lunga | ellipsis + min-width:0 (vezi L2) | OK daca se aplica layout-ul |
|
||||||
|
| Lista 500 / network drop | HTMX swap esueaza | — | GAP D-4 (defer TODOS) |
|
||||||
|
| Pill finalizat a11y | text-in-pill | stare prin TEXT + title | OK (invariant respectat) |
|
||||||
|
|
||||||
|
### Retokenizare px (auto-decis: BOUNDED)
|
||||||
|
Eng: retokenizarea completa px→token e scope creep (`13px→--fs-sm`=13.5px schimba layout,
|
||||||
|
risc regresie vizuala fara baza AC). **Auto-decis: doar instanta sub-12px** (`eticheta-problema`
|
||||||
|
10px→`--fs-xs`) — singura cu acoperire AC. (Suprascrie sugestia CEO 0B-bis de auto-aprobare larga.)
|
||||||
|
|
||||||
|
## Decision Audit Trail
|
||||||
|
|
||||||
|
| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 1 | CEO | Rand=2 linii cu placuta+codRAR+op+data+pill | Premisa (user) | — | directiva user la poarta | mutare cod RAR in modal |
|
||||||
|
| 2 | CEO | Identificator primar=placuta, nu VIN | Mechanical | P1 | operator scaneaza placuta | VIN primar |
|
||||||
|
| 3 | Eng | "data" = `data_prestatie`, nu `updated_at` | Mechanical | P5 | semantic corect, fara secunde, fara helper nou | slice updated_at |
|
||||||
|
| 4 | Eng | Pastreaza clase `slim-vin`/`slim-meta` | Mechanical | P3 | minimizeaza churn de teste | redenumire clase |
|
||||||
|
| 5 | Eng | Fallback `vehicul_nr=='—'` | Mechanical | P1 | evita em-dash singur ca id primar | lasa em-dash |
|
||||||
|
| 6 | Design | Bug 4a: widen `.col-stare`~140px | Mechanical | P5 | cauza reala (fixed 104px+nowrap) | doar nowrap/min-width |
|
||||||
|
| 7 | Design | 8→4 coloane preview (densitate) | Mechanical | P1 | match mockup; NU rezolva 4a singur | pastreaza 8 col |
|
||||||
|
| 8 | Eng | Guard `{% if _extra %}` pe `.chips` | Mechanical | P5 | elimina chenar gol | container mereu |
|
||||||
|
| 9 | Eng | Retokenizare px BOUNDED (doar 10px) | Taste→auto | P5 | evita shift vizual nebazat AC | retokenizare larga |
|
||||||
|
| 10 | Design | Filtre 4c: NO-ACTION (raport stale) | Mechanical | P4 | codul deja corect | re-implementare |
|
||||||
|
| 11 | CEO | Sec.2 titlu + sec.3 plan N/60: scoate | Mechanical | P1 | AC PRD 5.16/5.17 obligatorii | pastreaza |
|
||||||
|
| 12 | Design | Stare eroare lista (D-4): DEFER TODOS | Mechanical | P3 | pre-existent, in afara cererii | include acum |
|
||||||
|
| T1 | Design/Eng | eticheta_problema: **PASTREAZA micro-linie doar pe rand de eroare** (user) | Gust→rezolvat | — | normal/finalizat=2 linii strict; eroare=2+motiv (D6 loud-on-exception) | drop complet |
|
||||||
|
| T2 | Design | **DA — restileaza pill fill+dot ca mockup** (user) | Gust→rezolvat | — | pill permanent isi merita greutatea vizuala | lasa contur gri |
|
||||||
|
| T3 | trans | **NU aplica diacritice** (user) | Gust→rezolvat | — | non-blocant; ramane divergenta de finisaj acceptata | include/pas separat |
|
||||||
|
|
||||||
|
## PLAN APROBAT (user, 2026-06-29) — implementarea NU se executa in aceasta sesiune
|
||||||
|
|
||||||
|
> Status: **APROBAT ca plan**. User a ales "doar planul, nu implementa inca". Task-urile
|
||||||
|
> de mai jos sunt gata de executat intr-o sesiune viitoare (sau /ship cand exista cod).
|
||||||
|
|
||||||
|
### Spec final randul de lista (de implementat in `_submissions.html`)
|
||||||
|
- **L1**: `vehicul_nr` (placuta, primar, `--fs-md`/weight 600, `.slim-vin` reumplut) + **pill** dreapta.
|
||||||
|
- **L2** (`.slim-meta`): `cod_rar` (sau "nemapat", mono/accent, prima) · `operatie` (ink, ellipsis,
|
||||||
|
`min-width:0`) · `data_prestatie` (muted). Scoate: VIN primar, `#id_prezentare`, secundele.
|
||||||
|
- **Pill**: ramane pe FIECARE rand inclusiv Finalizat; restilat fill-tint + dot 7px + text colorat per stare.
|
||||||
|
- **eticheta_problema**: ramane micro-linie conditionala DOAR pe stari de problema; `10px`→`--fs-xs`.
|
||||||
|
- **Fallback**: `vehicul_nr == '—'` → nu randa em-dash singur (mesaj fallback).
|
||||||
|
- Pastreaza numele claselor `slim-vin`/`slim-meta` (reumple, nu redenumi) — minimizeaza churn teste.
|
||||||
|
|
||||||
|
### Implementation Tasks (agregat) — LIVRAT 2026-06-29 (toate verzi, 1392 teste)
|
||||||
|
- [x] **T-1 (INALTA) — `_submissions.html`** — refactor rand 4→2 linii cu placuta+codRAR+operatie+data_prestatie+pill; fallback placuta; clase pastrate. Teste rescrise: test_rand_slim_vin_operatie_pill, test_submissions_coloane_umane, test_placuta_pe_rand_identificator_primar (fost test_vin_pe_rand_separat_sub_nr), test_placuta_lipsa_nu_genereaza_rand_gol (fallback "fara numar").
|
||||||
|
- [x] **T-2 (INALTA) — `base.html` (CSS pill) + `_submissions.html`** — pill slim restilat (fill tint + dot 7px + text colorat per `stare_css` via currentColor), scopat `.lista-trimiteri-slim .pill`; ramane pe finalizat.
|
||||||
|
- [x] **T-3 (INALTA) — `base.html`** — bug 4a: `.tabel-trimiteri .col-stare` 104px→140px. nowrap pe col-vehicul neatins.
|
||||||
|
- [x] **T-4 (MEDIE) — `_preview_import.html` + `_preview_rand.html`** — reducere la 5 coloane (Stare/Vehicul/Operatie/Data/Actiuni); scoase col-id, col-km, col-note; motivul mutat in `title` pe pill, KM in modal.
|
||||||
|
- [x] **T-5 (MEDIE) — `_coada.html`** — titlu vizibil "Trimiterile tale" → `<h2 class="sr-only">` (a11y pastrat); badge "de rezolvat" + export CSV intr-un rand discret. `.sr-only` adaugat in base.html.
|
||||||
|
- [x] **T-6 (MEDIE) — `_status.html`** — linia plan in corp DOAR pe avertizare (`plan_warn`/`plan_limita_atinsa`); consum normal in badge antet + burger. Teste status mutate pe pagina completa.
|
||||||
|
- [x] **T-7 (MEDIE) — `_chips_prestatii.html`** — guard `{% if _extra_chips %}` pe containerul `.chips`, chenarul gol eliminat.
|
||||||
|
- [x] **T-8 (MICA) — `_submissions.html` / base.html** — `font-size:10px`→`var(--fs-xs)` (eticheta-problema, prin clasa scopata `.lista-trimiteri-slim .eticheta-problema`).
|
||||||
|
- [x] **T-9 (MICA) — `_form_editare.html` + base.html** — "Anuleaza"→"Renunta" (default); `.op-row-name` emfatic (bold, `--fs-sm`).
|
||||||
|
- [ ] **Defer — tracked in `TODOS.md`** (la cererea userului 2026-06-29): stare eroare HTMX lista (D-4); retokenizare px completa; diacritice in textul vizibil.
|
||||||
|
- [x] **Defer — inchis ca acceptabil** (netrackuit): teste regresie vizuala (tooling viitor); dropzone zona-mare (sec.5, raportul il marcheaza acceptabil).
|
||||||
|
|
||||||
|
### Verificare la implementare
|
||||||
|
`python3 -m pytest tests/test_web_submissions.py tests/test_web_submissions_layout.py tests/test_web_responsive.py tests/test_web_preview_compact.py -q`
|
||||||
|
Test plan complet: `~/.gstack/projects/romfast-rar-autopass/feat-5.18-corpus-knn-test-plan-20260629-071500.md`
|
||||||
|
|
||||||
|
## ADDENDUM 2026-06-29 — bug live mobil Mapari (CORECTIE la sectiunea 8)
|
||||||
|
|
||||||
|
Sectiunea 8 a raportului a marcat **Mapari ca "CONFORME"**, dar nu a testat corect
|
||||||
|
randarea mobila. User a raportat (cu screenshot, 390px) doua probleme reale, **REZOLVATE**:
|
||||||
|
|
||||||
|
1. **Butoanele Salveaza/Sterge taiate pe mobil.** Cauza: `.tabel-card td button {width:100%}`
|
||||||
|
(base.html, specificitate 0,1,2) batea `.act {width:44px}` (0,1,0) → cele doua butoane
|
||||||
|
`.act` deveneau full-width, iar al doilea (Sterge) iesea din card (celula are `nowrap`).
|
||||||
|
Fix: bloc `@media (max-width:767px)` nou (ultimul in `<style>`) — celula Actiuni devine
|
||||||
|
flex-row, butoanele `.act` `width:auto; flex:1 1 0` cu text vizibil. Acum ambele butoane
|
||||||
|
sunt complet vizibile, una langa alta, cu eticheta.
|
||||||
|
2. **Carduri prea inalte + label-uri inutile.** Cauza: `.tabel-card` randa etichetele
|
||||||
|
`data-eticheta` ca pseudo-titluri ("Operatie"/"Cod RAR"/"Actiuni") + linia redundanta
|
||||||
|
"acum: COD — nume" (duplica select-ul). Fix: pe mobil se ascund pseudo-etichetele
|
||||||
|
(`.tabel-card td::before{display:none}`) si linia "acum:" (`.map-acum{display:none}`),
|
||||||
|
padding strans. Cardul trece de la ~7 elemente la ~3 (nume + select + butoane).
|
||||||
|
|
||||||
|
Fisiere: `app/web/templates/base.html` (CSS), `app/web/templates/_mapari.html` (clasa `map-acum`).
|
||||||
|
Verificare: 80 teste web verzi (test_web_responsive + mapari + submissions + tabs + modal);
|
||||||
|
confirmare vizuala la 390px (render TestClient → screenshot Playwright). Atributele
|
||||||
|
`data-eticheta` raman in DOM (a11y + teste). NEPLASAT inca: commit (la cererea userului).
|
||||||
|
|
||||||
|
> Lectie pentru viitor: "conform" in raportul vizual trebuie sa includa explicit verificarea
|
||||||
|
> la 390px a PAGINILOR ACTIONABILE (butoane, formulare), nu doar a layout-ului general.
|
||||||
|
|
||||||
|
## GSTACK REVIEW REPORT
|
||||||
|
- Faze: CEO (premisa rezolvata de user) → Design (subagent, full) → Eng (subagent, full). DX: skip (fara suprafata developer).
|
||||||
|
- Voci: `[subagent-only]` — Codex indisponibil (limita utilizare, reset 18 iul). 2 subagenti Claude pe cod real.
|
||||||
|
- Decizii: 15 (12 auto, 3 gust rezolvate de user). Audit trail complet mai sus.
|
||||||
|
- Status: **APROBAT ca plan**; implementare amanata la cererea userului.
|
||||||
|
- Artefacte: test plan pe disc; restore point pe disc; task list agregat mai sus.
|
||||||
|
|
||||||
|
## NOT in scope (defer TODOS.md)
|
||||||
|
- Stare de eroare HTMX la incarcarea listei (D-4) — robustete pre-existenta, separata de cerere.
|
||||||
|
- Teste de regresie vizuala (Playwright snapshot vs mockup) — ideal 12 luni.
|
||||||
|
- Retokenizare px completa in template-uri — risc shift vizual fara baza AC.
|
||||||
|
- Dropzone import zona-mare (sec.5) — raport il marcheaza acceptabil.
|
||||||
|
|
||||||
|
## Ce exista deja (leverage)
|
||||||
|
- Toate cele 5 campuri pe `r.prez` (payload_view.py:86) → schimbare template-only.
|
||||||
|
- Modal detaliu are deja VIN integral + #id (test_detaliu_trimitere) → P4 confirmata, zero pierdere date.
|
||||||
|
- Sistem token `--fs-*` exista (base.html:52); lista si preview sunt suprafete CSS separate
|
||||||
|
(`.lista-trimiteri-slim` vs `.tabel-trimiteri`) → widen col-stare e SIGUR pt lista.
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
export OMP_NUM_THREADS=1
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
if [ $# -eq 0 ]; then
|
||||||
exec ./start.sh test both --send
|
exec ./start.sh test both --send
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ import pytest
|
|||||||
|
|
||||||
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
|
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
|
||||||
os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
|
os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
|
||||||
|
# Embeddings e ON implicit in app (config.py), dar in teste il lasam OFF ca sa nu
|
||||||
|
# lazy-load-eze modelul de ~230MB la fiecare test care atinge editorul de mapari
|
||||||
|
# (suita rapida, fara download in CI). Testele de embeddings il pornesc punctual.
|
||||||
|
os.environ.setdefault("AUTOPASS_EMBEDDINGS_ENABLED", "false")
|
||||||
|
# Seed-ul de operatii etichetate (SILVER, PRD 5.18) e ON in app, dar OFF in teste:
|
||||||
|
# multe teste presupun mapping_suggestions GOL la init_db. Testele US-004/005/006 il
|
||||||
|
# pornesc punctual (object.__setattr__ pe settings sau apel direct la seeder).
|
||||||
|
os.environ.setdefault("AUTOPASS_SEED_OPERATII_ENABLED", "false")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ def _signup(client, name, email, password="parola_test_001"):
|
|||||||
tok = _csrf(client, "/signup")
|
tok = _csrf(client, "/signup")
|
||||||
resp = client.post("/signup", data={"name": name, "cui": make_test_cui(email),
|
resp = client.post("/signup", data={"name": name, "cui": make_test_cui(email),
|
||||||
"email": email, "parola": password,
|
"email": email, "parola": password,
|
||||||
"csrf_token": tok}, follow_redirects=True)
|
"consent": "1", "csrf_token": tok},
|
||||||
|
follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
from app.db import get_connection
|
from app.db import get_connection
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ def _signup(client: TestClient, name: str, email: str, password: str = "parola_t
|
|||||||
"cui": make_test_cui(email),
|
"cui": make_test_cui(email),
|
||||||
"email": email,
|
"email": email,
|
||||||
"parola": password,
|
"parola": password,
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
assert resp.status_code == 200, f"signup esuat: {resp.text[:300]}"
|
assert resp.status_code == 200, f"signup esuat: {resp.text[:300]}"
|
||||||
@@ -261,3 +262,158 @@ def test_activare_cont_incomplet_refuzata(client):
|
|||||||
assert not _get_account_active(incomplete_id), (
|
assert not _get_account_active(incomplete_id), (
|
||||||
"Contul incomplet (fara email/CUI) a fost activat — gate pe account_is_complete nu functioneaza"
|
"Contul incomplet (fara email/CUI) a fost activat — gate pe account_is_complete nu functioneaza"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tier_trial(account_id: int) -> tuple[str, str | None]:
|
||||||
|
"""Citeste (tier, trial_until) din DB."""
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT tier, trial_until FROM accounts WHERE id=?", (account_id,)
|
||||||
|
).fetchone()
|
||||||
|
return (row["tier"], row["trial_until"]) if row else ("", None)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tier(account_id: int) -> str:
|
||||||
|
"""Citeste accounts.tier din DB."""
|
||||||
|
return _get_tier_trial(account_id)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_tier_din_admin_incheie_trial(client):
|
||||||
|
"""POST /admin/set-tier -> tier actualizat, trial_until=NULL (trial incheiat), 303.
|
||||||
|
|
||||||
|
Contul nou are trial Pro 30z; alocarea manuala trebuie sa-l incheie ca alegerea
|
||||||
|
sa aiba efect imediat (decizie user 2026-06-29)."""
|
||||||
|
target_id = _signup(client, "Firma Upgrade SRL", "upgrade@test.ro")
|
||||||
|
tier0, trial0 = _get_tier_trial(target_id)
|
||||||
|
assert tier0 == "free", "cont nou trebuie sa porneasca pe free"
|
||||||
|
assert trial0, "cont nou trebuie sa aiba trial_until setat (trial Pro 30z)"
|
||||||
|
|
||||||
|
admin_id = _signup(client, "Admin Tier SA", "admintier@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "admintier@test.ro")
|
||||||
|
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
resp = client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"tier": "pro",
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 303, f"asteptat 303 PRG, primit {resp.status_code}"
|
||||||
|
tier1, trial1 = _get_tier_trial(target_id)
|
||||||
|
assert tier1 == "pro", "tier-ul nu a fost mutat pe pro"
|
||||||
|
assert trial1 is None, "trial_until trebuie sters la alocarea manuala (efect imediat)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_tier_free_opreste_pro_imediat(client):
|
||||||
|
"""Setarea pe 'free' pe un cont in trial -> efectiv 'free' acum (trial incheiat).
|
||||||
|
|
||||||
|
Fara stergerea trial-ului, effective_tier ar fi ramas 'pro' inca ~30 zile."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.plans import effective_tier
|
||||||
|
|
||||||
|
target_id = _signup(client, "Firma Abuz Trial SRL", "abuztrial@test.ro")
|
||||||
|
admin_id = _signup(client, "Admin Stop SA", "adminstop@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "adminstop@test.ro")
|
||||||
|
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
resp = client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"tier": "free",
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 303
|
||||||
|
|
||||||
|
tier1, trial1 = _get_tier_trial(target_id)
|
||||||
|
assert tier1 == "free" and trial1 is None
|
||||||
|
eff = effective_tier({"tier": tier1, "trial_until": trial1}, datetime.now(timezone.utc))
|
||||||
|
assert eff == "free", "dupa setarea pe free, planul efectiv trebuie sa fie free imediat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_tier_invalid_respins(client):
|
||||||
|
"""Tier invalid -> nu schimba nimic (re-randare cu eroare sau ignorat)."""
|
||||||
|
target_id = _signup(client, "Firma Tier Invalid SRL", "tierinvalid@test.ro")
|
||||||
|
admin_id = _signup(client, "Admin TI SA", "adminti@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "adminti@test.ro")
|
||||||
|
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
resp = client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"tier": "platinum", # invalid
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code in (200, 422), f"tier invalid ar trebui respins, primit {resp.status_code}"
|
||||||
|
assert _get_tier(target_id) == "free", "tier invalid nu trebuie aplicat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_tier_fara_csrf_respins(client):
|
||||||
|
"""POST /admin/set-tier fara token CSRF valid -> respins, tier neschimbat."""
|
||||||
|
target_id = _signup(client, "Firma CSRF Tier SRL", "csrftier@test.ro")
|
||||||
|
admin_id = _signup(client, "Admin CSRF SA", "admincsrf@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "admincsrf@test.ro")
|
||||||
|
|
||||||
|
resp = client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"tier": "pro",
|
||||||
|
"csrf_token": "token-fals",
|
||||||
|
})
|
||||||
|
assert resp.status_code in (400, 403), f"CSRF invalid trebuie respins, primit {resp.status_code}"
|
||||||
|
assert _get_tier(target_id) == "free", "tier schimbat desi CSRF era invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_trial_din_admin(client):
|
||||||
|
"""POST /admin/set-trial -> trial_until setat, tier de baza neschimbat, efectiv pro, 303."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.plans import effective_tier
|
||||||
|
|
||||||
|
target_id = _signup(client, "Firma Trial SRL", "trialnou@test.ro")
|
||||||
|
# incheie intai orice trial (set-tier free) ca sa pornim de la baza curata
|
||||||
|
admin_id = _signup(client, "Admin Trial SA", "admintrialacord@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "admintrialacord@test.ro")
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id), "tier": "free", "csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert _get_tier_trial(target_id) == ("free", None)
|
||||||
|
|
||||||
|
# acorda trial Pro 15 zile
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
resp = client.post("/admin/set-trial", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"trial_days": "15",
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 303, f"asteptat 303 PRG, primit {resp.status_code}"
|
||||||
|
tier1, trial1 = _get_tier_trial(target_id)
|
||||||
|
assert tier1 == "free", "tier-ul de baza NU trebuie schimbat de acordarea de trial"
|
||||||
|
assert trial1, "trial_until trebuie setat"
|
||||||
|
eff = effective_tier({"tier": tier1, "trial_until": trial1}, datetime.now(timezone.utc))
|
||||||
|
assert eff == "pro", "trial activ trebuie sa ridice planul efectiv la pro"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_trial_zile_invalide_respins(client):
|
||||||
|
"""trial_days <= 0 -> 422, trial neschimbat."""
|
||||||
|
target_id = _signup(client, "Firma Trial Invalid SRL", "trialinvalid@test.ro")
|
||||||
|
admin_id = _signup(client, "Admin TInv SA", "admintinv@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "admintinv@test.ro")
|
||||||
|
# porneste de la trial sters
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id), "tier": "free", "csrf_token": csrf,
|
||||||
|
})
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
resp = client.post("/admin/set-trial", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"trial_days": "0",
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert _get_tier_trial(target_id) == ("free", None), "trial nu trebuie setat la zile invalide"
|
||||||
|
|||||||
150
tests/test_embeddings_corpus_etichetat.py
Normal file
150
tests/test_embeddings_corpus_etichetat.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""US-005 (PRD 5.18) — embeddings indexeaza corpusul etichetat (NU nomenclatorul).
|
||||||
|
|
||||||
|
k-NN peste exemple reale etichetate (denumire_normalizata -> cod, is_nul) e net mai
|
||||||
|
precis decat peste cele 18 categorii generice. Acopera si simetria corpus/query (F1):
|
||||||
|
corpusul e text NORMALIZAT, deci query-ul trebuie normalizat la fel inainte de embedding.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Backend mock determinist: vector = histograma de caractere (similaritate stabila).
|
||||||
|
class MockBackend:
|
||||||
|
def embed(self, texts):
|
||||||
|
out = []
|
||||||
|
for t in texts:
|
||||||
|
v = [0.0] * 27
|
||||||
|
for ch in t.upper():
|
||||||
|
if "A" <= ch <= "Z":
|
||||||
|
v[ord(ch) - 65] += 1.0
|
||||||
|
else:
|
||||||
|
v[26] += 1.0
|
||||||
|
out.append(v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us005.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||||
|
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "true") # US-005 are nevoie de embeddings ON
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield monkeypatch
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def conn(env):
|
||||||
|
from app.db import get_connection
|
||||||
|
c = get_connection()
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_mock_engine():
|
||||||
|
import app.embeddings as emb
|
||||||
|
from app.embeddings import EmbeddingEngine
|
||||||
|
emb._engine = EmbeddingEngine(backend=MockBackend())
|
||||||
|
return emb
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_silver(conn, rows):
|
||||||
|
"""rows = [(denumire_normalizata, cod, is_nul)]."""
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO mapping_suggestions "
|
||||||
|
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, 'llm_seed', 0.7)",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_corpus_din_mapping_suggestions(conn):
|
||||||
|
emb = _inject_mock_engine()
|
||||||
|
_seed_silver(conn, [
|
||||||
|
("SCHIMB ULEI MOTOR", "OE-3", 0),
|
||||||
|
("INLOCUIT PLACUTE FRANA", "OE-1", 0),
|
||||||
|
("13 X ITP", None, 1),
|
||||||
|
])
|
||||||
|
from app.mapping import ensure_embeddings_corpus
|
||||||
|
ensure_embeddings_corpus(conn)
|
||||||
|
assert emb.has_corpus()
|
||||||
|
# Corpusul indexat = denumirile din mapping_suggestions, NU din nomenclator_rar.
|
||||||
|
texte = {it["denumire"] for it in emb._engine._corpus_items}
|
||||||
|
assert texte == {"SCHIMB ULEI MOTOR", "INLOCUIT PLACUTE FRANA", "13 X ITP"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_nearest_intoarce_is_nul(conn):
|
||||||
|
emb = _inject_mock_engine()
|
||||||
|
_seed_silver(conn, [
|
||||||
|
("SCHIMB ULEI MOTOR", "OE-3", 0),
|
||||||
|
("13 X ITP", None, 1),
|
||||||
|
])
|
||||||
|
from app.mapping import ensure_embeddings_corpus
|
||||||
|
ensure_embeddings_corpus(conn)
|
||||||
|
res = emb.suggest_nearest("13 X ITP", top_k=1)
|
||||||
|
assert res and res[0]["is_nul"] is True # vecin NUL -> semnal de supresie
|
||||||
|
res2 = emb.suggest_nearest("SCHIMB ULEI MOTOR", top_k=1)
|
||||||
|
assert res2 and res2[0]["is_nul"] is False
|
||||||
|
assert res2[0]["cod"] == "OE-3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_semnatura_corpus_pe_seed(conn):
|
||||||
|
emb = _inject_mock_engine()
|
||||||
|
_seed_silver(conn, [("SCHIMB ULEI MOTOR", "OE-3", 0)])
|
||||||
|
from app.mapping import ensure_embeddings_corpus
|
||||||
|
ensure_embeddings_corpus(conn)
|
||||||
|
sig1 = emb.corpus_signature()
|
||||||
|
assert sig1 is not None
|
||||||
|
# Re-apel fara schimbare -> aceeasi semnatura (nu re-indexeaza).
|
||||||
|
ensure_embeddings_corpus(conn)
|
||||||
|
assert emb.corpus_signature() == sig1
|
||||||
|
# Adaugare rand -> semnatura se schimba.
|
||||||
|
_seed_silver(conn, [("INLOCUIT BATERIE", "OE-1", 0)])
|
||||||
|
ensure_embeddings_corpus(conn)
|
||||||
|
assert emb.corpus_signature() != sig1
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_normalizat_ca_si_corpusul(conn, monkeypatch):
|
||||||
|
"""F1 (HIGH): enrich_suggestions interogheaza suggest_nearest cu textul NORMALIZAT."""
|
||||||
|
import app.embeddings as emb
|
||||||
|
captura = {}
|
||||||
|
monkeypatch.setattr(emb, "has_corpus", lambda: True)
|
||||||
|
|
||||||
|
def fake_suggest(text, top_k=1):
|
||||||
|
captura["text"] = text
|
||||||
|
return [{"cod": "OE-3", "is_nul": False, "similaritate": 0.99}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(emb, "suggest_nearest", fake_suggest)
|
||||||
|
from app.mapping import enrich_suggestions
|
||||||
|
enrich_suggestions(conn, "Schimb Uleiul Motor")
|
||||||
|
# Corpusul e denumire_normalizata -> query-ul trebuie normalizat la fel.
|
||||||
|
from app.mapping import normalize_for_match
|
||||||
|
assert captura["text"] == normalize_for_match("Schimb Uleiul Motor")
|
||||||
|
assert captura["text"] == "SCHIMB ULEIUL MOTOR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_degradare_gratioasa_pastrata(conn):
|
||||||
|
"""Backend care arunca -> ensure + enrich NU arunca exceptie."""
|
||||||
|
import app.embeddings as emb
|
||||||
|
from app.embeddings import EmbeddingEngine
|
||||||
|
|
||||||
|
class BrokenBackend:
|
||||||
|
def embed(self, texts):
|
||||||
|
raise RuntimeError("model indisponibil")
|
||||||
|
|
||||||
|
emb._engine = EmbeddingEngine(backend=BrokenBackend())
|
||||||
|
_seed_silver(conn, [("SCHIMB ULEI MOTOR", "OE-3", 0)])
|
||||||
|
from app.mapping import ensure_embeddings_corpus, enrich_suggestions
|
||||||
|
ensure_embeddings_corpus(conn) # nu arunca
|
||||||
|
out = enrich_suggestions(conn, "SCHIMB ULEI") # nu arunca
|
||||||
|
assert "sugestie_principala" in out
|
||||||
133
tests/test_enrich_corpus_etichetat.py
Normal file
133
tests/test_enrich_corpus_etichetat.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""US-006 (PRD 5.18) — enrich_suggestions = pre-filtru NUL + k-NN pe corpus etichetat.
|
||||||
|
|
||||||
|
Ordinea de precedenta: pre-filtru NUL -> (daca NUL: fara cod) altfel GOLD partajat >
|
||||||
|
exact (SILVER) > k-NN embeddings. k-NN sub prag -> abtinere. Vecin k-NN NUL -> supresie.
|
||||||
|
Invariant #13: nimic din asta nu intra in resolve_prestatii/load_mapping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us006.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||||
|
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "true")
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield monkeypatch
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def conn(env):
|
||||||
|
from app.db import get_connection
|
||||||
|
c = get_connection()
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _silver(conn, denumire_norm, cod, is_nul=0):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO mapping_suggestions "
|
||||||
|
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, 'llm_seed', 0.7)",
|
||||||
|
(denumire_norm, cod, is_nul),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_embedding(monkeypatch, cod, sim, is_nul=False):
|
||||||
|
import app.embeddings as emb
|
||||||
|
monkeypatch.setattr(emb, "has_corpus", lambda: True)
|
||||||
|
monkeypatch.setattr(emb, "suggest_nearest",
|
||||||
|
lambda text, top_k=1: [{"cod": cod, "is_nul": is_nul, "similaritate": sim}])
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefiltru_nul_supreseaza_inainte_de_knn(conn, monkeypatch):
|
||||||
|
# Embedding-ul AR sugera un cod, dar pre-filtrul NUL trebuie sa scurtcircuiteze.
|
||||||
|
chemat = {"da": False}
|
||||||
|
import app.embeddings as emb
|
||||||
|
monkeypatch.setattr(emb, "has_corpus", lambda: True)
|
||||||
|
|
||||||
|
def spion(text, top_k=1):
|
||||||
|
chemat["da"] = True
|
||||||
|
return [{"cod": "OE-1", "is_nul": False, "similaritate": 0.99}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(emb, "suggest_nearest", spion)
|
||||||
|
from app.mapping import enrich_suggestions
|
||||||
|
out = enrich_suggestions(conn, "13 X ITP")
|
||||||
|
assert out["sugestie_principala"] is None # non-operatie -> fara cod
|
||||||
|
assert out["surse"]["nul"] is True
|
||||||
|
assert chemat["da"] is False # k-NN nici macar interogat
|
||||||
|
|
||||||
|
|
||||||
|
def test_precedenta_gold_exact_embedding(conn, monkeypatch):
|
||||||
|
from app.shared_store import record_human_validation
|
||||||
|
from app.mapping import enrich_suggestions, normalize_for_match
|
||||||
|
den = "OPERATIE DE TEST UNICA"
|
||||||
|
norm = normalize_for_match(den)
|
||||||
|
|
||||||
|
# Toate trei sursele dau coduri diferite.
|
||||||
|
record_human_validation(conn, den, "OE-1") # GOLD partajat
|
||||||
|
_silver(conn, norm, "OE-2") # SILVER exact
|
||||||
|
_mock_embedding(monkeypatch, "OE-3", 0.99) # embedding
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
out = enrich_suggestions(conn, den)
|
||||||
|
assert out["sugestie_principala"] == {"cod_prestatie": "OE-1", "sursa": "gold_partajat"}
|
||||||
|
|
||||||
|
# Fara GOLD -> castiga SILVER.
|
||||||
|
conn.execute("DELETE FROM shared_mappings")
|
||||||
|
conn.commit()
|
||||||
|
out = enrich_suggestions(conn, den)
|
||||||
|
assert out["sugestie_principala"]["sursa"] == "silver"
|
||||||
|
assert out["sugestie_principala"]["cod_prestatie"] == "OE-2"
|
||||||
|
|
||||||
|
# Fara GOLD si fara SILVER -> castiga embedding.
|
||||||
|
conn.execute("DELETE FROM mapping_suggestions")
|
||||||
|
conn.commit()
|
||||||
|
out = enrich_suggestions(conn, den)
|
||||||
|
assert out["sugestie_principala"] == {"cod_prestatie": "OE-3", "sursa": "embedding"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_prag_similaritate(conn, monkeypatch):
|
||||||
|
from app.mapping import enrich_suggestions, EMB_MIN_SIMILARITATE
|
||||||
|
_mock_embedding(monkeypatch, "OE-3", EMB_MIN_SIMILARITATE + 0.01)
|
||||||
|
out = enrich_suggestions(conn, "CEVA NEVAZUT")
|
||||||
|
assert out["surse"]["embedding"] == "OE-3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_abtinere_sub_prag(conn, monkeypatch):
|
||||||
|
from app.mapping import enrich_suggestions, EMB_MIN_SIMILARITATE
|
||||||
|
_mock_embedding(monkeypatch, "OE-3", EMB_MIN_SIMILARITATE - 0.01)
|
||||||
|
out = enrich_suggestions(conn, "CEVA NEVAZUT")
|
||||||
|
assert out["surse"]["embedding"] is None # sub prag -> abtinere
|
||||||
|
assert out["sugestie_principala"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_vecin_knn_nul_supreseaza(conn, monkeypatch):
|
||||||
|
from app.mapping import enrich_suggestions
|
||||||
|
_mock_embedding(monkeypatch, None, 0.99, is_nul=True) # vecin NUL peste prag
|
||||||
|
out = enrich_suggestions(conn, "CEVA CARE SEAMANA CU GUNOI")
|
||||||
|
assert out["surse"]["embedding"] is None # NUL -> nu produce cod
|
||||||
|
assert out["surse"]["nul"] is True
|
||||||
|
assert out["sugestie_principala"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_invariant_13_resolve_neatins(conn):
|
||||||
|
"""Regresie #13: SILVER populat NU produce auto-rezolvare in resolve_prestatii."""
|
||||||
|
from app.mapping import resolve_prestatii, normalize_for_match
|
||||||
|
_silver(conn, normalize_for_match("OPERATIE X"), "OE-1")
|
||||||
|
resolved, unmapped = resolve_prestatii(
|
||||||
|
[{"cod_op_service": "OPERATIE X", "denumire": "OPERATIE X"}], mapping={}, valid_codes={"OE-1"}
|
||||||
|
)
|
||||||
|
assert resolved[0]["cod_prestatie"] is None # ramane nemapat, NU ia codul din SILVER
|
||||||
|
assert unmapped and unmapped[0]["cod_op_service"] == "OPERATIE X"
|
||||||
103
tests/test_eticheteaza_tool.py
Normal file
103
tests/test_eticheteaza_tool.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""US-002 (PRD 5.18) — etichetator offline multi-backend cu prompt procedural.
|
||||||
|
|
||||||
|
Toate testele ruleaza FARA retea reala (transport injectabil / inspectie body).
|
||||||
|
Acopera: prompt 3 pasi, envelope json_schema strict + enum, backend selectabil
|
||||||
|
prin env, scrub PII inainte de orice request, garda de truncare.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Numele pachetului `tools/mapare-llm` contine cratima -> nu e importabil ca modul.
|
||||||
|
# Incarcam fisierul direct prin importlib pe cale.
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_PATH = os.path.join(os.path.dirname(__file__), "..", "tools", "mapare-llm", "eticheteaza.py")
|
||||||
|
_spec = importlib.util.spec_from_file_location("eticheteaza", _PATH)
|
||||||
|
eticheteaza = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules["eticheteaza"] = eticheteaza # necesar pt. @dataclass introspection
|
||||||
|
_spec.loader.exec_module(eticheteaza)
|
||||||
|
|
||||||
|
|
||||||
|
def test_construieste_prompt_3pasi():
|
||||||
|
msgs = eticheteaza.construieste_mesaje(["INLOCUIT PLACUTE FRANA"])
|
||||||
|
assert isinstance(msgs, list) and msgs[0]["role"] == "system"
|
||||||
|
sys = msgs[0]["content"].upper()
|
||||||
|
# Procedura in 3 pasi explicita.
|
||||||
|
assert "PAS 1" in sys and "PAS 2" in sys and "PAS 3" in sys
|
||||||
|
# Regula NUL + avarie grava doar la accident.
|
||||||
|
assert "NUL" in sys
|
||||||
|
assert "ACCIDENT" in sys
|
||||||
|
# Dezactivare thinking Qwen3 (token /no_think undeva in mesaje).
|
||||||
|
joined = " ".join(m["content"] for m in msgs)
|
||||||
|
assert "/no_think" in joined
|
||||||
|
# User message enumera operatiile.
|
||||||
|
assert "1." in msgs[1]["content"] and "INLOCUIT PLACUTE FRANA" in msgs[1]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_envelope_json_schema_strict_si_enum():
|
||||||
|
backend = eticheteaza.get_backend("lmstudio")
|
||||||
|
body = eticheteaza.construieste_body(["REVIZIE"], backend)
|
||||||
|
rf = body["response_format"]
|
||||||
|
# Envelope COMPLET, NU json_object.
|
||||||
|
assert rf["type"] == "json_schema"
|
||||||
|
js = rf["json_schema"]
|
||||||
|
assert js["strict"] is True
|
||||||
|
assert "name" in js
|
||||||
|
schema = js["schema"]
|
||||||
|
cod_schema = schema["properties"]["rez"]["items"]["properties"]["cod"]
|
||||||
|
# cod = enum peste cele 19 ALL_LABELS (18 coduri + NUL).
|
||||||
|
assert set(cod_schema["enum"]) == set(eticheteaza.ALL_LABELS)
|
||||||
|
assert len(eticheteaza.ALL_LABELS) == 19
|
||||||
|
assert "NUL" in eticheteaza.ALL_LABELS
|
||||||
|
# temperatura 0 (determinist) si strict items.
|
||||||
|
assert body["temperature"] == 0
|
||||||
|
assert schema["properties"]["rez"]["items"]["additionalProperties"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_parseaza_raspuns_si_garda_truncare():
|
||||||
|
batch = ["A", "B", "C"]
|
||||||
|
# Raspuns complet, ordine amestecata, un cod invalid.
|
||||||
|
content = {"rez": [{"i": 2, "cod": "OE-1"}, {"i": 1, "cod": "NUL"}, {"i": 3, "cod": "INEXISTENT"}]}
|
||||||
|
codes = eticheteaza.parseaza_raspuns(content, len(batch))
|
||||||
|
assert codes == ["NUL", "OE-1", "?"] # cod invalid -> '?', NU ascuns
|
||||||
|
# Raspuns trunchiat: lipseste pozitia 3 -> '?' pe lipsa, nu eroare.
|
||||||
|
content_trunc = {"rez": [{"i": 1, "cod": "OE-1"}, {"i": 2, "cod": "OE-2"}]}
|
||||||
|
codes2 = eticheteaza.parseaza_raspuns(content_trunc, len(batch))
|
||||||
|
assert codes2 == ["OE-1", "OE-2", "?"]
|
||||||
|
assert len(codes2) == len(batch)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_selectabil_env(monkeypatch):
|
||||||
|
# Default = lmstudio (backend aprobat v1, D4).
|
||||||
|
monkeypatch.delenv("ETICHETARE_BACKEND", raising=False)
|
||||||
|
assert eticheteaza.get_backend().name == "lmstudio"
|
||||||
|
# Selectie prin env.
|
||||||
|
monkeypatch.setenv("ETICHETARE_BACKEND", "groq")
|
||||||
|
assert eticheteaza.get_backend().name == "groq"
|
||||||
|
# Endpoint + model configurabile prin env.
|
||||||
|
monkeypatch.setenv("ETICHETARE_BACKEND", "lmstudio")
|
||||||
|
monkeypatch.setenv("ETICHETARE_ENDPOINT", "http://exemplu:1234/v1/chat/completions")
|
||||||
|
monkeypatch.setenv("ETICHETARE_MODEL", "qwen/qwen3-custom")
|
||||||
|
b = eticheteaza.get_backend()
|
||||||
|
assert b.url == "http://exemplu:1234/v1/chat/completions"
|
||||||
|
assert b.model == "qwen/qwen3-custom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_pii_inainte_de_request(monkeypatch):
|
||||||
|
"""Nicio placuta/VIN nu ajunge la transport — scrub inainte de orice apel."""
|
||||||
|
capturat = {}
|
||||||
|
|
||||||
|
def fake_transport(url, headers, payload, timeout):
|
||||||
|
capturat["payload"] = payload
|
||||||
|
return {"choices": [{"message": {"content": '{"rez":[{"i":1,"cod":"OE-1"}]}'}}]}
|
||||||
|
|
||||||
|
backend = eticheteaza.get_backend("lmstudio")
|
||||||
|
codes, meta = eticheteaza.call(["VOPSIT USA B 123 ABC"], backend, transport=fake_transport)
|
||||||
|
assert codes == ["OE-1"]
|
||||||
|
body = capturat["payload"]
|
||||||
|
user_content = body["messages"][1]["content"]
|
||||||
|
assert "B 123 ABC" not in user_content
|
||||||
|
assert "[NR]" in user_content
|
||||||
|
assert meta["err"] is None
|
||||||
175
tests/test_genereaza_seed.py
Normal file
175
tests/test_genereaza_seed.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""US-003 (PRD 5.18) — generare seed etichetat in faze pe frecventa.
|
||||||
|
|
||||||
|
Pipeline dedup OBLIGATORIU inainte de orice apel LLM (D5):
|
||||||
|
brut -> normalize_for_match -> arunca chei vide -> dedup pe cheie (freq=suma NR)
|
||||||
|
-> reuse etichete existente (labels-groq + seed comis, conflict freq-max) -> de_etichetat.
|
||||||
|
|
||||||
|
Idempotenta cross-run (F2/F7): a doua rulare consuma seedul comis ca cache -> 0 apeluri LLM.
|
||||||
|
Toate testele FARA retea: `clasifica` e injectat (mock care inregistreaza ce primeste).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name: str):
|
||||||
|
path = os.path.join(os.path.dirname(__file__), "..", "tools", "mapare-llm", f"{name}.py")
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = mod
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
gs = _load("genereaza_seed")
|
||||||
|
|
||||||
|
|
||||||
|
def _scrie_csv(path, randuri):
|
||||||
|
"""randuri = [(denumire, nr)]. Format CSV ca docs/operatii-service (`;`, header)."""
|
||||||
|
linii = ['" ";"DENOP";"NR"']
|
||||||
|
for i, (den, nr) in enumerate(randuri, 1):
|
||||||
|
linii.append(f'"{i}";"{den}";"{nr}"')
|
||||||
|
path.write_text("\n".join(linii) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_recorder():
|
||||||
|
"""Returneaza (clasifica, vazute) — clasifica raspunde OE-1 pe tot, inregistreaza inputul."""
|
||||||
|
vazute = []
|
||||||
|
|
||||||
|
def clasifica(batch):
|
||||||
|
vazute.append(list(batch))
|
||||||
|
return ["OE-1"] * len(batch)
|
||||||
|
|
||||||
|
return clasifica, vazute
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_dedup_normalizat(tmp_path):
|
||||||
|
f1 = tmp_path / "a.csv"
|
||||||
|
f2 = tmp_path / "b.csv"
|
||||||
|
_scrie_csv(f1, [("REVIZIE", 10), ("D/R BARA FATA", 3)])
|
||||||
|
_scrie_csv(f2, [(" revizie ", 5)]) # acelasi logic, case+spatii
|
||||||
|
corpus = gs.agrega_corpus([str(f1), str(f2)])
|
||||||
|
assert "REVIZIE" in corpus
|
||||||
|
assert corpus["REVIZIE"]["freq"] == 15 # 10 + 5, dedup pe cheie
|
||||||
|
assert len([k for k in corpus]) == 2 # REVIZIE + D/R BARA FATA
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_cheie_normalizata_vida(tmp_path):
|
||||||
|
f = tmp_path / "a.csv"
|
||||||
|
_scrie_csv(f, [(" ", 99), ("REVIZIE", 5)]) # cheie vida (doar spatii)
|
||||||
|
corpus = gs.agrega_corpus([str(f)])
|
||||||
|
assert "" not in corpus
|
||||||
|
assert list(corpus) == ["REVIZIE"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordine_pe_frecventa(tmp_path):
|
||||||
|
f = tmp_path / "a.csv"
|
||||||
|
_scrie_csv(f, [("OP MICA", 5), ("OP MARE", 50), ("OP MEDIE", 20)])
|
||||||
|
seed = tmp_path / "seed.json"
|
||||||
|
clasifica, vazute = _mock_recorder()
|
||||||
|
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed),
|
||||||
|
etichetare_all=True, clasifica=clasifica, batch=32)
|
||||||
|
# Ordinea in care LLM-ul a vazut operatiile = desc pe frecventa.
|
||||||
|
primul_batch = vazute[0]
|
||||||
|
assert primul_batch[:3] == ["OP MARE", "OP MEDIE", "OP MICA"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reuse_in_spatiu_normalizat(tmp_path):
|
||||||
|
f = tmp_path / "a.csv"
|
||||||
|
_scrie_csv(f, [("Revizie", 10), ("SCHIMB ULEI", 5)])
|
||||||
|
labels = tmp_path / "labels.json"
|
||||||
|
labels.write_text(json.dumps({"REVIZIE": "OE-3"}), encoding="utf-8") # cheiat brut, dar normalizeaza la fel
|
||||||
|
seed = tmp_path / "seed.json"
|
||||||
|
clasifica, vazute = _mock_recorder()
|
||||||
|
gs.genereaza([str(f)], labels_path=str(labels), seed_path=str(seed),
|
||||||
|
etichetare_all=True, clasifica=clasifica)
|
||||||
|
trimise = {d for b in vazute for d in b}
|
||||||
|
assert "Revizie" not in trimise and "REVIZIE" not in trimise # deja etichetat -> nu se trimite
|
||||||
|
seed_data = json.loads(seed.read_text(encoding="utf-8"))
|
||||||
|
rev = [e for e in seed_data if e["denumire_normalizata"] == "REVIZIE"][0]
|
||||||
|
assert rev["cod"] == "OE-3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reuse_conflict_determinist(tmp_path):
|
||||||
|
f = tmp_path / "a.csv"
|
||||||
|
# Doua variante raw ale aceleiasi chei, etichetate diferit; freq decide.
|
||||||
|
_scrie_csv(f, [("CURATAT CATALIZATOR", 100), ("curatat catalizator", 5)])
|
||||||
|
labels = tmp_path / "labels.json"
|
||||||
|
labels.write_text(json.dumps({
|
||||||
|
"CURATAT CATALIZATOR": "OE-1", # freq 100
|
||||||
|
"curatat catalizator": "OE-2", # freq 5
|
||||||
|
}), encoding="utf-8")
|
||||||
|
seed = tmp_path / "seed.json"
|
||||||
|
clasifica, _ = _mock_recorder()
|
||||||
|
gs.genereaza([str(f)], labels_path=str(labels), seed_path=str(seed), etichetare_all=True, clasifica=clasifica)
|
||||||
|
seed_data = json.loads(seed.read_text(encoding="utf-8"))
|
||||||
|
cat = [e for e in seed_data if e["denumire_normalizata"] == "CURATAT CATALIZATOR"][0]
|
||||||
|
assert cat["cod"] == "OE-1" # freq-max castiga (100 > 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_duplicate_trimis_la_llm(tmp_path):
|
||||||
|
f1 = tmp_path / "a.csv"
|
||||||
|
f2 = tmp_path / "b.csv"
|
||||||
|
_scrie_csv(f1, [("REVIZIE", 10), (" revizie ", 4), ("OP NOUA", 7), (" ", 3)])
|
||||||
|
_scrie_csv(f2, [("REVIZIE", 2), ("OP NOUA", 1)]) # cross-file duplicate
|
||||||
|
labels = tmp_path / "labels.json"
|
||||||
|
labels.write_text(json.dumps({"REVIZIE": "OE-3"}), encoding="utf-8") # REVIZIE deja etichetat
|
||||||
|
seed = tmp_path / "seed.json"
|
||||||
|
clasifica, vazute = _mock_recorder()
|
||||||
|
from app.mapping import normalize_for_match
|
||||||
|
gs.genereaza([str(f1), str(f2)], labels_path=str(labels), seed_path=str(seed),
|
||||||
|
etichetare_all=True, clasifica=clasifica)
|
||||||
|
trimise = [d for b in vazute for d in b]
|
||||||
|
chei = [normalize_for_match(d) for d in trimise]
|
||||||
|
assert len(chei) == len(set(chei)) # nicio cheie normalizata trimisa de doua ori
|
||||||
|
assert "" not in chei # nicio cheie vida
|
||||||
|
assert "REVIZIE" not in chei # nicio cheie deja etichetata
|
||||||
|
assert "OP NOUA" in chei # doar ce lipseste
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerun_zero_apeluri_llm(tmp_path):
|
||||||
|
"""Criteriul real de idempotenta (F2/F7): a doua rulare = 0 apeluri LLM, seed identic."""
|
||||||
|
f = tmp_path / "a.csv"
|
||||||
|
_scrie_csv(f, [("OP UNU", 10), ("OP DOI", 5)])
|
||||||
|
seed = tmp_path / "seed.json"
|
||||||
|
|
||||||
|
clasifica1, vazute1 = _mock_recorder()
|
||||||
|
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed), etichetare_all=True, clasifica=clasifica1)
|
||||||
|
assert sum(len(b) for b in vazute1) == 2 # prima rulare eticheteaza ambele
|
||||||
|
bytes1 = seed.read_bytes()
|
||||||
|
|
||||||
|
clasifica2, vazute2 = _mock_recorder()
|
||||||
|
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed), etichetare_all=True, clasifica=clasifica2)
|
||||||
|
assert vazute2 == [] # a doua rulare: 0 apeluri LLM (seed = cache)
|
||||||
|
bytes2 = seed.read_bytes()
|
||||||
|
assert bytes1 == bytes2 # seed identic byte-cu-byte
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_seed_valid(tmp_path):
|
||||||
|
f = tmp_path / "a.csv"
|
||||||
|
_scrie_csv(f, [("OP REALA", 10), ("13 X ITP", 5)])
|
||||||
|
seed = tmp_path / "seed.json"
|
||||||
|
|
||||||
|
def clasifica(batch):
|
||||||
|
# marcheaza ITP ca NUL, restul OE-1
|
||||||
|
return ["NUL" if "ITP" in d.upper() else "OE-1" for d in batch]
|
||||||
|
|
||||||
|
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed), etichetare_all=True, clasifica=clasifica)
|
||||||
|
data = json.loads(seed.read_text(encoding="utf-8"))
|
||||||
|
chei = [e["denumire_normalizata"] for e in data]
|
||||||
|
assert len(chei) == len(set(chei)) # unice
|
||||||
|
assert all(e["denumire_normalizata"] for e in data) # non-vide
|
||||||
|
for e in data:
|
||||||
|
assert set(e) >= {"denumire", "denumire_normalizata", "cod", "is_nul", "source", "confidence"}
|
||||||
|
if e["is_nul"]:
|
||||||
|
assert e["cod"] is None # NUL -> cod NULL (oglindeste CHECK-ul DB)
|
||||||
|
else:
|
||||||
|
assert e["cod"]
|
||||||
|
nul = [e for e in data if e["is_nul"]][0]
|
||||||
|
assert "ITP" in nul["denumire_normalizata"]
|
||||||
28
tests/test_idempotency.py
Normal file
28
tests/test_idempotency.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""US-003 (PRD 5.20): build_key incorporeaza rar_env."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.idempotency import build_key, canonicalize_row
|
||||||
|
|
||||||
|
|
||||||
|
def _canon():
|
||||||
|
raw = {
|
||||||
|
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B 123 ABC",
|
||||||
|
"data_prestatie": "2026-01-10", "odometru_final": "123456.0",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
|
}
|
||||||
|
canon = canonicalize_row(raw)
|
||||||
|
canon["prestatii"] = raw["prestatii"]
|
||||||
|
return canon
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_difera_intre_test_si_prod():
|
||||||
|
canon = _canon()
|
||||||
|
assert build_key(1, canon, "test") != build_key(1, canon, "prod")
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_stabil_pe_env():
|
||||||
|
canon = _canon()
|
||||||
|
assert build_key(1, canon, "prod") == build_key(1, canon, "prod")
|
||||||
|
# None si 1 colapseaza la aceeasi cheie (account_or_default), pe acelasi env
|
||||||
|
assert build_key(None, canon, "test") == build_key(1, canon, "test")
|
||||||
@@ -272,14 +272,18 @@ def test_embeddings_functional_cand_flag_activ(conn, monkeypatch):
|
|||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
monkeypatch.setattr(emb_mod, "_engine", EmbeddingEngine(backend=_FakeEmbedBackend()))
|
monkeypatch.setattr(emb_mod, "_engine", EmbeddingEngine(backend=_FakeEmbedBackend()))
|
||||||
|
|
||||||
# Nomenclatorul (din fixtura conn) are OE-1..OE-4; adaug coduri cu denumiri keyword.
|
# Corpusul sursa = mapping_suggestions (SILVER) -- PRD 5.18 US-005.
|
||||||
|
# (Inainte era nomenclator_rar; migrat la mapping_suggestions ca k-NN sa
|
||||||
|
# opereze pe exemple reale etichetate, nu pe categorii generice RAR.)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
"INSERT OR REPLACE INTO mapping_suggestions "
|
||||||
("UL-1", "Schimb ulei"),
|
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
("Schimb ulei", "UL-1", 0, "llm", 0.95),
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
"INSERT OR REPLACE INTO mapping_suggestions "
|
||||||
("FR-1", "Placute frana"),
|
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
("Placute frana", "FR-1", 0, "llm", 0.95),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|||||||
113
tests/test_operatii_seed.py
Normal file
113
tests/test_operatii_seed.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""US-004 (PRD 5.18) — seeder corpus etichetat in mapping_suggestions (SILVER).
|
||||||
|
|
||||||
|
INSERT OR IGNORE din artefactul comis -> SILVER nu mai e gol in productie.
|
||||||
|
NB (F10): confirmarile UMANE stau in shared_mappings, NU aici; deci INSERT OR IGNORE
|
||||||
|
pastreaza codul LLM existent la re-seed (v1 = ignore, nu upsert).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us004.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield tmp
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def conn(env):
|
||||||
|
from app.db import get_connection
|
||||||
|
c = get_connection()
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _scrie_seed(tmp, items) -> str:
|
||||||
|
p = os.path.join(tmp, "operatii-etichetate.json")
|
||||||
|
with open(p, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(items, fh, ensure_ascii=False)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
SEED_OE = {"denumire": "SCHIMB ULEI MOTOR", "denumire_normalizata": "SCHIMB ULEI MOTOR",
|
||||||
|
"cod": "OE-3", "is_nul": False, "source": "llm_seed", "confidence": 0.7}
|
||||||
|
SEED_NUL = {"denumire": "13 X ITP", "denumire_normalizata": "13 X ITP",
|
||||||
|
"cod": None, "is_nul": True, "source": "llm_seed", "confidence": 0.7}
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_populeaza_mapping_suggestions(env, conn):
|
||||||
|
from app.operatii_seed import seed_operatii_etichetate
|
||||||
|
path = _scrie_seed(env, [SEED_OE])
|
||||||
|
n = seed_operatii_etichetate(conn, path)
|
||||||
|
conn.commit()
|
||||||
|
assert n == 1
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT cod_prestatie, source, confidence FROM mapping_suggestions "
|
||||||
|
"WHERE denumire_normalizata = 'SCHIMB ULEI MOTOR'"
|
||||||
|
).fetchone()
|
||||||
|
assert row["cod_prestatie"] == "OE-3"
|
||||||
|
assert row["source"] == "llm_seed"
|
||||||
|
assert abs(row["confidence"] - 0.7) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_nul_din_seed(env, conn):
|
||||||
|
from app.operatii_seed import seed_operatii_etichetate
|
||||||
|
path = _scrie_seed(env, [SEED_NUL])
|
||||||
|
seed_operatii_etichetate(conn, path)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT cod_prestatie, is_nul FROM mapping_suggestions WHERE denumire_normalizata = '13 X ITP'"
|
||||||
|
).fetchone()
|
||||||
|
assert row["is_nul"] == 1
|
||||||
|
assert row["cod_prestatie"] is None # respecta CHECK-ul (NUL -> cod NULL)
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_or_ignore_nu_clobber(env, conn):
|
||||||
|
from app.operatii_seed import seed_operatii_etichetate
|
||||||
|
# Un rand pre-existent (ex. embedding) pe aceeasi cheie, cu alt cod.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO mapping_suggestions (denumire_normalizata, cod_prestatie, is_nul, source, confidence) "
|
||||||
|
"VALUES ('SCHIMB ULEI MOTOR', 'OE-1', 0, 'embedding', 0.5)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
path = _scrie_seed(env, [SEED_OE])
|
||||||
|
n = seed_operatii_etichetate(conn, path)
|
||||||
|
conn.commit()
|
||||||
|
assert n == 0 # INSERT OR IGNORE -> nu suprascrie
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT cod_prestatie, source FROM mapping_suggestions WHERE denumire_normalizata = 'SCHIMB ULEI MOTOR'"
|
||||||
|
).fetchone()
|
||||||
|
assert row["cod_prestatie"] == "OE-1" # randul existent ramane neatins
|
||||||
|
assert row["source"] == "embedding"
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotent_la_reinit(env, conn):
|
||||||
|
from app.operatii_seed import seed_operatii_etichetate
|
||||||
|
path = _scrie_seed(env, [SEED_OE, SEED_NUL])
|
||||||
|
n1 = seed_operatii_etichetate(conn, path)
|
||||||
|
conn.commit()
|
||||||
|
n2 = seed_operatii_etichetate(conn, path)
|
||||||
|
conn.commit()
|
||||||
|
assert n1 == 2
|
||||||
|
assert n2 == 0 # a doua rulare nu dubleaza
|
||||||
|
total = conn.execute("SELECT COUNT(*) AS n FROM mapping_suggestions").fetchone()["n"]
|
||||||
|
assert total == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_inexistent_e_noop(env, conn):
|
||||||
|
from app.operatii_seed import seed_operatii_etichetate
|
||||||
|
n = seed_operatii_etichetate(conn, os.path.join(env, "nu-exista.json"))
|
||||||
|
assert n == 0
|
||||||
72
tests/test_prefiltru_nul.py
Normal file
72
tests/test_prefiltru_nul.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""US-001 (PRD 5.18) — pre-filtru determinist non-operatii (NUL).
|
||||||
|
|
||||||
|
Masuratoarea k-NN (memorie test-precizie-knn-embeddings) arata recall NUL doar 64%:
|
||||||
|
gunoiul evident (ITP, plata, discount, nr. inmatriculare, tractare) scapa ca OE-1.
|
||||||
|
Un pre-filtru determinist il marcheaza NUL INAINTE de k-NN.
|
||||||
|
|
||||||
|
Garantie non-negociabila (AC): ZERO fals-pozitiv pe operatii reale. Regulile
|
||||||
|
text/regex au fost calibrate pe `docs/operatii-service/*.csv` (vezi sesiunea de
|
||||||
|
implementare): triggerele ambigue (TRACTARE, NR INMATRICULARE/placuta) sunt
|
||||||
|
ECRANATE de un context de piesa/operatie (D/R, CARLIG, CAPAC, INLOCUIT...).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.mapping import prefiltru_nul
|
||||||
|
|
||||||
|
|
||||||
|
def test_itp_e_nul():
|
||||||
|
assert prefiltru_nul("13 X ITP") is True
|
||||||
|
assert prefiltru_nul("11XITP") is True # glue fara spatii
|
||||||
|
assert prefiltru_nul("ITP") is True
|
||||||
|
assert prefiltru_nul("2 X ITP") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_plata_discount_nul():
|
||||||
|
assert prefiltru_nul("DISCOUNT FIDELITATE 10%") is True
|
||||||
|
assert prefiltru_nul("REDUCERE COMERCIALA") is True
|
||||||
|
assert prefiltru_nul("ACHITAT DE CONF.URBAN") is True
|
||||||
|
assert prefiltru_nul("PLATA AVANS") is True
|
||||||
|
assert prefiltru_nul("TAXA DE MEDIU") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_nr_inmatriculare_nul():
|
||||||
|
assert prefiltru_nul("NR INMATRICULARE") is True
|
||||||
|
assert prefiltru_nul("NUMAR INMATRICULARE") is True
|
||||||
|
assert prefiltru_nul("B 123 ABC") is True # pattern placuta standalone
|
||||||
|
assert prefiltru_nul("CT 44 MKY") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_tractare_serviciu_nul():
|
||||||
|
# Serviciul de tractare (rmorca) = non-operatie de service.
|
||||||
|
assert prefiltru_nul("TRACTARE CTA-SLOBOZIA") is True
|
||||||
|
assert prefiltru_nul("TRACTARE 100 KM") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_operatie_reala_nu_e_nul():
|
||||||
|
# Punctul critic: trigger ambiguu intr-un context de piesa reala -> NU e NUL.
|
||||||
|
assert prefiltru_nul("INLOCUIT PLACUTE FRANA") is False
|
||||||
|
assert prefiltru_nul("D/R CARLIG TRACTARE") is False # carlig = piesa, nu serviciu
|
||||||
|
assert prefiltru_nul("D/R CAPAC TRACTARE BARA SPATE") is False
|
||||||
|
assert prefiltru_nul("D/R NR INMATRICULARE") is False # suport placuta = piesa
|
||||||
|
assert prefiltru_nul("D/R ELECTROMOTOR CT 44 MKY") is False # placuta lipita la o operatie reala
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_fals_pozitiv_pe_set_operatii_reale():
|
||||||
|
"""AC: zero fals-pozitiv pe un set de 20 operatii reale (din docs/operatii-service)."""
|
||||||
|
reale = [
|
||||||
|
"REVIZIE", "SCHIMB ULEI MOTOR", "INLOCUIT PLACUTE FRANA FATA",
|
||||||
|
"D/R BARA FATA", "VOPSIT USA DR FATA", "INLOCUIT FILTRU AER",
|
||||||
|
"AERISIT INSTALATIE FRANA", "INLOCUIT AMORTIZOR SPATE", "ABSORBANT SOC BARA SPATE",
|
||||||
|
"INLOCUIT CUREA DISTRIBUTIE", "REGLAT FARURI", "INLOCUIT BUJII",
|
||||||
|
"REPARAT ARIPA FATA DR", "INLOCUIT DISCURI FRANA", "GRESAT PLANETARA",
|
||||||
|
"INLOCUIT RULMENT ROATA", "MONTAT ANVELOPE", "INLOCUIT BATERIE",
|
||||||
|
"DIAGNOZA COMPUTERIZATA", "INLOCUIT CONTACT PORNIRE",
|
||||||
|
]
|
||||||
|
for op in reale:
|
||||||
|
assert prefiltru_nul(op) is False, f"fals-pozitiv pe operatie reala: {op!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_gol_nu_e_nul():
|
||||||
|
assert prefiltru_nul("") is False
|
||||||
|
assert prefiltru_nul(None) is False # type: ignore[arg-type]
|
||||||
50
tests/test_rar_env_disponibil.py
Normal file
50
tests/test_rar_env_disponibil.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""US-002 (PRD 5.20): medii_disponibile + rar_env_efectiv (REQ-DISP / REQ-DEFAULT)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.rar_env import medii_disponibile, rar_env_efectiv
|
||||||
|
|
||||||
|
|
||||||
|
def _cont(**kw):
|
||||||
|
base = {
|
||||||
|
"rar_test_enabled": 0, "rar_prod_enabled": 0,
|
||||||
|
"rar_creds_test_enc": None, "rar_creds_prod_enc": None,
|
||||||
|
"rar_env_default": "prod",
|
||||||
|
}
|
||||||
|
base.update(kw)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_doar_prod_cu_creds():
|
||||||
|
c = _cont(rar_prod_enabled=1, rar_creds_prod_enc="TOK")
|
||||||
|
assert medii_disponibile(c) == ["prod"]
|
||||||
|
assert rar_env_efectiv(c) == "prod"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambele():
|
||||||
|
c = _cont(
|
||||||
|
rar_test_enabled=1, rar_creds_test_enc="T",
|
||||||
|
rar_prod_enabled=1, rar_creds_prod_enc="P",
|
||||||
|
rar_env_default="test",
|
||||||
|
)
|
||||||
|
assert medii_disponibile(c) == ["test", "prod"]
|
||||||
|
assert rar_env_efectiv(c) == "test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_cand_lipsesc_creds():
|
||||||
|
# activat dar fara creds -> nu e disponibil
|
||||||
|
c = _cont(rar_test_enabled=1, rar_prod_enabled=1)
|
||||||
|
assert medii_disponibile(c) == []
|
||||||
|
assert rar_env_efectiv(c) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_cade_pe_singurul_disponibil():
|
||||||
|
# default='prod' dar prod nu e disponibil; doar test e -> efectiv = test
|
||||||
|
c = _cont(rar_test_enabled=1, rar_creds_test_enc="T", rar_env_default="prod")
|
||||||
|
assert medii_disponibile(c) == ["test"]
|
||||||
|
assert rar_env_efectiv(c) == "test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_fara_creds_nu_e_disponibil():
|
||||||
|
c = _cont(rar_prod_enabled=1, rar_creds_prod_enc=" ") # whitespace = gol
|
||||||
|
assert medii_disponibile(c) == []
|
||||||
145
tests/test_schema_migrate.py
Normal file
145
tests/test_schema_migrate.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""US-001 (PRD 5.20): schema medii per cont + env pe submission + migrare/backfill."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fresh_conn(monkeypatch):
|
||||||
|
"""DB nou cu schema curenta (init_db)."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import get_connection, init_db
|
||||||
|
init_db()
|
||||||
|
c = get_connection()
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _old_db(path: str) -> sqlite3.Connection:
|
||||||
|
"""Construieste un DB in forma PRE-5.20 (fara coloanele de mediu)."""
|
||||||
|
conn = sqlite3.connect(path, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, "
|
||||||
|
"cui TEXT, rar_creds_enc TEXT)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE submissions (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||||
|
"idempotency_key TEXT NOT NULL UNIQUE, account_id INTEGER, status TEXT, "
|
||||||
|
"payload_json TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_old(path: str, env: str, monkeypatch) -> sqlite3.Connection:
|
||||||
|
monkeypatch.setenv("AUTOPASS_RAR_ENV", env)
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
conn = sqlite3.connect(path, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
from app.db import _migrate
|
||||||
|
_migrate(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def test_coloane_medii_pe_cont(fresh_conn):
|
||||||
|
acc = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||||
|
assert {
|
||||||
|
"rar_test_enabled", "rar_prod_enabled",
|
||||||
|
"rar_creds_test_enc", "rar_creds_prod_enc", "rar_env_default",
|
||||||
|
} <= acc
|
||||||
|
sub = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(submissions)").fetchall()}
|
||||||
|
assert "rar_env" in sub
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_client_prod_on_test_off(fresh_conn):
|
||||||
|
from app.accounts import create_account
|
||||||
|
aid = create_account(fresh_conn, "Service X")
|
||||||
|
row = fresh_conn.execute(
|
||||||
|
"SELECT rar_test_enabled, rar_prod_enabled, rar_env_default FROM accounts WHERE id=?",
|
||||||
|
(aid,),
|
||||||
|
).fetchone()
|
||||||
|
assert row["rar_prod_enabled"] == 1
|
||||||
|
assert row["rar_test_enabled"] == 0
|
||||||
|
assert row["rar_env_default"] == "prod"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("env,slot,other", [
|
||||||
|
("test", "rar_creds_test_enc", "rar_creds_prod_enc"),
|
||||||
|
("prod", "rar_creds_prod_enc", "rar_creds_test_enc"),
|
||||||
|
])
|
||||||
|
def test_migrare_creds_in_slotul_env_global(tmp_path, monkeypatch, env, slot, other):
|
||||||
|
path = str(tmp_path / "old.db")
|
||||||
|
old = _old_db(path)
|
||||||
|
old.execute(
|
||||||
|
"INSERT INTO accounts (id, name, rar_creds_enc) VALUES (5, 'Legacy', 'TOKEN_CREDS')"
|
||||||
|
)
|
||||||
|
old.close()
|
||||||
|
conn = _migrate_old(path, env, monkeypatch)
|
||||||
|
row = conn.execute("SELECT * FROM accounts WHERE id=5").fetchone()
|
||||||
|
assert row[slot] == "TOKEN_CREDS"
|
||||||
|
assert row[other] is None
|
||||||
|
assert row[f"rar_{env}_enabled"] == 1
|
||||||
|
assert row[f"rar_{'prod' if env == 'test' else 'test'}_enabled"] == 0
|
||||||
|
assert row["rar_env_default"] == env
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrare_cont_fara_creds_ramane_pe_default(tmp_path, monkeypatch):
|
||||||
|
path = str(tmp_path / "old.db")
|
||||||
|
old = _old_db(path)
|
||||||
|
old.execute("INSERT INTO accounts (id, name, rar_creds_enc) VALUES (6, 'NoCreds', NULL)")
|
||||||
|
old.close()
|
||||||
|
conn = _migrate_old(path, "test", monkeypatch)
|
||||||
|
row = conn.execute("SELECT * FROM accounts WHERE id=6").fetchone()
|
||||||
|
assert row["rar_prod_enabled"] == 1
|
||||||
|
assert row["rar_test_enabled"] == 0
|
||||||
|
assert row["rar_env_default"] == "prod"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_submissions_rar_env(tmp_path, monkeypatch):
|
||||||
|
"""Un rand PRE-migrare ajunge cu env-ul global (NU 'test') + cheie recalculata env-aware."""
|
||||||
|
path = str(tmp_path / "old.db")
|
||||||
|
old = _old_db(path)
|
||||||
|
payload = {
|
||||||
|
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B123ABC",
|
||||||
|
"data_prestatie": "2026-01-10", "odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
|
}
|
||||||
|
old.execute(
|
||||||
|
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||||
|
"VALUES ('LEGACY_KEY', 7, 'sent', ?)",
|
||||||
|
(json.dumps(payload),),
|
||||||
|
)
|
||||||
|
old.close()
|
||||||
|
|
||||||
|
conn = _migrate_old(path, "prod", monkeypatch)
|
||||||
|
row = conn.execute("SELECT rar_env, idempotency_key FROM submissions").fetchone()
|
||||||
|
assert row["rar_env"] == "prod" # ancora globala, NU DEFAULT 'test'
|
||||||
|
|
||||||
|
from app.idempotency import build_key, canonicalize_row
|
||||||
|
canon = canonicalize_row(payload)
|
||||||
|
canon["prestatii"] = payload["prestatii"]
|
||||||
|
assert row["idempotency_key"] == build_key(7, canon, "prod")
|
||||||
|
# si difera de varianta env-aware pe test (reconciliere pe endpoint corect)
|
||||||
|
assert row["idempotency_key"] != build_key(7, canon, "test")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrare_idempotenta(fresh_conn):
|
||||||
|
"""A doua rulare _migrate pe DB deja migrat nu strica nimic."""
|
||||||
|
from app.db import _migrate
|
||||||
|
_migrate(fresh_conn) # nu arunca, nu dubleaza coloane
|
||||||
|
acc = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||||
|
assert "rar_env_default" in acc
|
||||||
@@ -141,6 +141,21 @@ def test_seed_suggestions_nul_cu_cod_explicit_tot_nul(conn):
|
|||||||
assert row["cod_prestatie"] is None # cod explicit ignorat cand is_nul
|
assert row["cod_prestatie"] is None # cod explicit ignorat cand is_nul
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_suggestions_cod_whitespace_devine_null(conn):
|
||||||
|
"""Rand non-NUL cu cod whitespace-only (' ') -> cod_prestatie NULL, NU '' (corectitudine)."""
|
||||||
|
from app.shared_store import seed_suggestions, lookup_suggestion
|
||||||
|
|
||||||
|
seed_suggestions(conn, [
|
||||||
|
{"denumire": "OPERATIE CU COD GOL", "cod_prestatie": " ", "source": "llm", "confidence": 0.5},
|
||||||
|
])
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
row = lookup_suggestion(conn, "OPERATIE CU COD GOL")
|
||||||
|
assert row is not None
|
||||||
|
assert row["is_nul"] == 0 # nu e marcat NUL
|
||||||
|
assert row["cod_prestatie"] is None # whitespace -> NULL, nu '' (rand non-NUL fara cod gol)
|
||||||
|
|
||||||
|
|
||||||
def test_seed_suggestions_normalizare_diacritice(conn):
|
def test_seed_suggestions_normalizare_diacritice(conn):
|
||||||
"""Lookup pe forma cu diacritice gaseste randul seedat fara diacritice (normalize_for_match)."""
|
"""Lookup pe forma cu diacritice gaseste randul seedat fara diacritice (normalize_for_match)."""
|
||||||
from app.shared_store import seed_suggestions, lookup_suggestion
|
from app.shared_store import seed_suggestions, lookup_suggestion
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ def test_signup_fara_cui_422(client):
|
|||||||
"cui": "",
|
"cui": "",
|
||||||
"email": "fara_cui@test.com",
|
"email": "fara_cui@test.com",
|
||||||
"parola": "parolasecreta123",
|
"parola": "parolasecreta123",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
# trebuie sa returneze 422 (sau sa randeze formularul cu eroare)
|
# trebuie sa returneze 422 (sau sa randeze formularul cu eroare)
|
||||||
@@ -96,6 +97,7 @@ def test_signup_scrie_email_pe_account(client):
|
|||||||
"cui": "RO9999001",
|
"cui": "RO9999001",
|
||||||
"email": "cu_email@test.com",
|
"email": "cu_email@test.com",
|
||||||
"parola": "parolasecreta123",
|
"parola": "parolasecreta123",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -131,6 +133,7 @@ def test_signup_email_duplicat_mesaj_email(client):
|
|||||||
"cui": make_test_cui("email-dup-c1"),
|
"cui": make_test_cui("email-dup-c1"),
|
||||||
"email": "emaildup@test.com",
|
"email": "emaildup@test.com",
|
||||||
"parola": "parolasecreta123",
|
"parola": "parolasecreta123",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
assert resp1.status_code == 200
|
assert resp1.status_code == 200
|
||||||
@@ -145,6 +148,7 @@ def test_signup_email_duplicat_mesaj_email(client):
|
|||||||
"cui": cui_nou,
|
"cui": cui_nou,
|
||||||
"email": "emaildup@test.com",
|
"email": "emaildup@test.com",
|
||||||
"parola": "parolasecreta456",
|
"parola": "parolasecreta456",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token2,
|
"csrf_token": token2,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,6 +183,7 @@ def test_signup_cui_existent_mesaj_prietenos(client):
|
|||||||
"cui": "RO8888001",
|
"cui": "RO8888001",
|
||||||
"email": "firma1@test.com",
|
"email": "firma1@test.com",
|
||||||
"parola": "parolasecreta123",
|
"parola": "parolasecreta123",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -190,6 +195,7 @@ def test_signup_cui_existent_mesaj_prietenos(client):
|
|||||||
"cui": "RO8888001",
|
"cui": "RO8888001",
|
||||||
"email": "firma2@test.com",
|
"email": "firma2@test.com",
|
||||||
"parola": "parolasecreta456",
|
"parola": "parolasecreta456",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token2,
|
"csrf_token": token2,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ def _do_signup(c: TestClient, name: str, email: str, parola: str = "parolasecret
|
|||||||
"cui": make_test_cui(email),
|
"cui": make_test_cui(email),
|
||||||
"email": email,
|
"email": email,
|
||||||
"parola": parola,
|
"parola": parola,
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ def _signup(client, name, email, password="parola_test_001"):
|
|||||||
from tests.conftest import make_test_cui
|
from tests.conftest import make_test_cui
|
||||||
tok = _csrf(client, "/signup")
|
tok = _csrf(client, "/signup")
|
||||||
client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email,
|
client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email,
|
||||||
"parola": password, "csrf_token": tok}, follow_redirects=True)
|
"parola": password, "consent": "1", "csrf_token": tok},
|
||||||
|
follow_redirects=True)
|
||||||
from app.db import get_connection
|
from app.db import get_connection
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
|
|||||||
140
tests/test_web_badge_sursa.py
Normal file
140
tests/test_web_badge_sursa.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""TDD 5.18 US-007 — Badge sursa sugestie in editorul de mapare (_mapari.html).
|
||||||
|
|
||||||
|
Chip mic langa sugestia sistemului care arata DE UNDE vine codul propus:
|
||||||
|
- "confirmat" -> GOLD partajat (validat de om, shared_mappings)
|
||||||
|
- "similar" -> SILVER exact-match / k-NN embeddings (exemplu deja vazut)
|
||||||
|
- "non-operatie" -> pre-filtru NUL determinist (ITP/plata/discount...)
|
||||||
|
|
||||||
|
Toate suggestion-only (#13): badge-ul e doar indiciu vizual, nu schimba enqueue.
|
||||||
|
Render real prin GET /_fragments/mapari (fragmentul HTMX scoped pe cont).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(monkeypatch):
|
||||||
|
"""DB temporara cu schema, auth web dezactivata (mod dev -> cont id=1)."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "badge_sursa_test.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield monkeypatch
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(env):
|
||||||
|
from app.main import app
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_nomenclator(conn):
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||||
|
[("OE-1", "REPARATIE"), ("OE-3", "REVIZIE PERIODICA")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_needs_mapping(conn, *, op: str, denumire: str, key: str):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
|
||||||
|
"VALUES (1, 'needs_mapping', ?, ?)",
|
||||||
|
(json.dumps({
|
||||||
|
"vin": "WVWZZZ1KZAW001111",
|
||||||
|
"prestatii": [{"cod_op_service": op, "denumire": denumire}],
|
||||||
|
}), key),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_badge_gold_confirmat(env, client):
|
||||||
|
"""O operatie cu match in GOLD partajat -> chip 'confirmat' in coloana Sugestii."""
|
||||||
|
from app.db import get_connection
|
||||||
|
from app.shared_store import record_human_validation
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
_seed_nomenclator(conn)
|
||||||
|
record_human_validation(conn, "Revizie anuala", "OE-3")
|
||||||
|
_insert_needs_mapping(conn, op="OP-REV", denumire="Revizie anuala", key="badge-gold-1")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
resp = client.get("/_fragments/mapari")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
html = resp.text
|
||||||
|
assert "sugg-sursa--confirmat" in html
|
||||||
|
assert ">confirmat<" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_badge_similar_silver(env, client):
|
||||||
|
"""O operatie cu match in SILVER (mapping_suggestions) -> chip 'similar'."""
|
||||||
|
from app.db import get_connection
|
||||||
|
from app.shared_store import seed_suggestions
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
_seed_nomenclator(conn)
|
||||||
|
seed_suggestions(conn, [
|
||||||
|
{"denumire": "Reparatie motor", "cod_prestatie": "OE-1", "source": "llm", "confidence": 0.9},
|
||||||
|
])
|
||||||
|
_insert_needs_mapping(conn, op="OP-REP", denumire="Reparatie motor", key="badge-similar-1")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
resp = client.get("/_fragments/mapari")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
html = resp.text
|
||||||
|
assert "sugg-sursa--similar" in html
|
||||||
|
assert ">similar<" in html
|
||||||
|
# NU trebuie sa fie marcat confirmat (sursa e SILVER, nu GOLD).
|
||||||
|
assert "sugg-sursa--confirmat" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_badge_nul_non_operatie(env, client):
|
||||||
|
"""O operatie prinsa de pre-filtrul NUL (ITP) -> chip 'non-operatie', fara cod sugerat."""
|
||||||
|
from app.db import get_connection
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
_seed_nomenclator(conn)
|
||||||
|
_insert_needs_mapping(conn, op="OP-ITP", denumire="ITP CT 12 ABC", key="badge-nul-1")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
resp = client.get("/_fragments/mapari")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
html = resp.text
|
||||||
|
assert "sugg-sursa--nul" in html
|
||||||
|
assert ">non-operatie<" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_fara_sursa_fara_badge(env, client):
|
||||||
|
"""O operatie fara nicio sursa (necunoscuta) NU primeste chip de sursa."""
|
||||||
|
from app.db import get_connection
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
_seed_nomenclator(conn)
|
||||||
|
_insert_needs_mapping(conn, op="OP-NISA", denumire="Operatie complet necunoscuta xyz", key="badge-none-1")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
resp = client.get("/_fragments/mapari")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert "sugg-sursa" not in resp.text
|
||||||
@@ -224,8 +224,8 @@ def test_logo_linkeaza_acasa(client):
|
|||||||
"In prezent logo-ul nu e un link."
|
"In prezent logo-ul nu e un link."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Titlul "ROMFAST AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/">
|
# Titlul "ROA AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/">
|
||||||
# (PRD AC US-010: Logo-ul ROMFAST + titlul linkeaza la /; titlul a fost redenumit in 5.16)
|
# (PRD AC US-010: Logo-ul ROMFAST + titlul linkeaza la /; titlul a fost redenumit in 5.16)
|
||||||
assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?ROMFAST AUTOPASS', header_html, re.DOTALL), (
|
assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?ROA AUTOPASS', header_html, re.DOTALL), (
|
||||||
"Titlul 'ROMFAST AUTOPASS' trebuie sa fie intr-un <a href='/'> in header."
|
"Titlul 'ROA AUTOPASS' trebuie sa fie intr-un <a href='/'> in header."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -741,20 +741,20 @@ def test_strip_sanatate_fara_hex_hardcodat():
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PRD 5.16 US-010: Titlu ROMFAST AUTOPASS + account_name in antet
|
# PRD 5.16 US-010: Titlu ROA AUTOPASS + account_name in antet
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def test_titlu_romfast_autopass(client):
|
def test_titlu_romfast_autopass(client):
|
||||||
"""US-010 (PRD 5.16): titlul din antet si tag-ul <title> sunt 'ROMFAST AUTOPASS',
|
"""US-010 (PRD 5.16): titlul din antet si tag-ul <title> sunt 'ROA AUTOPASS',
|
||||||
nu 'Gateway RAR AUTOPASS'."""
|
nu 'Gateway RAR AUTOPASS'."""
|
||||||
_create_account_user("titlutest@test.com", name="Service Titlu")
|
_create_account_user("titlutest@test.com", name="Service Titlu")
|
||||||
_login(client, "titlutest@test.com")
|
_login(client, "titlutest@test.com")
|
||||||
html = client.get("/?tab=acasa").text
|
html = client.get("/?tab=acasa").text
|
||||||
|
|
||||||
assert "ROMFAST AUTOPASS" in html, \
|
assert "ROA AUTOPASS" in html, \
|
||||||
"Titlul 'ROMFAST AUTOPASS' lipseste din antet (US-010 PRD 5.16)"
|
"Titlul 'ROA AUTOPASS' lipseste din antet (US-010 PRD 5.16)"
|
||||||
assert "Gateway RAR AUTOPASS" not in html, \
|
assert "Gateway RAR AUTOPASS" not in html, \
|
||||||
"Titlul vechi 'Gateway RAR AUTOPASS' inca prezent — inlocuieste cu 'ROMFAST AUTOPASS'"
|
"Titlul vechi 'Gateway RAR AUTOPASS' inca prezent — inlocuieste cu 'ROA AUTOPASS'"
|
||||||
|
|
||||||
|
|
||||||
def test_header_arata_nume_service_logat(client):
|
def test_header_arata_nume_service_logat(client):
|
||||||
@@ -772,7 +772,7 @@ def test_header_arata_nume_service_logat(client):
|
|||||||
|
|
||||||
def test_login_branded_nu_schelet(client):
|
def test_login_branded_nu_schelet(client):
|
||||||
"""US-010 (PRD 5.16): /login are layout 2-coloane branduit cu clasa .login-shell,
|
"""US-010 (PRD 5.16): /login are layout 2-coloane branduit cu clasa .login-shell,
|
||||||
titlul 'ROMFAST AUTOPASS', si formular cu POST /login (CSRF intact)."""
|
titlul 'ROA AUTOPASS', si formular cu POST /login (CSRF intact)."""
|
||||||
resp = client.get("/login")
|
resp = client.get("/login")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
@@ -781,8 +781,8 @@ def test_login_branded_nu_schelet(client):
|
|||||||
"Clasa .login-shell lipseste din /login (US-010 PRD 5.16) — layout 2-coloane nenimplementat"
|
"Clasa .login-shell lipseste din /login (US-010 PRD 5.16) — layout 2-coloane nenimplementat"
|
||||||
assert "login-aside" in html, \
|
assert "login-aside" in html, \
|
||||||
"Clasa .login-aside lipseste — coloana stanga de brand lipseste (US-010)"
|
"Clasa .login-aside lipseste — coloana stanga de brand lipseste (US-010)"
|
||||||
assert "ROMFAST AUTOPASS" in html, \
|
assert "ROA AUTOPASS" in html, \
|
||||||
"Titlul 'ROMFAST AUTOPASS' lipseste din /login (US-010 PRD 5.16)"
|
"Titlul 'ROA AUTOPASS' lipseste din /login (US-010 PRD 5.16)"
|
||||||
# Formular intact: POST /login cu csrf_token
|
# Formular intact: POST /login cu csrf_token
|
||||||
assert 'action="/login"' in html, "Actiunea formularului /login s-a schimbat — CSRF route invalida"
|
assert 'action="/login"' in html, "Actiunea formularului /login s-a schimbat — CSRF route invalida"
|
||||||
assert 'name="csrf_token"' in html, "csrf_token lipseste din formular — securitate compromisa"
|
assert 'name="csrf_token"' in html, "csrf_token lipseste din formular — securitate compromisa"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ def test_signup_creeaza_cont_user_si_cheie(client):
|
|||||||
"cui": "RO12345678",
|
"cui": "RO12345678",
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
"parola": "parolasecreta",
|
"parola": "parolasecreta",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -87,6 +88,7 @@ def test_signup_email_duplicat_eroare(client):
|
|||||||
"cui": make_test_cui("dup@example.com"),
|
"cui": make_test_cui("dup@example.com"),
|
||||||
"email": "dup@example.com",
|
"email": "dup@example.com",
|
||||||
"parola": "parolasecreta",
|
"parola": "parolasecreta",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ def test_signup_email_duplicat_eroare(client):
|
|||||||
"cui": make_test_cui("dup-b@example.com"),
|
"cui": make_test_cui("dup-b@example.com"),
|
||||||
"email": "dup@example.com",
|
"email": "dup@example.com",
|
||||||
"parola": "altaparola123",
|
"parola": "altaparola123",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
assert resp2.status_code in (200, 422)
|
assert resp2.status_code in (200, 422)
|
||||||
@@ -139,6 +142,72 @@ def test_signup_parola_scurta_eroare(client):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_signup_fara_consent_eroare(client):
|
||||||
|
"""Consimtamant GDPR lipsa -> 422, fara creare cont; mesaj despre Termeni/GDPR.
|
||||||
|
|
||||||
|
Checkbox-ul de consimtamant trebuie validat server-side (functional, nu doar client-side):
|
||||||
|
fara el contul nu se creeaza si planul/datele introduse se pastreaza in re-render.
|
||||||
|
"""
|
||||||
|
from tests.conftest import make_test_cui
|
||||||
|
resp = client.get("/signup")
|
||||||
|
token = _csrf(resp.text)
|
||||||
|
|
||||||
|
resp = client.post("/signup", data={
|
||||||
|
"name": "Service Fara Consent",
|
||||||
|
"cui": make_test_cui("fara-consent@test.com"),
|
||||||
|
"email": "fara-consent@test.com",
|
||||||
|
"parola": "parolasecreta123",
|
||||||
|
# fara "consent"
|
||||||
|
"csrf_token": token,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "rfak_" not in resp.text
|
||||||
|
assert "GDPR" in resp.text or "Termeni" in resp.text
|
||||||
|
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
acct = conn.execute(
|
||||||
|
"SELECT * FROM accounts WHERE name='Service Fara Consent'"
|
||||||
|
).fetchone()
|
||||||
|
assert acct is None, "Cont creat desi consimtamantul lipsea"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_signup_salveaza_requested_plan_si_consent(client):
|
||||||
|
"""POST /signup cu plan ales -> accounts.requested_plan = codul ales, consent_at setat,
|
||||||
|
iar tier RAMANE 'free' (planul cerut NU acorda drepturi)."""
|
||||||
|
from tests.conftest import make_test_cui
|
||||||
|
resp = client.get("/signup")
|
||||||
|
token = _csrf(resp.text)
|
||||||
|
|
||||||
|
resp = client.post("/signup", data={
|
||||||
|
"name": "Service Plan Pro",
|
||||||
|
"cui": make_test_cui("plan-pro@test.com"),
|
||||||
|
"email": "plan-pro@test.com",
|
||||||
|
"parola": "parolasecreta123",
|
||||||
|
"plan": "pro",
|
||||||
|
"consent": "1",
|
||||||
|
"csrf_token": token,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "rfak_" in resp.text
|
||||||
|
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
acct = conn.execute(
|
||||||
|
"SELECT * FROM accounts WHERE name='Service Plan Pro'"
|
||||||
|
).fetchone()
|
||||||
|
assert acct is not None
|
||||||
|
assert acct["requested_plan"] == "pro", "Planul cerut nu a fost salvat"
|
||||||
|
assert acct["tier"] == "free", "tier NU trebuie urcat din planul cerut (doar dupa plata)"
|
||||||
|
assert acct["consent_at"], "consent_at trebuie setat la signup cu consimtamant"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def test_cheie_afisata_o_data(client):
|
def test_cheie_afisata_o_data(client):
|
||||||
"""Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine."""
|
"""Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine."""
|
||||||
from tests.conftest import make_test_cui
|
from tests.conftest import make_test_cui
|
||||||
@@ -150,6 +219,7 @@ def test_cheie_afisata_o_data(client):
|
|||||||
"cui": make_test_cui("cheie@test.com"),
|
"cui": make_test_cui("cheie@test.com"),
|
||||||
"email": "cheie@test.com",
|
"email": "cheie@test.com",
|
||||||
"parola": "parolasecreta",
|
"parola": "parolasecreta",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
assert resp_post.status_code == 200
|
assert resp_post.status_code == 200
|
||||||
|
|||||||
@@ -495,7 +495,9 @@ def _insert_submissions_sent(account_id: int, n: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_afisaj_plan_si_zile_trial(client):
|
def test_afisaj_plan_si_zile_trial(client):
|
||||||
"""US-006: cont in trial Pro -> fragment status arata 'trial N zile ramase'.
|
"""US-006 + T-6 (5.16): cont in trial Pro -> linia de plan din meniul burger (pagina
|
||||||
|
completa) arata 'Plan: Pro · trial N zile ramase'. In starea normala (non-warn) plan_linie
|
||||||
|
NU mai e rand in corpul fragmentului status — traieste in badge antet + burger.
|
||||||
Contul nou primeste trial_until=now+30z automat la creare.
|
Contul nou primeste trial_until=now+30z automat la creare.
|
||||||
"""
|
"""
|
||||||
acct_id, _ = _create_account_user("trialzile@test.com")
|
acct_id, _ = _create_account_user("trialzile@test.com")
|
||||||
@@ -505,7 +507,7 @@ def test_afisaj_plan_si_zile_trial(client):
|
|||||||
future = (datetime.now(timezone.utc) + timedelta(days=18, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
future = (datetime.now(timezone.utc) + timedelta(days=18, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
_set_trial_until(acct_id, future)
|
_set_trial_until(acct_id, future)
|
||||||
|
|
||||||
resp = client.get("/_fragments/status")
|
resp = client.get("/", follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
|
|
||||||
@@ -516,7 +518,8 @@ def test_afisaj_plan_si_zile_trial(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_afisaj_consum_lunar(client):
|
def test_afisaj_consum_lunar(client):
|
||||||
"""US-006: cont free (fara trial) -> fragment status arata 'Gratuit · N/60 luna asta'."""
|
"""US-006 + T-6 (5.16): cont free (fara trial) -> linia de plan din burger (pagina
|
||||||
|
completa) arata 'Gratuit · N/60 luna asta'. Consumul normal nu mai e rand in corp."""
|
||||||
acct_id, _ = _create_account_user("consumlun@test.com")
|
acct_id, _ = _create_account_user("consumlun@test.com")
|
||||||
_login(client, "consumlun@test.com", "parolasecreta10")
|
_login(client, "consumlun@test.com", "parolasecreta10")
|
||||||
|
|
||||||
@@ -525,7 +528,7 @@ def test_afisaj_consum_lunar(client):
|
|||||||
# Insereaza 5 submissions sent luna asta
|
# Insereaza 5 submissions sent luna asta
|
||||||
_insert_submissions_sent(acct_id, 5)
|
_insert_submissions_sent(acct_id, 5)
|
||||||
|
|
||||||
resp = client.get("/_fragments/status")
|
resp = client.get("/", follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
|
|
||||||
@@ -584,7 +587,8 @@ def test_copy_pluralizare_zi_zile(client):
|
|||||||
future_18 = (datetime.now(timezone.utc) + timedelta(days=18, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
future_18 = (datetime.now(timezone.utc) + timedelta(days=18, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
_set_trial_until(acct_id, future_18)
|
_set_trial_until(acct_id, future_18)
|
||||||
|
|
||||||
resp = client.get("/_fragments/status")
|
# T-6 (5.16): linia de plan (cu pluralizarea zilelor) traieste in burger pe pagina completa.
|
||||||
|
resp = client.get("/", follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
assert "18 zile" in html, f"'18 zile' lipseste. HTML: {html[:800]}"
|
assert "18 zile" in html, f"'18 zile' lipseste. HTML: {html[:800]}"
|
||||||
@@ -596,7 +600,7 @@ def test_copy_pluralizare_zi_zile(client):
|
|||||||
future_1 = (datetime.now(timezone.utc) + timedelta(days=1, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
future_1 = (datetime.now(timezone.utc) + timedelta(days=1, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
_set_trial_until(acct_id, future_1)
|
_set_trial_until(acct_id, future_1)
|
||||||
|
|
||||||
resp = client.get("/_fragments/status")
|
resp = client.get("/", follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
assert "1 zi" in html, f"'1 zi' (singular) lipseste la o zi ramasa. HTML: {html[:800]}"
|
assert "1 zi" in html, f"'1 zi' (singular) lipseste la o zi ramasa. HTML: {html[:800]}"
|
||||||
|
|||||||
@@ -107,8 +107,9 @@ def test_submissions_coloane_umane(client):
|
|||||||
assert "B777ZZZ" in html, "Nr inmatriculare din payload lipseste"
|
assert "B777ZZZ" in html, "Nr inmatriculare din payload lipseste"
|
||||||
assert "Reparatie frane" in html, "Operatia din payload lipseste"
|
assert "Reparatie frane" in html, "Operatia din payload lipseste"
|
||||||
|
|
||||||
# Nr. prezentare RAR accesibil pe linia meta discreta
|
# 5.16: #id_prezentare nu mai e pe rand (randul are MAX 2 linii) — detaliul complet
|
||||||
assert "68516" in html, "Nr. prezentare RAR lipseste din linia meta"
|
# (inclusiv nr. prezentare RAR) traieste in modalul de detaliu.
|
||||||
|
assert "68516" not in html, "Nr. prezentare RAR nu trebuie sa mai apara pe randul slim"
|
||||||
|
|
||||||
|
|
||||||
def test_tab_eticheta_trimiteri(client):
|
def test_tab_eticheta_trimiteri(client):
|
||||||
@@ -426,9 +427,9 @@ def test_detaliu_trimitere_404_cross_account(client):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_rand_slim_vin_operatie_pill(client):
|
def test_rand_slim_vin_operatie_pill(client):
|
||||||
"""US-004: fiecare rand slim afiseaza VIN scurt in .slim-vin, operatie+ora in
|
"""5.16: fiecare rand slim are 2 linii — L1 placuta (nr. inmatriculare) in .slim-vin,
|
||||||
.slim-meta si un pill de stare cu clasa stare_css si eticheta stare_scurt.
|
L2 cod RAR · operatie · data in .slim-meta, plus un pill de stare cu clasa stare_css
|
||||||
Lista e inconjurata de .lista-trimiteri-slim.
|
si eticheta stare_scurt. Lista e inconjurata de .lista-trimiteri-slim.
|
||||||
"""
|
"""
|
||||||
acct = _create_account_user("slim1@test.com")
|
acct = _create_account_user("slim1@test.com")
|
||||||
_insert_submission(acct, "sent", id_prezentare=80001)
|
_insert_submission(acct, "sent", id_prezentare=80001)
|
||||||
@@ -442,14 +443,16 @@ def test_rand_slim_vin_operatie_pill(client):
|
|||||||
assert "lista-trimiteri-slim" in html, "lista-trimiteri-slim lipseste din raspuns"
|
assert "lista-trimiteri-slim" in html, "lista-trimiteri-slim lipseste din raspuns"
|
||||||
assert "trimitere-slim" in html, "trimitere-slim lipseste din raspuns"
|
assert "trimitere-slim" in html, "trimitere-slim lipseste din raspuns"
|
||||||
|
|
||||||
# VIN scurt in clasa slim-vin (mono, linia 1)
|
# L1: placuta (identificator primar) in clasa slim-vin
|
||||||
assert "slim-vin" in html, "slim-vin lipseste — linia 1 VIN mono"
|
assert "slim-vin" in html, "slim-vin lipseste — linia 1 placuta"
|
||||||
|
assert "B777ZZZ" in html, "placuta (nr. inmatriculare) lipseste de pe rand"
|
||||||
|
|
||||||
# Linia 2 muted (operatie + ora/data)
|
# L2: cod RAR · operatie · data (slim-meta / slim-rand2)
|
||||||
assert "slim-meta" in html, "slim-meta lipseste — linia 2 muted"
|
assert "slim-meta" in html, "slim-meta lipseste — linia 2"
|
||||||
|
assert "slim-rand2" in html, "slim-rand2 lipseste — linia 2 (cod RAR · operatie · data)"
|
||||||
|
|
||||||
# VIN scurt randat (WVWZZZ1JZXW000777 -> …000777)
|
# VIN integral nu mai e pe rand (5.16) — traieste in modalul de detaliu.
|
||||||
assert "000777" in html, "VIN scurt (ultimele 6 cifre) lipseste"
|
assert "000777" not in html, "VIN scurt nu mai trebuie randat pe randul slim (2 linii)"
|
||||||
|
|
||||||
# Pill de stare: clasa CSS + eticheta scurta
|
# Pill de stare: clasa CSS + eticheta scurta
|
||||||
assert "s-sent" in html, "clasa pill s-sent lipseste"
|
assert "s-sent" in html, "clasa pill s-sent lipseste"
|
||||||
|
|||||||
@@ -81,12 +81,13 @@ def client(monkeypatch):
|
|||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
def test_vin_pe_rand_separat_sub_nr(client):
|
def test_placuta_pe_rand_identificator_primar(client):
|
||||||
"""VIN-ul apare intr-un element block-level cu clasa slim-vin (PRD 5.15 US-004).
|
"""Placuta (nr. inmatriculare) e identificatorul PRIMAR, linia 1 a randului slim
|
||||||
|
(5.16): in <div class="slim-vin"> (block-level, prominent).
|
||||||
|
|
||||||
PRD 5.10 (US-005): VIN era <div class="muted"> sub nr in coloana Vehicul.
|
PRD 5.15 (US-004): VIN era identificatorul primar pe linia 1.
|
||||||
PRD 5.15 (US-004): VIN e acum identificatorul PRINCIPAL, linia 1 a randului slim,
|
5.16 (directiva user): operatorul scaneaza placuta de pe comanda, nu VIN-ul de 17
|
||||||
in <div class="slim-vin"> (mono, prominent, block-level). NU mai e muted.
|
caractere — placuta devine linia 1, VIN integral se muta in modalul de detaliu.
|
||||||
"""
|
"""
|
||||||
acct = _create_account_user("vin_layout@test.com")
|
acct = _create_account_user("vin_layout@test.com")
|
||||||
_ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ")
|
_ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ")
|
||||||
@@ -96,46 +97,51 @@ def test_vin_pe_rand_separat_sub_nr(client):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
|
|
||||||
# VIN trunchiat trebuie sa apara in HTML
|
# Placuta trebuie sa apara in HTML
|
||||||
assert "000001" in html, "VIN-ul trunchiat trebuie sa apara in lista slim"
|
assert "B123XYZ" in html, "placuta (nr. inmatriculare) trebuie sa apara in lista slim"
|
||||||
|
|
||||||
# VIN e intr-un element block-level (div cu clasa slim-vin)
|
# Placuta e intr-un element block-level (div cu clasa slim-vin)
|
||||||
# Pattern: <div class="slim-vin">...000001...</div>
|
plac = "B123XYZ"
|
||||||
vin_fragment = "000001"
|
|
||||||
found_slim_vin = re.search(
|
found_slim_vin = re.search(
|
||||||
rf'<div[^>]*class="slim-vin[^"]*"[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</div>',
|
rf'<div[^>]*class="slim-vin[^"]*"[^>]*>[^<]*{re.escape(plac)}[^<]*</div>',
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
assert found_slim_vin, (
|
assert found_slim_vin, (
|
||||||
f"VIN '{vin_fragment}' trebuie sa fie in <div class=\"slim-vin\"> (block-level, "
|
f"placuta '{plac}' trebuie sa fie in <div class=\"slim-vin\"> (linia 1 a "
|
||||||
f"mono, linia 1 a randului slim). HTML gasit: "
|
f"randului slim). HTML gasit: "
|
||||||
+ html[max(0, html.find(vin_fragment) - 80):html.find(vin_fragment) + 80]
|
+ html[max(0, html.find(plac) - 80):html.find(plac) + 80]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# VIN integral NU mai e pe rand (max 2 linii) — traieste in modalul de detaliu.
|
||||||
|
assert "000001" not in html, "VIN-ul nu mai trebuie randat pe randul slim (5.16)"
|
||||||
|
|
||||||
def test_vin_lipsa_nu_genereaza_rand_gol(client):
|
|
||||||
"""Cand VIN-ul lipseste (sau e EMPTY='—'), slim-vin nu afiseaza '—' izolat.
|
def test_placuta_lipsa_nu_genereaza_rand_gol(client):
|
||||||
Fallback: slim-vin afiseaza vehicul_nr (nr. inmatriculare) cu clasa muted.
|
"""Cand placuta SI VIN-ul lipsesc, slim-vin nu afiseaza '—' izolat ca identificator.
|
||||||
(PRD 5.15 US-004: slim-vin are garda vin != '—')
|
Fallback (5.16): VIN scurt daca exista, altfel mesaj neutru ('fara numar') — niciodata
|
||||||
|
un em-dash singur ca identificator primar.
|
||||||
"""
|
"""
|
||||||
acct = _create_account_user("vin_gol@test.com")
|
acct = _create_account_user("vin_gol@test.com")
|
||||||
sid = _ins(acct, vin="", nr="B999TST") # VIN gol -> vin_scurt='—'
|
# Placuta prezenta -> e identificatorul primar pe linia 1.
|
||||||
|
sid1 = _ins(acct, vin="", nr="B999TST")
|
||||||
|
# Placuta SI VIN absente -> fallback 'fara numar' (nu '—' izolat).
|
||||||
|
sid2 = _ins(acct, vin="", nr="")
|
||||||
_login(client, "vin_gol@test.com")
|
_login(client, "vin_gol@test.com")
|
||||||
|
|
||||||
resp = client.get("/_fragments/submissions")
|
resp = client.get("/_fragments/submissions")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
|
|
||||||
# Randul trebuie sa existe
|
# Ambele randuri exista
|
||||||
assert f'id="trimitere-row-{sid}"' in html
|
assert f'id="trimitere-row-{sid1}"' in html
|
||||||
|
assert f'id="trimitere-row-{sid2}"' in html
|
||||||
|
|
||||||
# slim-vin NU trebuie sa contina '—' izolat (VIN lipsa -> fallback vehicul_nr)
|
# Placuta vizibila cand exista
|
||||||
slim_vin_match = re.search(r'<div[^>]*class="slim-vin[^"]*"[^>]*>([^<]*)</div>', html)
|
assert "B999TST" in html, "placuta (nr. inmatriculare) lipseste de pe rand"
|
||||||
assert slim_vin_match, "slim-vin lipseste din randul cu VIN gol"
|
|
||||||
slim_vin_content = slim_vin_match.group(1).strip()
|
# Niciun slim-vin nu contine '—' izolat
|
||||||
assert slim_vin_content != "—", (
|
for m in re.finditer(r'<div[^>]*class="slim-vin[^"]*"[^>]*>([^<]*)</div>', html):
|
||||||
"slim-vin afiseaza '—' izolat cand VIN lipseste — "
|
assert m.group(1).strip() != "—", "slim-vin afiseaza '—' izolat ca identificator"
|
||||||
"trebuie sa afiseze vehicul_nr ca fallback"
|
|
||||||
)
|
# Fallback neutru cand placuta + VIN lipsesc
|
||||||
# Fallback: nr inmatriculare vizibil
|
assert "fara numar" in html, "fallback 'fara numar' lipseste cand placuta+VIN absente"
|
||||||
assert "B999TST" in html, "Nr inmatriculare (fallback) lipseste cand VIN e gol"
|
|
||||||
|
|||||||
185
tests/test_worker_keepalive_rar.py
Normal file
185
tests/test_worker_keepalive_rar.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""Teste keepalive RAR — login de proba periodic ca dashboard-ul sa nu afiseze
|
||||||
|
fals "RAR inaccesibil" doar din lipsa de trafic.
|
||||||
|
|
||||||
|
Comportament asteptat (_maybe_keepalive):
|
||||||
|
- login vechi/lipsa + creds durabile -> sondeaza (get_token apelat) si forteaza
|
||||||
|
login real (invalidate inainte);
|
||||||
|
- login proaspat (sub interval) -> NU sondeaza;
|
||||||
|
- interval=0 -> dezactivat;
|
||||||
|
- fara cont cu creds durabile -> nu sondeaza;
|
||||||
|
- gating: dupa o incercare, nu re-sondeaza in cadrul intervalului (nu hartui RAR).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import get_connection, init_db
|
||||||
|
init_db()
|
||||||
|
conn = get_connection()
|
||||||
|
yield conn, get_settings()
|
||||||
|
conn.close()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSessions:
|
||||||
|
"""Imita AccountSessions: get_token reusit reimprospateaza heartbeat-ul (ca realul)."""
|
||||||
|
|
||||||
|
def __init__(self, conn, *, fail: bool = False):
|
||||||
|
self._conn = conn
|
||||||
|
self._fail = fail
|
||||||
|
self.invalidated: list[int] = []
|
||||||
|
self.tokens: list[int] = []
|
||||||
|
|
||||||
|
def invalidate(self, account_id: int) -> None:
|
||||||
|
self.invalidated.append(account_id)
|
||||||
|
|
||||||
|
def get_token(self, conn, account_id: int, creds) -> str | None:
|
||||||
|
self.tokens.append(account_id)
|
||||||
|
if self._fail:
|
||||||
|
raise RuntimeError("RAR jos")
|
||||||
|
from app.db import write_heartbeat
|
||||||
|
write_heartbeat(conn, rar_login_ok=True, detail=f"login proba (cont {account_id})")
|
||||||
|
return "tok"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_last_login(conn, *, ago_s: float | None):
|
||||||
|
"""Seteaza last_rar_login_ok la now-ago_s (None = niciun login)."""
|
||||||
|
from app.db import write_heartbeat
|
||||||
|
write_heartbeat(conn, detail="poll") # asigura randul heartbeat
|
||||||
|
if ago_s is None:
|
||||||
|
conn.execute("UPDATE worker_heartbeat SET last_rar_login_ok=NULL WHERE id=1")
|
||||||
|
else:
|
||||||
|
ts = (datetime.now(timezone.utc) - timedelta(seconds=ago_s)).isoformat()
|
||||||
|
conn.execute("UPDATE worker_heartbeat SET last_rar_login_ok=? WHERE id=1", (ts,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _account_cu_creds(conn) -> int:
|
||||||
|
from app.accounts import create_account
|
||||||
|
from app.crypto import encrypt_creds
|
||||||
|
acct = create_account(conn, "Service Cu Creds", email="svc@example.com")
|
||||||
|
enc = encrypt_creds({"email": "svc@example.com", "password": "secret"})
|
||||||
|
conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, acct))
|
||||||
|
conn.commit()
|
||||||
|
return acct
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_vechi_sondeaza_si_reimprospateaza(env):
|
||||||
|
"""Login mai vechi decat intervalul + creds durabile -> proba reala, heartbeat reimprospatat."""
|
||||||
|
from app.worker.__main__ import _maybe_keepalive
|
||||||
|
from app.db import read_heartbeat
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
settings.worker_rar_keepalive_interval_s = 86400
|
||||||
|
acct = _account_cu_creds(conn)
|
||||||
|
_set_last_login(conn, ago_s=100000) # > 24h
|
||||||
|
|
||||||
|
sessions = _FakeSessions(conn)
|
||||||
|
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
|
||||||
|
|
||||||
|
assert sessions.tokens == [acct] # a sondat contul cu creds
|
||||||
|
assert sessions.invalidated == [acct] # a fortat login real (nu token din cache)
|
||||||
|
last = read_heartbeat(conn)["last_rar_login_ok"]
|
||||||
|
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds()
|
||||||
|
assert age < 60 # heartbeat reimprospatat de proba
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_proaspat_nu_sondeaza(env):
|
||||||
|
"""Login sub interval -> niciun login de proba."""
|
||||||
|
from app.worker.__main__ import _maybe_keepalive
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
settings.worker_rar_keepalive_interval_s = 86400
|
||||||
|
_account_cu_creds(conn)
|
||||||
|
_set_last_login(conn, ago_s=3600) # 1h < 24h
|
||||||
|
|
||||||
|
sessions = _FakeSessions(conn)
|
||||||
|
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
|
||||||
|
|
||||||
|
assert sessions.tokens == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_interval_zero_dezactivat(env):
|
||||||
|
"""interval=0 -> keepalive dezactivat, nicio proba chiar cu login vechi."""
|
||||||
|
from app.worker.__main__ import _maybe_keepalive
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
settings.worker_rar_keepalive_interval_s = 0
|
||||||
|
_account_cu_creds(conn)
|
||||||
|
_set_last_login(conn, ago_s=100000)
|
||||||
|
|
||||||
|
sessions = _FakeSessions(conn)
|
||||||
|
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
|
||||||
|
|
||||||
|
assert sessions.tokens == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fara_creds_durabile_nu_sondeaza(env):
|
||||||
|
"""Niciun cont cu creds durabile + fara test-creds -> nimic de sondat."""
|
||||||
|
from app.worker.__main__ import _maybe_keepalive
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
settings.worker_rar_keepalive_interval_s = 86400
|
||||||
|
settings.worker_use_test_creds = False
|
||||||
|
_set_last_login(conn, ago_s=100000)
|
||||||
|
|
||||||
|
sessions = _FakeSessions(conn)
|
||||||
|
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
|
||||||
|
|
||||||
|
assert sessions.tokens == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_sare_creds_nedecriptabile(env):
|
||||||
|
"""Cont cu creds criptate sub alta cheie (decrypt -> None) e sarit; alege contul valid.
|
||||||
|
|
||||||
|
Reproduce bug-ul real: start.sh both genereaza o cheie efemera noua la fiecare
|
||||||
|
pornire, deci creds-urile durabile vechi nu se mai decripteaza.
|
||||||
|
"""
|
||||||
|
from app.worker.__main__ import _keepalive_target
|
||||||
|
from app.accounts import create_account
|
||||||
|
from app.crypto import encrypt_creds
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
settings.worker_use_test_creds = False
|
||||||
|
# Cont cu creds GUNOI (nedecriptabile sub cheia curenta), id mai mic.
|
||||||
|
bad = create_account(conn, "Cont Cheie Veche", email="old@example.com")
|
||||||
|
conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", ("gAAAAA-token-invalid", bad))
|
||||||
|
# Cont cu creds valide, id mai mare.
|
||||||
|
good = create_account(conn, "Cont Valid", email="good@example.com")
|
||||||
|
enc = encrypt_creds({"email": "good@example.com", "password": "pw"})
|
||||||
|
conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, good))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
acct_id, creds = _keepalive_target(conn, settings)
|
||||||
|
assert acct_id == good # a sarit contul nedecriptabil
|
||||||
|
assert creds and creds["email"] == "good@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gating_nu_hartuieste_pe_esec(env):
|
||||||
|
"""Pe esec (RAR jos) login-ul ramane vechi; a doua trecere imediata NU re-sondeaza."""
|
||||||
|
from app.worker.__main__ import _maybe_keepalive
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
settings.worker_rar_keepalive_interval_s = 86400
|
||||||
|
_account_cu_creds(conn)
|
||||||
|
_set_last_login(conn, ago_s=100000)
|
||||||
|
|
||||||
|
state = {"last_attempt": 0.0}
|
||||||
|
sessions = _FakeSessions(conn, fail=True)
|
||||||
|
_maybe_keepalive(conn, settings, sessions, state) # incearca, esueaza
|
||||||
|
_maybe_keepalive(conn, settings, sessions, state) # gating: nu re-incearca
|
||||||
|
|
||||||
|
assert sessions.tokens == [sessions.invalidated[0]] # o singura proba
|
||||||
|
assert len(sessions.tokens) == 1
|
||||||
258
tools/mapare-llm/eticheteaza.py
Normal file
258
tools/mapare-llm/eticheteaza.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""Etichetator offline operatii service -> coduri RAR (US-002, PRD 5.18).
|
||||||
|
|
||||||
|
Backend implicit = **LM Studio local** (Qwen3-4B, GPU RX 6600M via Tailscale),
|
||||||
|
backend-ul APROBAT pentru bootstrap-ul v1 (decizia D4). Groq / OpenRouter raman
|
||||||
|
fallback-uri interschimbabile, dar NU sunt calea aprobata pentru v1.
|
||||||
|
|
||||||
|
Particularitati care justifica un tool NOU (nu reuse de `or_common.call`):
|
||||||
|
- LM Studio RESPINGE `response_format: json_object` (eroare 400). Cere envelope
|
||||||
|
`json_schema` STRICT complet: {"type":"json_schema","json_schema":{...,"strict":true}}.
|
||||||
|
- `cod` e ENUM peste cele 19 etichete (18 coduri RAR + NUL) -> modelul nu poate
|
||||||
|
inventa coduri; orice abatere e prinsa de garda de truncare ('?').
|
||||||
|
- Qwen3 emite `<think>...` daca nu dezactivam thinking-ul -> umfla tokeni/latenta
|
||||||
|
sub structured output strict. Punem `/no_think` in promptul de sistem.
|
||||||
|
|
||||||
|
Setari conservatoare OBLIGATORII pe GPU-box (a facut shutdown sub sarcina 2026-06-29,
|
||||||
|
probabil termic/alimentare): in LM Studio incarca modelul cu `n_parallel=1`,
|
||||||
|
`n_ctx=4096`, batch 32-40, monitorizeaza temperatura. NU mari batch/context fara
|
||||||
|
headroom termic. Vezi memorie `lmstudio-gpu-etichetare`.
|
||||||
|
|
||||||
|
Reutilizeaza din `or_common`: scrub-ul PII (F3) si lista de coduri.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# --- Coduri + scrub PII: sursa de adevar = or_common (acelasi nomenclator de etichete) ---
|
||||||
|
import importlib.util as _ilu
|
||||||
|
|
||||||
|
_OR_PATH = os.path.join(os.path.dirname(__file__), "or_common.py")
|
||||||
|
_spec = _ilu.spec_from_file_location("or_common", _OR_PATH)
|
||||||
|
or_common = _ilu.module_from_spec(_spec)
|
||||||
|
sys.modules.setdefault("or_common", or_common)
|
||||||
|
_spec.loader.exec_module(or_common)
|
||||||
|
|
||||||
|
scrub = or_common.scrub # VIN/placuta -> [VIN]/[NR]
|
||||||
|
|
||||||
|
# Cele 19 etichete (18 coduri RAR + NUL), extrase din CODURI (sursa unica or_common).
|
||||||
|
ALL_LABELS: list[str] = [c.split("=")[0].strip() for c in or_common.CODURI.replace(", ", ",").split(",")]
|
||||||
|
assert "NUL" in ALL_LABELS and len(ALL_LABELS) == 19, ALL_LABELS
|
||||||
|
_VALID = set(ALL_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Prompt procedural in 3 pasi (versionat) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
PROMPT_VERSION = "3pasi-v1"
|
||||||
|
|
||||||
|
_CODURI_LISTA = or_common.CODURI
|
||||||
|
|
||||||
|
SYS = (
|
||||||
|
"Esti expert RAR AUTOPASS. Clasifici fiecare operatie de service-auto in EXACT unul "
|
||||||
|
"din aceste coduri:\n" + _CODURI_LISTA + "\n\n"
|
||||||
|
"Urmeaza PROCEDURA in 3 pasi, in ordine:\n"
|
||||||
|
"PAS 1 (non-operatie -> NUL): daca textul NU e o operatie tehnica de service "
|
||||||
|
"(ITP, plata/achitat, discount/reducere, taxa, nr inmatriculare/placuta, manopera "
|
||||||
|
"generica, sau DOAR un nume de piesa fara actiune) -> cod = NUL. Opreste-te.\n"
|
||||||
|
"PAS 2 (avarie din ACCIDENT -> avarie grava): foloseste codurile de avarie grava DOAR "
|
||||||
|
"pentru daune in urma unui accident, pe sistemul avariat:\n"
|
||||||
|
" caroserie/structura rezistenta -> OE-C; sasiu -> OE-S; directie -> OE-D; "
|
||||||
|
"franare -> OE-F; sistem de retinere/airbag -> OE-R; ADAS (asistenta condus) -> OE-A.\n"
|
||||||
|
" Reparatiile curente, de uzura (NU dintr-un accident) NU sunt avarii grave -> mergi la PAS 3.\n"
|
||||||
|
"PAS 3 (operatie obisnuita): \n"
|
||||||
|
" inlocuire / D-R / reparare / vopsire / retus piese -> OE-1 (REPARATIE);\n"
|
||||||
|
" schimb ulei motor + filtre -> OE-3 (REVIZIE PERIODICA);\n"
|
||||||
|
" aerisit / gresat / completat nivele -> OE-2 (INTRETINERE);\n"
|
||||||
|
" reglare functionala (geometrie directie, faruri, ralanti) -> OE-4;\n"
|
||||||
|
" actualizare/programare software -> OE-7; schimb sezonier anvelope -> OE-8;\n"
|
||||||
|
" istoric/reparatie/inlocuire odometru -> OE-I / R-ODO / I-ODO; tahograf -> AITLV.\n\n"
|
||||||
|
"Raspunde DOAR cu JSON conform schemei. /no_think"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def construieste_mesaje(batch: list[str]) -> list[dict]:
|
||||||
|
"""Mesajele chat (system procedural + user enumerat). Scrub PII pe fiecare item."""
|
||||||
|
user = "\n".join(f"{i + 1}. {scrub(o)}" for i, o in enumerate(batch))
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": SYS},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Schema json_schema strict (envelope complet — LM Studio respinge json_object) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _response_format() -> dict:
|
||||||
|
return {
|
||||||
|
"type": "json_schema",
|
||||||
|
"json_schema": {
|
||||||
|
"name": "etichete_operatii",
|
||||||
|
"strict": True,
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"rez": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"i": {"type": "integer"},
|
||||||
|
"cod": {"type": "string", "enum": ALL_LABELS},
|
||||||
|
},
|
||||||
|
"required": ["i", "cod"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["rez"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Backend-uri (LM Studio default; Groq/OpenRouter fallback) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Backend:
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
model: str
|
||||||
|
api_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Endpoint LM Studio implicit = GPU-box pe Tailscale (memorie lmstudio-gpu-etichetare).
|
||||||
|
_DEFAULT_LMSTUDIO_URL = "http://100.64.151.22:1234/v1/chat/completions"
|
||||||
|
|
||||||
|
_BACKENDS = {
|
||||||
|
"lmstudio": {"url": _DEFAULT_LMSTUDIO_URL, "model": "qwen/qwen3-4b", "key_env": None},
|
||||||
|
"groq": {"url": "https://api.groq.com/openai/v1/chat/completions",
|
||||||
|
"model": "llama-3.3-70b-versatile", "key_env": "GROQ_KEY"},
|
||||||
|
"openrouter": {"url": "https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
"model": "qwen/qwen3-4b:free", "key_env": "OPENROUTER_KEY"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_backend(name: str | None = None) -> Backend:
|
||||||
|
"""Construieste backend-ul din env. Default = lmstudio (D4).
|
||||||
|
|
||||||
|
Override-uri: ETICHETARE_BACKEND, ETICHETARE_ENDPOINT, ETICHETARE_MODEL.
|
||||||
|
Cheia API (Groq/OpenRouter) se citeste din env-ul indicat de backend; LM Studio
|
||||||
|
local nu cere cheie.
|
||||||
|
"""
|
||||||
|
name = (name or os.environ.get("ETICHETARE_BACKEND") or "lmstudio").strip().lower()
|
||||||
|
if name not in _BACKENDS:
|
||||||
|
raise ValueError(f"backend necunoscut: {name} (alege din {list(_BACKENDS)})")
|
||||||
|
cfg = _BACKENDS[name]
|
||||||
|
url = os.environ.get("ETICHETARE_ENDPOINT") or cfg["url"]
|
||||||
|
model = os.environ.get("ETICHETARE_MODEL") or cfg["model"]
|
||||||
|
api_key = os.environ.get(cfg["key_env"]) if cfg["key_env"] else None
|
||||||
|
return Backend(name=name, url=url, model=model, api_key=api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def construieste_body(batch: list[str], backend: Backend) -> dict:
|
||||||
|
"""Corpul request-ului OpenAI-compatibil cu envelope json_schema strict."""
|
||||||
|
return {
|
||||||
|
"model": backend.model,
|
||||||
|
"messages": construieste_mesaje(batch),
|
||||||
|
"temperature": 0,
|
||||||
|
"response_format": _response_format(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Parsare + garda de truncare #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def parseaza_raspuns(content: dict, n: int) -> list[str]:
|
||||||
|
"""Mapeaza raspunsul {"rez":[{i,cod}]} la o lista paralela cu batch-ul (len n).
|
||||||
|
|
||||||
|
Garda de truncare/validare (F8): pozitiile lipsa SAU codurile in afara enum-ului
|
||||||
|
devin '?', NU sunt ascunse tacit. Apelantul logheaza cate '?' au ramas.
|
||||||
|
"""
|
||||||
|
by_i: dict[int, str] = {}
|
||||||
|
for x in content.get("rez") or []:
|
||||||
|
try:
|
||||||
|
idx = int(x["i"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
cod = str(x.get("cod") or "").strip().upper()
|
||||||
|
by_i[idx] = cod if cod in _VALID else "?"
|
||||||
|
return [by_i.get(i + 1, "?") for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Transport (injectabil in teste) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _urllib_transport(url: str, headers: dict, payload: dict, timeout: int) -> dict:
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
def call(
|
||||||
|
batch: list[str],
|
||||||
|
backend: Backend,
|
||||||
|
*,
|
||||||
|
timeout: int = 180,
|
||||||
|
max_attempts: int = 5,
|
||||||
|
transport=None,
|
||||||
|
) -> tuple[list[str], dict]:
|
||||||
|
"""Un apel pe un batch. Intoarce (codes, meta).
|
||||||
|
|
||||||
|
codes: lista paralela cu batch; '?' pe pozitiile fara raspuns valid (garda F8).
|
||||||
|
meta: {ms, err, missing} — `missing` = cate '?' au ramas (truncare/cod invalid).
|
||||||
|
transport: callable(url, headers, payload, timeout) -> dict raspuns OpenAI
|
||||||
|
(injectabil in teste; default urllib).
|
||||||
|
"""
|
||||||
|
transport = transport or _urllib_transport
|
||||||
|
body = construieste_body(batch, backend)
|
||||||
|
headers = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}
|
||||||
|
if backend.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {backend.api_key}"
|
||||||
|
t0 = time.time()
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
resp = transport(backend.url, headers, body, timeout)
|
||||||
|
content = json.loads(resp["choices"][0]["message"]["content"])
|
||||||
|
codes = parseaza_raspuns(content, len(batch))
|
||||||
|
missing = codes.count("?")
|
||||||
|
return codes, {"ms": int((time.time() - t0) * 1000), "err": None, "missing": missing}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code in (429, 500, 502, 503):
|
||||||
|
wait = float(e.headers.get("retry-after", 0)) or min(2 ** attempt, 30)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
return ["?"] * len(batch), {"ms": int((time.time() - t0) * 1000), "err": f"HTTP {e.code}", "missing": len(batch)}
|
||||||
|
except Exception as e: # noqa: BLE001 — degradare gratioasa, batch-ul devine '?'
|
||||||
|
if attempt < max_attempts - 1:
|
||||||
|
time.sleep(min(2 ** attempt, 20))
|
||||||
|
continue
|
||||||
|
return ["?"] * len(batch), {"ms": int((time.time() - t0) * 1000), "err": type(e).__name__, "missing": len(batch)}
|
||||||
|
return ["?"] * len(batch), {"ms": int((time.time() - t0) * 1000), "err": "max_attempts", "missing": len(batch)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Sanity-check manual: 1 batch mic pe backend-ul configurat (default lmstudio).
|
||||||
|
import sys
|
||||||
|
|
||||||
|
probe = sys.argv[1:] or ["13 X ITP", "INLOCUIT PLACUTE FRANA FATA", "SCHIMB ULEI MOTOR SI FILTRE"]
|
||||||
|
b = get_backend()
|
||||||
|
print(f"backend={b.name} url={b.url} model={b.model}")
|
||||||
|
codes, meta = call(probe, b)
|
||||||
|
for op, c in zip(probe, codes):
|
||||||
|
print(f" {c:6} {op}")
|
||||||
|
print("meta:", meta)
|
||||||
346
tools/mapare-llm/genereaza_seed.py
Normal file
346
tools/mapare-llm/genereaza_seed.py
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
"""Generare seed etichetat operatie->cod (US-003, PRD 5.18).
|
||||||
|
|
||||||
|
Produce artefactul `app/data/operatii-etichetate.json` (comis in repo), consumat de
|
||||||
|
seeder (US-004) si de corpusul embeddings (US-005). NU cheama LLM la runtime — o
|
||||||
|
singura data, offline, pe LM Studio (backend implicit, D4).
|
||||||
|
|
||||||
|
Pipeline dedup OBLIGATORIU, in ordine, INAINTE de orice apel LLM (D5):
|
||||||
|
1. Agrega cele N CSV-uri -> freq pe denumire RAW (NR ne-numeric -> skip rand, F9).
|
||||||
|
2. `cheie = normalize_for_match(denumire)` (ACEEASI functie ca DB/k-NN, NU strip exact).
|
||||||
|
Arunca randurile cu `cheie == ""` inainte de dedup (coliziune pe slot UNIQUE gol, F6).
|
||||||
|
3. Dedup pe cheie: un reprezentant per cheie, `freq = suma NR`.
|
||||||
|
4. Harta `cheie -> cod` din TOATE etichetele existente: `labels-groq-partial.json` (cheiat
|
||||||
|
brut) + seedul comis anterior (cheiat normalizat). Conflict (acelasi cheie, coduri diferite
|
||||||
|
pe variante raw) -> castiga codul cu freq-max, tie-break pe cod sortat (F3).
|
||||||
|
5. `de_etichetat = corpus(in prag) - harta`. Sortat desc pe freq = SINGURUL input la LLM.
|
||||||
|
|
||||||
|
Idempotenta cross-run (F2/F7): seedul comis = cache de etichete -> re-run = 0 apeluri LLM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import glob
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
# Functia de normalizare = sursa unica de adevar (consistenta cu DB/k-NN).
|
||||||
|
_APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
if _APP_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _APP_ROOT)
|
||||||
|
from app.mapping import normalize_for_match # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _load_eticheteaza():
|
||||||
|
path = os.path.join(os.path.dirname(__file__), "eticheteaza.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("eticheteaza", path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules.setdefault("eticheteaza", mod)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
# Cai implicite (relative la repo).
|
||||||
|
DEFAULT_CSV_GLOB = os.path.join(_APP_ROOT, "docs", "operatii-service", "*.csv")
|
||||||
|
DEFAULT_LABELS = os.path.join(_APP_ROOT, "tools", "mapare-llm", "labels-groq-partial.json")
|
||||||
|
DEFAULT_SEED = os.path.join(_APP_ROOT, "app", "data", "operatii-etichetate.json")
|
||||||
|
|
||||||
|
NUL_LABEL = "NUL"
|
||||||
|
DEFAULT_CONFIDENCE = 0.7
|
||||||
|
DEFAULT_SOURCE = "llm_seed"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Pasul 1-3: corpus agregat pe cheie normalizata #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _freq_raw(csv_paths: list[str]) -> Counter:
|
||||||
|
"""Counter denumire_raw -> suma NR. NR ne-numeric -> skip rand (F9), nu zero-weight."""
|
||||||
|
freq: Counter = Counter()
|
||||||
|
for f in csv_paths:
|
||||||
|
with open(f, encoding="utf-8", errors="replace") as fh:
|
||||||
|
for r in list(csv.reader(fh, delimiter=";"))[1:]:
|
||||||
|
if len(r) <= 2:
|
||||||
|
continue
|
||||||
|
den = r[1].strip()
|
||||||
|
if not den:
|
||||||
|
continue
|
||||||
|
nr_raw = (r[2] or "").strip()
|
||||||
|
try:
|
||||||
|
nr = int(nr_raw)
|
||||||
|
except ValueError:
|
||||||
|
continue # F9: skip rand cu NR ne-numeric
|
||||||
|
freq[den] += nr
|
||||||
|
return freq
|
||||||
|
|
||||||
|
|
||||||
|
def _corpus_din_freq(freq_raw: Counter) -> dict[str, dict]:
|
||||||
|
"""{cheie_normalizata -> {denumire, freq}}. Arunca cheile vide (F6).
|
||||||
|
|
||||||
|
`denumire` = varianta raw cu freq individual maxim (tie-break: raw sortat asc),
|
||||||
|
folosita ca text trimis la LLM si stocata in seed.
|
||||||
|
"""
|
||||||
|
grup: dict[str, list[tuple[str, int]]] = defaultdict(list)
|
||||||
|
for raw, n in freq_raw.items():
|
||||||
|
cheie = normalize_for_match(raw)
|
||||||
|
if not cheie:
|
||||||
|
continue # F6
|
||||||
|
grup[cheie].append((raw, n))
|
||||||
|
|
||||||
|
corpus: dict[str, dict] = {}
|
||||||
|
for cheie, variante in grup.items():
|
||||||
|
freq = sum(n for _, n in variante)
|
||||||
|
# reprezentant determinist: freq max, tie-break raw sortat.
|
||||||
|
denumire = sorted(variante, key=lambda rn: (-rn[1], rn[0]))[0][0]
|
||||||
|
corpus[cheie] = {"denumire": denumire, "freq": freq}
|
||||||
|
return corpus
|
||||||
|
|
||||||
|
|
||||||
|
def agrega_corpus(csv_paths: list[str]) -> dict[str, dict]:
|
||||||
|
"""{cheie_normalizata -> {denumire, freq}} din CSV-uri (pasii 1-3)."""
|
||||||
|
return _corpus_din_freq(_freq_raw(csv_paths))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Pasul 4: harta cheie -> cod din etichetele existente (reuse + conflict) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _incarca_seed(seed_path: str | None) -> list[dict]:
|
||||||
|
if not seed_path or not os.path.exists(seed_path):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(seed_path, encoding="utf-8") as fh:
|
||||||
|
return json.loads(fh.read())
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def construieste_harta_etichete(
|
||||||
|
freq_raw: Counter,
|
||||||
|
corpus: dict[str, dict],
|
||||||
|
labels_path: str | None,
|
||||||
|
seed_existent: list[dict],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Harta cheie_normalizata -> eticheta (cod RAR sau 'NUL'), reuse in spatiu normalizat.
|
||||||
|
|
||||||
|
Voturi ponderate pe freq; conflict pe acelasi cheie -> freq-max, tie-break cod sortat (F3).
|
||||||
|
"""
|
||||||
|
votes: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||||
|
|
||||||
|
# labels-groq-partial.json: cheiat pe text BRUT.
|
||||||
|
if labels_path and os.path.exists(labels_path):
|
||||||
|
with open(labels_path, encoding="utf-8") as fh:
|
||||||
|
labels = json.loads(fh.read())
|
||||||
|
for raw, cod in labels.items():
|
||||||
|
cheie = normalize_for_match(raw)
|
||||||
|
if not cheie:
|
||||||
|
continue
|
||||||
|
cod = str(cod or "").strip().upper()
|
||||||
|
if not cod:
|
||||||
|
continue
|
||||||
|
votes[cheie][cod] += freq_raw.get(raw, 0)
|
||||||
|
|
||||||
|
# seed comis anterior: cheiat normalizat (cache cross-run).
|
||||||
|
for e in seed_existent:
|
||||||
|
cheie = e.get("denumire_normalizata")
|
||||||
|
if not cheie:
|
||||||
|
continue
|
||||||
|
eticheta = NUL_LABEL if e.get("is_nul") else str(e.get("cod") or "").strip().upper()
|
||||||
|
if not eticheta:
|
||||||
|
continue
|
||||||
|
votes[cheie][eticheta] += corpus.get(cheie, {}).get("freq", 0)
|
||||||
|
|
||||||
|
harta: dict[str, str] = {}
|
||||||
|
for cheie, codmap in votes.items():
|
||||||
|
# freq desc, apoi cod asc -> determinist.
|
||||||
|
harta[cheie] = sorted(codmap.items(), key=lambda kv: (-kv[1], kv[0]))[0][0]
|
||||||
|
return harta
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Pasul 5: selectie de_etichetat (prag de volum) + orchestrare #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def selecteaza_de_etichetat(
|
||||||
|
corpus: dict[str, dict],
|
||||||
|
harta: dict[str, str],
|
||||||
|
*,
|
||||||
|
target_volum: float,
|
||||||
|
etichetare_all: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Cheile ne-etichetate, sortate desc pe freq, in interiorul pragului de volum."""
|
||||||
|
ordered = sorted(corpus, key=lambda k: (-corpus[k]["freq"], k))
|
||||||
|
if etichetare_all:
|
||||||
|
in_prag = ordered
|
||||||
|
else:
|
||||||
|
total = sum(c["freq"] for c in corpus.values()) or 1
|
||||||
|
in_prag = []
|
||||||
|
cum = 0
|
||||||
|
for k in ordered:
|
||||||
|
in_prag.append(k)
|
||||||
|
cum += corpus[k]["freq"]
|
||||||
|
if cum / total >= target_volum:
|
||||||
|
break
|
||||||
|
return [k for k in in_prag if k not in harta]
|
||||||
|
|
||||||
|
|
||||||
|
def genereaza(
|
||||||
|
csv_paths: list[str],
|
||||||
|
*,
|
||||||
|
labels_path: str | None = DEFAULT_LABELS,
|
||||||
|
seed_path: str = DEFAULT_SEED,
|
||||||
|
target_volum: float = 0.9,
|
||||||
|
etichetare_all: bool = False,
|
||||||
|
clasifica=None,
|
||||||
|
batch: int = 32,
|
||||||
|
confidence: float = DEFAULT_CONFIDENCE,
|
||||||
|
source: str = DEFAULT_SOURCE,
|
||||||
|
progres=None,
|
||||||
|
checkpoint_every: int = 1,
|
||||||
|
pauza: float = 0.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Genereaza/actualizeaza seedul. Intoarce statistici. Scrie `seed_path`.
|
||||||
|
|
||||||
|
`clasifica(batch_denumiri) -> list[cod]` e injectabil (teste); default = LM Studio.
|
||||||
|
`progres(mesaj)` e un callback optional de logare.
|
||||||
|
|
||||||
|
Checkpointing (`checkpoint_every` batch-uri): seedul se scrie pe disc periodic in
|
||||||
|
timpul rularii, NU doar la final. Esential pe GPU-box-ul instabil (shutdown termic
|
||||||
|
sub sarcina, memorie lmstudio-gpu-etichetare): un crash la batch-ul 80/104 pastreaza
|
||||||
|
progresul, iar re-run-ul continua din cache (idempotenta cross-run). 0 = doar la final.
|
||||||
|
"""
|
||||||
|
freq_raw = _freq_raw(csv_paths)
|
||||||
|
corpus = _corpus_din_freq(freq_raw)
|
||||||
|
seed_existent = _incarca_seed(seed_path)
|
||||||
|
harta = construieste_harta_etichete(freq_raw, corpus, labels_path, seed_existent)
|
||||||
|
de_etichetat = selecteaza_de_etichetat(
|
||||||
|
corpus, harta, target_volum=target_volum, etichetare_all=etichetare_all
|
||||||
|
)
|
||||||
|
reused = len(harta)
|
||||||
|
|
||||||
|
brute = int(sum(freq_raw.values()))
|
||||||
|
if progres:
|
||||||
|
progres(f"{len(freq_raw)} randuri brute distincte -> {len(corpus)} dupa normalizare "
|
||||||
|
f"-> {len(de_etichetat)} trimise la LLM (deja: {len(harta)})")
|
||||||
|
|
||||||
|
clasif = clasifica
|
||||||
|
if clasif is None:
|
||||||
|
et = _load_eticheteaza()
|
||||||
|
backend = et.get_backend()
|
||||||
|
if progres:
|
||||||
|
progres(f"backend={backend.name} url={backend.url} model={backend.model}")
|
||||||
|
|
||||||
|
def clasif(batch_denumiri):
|
||||||
|
return et.call(batch_denumiri, backend)[0]
|
||||||
|
|
||||||
|
apeluri = 0
|
||||||
|
valide = _valid_labels()
|
||||||
|
nr_batch = (len(de_etichetat) + batch - 1) // batch
|
||||||
|
for k in range(0, len(de_etichetat), batch):
|
||||||
|
chunk = de_etichetat[k:k + batch]
|
||||||
|
denumiri = [corpus[c]["denumire"] for c in chunk]
|
||||||
|
codes = clasif(denumiri)
|
||||||
|
apeluri += 1
|
||||||
|
for cheie, cod in zip(chunk, codes):
|
||||||
|
cod = str(cod or "").strip().upper()
|
||||||
|
if cod in valide: # '?' / cod invalid -> ramane ne-etichetat (retry la urmatorul run)
|
||||||
|
harta[cheie] = cod
|
||||||
|
if progres:
|
||||||
|
progres(f" batch {apeluri}/{nr_batch} "
|
||||||
|
f"-> total etichetat {sum(1 for c in harta if c in corpus)}")
|
||||||
|
# Checkpoint periodic: protejeaza progresul pe GPU-box instabil.
|
||||||
|
if checkpoint_every and apeluri % checkpoint_every == 0:
|
||||||
|
_scrie_seed(seed_path, _construieste_seed(corpus, harta, confidence=confidence, source=source))
|
||||||
|
# Pauza intre batch-uri: ragaz termic pentru GPU-box (shutdown sub sarcina sustinuta).
|
||||||
|
if pauza and k + batch < len(de_etichetat):
|
||||||
|
import time as _t
|
||||||
|
_t.sleep(pauza)
|
||||||
|
|
||||||
|
seed = _construieste_seed(corpus, harta, confidence=confidence, source=source)
|
||||||
|
_scrie_seed(seed_path, seed)
|
||||||
|
return {
|
||||||
|
"brute": brute,
|
||||||
|
"distincte": len(corpus),
|
||||||
|
"deja_etichetate": reused,
|
||||||
|
"de_etichetat": len(de_etichetat),
|
||||||
|
"apeluri_llm": apeluri,
|
||||||
|
"seed": len(seed),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_labels() -> set[str]:
|
||||||
|
et = _load_eticheteaza()
|
||||||
|
return set(et.ALL_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
def _construieste_seed(corpus, harta, *, confidence, source) -> list[dict]:
|
||||||
|
"""Seed ordonat determinist (pe cheie) -> byte-stabil intre rulari."""
|
||||||
|
out = []
|
||||||
|
for cheie in sorted(harta):
|
||||||
|
if cheie not in corpus:
|
||||||
|
continue # eticheta fara corespondent in corpusul curent
|
||||||
|
eticheta = harta[cheie]
|
||||||
|
is_nul = eticheta == NUL_LABEL
|
||||||
|
out.append({
|
||||||
|
"denumire": corpus[cheie]["denumire"],
|
||||||
|
"denumire_normalizata": cheie,
|
||||||
|
"cod": None if is_nul else eticheta,
|
||||||
|
"is_nul": is_nul,
|
||||||
|
"source": source,
|
||||||
|
"confidence": confidence,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _scrie_seed(seed_path: str, seed: list[dict]) -> None:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(seed_path)), exist_ok=True)
|
||||||
|
with open(seed_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(seed, fh, ensure_ascii=False, indent=2)
|
||||||
|
fh.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description="Genereaza seed etichetat operatie->cod (LM Studio).")
|
||||||
|
ap.add_argument("--target-volum", type=float, default=0.9,
|
||||||
|
help="prag de acoperire pe volum (default 0.9 = D1)")
|
||||||
|
ap.add_argument("--all", action="store_true", help="eticheteaza tot corpusul, ignora pragul")
|
||||||
|
ap.add_argument("--batch", type=int, default=32, help="dimensiune batch (conservator: 32-40)")
|
||||||
|
ap.add_argument("--pauza", type=float, default=1.5,
|
||||||
|
help="secunde de pauza intre batch-uri (ragaz termic GPU); 0 = fara")
|
||||||
|
ap.add_argument("--checkpoint-every", type=int, default=1,
|
||||||
|
help="scrie seedul la fiecare N batch-uri (1 = dupa fiecare, crash-safe)")
|
||||||
|
ap.add_argument("--confidence", type=float, default=DEFAULT_CONFIDENCE)
|
||||||
|
ap.add_argument("--csv-glob", default=DEFAULT_CSV_GLOB)
|
||||||
|
ap.add_argument("--labels", default=DEFAULT_LABELS)
|
||||||
|
ap.add_argument("--seed", default=DEFAULT_SEED)
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
csv_paths = sorted(glob.glob(args.csv_glob))
|
||||||
|
if not csv_paths:
|
||||||
|
ap.error(f"niciun CSV gasit la {args.csv_glob}")
|
||||||
|
|
||||||
|
stats = genereaza(
|
||||||
|
csv_paths,
|
||||||
|
labels_path=args.labels,
|
||||||
|
seed_path=args.seed,
|
||||||
|
target_volum=args.target_volum,
|
||||||
|
etichetare_all=args.all,
|
||||||
|
batch=args.batch,
|
||||||
|
pauza=args.pauza,
|
||||||
|
checkpoint_every=args.checkpoint_every,
|
||||||
|
confidence=args.confidence,
|
||||||
|
progres=lambda m: print(m, flush=True),
|
||||||
|
)
|
||||||
|
print("GATA:", json.dumps(stats, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user