diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index 1244450..7d50059 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -44,9 +44,12 @@ from ...import_parse import ( parse_file, ) from ...mapping import ( + _emite_text_rule_hits, account_or_default, has_no_auto_send, load_mapping_meta, + load_nomenclator_codes, + load_text_rules, normalize_for_match, resolve_prestatii, ) @@ -126,6 +129,8 @@ def _resolve_row_for_preview( mapping_meta: dict[str, dict], formula_columns: list[str], override: dict[str, Any] | None = None, + valid_codes: set[str] | None = None, + text_rules: list[dict] | None = None, ) -> dict[str, Any]: """Rezolva un rand din import pentru preview: aplica mapare coloane + validare. @@ -201,7 +206,7 @@ def _resolve_row_for_preview( # Rezolvare prestatii prestatii = mapped.get("prestatii") or [] - resolved, unmapped = resolve_prestatii(prestatii, mapping) + resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules) mapped["prestatii"] = resolved # Determinare stare @@ -745,6 +750,9 @@ def preview_import( # Incarca maparea de operatii o singura data (Eng#5: load_mapping o singura data) mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} + # T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri. + valid_codes = load_nomenclator_codes(conn) or None + text_rules = load_text_rules(conn, acct) # Reconstruieste parsed info (coercion_flags si date_col_format) din datele stocate # Nota: import_rows stocheaza raw_json dupa coercion (din parse_file) @@ -797,6 +805,8 @@ def preview_import( mapping_meta=mapping_meta, formula_columns=formula_columns, override=overrides[i] or None, + valid_codes=valid_codes, + text_rules=text_rules, ) # Calculeaza cheia de idempotenta pentru randurile ok/needs_review @@ -1030,6 +1040,9 @@ def commit_import( # Incarca maparea de operatii mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} + # T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri. + valid_codes = load_nomenclator_codes(conn) or None + text_rules = load_text_rules(conn, acct) # Construieste payload-urile submissions enqueued: list[dict] = [] @@ -1076,7 +1089,7 @@ def commit_import( # Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview) prestatii = mapped.get("prestatii") or [] - resolved, _ = resolve_prestatii(prestatii, mapping) + resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules) mapped["prestatii"] = resolved # Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie) @@ -1127,6 +1140,8 @@ def commit_import( toctou_collisions.append(row_index) else: sub_id = cur.lastrowid + # US-010: telemetrie pentru itemii rezolvati prin regula text. + _emite_text_rule_hits(conn, acct, int(sub_id), resolved) enqueued.append({ "submission_id": sub_id, "row_index": row_index, diff --git a/app/api/v1/router.py b/app/api/v1/router.py index e9c05b0..bf59b26 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -25,11 +25,13 @@ from ...db import get_connection from ...errors import eroare as err_eroare from ...idempotency import build_key, canonicalize_row from ...mapping import ( + _emite_text_rule_hits, account_or_default, account_scope_clause, classify_prezentare, load_mapping_meta, load_nomenclator_codes, + load_text_rules, pending_unmapped, reresolve_account, save_mapping, @@ -65,13 +67,13 @@ def _effective_on_unmapped_error(conn, acct: int, req_value: bool | None) -> boo return bool(row["on_unmapped_error_default"]) if row else False -def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode) -> dict: +def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules=None) -> dict: """classify_prezentare + aplicarea modului on_unmapped_error. Cand exista coduri nemapate si error_mode=True, marcheaza outcome-ul ca respingere (blocked_error=True): rutele NU mai fac enqueue, ci intorc o eroare per-element. """ - cl = classify_prezentare(content, mapping, mapping_meta, valid_codes) + cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, text_rules) cl["blocked_error"] = bool(cl["unmapped"]) and error_mode return cl @@ -164,6 +166,8 @@ def create_prezentari( # Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat. # valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot). valid_codes = load_nomenclator_codes(conn) or None + # Reguli text incarcate o data per cerere (seam partajat cu dry-run, invariant 5.2). + text_rules = load_text_rules(conn, acct) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) for prez in req.prezentari: content = prez.model_dump() @@ -187,7 +191,7 @@ def create_prezentari( # retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam # creds + reset), printr-un UPDATE compare-and-swap pe status='error'. if existing["status"] == "error": - cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode) + cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) if cl["blocked_error"]: # on_unmapped_error=True: nu reactivam; randul ramane 'error'. results.append(_rezultat_respins(existing["id"], cl)) @@ -208,6 +212,8 @@ def create_prezentari( "UPDATE accounts SET rar_creds_enc=? WHERE id=?", (encrypt_creds(req.rar_credentials.model_dump()), acct), ) + # US-010: telemetrie pentru itemii rezolvati prin regula text. + _emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"]) # Raspuns onest si la reactivare (PRD 5.7): daca re-clasificarea # cade pe needs_data/needs_mapping, expune motivul (nu doar status). results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True)) @@ -230,7 +236,7 @@ def create_prezentari( # Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea # (canonicalize + mapare op->cod + validare + auto_send gate). - cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode) + cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) if cl["blocked_error"]: # on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat). results.append(_rezultat_respins(None, cl)) @@ -240,8 +246,11 @@ def create_prezentari( "VALUES (?, ?, ?, ?, ?, ?)", (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc), ) + sub_id = int(cur.lastrowid) + # US-010: telemetrie pentru itemii rezolvati prin regula text. + _emite_text_rule_hits(conn, acct, sub_id, cl["resolved"]) # Raspuns onest (PRD 5.7): pe needs_data/needs_mapping expune erori/nemapate/motiv. - results.append(_rezultat_enqueue(int(cur.lastrowid), cl)) + results.append(_rezultat_enqueue(sub_id, cl)) # US-004: audit cerere API per cont. Doar metadate (count + distributie status), # NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL). @@ -284,10 +293,12 @@ def valideaza_prezentari( mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} valid_codes = load_nomenclator_codes(conn) or None + # Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text. + text_rules = load_text_rules(conn, acct) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) for i, prez in enumerate(req.prezentari): content = prez.model_dump() - res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode) + res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) if res["blocked_error"]: res = {**res, "status": "error"} # US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT diff --git a/app/mapping.py b/app/mapping.py index d834332..2ee0036 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -87,27 +87,127 @@ def suggest_codes( ] +# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text (US-010). +# Forma: "text_rule:". Payload-harmless — +# RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect. +COD_SURSA_TEXT_RULE_PREFIX = "text_rule:" + + +def _rezolva_din_reguli_text( + item: dict, + text_rules: list[dict] | None, + valid_codes: set[str] | None, +) -> tuple[str | None, str | None, bool | None]: + """Cauta prima regula text (in ordinea data) al carei pattern e substring al + textului operatiei. Intoarce (cod uppercase, pattern original, auto_send) daca e + valid, altfel (None, None, None). + + Textul operatiei = `denumire` daca exista, altfel `cod_op_service`. Ambele parti + (text si pattern) se normalizeaza cu `normalize_for_match` (fara diacritice, + uppercase, spatii colapsate) -> match insensibil la caz/diacritice. + + `text_rules` e deja ordonata (priority ASC, id ASC) de `load_text_rules`, deci + prima regula care da match castiga. Daca regula castigatoare are un cod absent din + `valid_codes` (cand `valid_codes` e setat), nu intoarcem un cod invalid -> + (None, None, None) (operatia ramane nemapata), coerent cu garda din `resolve_prestatii`. + + Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie US-010), nu cel + normalizat folosit la match. `auto_send` = flagul regulii castigatoare: cand e + falsy (DEFAULT 0, decizia CEO de siguranta) randul trebuie TINUT pentru verificare + umana, nu trimis automat la RAR (blast radius substring + FINALIZATA ireversibil). + """ + if not text_rules: + return None, None, None + text = normalize_for_match(item.get("denumire") or item.get("cod_op_service")) + if not text: + return None, None, None + for rule in text_rules: + pat = normalize_for_match(rule.get("pattern")) + if not pat or pat not in text: + continue + # Prima regula care da match castiga. + cod = (rule.get("cod_prestatie") or "").strip().upper() + if not cod: + return None, None, None + if valid_codes is not None and cod not in valid_codes: + return None, None, None # cod invalid in nomenclator -> nu il punem; ramane nemapat + return cod, rule.get("pattern"), bool(rule.get("auto_send")) + return None, None, None + + +def text_rule_hits(resolved: list[dict] | None) -> list[dict]: + """Extrage din itemii rezolvati cei care au primit cod dintr-o regula text (US-010). + + Intoarce [{pattern, cod_prestatie}] pentru fiecare item al carui `cod_sursa` + incepe cu `COD_SURSA_TEXT_RULE_PREFIX`. Pur (fara DB); apelantii cu `conn` il + folosesc ca sa emita `log_event("text_rule_hit", ...)`. + """ + hits: list[dict] = [] + for item in resolved or []: + sursa = item.get("cod_sursa") + if isinstance(sursa, str) and sursa.startswith(COD_SURSA_TEXT_RULE_PREFIX): + hits.append({ + "pattern": sursa[len(COD_SURSA_TEXT_RULE_PREFIX):], + "cod_prestatie": item.get("cod_prestatie"), + }) + return hits + + +def text_rules_overlap(pattern: str, existing_rules: list[dict] | None) -> list[dict]: + """Reguli text existente care se SUPRAPUN cu `pattern` (US-011, avertisment neblocant). + + Overlap = pattern-ul nou normalizat (`normalize_for_match`) e substring al unei + reguli existente SAU invers (oricare directie). Pur, determinist, fara DB. + + Un pattern IDENTIC dupa normalizare NU e overlap: e un upsert (update al codului), + nu o suprapunere care merita avertisment. Intoarce dict-urile originale din + `existing_rules` care se suprapun (in ordinea data). + """ + pat = normalize_for_match(pattern) + if not pat: + return [] + hits: list[dict] = [] + for rule in existing_rules or []: + other = normalize_for_match(rule.get("pattern")) + if not other or other == pat: + continue # gol sau identic -> nu e overlap + if pat in other or other in pat: + hits.append(rule) + return hits + + def resolve_prestatii( prestatii: list[dict] | None, mapping: dict[str, str], valid_codes: set[str] | None = None, + text_rules: list[dict] | None = None, ) -> tuple[list[dict], list[dict]]: """Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste. Reguli (hibrid): - item cu `cod_prestatie` valid (in nomenclator) -> pastrat ca atare. - item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie. - - item fara cod si fara mapare -> ramane nemapat. + - item fara cod, nemapat exact, dar al carui text da match pe o regula text + (substring) -> umplem cod_prestatie din prima regula care potriveste. + - item fara cod, fara mapare si fara regula text -> ramane nemapat. - item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de mapat: il promovam la `cod_op_service` (daca nu exista deja) ca sa intre in fluxul needs_mapping. Confirmat live (2026-06-23): RAR accepta NUMAI coduri din nomenclator (coloana COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL la RAR (terminal) -> nu-l trimitem niciodata raw. + Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service` + in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista + cod valid SI op nu e in `mapping`. + `valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece neatins"); rutele API il paseaza intotdeauna. + `text_rules` = lista de dict-uri ca cea intoarsa de `load_text_rules` + ([{pattern, cod_prestatie, auto_send, priority}], ordonata priority ASC, id ASC). + Default None = comportament actual neschimbat (fara reguli text). + Intoarce (prestatii_rezolvate, nemapate). `prestatii_rezolvate` pastreaza si campurile originale (cod_op_service/denumire) ca re-rezolvarea sa aiba contextul; payload-ul RAR citeste doar cod_prestatie. `nemapate` = @@ -117,6 +217,12 @@ def resolve_prestatii( unmapped: list[dict] = [] for item in prestatii or []: it = dict(item) + # Curata adnotarile aditive ale rezolvarii (cod_sursa US-010 + flagul de + # hold pe regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare. + # Altfel, un item re-rezolvat acum prin alta cale (ex. mapare exacta) ar pastra + # un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit. + it.pop("cod_sursa", None) + it.pop("regula_fara_autosend", None) cod = (it.get("cod_prestatie") or "").strip().upper() op = (it.get("cod_op_service") or "").strip() cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes) @@ -134,8 +240,22 @@ def resolve_prestatii( if op and op in mapping: it["cod_prestatie"] = mapping[op] elif op: - it["cod_prestatie"] = None - unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")}) + # Mapare exacta absenta -> incearca regulile text (substring). + cod_regula, pattern_regula, auto_send_regula = _rezolva_din_reguli_text( + it, text_rules, valid_codes + ) + if cod_regula is not None: + it["cod_prestatie"] = cod_regula + # Adnotare aditiva (US-010): marcheaza ca rezolvat-prin-regula cu + # pattern-ul sursa. Payload-harmless (RAR citeste doar cod_prestatie). + it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}" + # Siguranta CEO (US-001): regula cu auto_send=0 rezolva codul dar + # TINE randul pentru verificare umana (has_no_auto_send -> True). + if not auto_send_regula: + it["regula_fara_autosend"] = True + else: + it["cod_prestatie"] = None + unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")}) # item fara cod si fara op: il lasam asa; validarea de continut prinde # "prestatii goale"/cod lipsa. resolved.append(it) @@ -254,6 +374,7 @@ def classify_prezentare( mapping: dict[str, str], mapping_meta: dict[str, dict], valid_codes: set[str] | None = None, + text_rules: list[dict] | None = None, ) -> dict: """Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte. @@ -273,7 +394,7 @@ def classify_prezentare( "odometru_final": canon["odometru_final"], }) - resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes) + resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules) c["prestatii"] = resolved if unmapped: @@ -311,12 +432,17 @@ def classify_prezentare( def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool: - """Verifica daca vreun item rezolvat via mapping are auto_send=0. + """Verifica daca vreun item rezolvat are auto_send=0 (mapare exacta SAU regula text). - T6/OV-1: un cod nou-mapat cu auto_send=0 nu trebuie trimis automat. - Items cu cod_prestatie direct (nu via cod_op_service) nu sunt afectate. + T6/OV-1: un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat. + PRD 5.8 US-001 (decizia CEO): la fel pentru un item rezolvat printr-o REGULA TEXT cu + auto_send=0 — marcat de `resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri + randul ramane needs_mapping (review manual) pana cand operatorul activeaza „In coada". + Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate. """ for item in resolved: + if item.get("regula_fara_autosend"): + return True op = (item.get("cod_op_service") or "").strip() if op and op in mapping_meta and not mapping_meta[op]["auto_send"]: return True @@ -395,6 +521,85 @@ def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestati ) +def load_text_rules(conn, account_id: int | None) -> list[dict]: + """Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC. + + Fiecare element: {pattern, cod_prestatie, auto_send, priority}. + Aplica account_or_default (None == 1). + """ + acct = account_or_default(account_id) + rows = conn.execute( + "SELECT pattern, cod_prestatie, auto_send, priority " + "FROM operation_text_rules " + "WHERE account_id=? " + "ORDER BY priority ASC, id ASC", + (acct,), + ).fetchall() + return [dict(r) for r in rows] + + +def save_text_rule( + conn, + account_id: int | None, + pattern: str, + cod_prestatie: str, + auto_send: bool, +) -> None: + """Upsert o regula text pe (account_id, pattern). + + auto_send boolean -> 0/1. Daca regula exista deja (acelasi cont + pattern), + actualizeaza cod_prestatie si auto_send. + """ + acct = account_or_default(account_id) + pat = (pattern or "").strip() + cod = (cod_prestatie or "").strip().upper() + if not pat or not cod: + raise ValueError("pattern si cod_prestatie sunt obligatorii") + conn.execute( + "INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(account_id, pattern) DO UPDATE SET " + "cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send", + (acct, pat, cod, 1 if auto_send else 0), + ) + + +def delete_text_rule(conn, account_id: int | None, pattern: str) -> None: + """Sterge regula cu (account_id, pattern) daca exista.""" + acct = account_or_default(account_id) + pat = (pattern or "").strip() + conn.execute( + "DELETE FROM operation_text_rules WHERE account_id=? AND pattern=?", + (acct, pat), + ) + + +def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None: + """Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text. + + US-010: telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event + inghite exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} — + fara PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import. + """ + hits = text_rule_hits(resolved) + if not hits: + return + from .observ import log_event # import local: best-effort, fara ciclu la import-time + for hit in hits: + log_event( + "text_rule_hit", + account_id=account_id, + cod=hit.get("cod_prestatie"), + conn=conn, + context={ + "submission_id": submission_id, + "account_id": account_id, + "pattern": hit.get("pattern"), + "cod_prestatie": hit.get("cod_prestatie"), + }, + ) + + def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) -> dict[str, int]: """Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare. @@ -413,6 +618,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} valid_codes = load_nomenclator_codes(conn) or None + # T2: incarca regulile text O DATA, inainte de bucla pe randuri. + text_rules = load_text_rules(conn, acct) if batch_id is not None: # T7: scope la batch-ul specificat (import commit explicit). @@ -438,10 +645,13 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) content = json.loads(r["payload_json"]) except (ValueError, TypeError): continue - resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes) + resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules) content["prestatii"] = resolved payload_json = json.dumps(content, ensure_ascii=False) + # US-010: telemetrie pentru itemii rezolvati prin regula text. + _emite_text_rule_hits(conn, acct, r["id"], resolved) + if unmapped: conn.execute( "UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?", diff --git a/app/payload_view.py b/app/payload_view.py index 37f293a..592c090 100644 --- a/app/payload_view.py +++ b/app/payload_view.py @@ -35,6 +35,17 @@ def _clean_odometru(value: Any) -> str: return s +def _clean_cod_rar(value: Any) -> str: + """Cod RAR afisat curat: uppercase + strip '.0' defensiv (coercion Excel 'OE-2.0' -> 'OE-2'). + + Codurile RAR nu au zecimale, dar fii defensiv ca la odometru. + """ + s = _clean_str(value) + if s.endswith(".0"): + s = s[:-2] + return s.upper() if s else "" + + def _vin_scurt(vin: str) -> str: """Forma trunchiata a VIN-ului pentru tabel (integral ramane in detaliu). @@ -101,6 +112,9 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]: denumire = _clean_str(item.get("denumire")) operatie = denumire or cod + # cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0" + cod_rar = _clean_cod_rar(item.get("cod_prestatie")) + return { "vehicul_nr": nr or EMPTY, "vin": vin or EMPTY, @@ -109,4 +123,5 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]: "data_prestatie": data_prest or EMPTY, "odometru": odo or EMPTY, "cod": cod or EMPTY, + "cod_rar": cod_rar or EMPTY, } diff --git a/app/schema.sql b/app/schema.sql index 4f0d15a..be9a0c4 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -182,6 +182,22 @@ CREATE INDEX IF NOT EXISTS idx_app_events_ts ON app_events(ts); CREATE INDEX IF NOT EXISTS idx_app_events_account ON app_events(account_id, ts); CREATE INDEX IF NOT EXISTS idx_app_events_tip ON app_events(tip); +-- Reguli automate de mapare pe text (substring, per cont). PRD 5.8 US-001. +-- auto_send DEFAULT 0 (decizie CEO 2026-06-24): substring are blast-radius mai mare +-- decat maparea exacta; o regula noua rezolva codul dar tine randul pentru verificare +-- umana pana cand operatorul activeaza explicit "In coada". +CREATE TABLE IF NOT EXISTS operation_text_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + pattern TEXT NOT NULL, + cod_prestatie TEXT NOT NULL, + auto_send INTEGER NOT NULL DEFAULT 0, + priority INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (account_id, pattern) +); +CREATE INDEX IF NOT EXISTS idx_text_rules_account ON operation_text_rules(account_id); + -- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici. CREATE TABLE IF NOT EXISTS worker_heartbeat ( id INTEGER PRIMARY KEY CHECK (id = 1), diff --git a/app/web/labels.py b/app/web/labels.py index c99e75f..d3d3419 100644 --- a/app/web/labels.py +++ b/app/web/labels.py @@ -58,6 +58,38 @@ STARI_SUBMISSION: dict[str, Eticheta] = { } +# --------------------------------------------------------------------------- +# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri (US-006) +# Dict propriu — NU element in tuple Eticheta (ar rupe template-urile care +# despacheteaza 3 elemente). eticheta_stare ramane neatinsa. +# --------------------------------------------------------------------------- + +ETICHETE_SCURTE: dict[str, str] = { + "queued": "In coada", + "sending": "Se trimite", + "sent": "Finalizat", + "needs_mapping": "De mapat", + "needs_data": "Date lipsa", + "error": "Eroare", +} + + +def eticheta_scurta(status: str) -> str: + """ + Returneaza eticheta compacta (pill) pentru o stare de submission. + + Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda + stari noi adaugate in schema fara mapare corespunzatoare. + """ + try: + return ETICHETE_SCURTE[status] + except KeyError: + raise KeyError( + f"Starea de submission {status!r} nu are eticheta scurta in labels.py. " + "Adauga-o in ETICHETE_SCURTE." + ) + + def eticheta_stare(status: str) -> Eticheta: """ Returneaza (text, subtext, css_class) pentru o stare de submission. diff --git a/app/web/routes.py b/app/web/routes.py index 20c0faa..422d5be 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -30,6 +30,7 @@ from ..web.csrf import get_csrf_token, verify_csrf from .labels import ( ETICHETA_ULTIMA_AUTENTIFICARE_RAR, eticheta_rar, + eticheta_scurta, eticheta_stare, eticheta_worker, format_data_rar, @@ -62,16 +63,23 @@ from ..submissions_admin import ( ) from ..mapping import ( DEFAULT_ACCOUNT_ID, + _emite_text_rule_hits, account_or_default, account_scope_clause, + delete_text_rule, has_no_auto_send, load_mapping_meta, load_nomenclator, + load_nomenclator_codes, + load_text_rules, + normalize_for_match, pending_unmapped, reresolve_account, resolve_prestatii, save_mapping, + save_text_rule, suggest_codes, + text_rules_overlap, ) # Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5) @@ -228,6 +236,7 @@ def _render_panel_mapari(request: Request, conn, account_id: int) -> str: "pending": pending_unmapped(conn, account_id), "saved_mappings": _load_saved_op_mappings(conn, account_id), "column_formats": _load_column_formats(conn, account_id), + "text_rules": load_text_rules(conn, account_id), "nomenclator": load_nomenclator(conn), "message": None, "csrf_token": get_csrf_token(request), @@ -646,6 +655,8 @@ def _submission_row_view(r) -> dict: return { "id": r["id"], "status": r["status"], + # PRD 5.8 US-007/US-006: pill = eticheta scurta; textul lung ramane ca tooltip (title=). + "stare_scurt": eticheta_scurta(r["status"]), "stare_text": eticheta[0], "stare_css": eticheta[2], "prez": prezentare_din_payload(r["payload_json"]), @@ -981,9 +992,14 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR # la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata. mapping_meta = load_mapping_meta(conn, account_id) mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()} - resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping) + valid_codes = load_nomenclator_codes(conn) or None + text_rules = load_text_rules(conn, account_id) + resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules) content["prestatii"] = resolved + # US-010: telemetrie pentru itemii rezolvati prin regula text (calea corectie web). + _emite_text_rule_hits(conn, account_id, row["id"], resolved) + # Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie. canon = canonicalize_row(content) content.update({ @@ -1228,6 +1244,7 @@ def _render_mapari( "pending": pending_unmapped(conn, account_id), "saved_mappings": _load_saved_op_mappings(conn, account_id), "column_formats": _load_column_formats(conn, account_id), + "text_rules": load_text_rules(conn, account_id), "nomenclator": load_nomenclator(conn), "message": message, "csrf_token": get_csrf_token(request), @@ -1342,6 +1359,138 @@ def post_sterge_mapare_salvata( conn.close() +# =========================================================================== # +# US-004 (5.8) — Reguli automate (text): substring -> cod RAR # +# Adaugare/stergere reguli text scoped pe sesiune; salvarea re-rezolva blocajele.# +# =========================================================================== # + +@router.post("/mapari/reguli-text", response_class=HTMLResponse) +def post_salveaza_regula_text( + request: Request, + pattern: str = Form(...), + cod_prestatie: str = Form(...), + csrf_token: str | None = Form(None), + auto_send: bool = Form(False), +) -> HTMLResponse: + """Salveaza o regula text (substring -> cod RAR) + re-rezolva submission-urile blocate. + + Scoped pe contul sesiunii (save_text_rule foloseste account_or_default(sesiune)). + Valideaza cod_prestatie fata de nomenclator INAINTE de save (cod necunoscut -> + respins inline, fara salvare). La succes: mesaj „Regula salvata. Deblocate: N" + + trigger trimiteriChanged (refresh lista), ca maparea inline (5.7). + """ + account_id = require_login(request) + verify_csrf(request, csrf_token) + conn = get_connection() + try: + pat = (pattern or "").strip() + cod = (cod_prestatie or "").strip().upper() + if not pat or not cod: + return _render_mapari( + request, conn, account_id, + message="Completeaza textul cautat si codul RAR.", + ) + valid_codes = load_nomenclator_codes(conn) + if valid_codes and cod not in valid_codes: + return _render_mapari( + request, conn, account_id, + message=f"Cod RAR necunoscut in nomenclator: {cod}.", + ) + # US-011: avertisment neblocant daca regula noua se suprapune (substring, + # oricare directie) cu una existenta. Calculam INAINTE de save, fata de + # regulile curente, ca pattern-ul nou sa nu se compare cu sine. + overlap = text_rules_overlap(pat, load_text_rules(conn, account_id)) + save_text_rule(conn, account_id, pat, cod, auto_send) + stats = reresolve_account(conn, account_id) + msg = f"Regula salvata. Deblocate: {stats['requeued']}." + if overlap: + parti = "; ".join( + f"«{r.get('pattern')}» -> {r.get('cod_prestatie')}" for r in overlap + ) + msg += ( + f" Se suprapune cu regula {parti}; " + "ordinea (priority, id) decide care se aplica prima." + ) + resp = _render_mapari(request, conn, account_id, message=msg) + resp.headers["HX-Trigger"] = "trimiteriChanged" + return resp + finally: + conn.close() + + +@router.post("/mapari/reguli-text/sterge", response_class=HTMLResponse) +def post_sterge_regula_text( + request: Request, + pattern: str = Form(...), + csrf_token: str | None = Form(None), +) -> HTMLResponse: + """Sterge o regula text. Scoped pe contul sesiunii.""" + account_id = require_login(request) + verify_csrf(request, csrf_token) + conn = get_connection() + try: + pat = (pattern or "").strip() + delete_text_rule(conn, account_id, pat) + return _render_mapari( + request, conn, account_id, + message=f"Regula stearsa: «{pat}».", + ) + finally: + conn.close() + + +@router.post("/mapari/reguli-text/preview", response_class=HTMLResponse) +def post_preview_regula_text( + request: Request, + pattern: str = Form(""), + csrf_token: str | None = Form(None), +) -> HTMLResponse: + """Preview pre-salvare (US-009): cate operatii nemapate ar potrivi regula. + + NU salveaza nimic (zero scriere DB). Normalizeaza pattern-ul cu + normalize_for_match, numara operatiile DISTINCTE nemapate ale contului + (needs_mapping, reuse pending_unmapped) al caror text (denumire sau + cod_op_service, normalizat) CONTINE pattern-ul + intoarce pana la 3 exemple. + Pattern gol -> fragment gol (nu numara „tot"). Scoped pe contul sesiunii. + """ + from markupsafe import escape + + account_id = require_login(request) + verify_csrf(request, csrf_token) + pat = normalize_for_match(pattern) + if not pat: + return HTMLResponse(content="") + conn = get_connection() + try: + pending = pending_unmapped(conn, account_id) + finally: + conn.close() + + matches = [] + for entry in pending: + text = normalize_for_match(entry.get("denumire") or entry.get("cod_op_service")) + if pat in text: + matches.append(entry) + + if not matches: + return HTMLResponse( + content='Nicio potrivire acum.' + ) + + exemple = ", ".join( + f"«{escape((e.get('denumire') or e.get('cod_op_service') or '').strip())}»" + for e in matches[:3] + ) + n = len(matches) + plural = "operatie nemapata" if n == 1 else "operatii nemapate" + return HTMLResponse( + content=( + f'' + f'Potriveste {n} {plural}: {exemple}.' + ) + ) + + # =========================================================================== # # US-006 — Formate de coloane salvate: editare format data + stergere # # CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). # @@ -1510,6 +1659,11 @@ def _web_compute_preview( # Mapare operatii (o singura incarcare — Eng#5) mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} + # US-010/US-003 (paritate): preview-ul web trebuie sa aplice ACELEASI reguli text + + # validare nomenclator ca si commit-ul (2426), altfel un rand rezolvabil doar prin + # regula text ar fi marcat needs_mapping si exclus din commit. Incarcate o data. + valid_codes = load_nomenclator_codes(conn) or None + text_rules = load_text_rules(conn, acct) # Detectie coercion flags din valorile stocate (VIN numeric) coercion_flags_map: dict[int, list[str]] = {} @@ -1553,6 +1707,8 @@ def _web_compute_preview( mapping_meta=mapping_meta, formula_columns=formula_columns, override=overrides[i] or None, + valid_codes=valid_codes, + text_rules=text_rules, ) key: str | None = None @@ -2237,6 +2393,9 @@ async def web_confirma_import( # Mapare operatii mapping_meta = load_mapping_meta(conn, acct) mapping_ops = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} + # T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri. + valid_codes = load_nomenclator_codes(conn) or None + text_rules = load_text_rules(conn, acct) # Enqueue in tranzactie explicita (Issue 6) — INSERT ON CONFLICT DO NOTHING (TOCTOU) enqueued: list[dict] = [] @@ -2275,7 +2434,7 @@ async def web_confirma_import( # Rezolva prestatii prestatii = mapped.get("prestatii") or [] - resolved_p, _ = resolve_prestatii(prestatii, mapping_ops) + resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules) mapped["prestatii"] = resolved_p # Canonicalizare @@ -2319,6 +2478,8 @@ async def web_confirma_import( if cur.rowcount == 0: toctou.append(row_index) else: + # US-010: telemetrie pentru itemii rezolvati prin regula text. + _emite_text_rule_hits(conn, acct, int(cur.lastrowid), resolved_p) enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index}) conn.execute("COMMIT") diff --git a/app/web/templates/_coada.html b/app/web/templates/_coada.html index 113b893..d09df7e 100644 --- a/app/web/templates/_coada.html +++ b/app/web/templates/_coada.html @@ -67,7 +67,9 @@ - -
+ + diff --git a/app/web/templates/_mapari.html b/app/web/templates/_mapari.html index 1a28c4e..dc6ba30 100644 --- a/app/web/templates/_mapari.html +++ b/app/web/templates/_mapari.html @@ -254,4 +254,100 @@ {% endif %} + + + +
+

Reguli automate (text)

+

+ O regula leaga orice operatie al carei text contine (nu egal, ci substring) + un cuvant de un cod RAR. Util pentru operatii fara cod intern: ex. orice operatie care + contine „verificare" primeste codul ales. Match insensibil la majuscule/diacritice. + In coada: implicit oprit — regula rezolva codul dar tine randul pentru + verificare umana pana activezi „In coada". +

+ + {% if not text_rules %} +
+ Inca nu ai reguli. Ex: operatia contine «verificare» → OE-2. + Mapeaza automat operatii similare fara cod intern. Adauga prima regula mai jos. +
+ {% endif %} + +
+ + + + + + + + + {% for r in text_rules %} + + + + + + + {% endfor %} + {# Rand de adaugare (mereu prezent ca placeholder, inclusiv in empty state). #} + + + + + + + {# Preview pre-salvare (US-009): cate operatii nemapate potriveste pattern-ul. #} + + + + +
Daca operatia contineCod RARIn coadaActiuni
+
+ + +
+
contine «{{ r.pattern }}»
+
+ {{ r.cod_prestatie }} + + {% if r.auto_send %}Auto (in coada){% else %}Manual (verificare){% endif %} + + +
+
+ + +
+
+ + + {{ ui.autosend_toggle(form_id="rt-add", checked=False) }} + + +
+
+
+
+
+ diff --git a/app/web/templates/_submissions.html b/app/web/templates/_submissions.html index 44cd712..dd085d9 100644 --- a/app/web/templates/_submissions.html +++ b/app/web/templates/_submissions.html @@ -15,46 +15,71 @@ Sterge selectate -
+
- - - - - - - - - + + + + + + + + {% for r in rows %} + {# US-008: detaliul apare ca rand-sibling expandabil SUB acest rand (#detaliu-{id}), + nu in panoul global de la baza. Randul e clickabil/focusabil (toggle prin JS in + base.html: single-open + pauza poll). #} - - - - + + - - - - - + + + + + + {# US-008: rand-sibling de detaliu, ascuns pana la deschidere. Placeholder „Se + incarca…" prin hx-indicator cat raspunde HTMX. #} + + {% endfor %} diff --git a/app/web/templates/_trimitere_detaliu.html b/app/web/templates/_trimitere_detaliu.html index 41692aa..75a1a61 100644 --- a/app/web/templates/_trimitere_detaliu.html +++ b/app/web/templates/_trimitere_detaliu.html @@ -1,11 +1,13 @@ {% from "_eroare.html" import card_erori %} {% import '_macros.html' as ui %} -
+{# US-008: conectorul detaliului = fundal subtil + border-top pe randul-sibling + (.detaliu-rand, base.html), NU border-left accent (evita AI-slop). #} +

Detaliu trimitere #{{ id }}

{{ stare_text }}
@@ -51,12 +53,12 @@ {% if gestionabil %}
+ hx-target="#detaliu-{{ id }}" hx-swap="innerHTML" style="margin:0;">
`+OOB contoare (nu pe sectiune), form propriu, mutual-exclusion, reuse grila `_trimitere_detaliu.html` (US-002); Mapari + formate de coloane ca tabele `.tablewrap`, H4 auto_send stocat (US-005/006); bifa "auto-send"→comutator etichetat pe COADA ("Pune automat in coada"/"Tine pentru verificare"), scoped pe operatie, `name=auto_send` pastrat (zero backend) (US-007). 523 teste pass. **VERIFY**: E2E browser pe `/` (Acasa unificata, upload slim, editare rand needs_data→ok cu swap pe rand + contoare OOB, Mapari tabelar + comutator) + LIVE pe RAR test — import fara coloana data → editarea completeaza data (override) → commit → worker login RAR test → `postPrezentare` → `sent` cu `idPrezentare=68696`, confirmat independent in lista finalizate RAR. 3 bug-uri JS (htmx 1.9.12) prinse DOAR la E2E in browser (invizibile la TestClient) si reparate: `useTemplateFragments=true` (raspunsul ``+OOB era parsat in context de tabel → `swapError` + contoare pierdute), re-activare `confirm-btn` deferita pe tick (race `editing=true` tranzitoriu), `n-hint` ok-count actualizat de `updateN`. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare) NEATINS — singura atingere de schema: 1 coloana nullable `override_json` cu migrare defensiva. PRD: [prd-3.6](prd/prd-3.6-editare-preview-acasa-unificata.md). | ISTORIC: 3.5 LIVRAT (dashboard compact). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). +**Ultima actualizare**: 2026-06-24 — 5.8 LIVRAT (UX tabel trimiteri + reguli mapare pe text). 11 stories/4 valuri, TDD prin echipa de workeri (lead orchestreaza, nu scrie cod). Reguli text per cont (`operation_text_rules`, substring insensibil diacritice/caz): `resolve_prestatii` capata param aditiv `text_rules` cu precedenta stricta (cod direct > mapare exacta > regula text > nemapat), threadat pe TOATE cele 6 callsite-uri + `valid_codes` + seam `classify_prezentare` (dry-run = trimitere reala); UI Mapari sectiune noua + preview pre-salvare + avertizare overlap + telemetrie `text_rule_hit` in jurnal. UX tabel: `cod_rar` sub operatie, `eticheta_scurta` pill, fara scroll orizontal (scopat `.tabel-trimiteri` + carduri <768px), detaliu INLINE rand-sibling expandabil (chevron+fundal+a11y, pauza poll 15s). VERIFY context curat: **814 passed, 1 skipped** (live opt-in); E2E functional TestClient (browser pixel-level + live RAR neprobate — sandbox/login/lipsa creds). **`/code-review high`: 1 bug critic reparat** — regula text `auto_send=0` (DEFAULT, decizia CEO siguranta) trimitea automat la RAR in loc sa TINA randul pentru review (`has_no_auto_send` ignora regula text care a rezolvat itemul); reparat TDD + curatare adnotari stale (repara si o telemetrie falsa latenta). Backend trimitere + schema-send NEATINSE (schema pur aditiv). PRD: [prd-5.8](prd/prd-5.8-ux-tabel-trimiteri-reguli-text.md). | 2026-06-24 — 5.8 PLAN: eng + CEO review rulate (6 decizii incorporate, 11 stories/4 valuri). | 2026-06-23 — 5.7 LIVRAT (raspuns API onest la blocaje + mapare inline din detaliu). Raportat din client VFP: `POST /v1/prezentari` intorcea `submission_id`+`status` FARA motiv pe randuri blocate (`erori` se popula doar pe ramura `on_unmapped_error=True`) → un `needs_data`/`needs_mapping` parea succes ("raspuns fara erori"). Reparat ADITIV: `SubmissionResult` += `nemapate` + `motiv`; `create_prezentari` populeaza `erori` (validare continut, 3 niveluri) / `nemapate` (coduri fara mapare, COD_NEMAPAT) / `motiv` (rezumat uman) pe TOATE caile non-`queued` — enqueue, respins (`on_unmapped_error=True`) si reactivare dedup peste `error`. UI: mapare inline in panoul de detaliu trimitere (`POST /trimitere/{id}/mapeaza`, reuse EXACT `save_mapping`+`reresolve_account`, scoped sesiune + CSRF, re-rezolva pe `batch_id`-ul randului → deblocheaza si randurile-frate; apare doar pe operatii nemapate reale, nu pe auto_send=0) — fara drum prin tab-ul Mapari. `/code-review high`: 2 buguri reale reparate (reactivarea omitea `erori`/`nemapate`/`motiv`; dublu `load_nomenclator` in `_detaliu_ctx`), restul candidatilor infirmati la verify (parse `auto_send` corect via `or ""`; lipsa `conn/account_id` pe ramuri de corectie nereachable cu needs_mapping+unmapped). `pytest -q` **765 passed, 0 failed** (+1 skipped live). **PROBA LIVE `--send` (2026-06-23): mapare inline E2E pe RAR test** — `POST /v1/prezentari` cu operatie nemapata → `needs_mapping` (raspuns onest cu `nemapate`+`motiv`) → mapare inline din panoul de detaliu in browser (Playwright) → `queued` → worker login RAR + `postPrezentare` → `sent idPrezentare=68827`, confirmat independent in finalizate RAR + jurnal `app_events` (`rar_login ok` → `submission_sent`). Automatizat ca test live opt-in `tests/test_live_rar.py` (skip implicit; `AUTOPASS_LIVE_RAR=1` + creds test reproduc tot lantul → `idPrezentare=68828`). PRD: [prd-5.7](prd/prd-5.7-raspuns-onest-mapare-inline.md). | ISTORIC: FIX out-of-process (raportat din client VFP): `cod_prestatie` necunoscut in nomenclator era trimis raw la RAR → **HTTP 500** (`ORA-12899`, coloana `COD_PRESTATIE` max 5 car.) + record PARTIAL `FINALIZATA` (RAR ne-tranzactional) pe care reconcilierea il marca fals `sent`. Reparat: validare `cod_prestatie` fata de nomenclator la ingestie (cod necunoscut → tratat ca operatie de mapat, nu se mai trimite raw) + optiune boolean `on_unmapped_error` (`false` default → needs_mapping | `true` → respinge) per-cerere cu default per-cont `accounts.on_unmapped_error_default` (migrare aditiva). Confirmat live raspunsul RAR (500 pe cod intern vs 200 pe `OE-1`). Inclus si in `c842e33`: fix lease orfan worker (nepotrivire format data sending_since vs cutoff → orice rand `sending` parea expirat) + guard anti-dublu-POST + fix UI `hx-confirm` mostenit pe randuri (alerta de stergere la click pe rand). Teste: **748 passed** (cele 2 esecuri pre-existente fara legatura). Contract + CLAUDE.md actualizate. | 5.6 IMPLEMENTAT + VERIFY PASS (asteapta commit). Cele 14 stories din PRD 5.6 livrate TDD (RED->GREEN), `pytest -q` **741 passed, 0 failed**. Lifecycle trimiteri blocate (Val A primul, decizie #18): `app/submissions_admin.py` (sterge/repune scoped, 404-before-409); reactivare dedup peste `error` cu CAS + invalidare sesiune worker la creds noi (T1) + propagare `accounts.rar_creds_enc` (#17) + camp aditiv `reactivated:true` (#19); retentie randuri blocate 30z + `purge_after` curatat la reactivare/requeue (T2); API `DELETE`/`/repune` (200+JSON, #20); UI butoane + bulk + banner "Necesita atentia ta" actionabil cu deep-link. Observabilitate: `app/observ.py log_event` (dublu canal `app_events` DB + `RotatingFileHandler` per-proces, redactare creds/PII la scriere via `app/security.redact_pii`/`vin_partial`), `request_id` middleware + `X-Request-ID` pe toate raspunsurile (T8), handler global excepții -> 500 envelope 6-chei + request_id (T7), audit cerere API (`api_prezentari`/`api_auth_esuat`) + audit worker (`rar_login`/tranzitii), tab "Jurnal" filtrabil scoped (non-admin doar contul sau), retentie jurnal 90z. Live RAR `--send` NEPROBAT in sesiune (recomandat la deploy: confirma `rar_login` ok + `submission_sent` in jurnal). PRD actualizat cu raport VERIFY; contract actualizat cu endpointurile noi (T10). | ISTORIC: HOTFIX livrat + 5.6 APROBAT. Hotfix 500 pe `POST /v1/prezentari` (raportat din client Visual FoxPro): `AUTOPASS_CREDS_KEY` din `.env` nu respecta formatul Fernet (32 bytes url-safe base64) → `ValueError` la primul `encrypt_creds` → 500 brut. Reparat: cheie Fernet valida in `.env` + `crypto.validate_creds_key()` apelata in `main.lifespan` (fail-fast la startup, mesaj clar in loc de 500 la primul POST). Confirmat live: POST VFP → 200 `queued`; trimitere reala pe RAR test → `sent idPrezentare=68818` (verificat independent in finalizate). Corectat si mesajul fals din dashboard pentru starea `error` in `labels.py` ("se reincearca automat" → starea e terminala, NU se reincearca). Investigatia a expus 3 goluri structurale (500 brut fara traducere 3 niveluri; lipsa jurnal de aplicatie la nivel de eveniment; lacune de lifecycle — randuri blocate permanente, dedup blocat de un rand `error`, banner "Necesita atentia ta" neactionabil) → **PRD 5.6 APROBAT** (14 stories; decizii §5 rezolvate cu user). PRD: [prd-5.6](prd/prd-5.6-observabilitate-jurnal.md). | ISTORIC: 5.5 LIVRAT (uniformizare/standardizare UI/UX: tabele la grila Trimiteri, meniu hamburger + tab-bar redus Acasa/Mapari, sterge Ajutor de pe Acasa, panou admin cu selectie+bulk pe model nou `accounts.status`. 9 stories in 3 valuri, UI pur cu o singura exceptie backend = stare cont; stergere soft cu purjare PII imediata GDPR. VERIFY 671 teste + E2E browser (2 bug-uri prinse) + `/code-review high` (2 bug-uri reale reparate). Commit `1fbd894`, vezi randul 5.5). | ISTORIC: 5.4 LIVRAT (Erori pe 3 niveluri problema+cauza+fix pe API si UI: catalog central pur `app/errors.py` ca SINGURA sursa de adevar cod→{problema,fix}, consumat de API+UI+worker — face imposibila divergenta intre canale, acelasi invariant ca 5.2. 8 stories in 5 valuri. Tot ADITIV: `field`/`message`/`error` pastrate la octet, adaugam `cod/problema/cauza/fix`; `rar_error` stocat = SUPERSET (chei vechi intacte → `labels.py` nu se rupe intre valuri, zero migrare). Scope = fluxul de declarare; login/signup/CSRF neatinse. UI progresiv: lista compacta, 3 niveluri complete in detaliu/preview, AA light+dark. VERIFY context curat PASS 628 teste (byte-compat+superset verificate direct, E2E API+web; live RAR neprobat — lipsa creds key). `/code-review high`: 2 bug-uri reale reparate in `labels.py` (`motiv_uman` fara ramura 3-niveluri → 401 creds garbled in coloana Motiv; `parse_erori` element gol pe `{}`). 631 teste. Backend trimitere + schema NEATINSE. PRD: [prd-5.4](prd/prd-5.4-erori-3-niveluri.md)). | ISTORIC: 5.3 LIVRAT (Light/Dark mode: tema light ca bloc `[data-theme="light"]` peste variabilele `:root` — dark NESCHIMBAT la octet; comutator soare/luna in header pe toate paginile, default OS-aware cu fallback dark, persistenta `localStorage` doar la comutare explicita, script anti-FOUC in `` pre-paint; suprafetele de stare hardcodate convertite la `color-mix` in `base.html` + 7 fragmente. Zero backend — pur frontend. VERIFY 2 runde: r1 FAIL a prins literalii dark ramasi in 7 fragmente HTMX (text invizibil in light, test vacuu pe doar base.html) → fix US-003 + test care scaneaza fragmentele; r2 PASS E2E browser (banner light ~13:1 contrast, toggle instant+persista+anti-FOUC, dark identic). `/code-review` high: 1 finding reparat (light `--ok` green sub AA ca text → green-700, ~5.0:1). 584 teste. PRD: [prd-5.3](prd/prd-5.3-light-dark-mode.md)). | ISTORIC: 5.2 LIVRAT (Endpoint dry-run `POST /v1/prezentari/valideaza`: valideaza payload + mapare si intoarce verdictul real — `status_estimat` queued/needs_data/needs_mapping + erori `[{field,message}]` + coduri nemapate + prestatii rezolvate — FARA enqueue, FARA creds, zero scriere DB). 1 story TDD. Cheia de design: helper pur partajat `classify_prezentare` folosit de AMBELE rute, ca dry-run-ul sa nu poata diverge de trimiterea reala (invariant de corectitudine); `create_prezentari` refactorizat pe el cu comportament identic. Scope minim per decizie user: doar validare+mapare (fara idempotency/duplicat, `idempotency.py` neatins), hub `/integrare` amanat ca follow-up (descoperibilitate). VERIFY context curat PASS (577 teste; E2E API cu cele 3 verdicte + COUNT(*)=0 dupa dry-run + fara leak creds in raspuns; regresia de aur verde; live RAR `FINALIZATA` neprobat — lipsa creds key, endpoint read-only nu atinge worker/coada/schema). `/code-review` high: 0 findings (refactor faithful, mutable-default Pydantic-safe, import local necesar anti-circular). PRD: [prd-5.2](prd/prd-5.2-dryrun-valideaza.md). | ISTORIC: 5.1 LIVRAT (Hub de integrare `/integrare`: exemple cod multi-limbaj + retetar VFP cu 2 dialecte + `GET /v1/ping` readiness + export Postman/OpenAPI + "Testeaza conexiunea"). 4 stories in 2 valuri (Val 1 = US-001/US-002/US-004 paralel pe fisiere disjuncte via Agent team; Val 2 = US-003 UI). Atentie operationala: US-003 a rulat intr-un worktree branched din ultimul commit (FARA modificarile necomise ale US-004 din working-tree) si la "copiere manuala" a SUPRASCRIS `routes.py`, stergand ruta `POST /integrare/test-cheie` (8 teste 404) — reparat prin re-aplicarea rutei de catre autorul US-004 pe `routes.py` curent. Lectie: stories care ating acelasi fisier in valuri diferite + worktree = clobber daca worktree-ul nu vede working-tree-ul; foloseste fisiere disjuncte SAU merge atent de catre lead. VERIFY context curat PASS (568 teste) + E2E browser Playwright (deep-link server-side, IA pe 2 niveluri, VFP cu 3 niveluri de tab comuta corect, copy, htmx test-cheie → fragment eroare, 0 erori consola) + enqueue live (`POST /v1/prezentari` → queued); live RAR `FINALIZATA` NEPROBAT in sesiune (lipsa `AUTOPASS_CREDS_KEY`/creds RAR test) — risc minim, backend trimitere NEATINS. `/code-review` high a prins 4 bug-uri reale (toate in suprafata noua, reparate + lock-uite cu teste): snippet C# JSON multi-linie nevalid (CS1010), snippet VFP `json.dumps(indent=0)` inca cu newline-uri → string literal rupt in ambele dialecte, snippet Node `node:buffer` nu exporta FormData → TypeError, script `_integrare.html` ne-scoped acumuland event-listeneri pe tab-bar-ul principal la fiecare swap htmx (scoped pe `#integrare-section`). Notat ca cleanup viitor (nereparat): `_render_integrare` dubleaza SQL `are_creds`/`are_cheie`, `ping` cu 2 conexiuni DB + `account_for_key` de 2 ori, `_campuri_obligatorii` necache-uit, panouri limbaj copy-paste (candidat macro Jinja2). Backend trimitere (worker/masina stari/idempotenta/mapping) si schema NEATINSE. PRD: [prd-5.1](prd/prd-5.1-hub-integrare.md). | ISTORIC: 3.6 INCHIS (editare celule in preview + Acasa unificata). CLOSE: `/code-review` high a prins 1 bug real (decriptare `override_json` neprotejata de try/except in ambele cai de preview — 500 pe tot batch-ul la rotatie cheie Fernet vs. `raw_json` care degrada gratios), reparat in `import_router.preview_import` + `routes._web_compute_preview`; duplicarea `_override_of`/canonicalize notata ca cleanup viitor. 523 teste pass. 7 stories in 3 valuri, executate de 2 echipe in paralel (TeamCreate) pe fisiere disjuncte (core: routes/import/templates; mapari: `_mapari.html`) + US-007 secvential. Livrate: tab "Trimiteri" eliminat→sectiune "Trimiterile tale" sub upload pe Acasa (US-003); upload bara slim accentuata cu hero la first-run (US-004); editare de celule in preview prin `import_rows.override_json` (Approach B, Fernet, patch canonic aplicat ULTIMUL in `_resolve_row_for_preview`+`commit_import` — completeaza inclusiv coloane ABSENTE din fisier), mutatie pura cu status rederivat (US-001); buton Editeaza pe rand cu swap pe ``+OOB contoare (nu pe sectiune), form propriu, mutual-exclusion, reuse grila `_trimitere_detaliu.html` (US-002); Mapari + formate de coloane ca tabele `.tablewrap`, H4 auto_send stocat (US-005/006); bifa "auto-send"→comutator etichetat pe COADA ("Pune automat in coada"/"Tine pentru verificare"), scoped pe operatie, `name=auto_send` pastrat (zero backend) (US-007). 523 teste pass. **VERIFY**: E2E browser pe `/` (Acasa unificata, upload slim, editare rand needs_data→ok cu swap pe rand + contoare OOB, Mapari tabelar + comutator) + LIVE pe RAR test — import fara coloana data → editarea completeaza data (override) → commit → worker login RAR test → `postPrezentare` → `sent` cu `idPrezentare=68696`, confirmat independent in lista finalizate RAR. 3 bug-uri JS (htmx 1.9.12) prinse DOAR la E2E in browser (invizibile la TestClient) si reparate: `useTemplateFragments=true` (raspunsul ``+OOB era parsat in context de tabel → `swapError` + contoare pierdute), re-activare `confirm-btn` deferita pe tick (race `editing=true` tranzitoriu), `n-hint` ok-count actualizat de `updateN`. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare) NEATINS — singura atingere de schema: 1 coloana nullable `override_json` cu migrare defensiva. PRD: [prd-3.6](prd/prd-3.6-editare-preview-acasa-unificata.md). | ISTORIC: 3.5 LIVRAT (dashboard compact). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). > 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). @@ -102,6 +102,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi | 5.5 | Uniformizare/standardizare UI/UX: tabele la grila Trimiteri (Mapari compact + toggle Auto/Manual + Ajutor; Nomenclator), meniu hamburger (Cont/Integrare/Nomenclator/Admin/logout) cu tab-bar redus la Acasa+Mapari, sterge Ajutor de pe Acasa, panou admin cu selectie+bulk (Activeaza/Blocheaza/Arhiveaza/Sterge) pe model nou de stare cont | DONE | 2026-06-23 | 9 stories, 3 valuri. UI pur (reskin+reasezare) cu O SINGURA exceptie backend: `accounts.status` (pending/active/blocked/archived/deleted, migrare defensiva, gate worker `claim_one` pastrand `active=1 ⇔ status='active'`). Macro autosend rescris compact pastrand semantica de prezenta `auto_send` (zero backend). Stergere = SOFT: tombstone, dar PII (creds RAR + chei API + CUI) purjate IMEDIAT la stergere (GDPR/L.142), nu prin retentie. VERIFY 671 teste pass (+40); E2E browser Playwright a prins 2 bug-uri invizibile la TestClient (bara bulk `display:flex` inline invingea `[hidden]`; arhivate cadeau sub "in asteptare" → grupare pe status). `/code-review high`: 2 bug-uri reale reparate (soft delete pastra creds+CUI fara purjare → purjare PII la stergere; apostrof in nume rupea `confirm()` inline kebab) + cleanup `_lifecycle_route`. Debt acceptat: `/admin/deactivate`+`set_active` pastrate legacy (test CLI). Backend trimitere neatins (exceptie: gate cont). Commit `1fbd894`. Design: [5.5-uniformizare-ui](design/5.5-uniformizare-ui.md). PRD: [prd-5.5](prd/prd-5.5-uniformizare-ui.md) | | 5.4 | Erori pe 3 niveluri (problema + cauza + fix) pe API si UI | DONE | 2026-06-22 | Catalog central pur `app/errors.py` (24 coduri, `eroare()` → `{field,cod,problema,cauza,fix,message}`, ridica pe cod necunoscut) ca SINGURA sursa de adevar, consumat de API+UI+worker (acelasi invariant anti-divergenta ca 5.2). 8 stories, 5 valuri. Tot ADITIV (decizie user): `field`/`message`/`error` pastrate la octet, adaugam 3 niveluri + cod stabil; `rar_error` stocat = SUPERSET (chei vechi intacte → `labels.py` nu se rupe intre valuri, zero migrare). Scope = fluxul de declarare (validare continut, RAR 400/401, import, mapare op→cod); login/signup/CSRF NEATINSE. US-001 catalog; US-002 `validation.py` (cod+3n, byte-compat); US-003 `mapping.py`+`/valideaza` (needs_mapping/auto_send superset, `nemapate` 3n); US-004 worker RAR 400→`RAR_VALIDARE` (field_errors passthrough) + 401→`RAR_CREDS_INVALIDE` (clasificare transient neschimbata, fara echo creds); US-005 `import_router` detalii superset; US-006 `labels.parse_erori` (degradeaza gratios, lectia 3.6) + macro `_eroare.html` (progresiv: lista compacta, 3n complete in detaliu/preview; AA light+dark, accent≠rosu); US-007 upload/mapcoloane web prin macro; US-008 contract documentat. VERIFY context curat PASS (628 teste; byte-compat+superset verificate direct; E2E API `/valideaza` 3 niveluri + regresia de aur queued; E2E web fragmente upload+detaliu; live RAR `FINALIZATA` neprobat — lipsa creds key, endpoint-urile/UI noi nu ating trimiterea). `/code-review high`: 2 bug-uri reale reparate in `labels.py` — `motiv_uman` nu avea ramura pentru dict-ul 3-niveluri (401 creds → text garbled "field: None; cod:..." in coloana Motiv) + `parse_erori` intorcea element gol pe `{}`/`[{}]` (cutie de eroare goala); cleanup notat ca viitor (dublare `parse_erori`/`motiv_uman`, enrichment COD_NEMAPAT pe 2 straturi, upload handlers copy-paste cross-channel `routes.py`/`import_router`, `json.loads` mort in ramura COLOANE_FORMAT_JSON). 631 teste. Backend trimitere (worker masina stari/idempotenta/mapping-rezolvare) si schema NEATINSE. PRD: [prd-5.4](prd/prd-5.4-erori-3-niveluri.md) | | 5.6 | Observabilitate & jurnal aplicatie + lifecycle trimiteri blocate: jurnal structurat de evenimente (tabela `app_events` + log text) cu vizualizator in dashboard, handler global 500→eroare 3 niveluri + `request_id`, audit cereri API + login RAR + ciclu trimiteri (worker), redactare PII/creds, retentie/purjare; plus stergere/re-pune in coada randuri blocate (UI+API), dedup care nu mai e blocat de un rand `error`, purjare randuri blocate, banner "Necesita atentia ta" actionabil (link + identificare rand) | APROBAT | 2026-06-23 | Nascut din incidentul 500 (client VFP). HOTFIX deja livrat in afara procesului (US-000): cheie Fernet valida in `.env` + `crypto.validate_creds_key()` fail-fast in `main.lifespan` + mesaj `error` corectat in `labels.py`; confirmat live POST→200, send RAR test→`idPrezentare=68818`. 14 stories ramase: observabilitate US-001..008, lifecycle US-009..013, banner US-014. Decizii §5 rezolvate cu user: retentie jurnal 90z (`AUTOPASS_LOG_RETENTION_DAYS`) + `RotatingFileHandler`; tipuri evenimente extensibile; jurnal non-admin scoped pe cont (admin vede tot); la resubmit doar `error` se re-activeaza (needs_* raman deduped); retentie randuri blocate 30z (`AUTOPASS_BLOCKED_RETENTION_DAYS`); stergere UI cu confirmare simpla + bulk pe lista. NEIMPLEMENTAT inca (doar hotfix-ul e in cod). PRD: [prd-5.6](prd/prd-5.6-observabilitate-jurnal.md) | +| 5.8 | UX tabel trimiteri (detaliu inline, fara scroll, cod RAR sub operatie) + reguli mapare pe text (substring, per cont) | DONE | 2026-06-24 | 11 stories (4 valuri) livrate TDD, executate de echipa de workeri (lead orchestreaza). Reguli text US-001..004 (tabela `operation_text_rules` per cont; `resolve_prestatii` cu param aditiv `text_rules` + precedenta stricta cod-direct>mapare-exacta>regula-text>nemapat; threading pe TOATE cele 6 callsite-uri + `valid_codes` + seam `classify_prezentare`; UI sectiune Mapari cu „contine"/empty state). UX tabel US-005..008 (`cod_rar` in payload_view; `eticheta_scurta` separata; tabel fara scroll orizontal scopat `.tabel-trimiteri` + responsive carduri <768px; detaliu INLINE ca rand-sibling expandabil cu chevron+fundal+a11y+pauza poll 15s via `htmx:beforeRequest`). Expansiuni Val 4 US-009..011 (preview pre-salvare cati nemapate potriveste; telemetrie `text_rule_hit` in `app_events` pe TOATE caile inclusiv corectie+import web; avertizare overlap neblocanta). VERIFY context curat: 814 passed, 1 skipped (live opt-in); E2E functional TestClient (browser pixel-level neprobat — sandbox + /login; live RAR neprobat — lipsa creds). **`/code-review high`: 1 bug critic real reparat** — regula text cu `auto_send=0` (DEFAULT, decizia CEO de siguranta) trecea randul pe `queued` (trimis automat la RAR) in loc sa-l TINA `needs_mapping` pentru review: `has_no_auto_send` inspecta doar `operations_mapping`, nu si regula text care a rezolvat itemul; VERIFY initial ratase cazul. Reparat TDD (`mapping.py`: `_rezolva_din_reguli_text` intoarce auto_send; `resolve_prestatii` marcheaza `regula_fara_autosend` + curata adnotari stale — repara si o telemetrie falsa latenta; `has_no_auto_send` prinde flagul). Backend trimitere (worker/masina stari/idempotenta/contract RAR) + schema-send NEATINSE (schema = pur aditiv: tabela noua). PRD: [prd-5.8](prd/prd-5.8-ux-tabel-trimiteri-reguli-text.md) | | 5.7 | Raspuns API onest la blocaje (`erori`/`nemapate`/`motiv` pe orice status != `queued`) + mapare inline din panoul de detaliu trimitere | DONE | 2026-06-23 | Raportat din client VFP: `POST /v1/prezentari` raspundea `submission_id`+`status` fara motiv pe randuri blocate (`erori` doar pe `on_unmapped_error=True`) → `needs_data`/`needs_mapping` parea succes. 3 stories TDD. US-001 (backend API): `SubmissionResult` += `nemapate`+`motiv` (ADITIV), `create_prezentari` populeaza `erori`/`nemapate`/`motiv` pe enqueue + respins + reactivare via helperele `_rezultat_enqueue`/`_rezultat_respins`/`_motiv_clasificare`; `on_unmapped_error=True` pastreaza `erori`=COD_NEMAPAT (compat). US-002 (web): ruta `POST /trimitere/{id}/mapeaza` (reuse EXACT `save_mapping`+`reresolve_account`, scoped sesiune 404 + CSRF, re-rezolva pe `batch_id`-ul randului) + `_nemapate_pentru_submission` + context in `_detaliu_ctx`. US-003 (UI): sectiune "Mapeaza codul operatiei" in `_trimitere_detaliu.html` (selector cod RAR cu sugestie fuzzy preselectata >=60, `ui.autosend_toggle`), doar pe operatii nemapate reale. `/code-review high`: 2 buguri reale reparate (reactivarea omitea `erori`/`nemapate`/`motiv`; dublu `load_nomenclator`), restul infirmate. `pytest -q` **765 passed, 0 failed** (+1 skipped live). **Live RAR `--send` PROBAT (2026-06-23)**: mapare inline in browser → `queued` → worker → `sent idPrezentare=68827` (confirmat independent in finalizate RAR + jurnal `app_events`); automatizat ca test live opt-in `tests/test_live_rar.py` (skip implicit; `AUTOPASS_LIVE_RAR=1` + creds test → reproduce tot lantul, `idPrezentare=68828`). Backend trimitere (worker/masina stari/idempotenta) si schema NEATINSE. PRD: [prd-5.7](prd/prd-5.7-raspuns-onest-mapare-inline.md) | ### Etapa 4 — Deprioritizat (post Etapa 5, daca apare nevoia din uz real) diff --git a/docs/prd/prd-5.8-ux-tabel-trimiteri-reguli-text.md b/docs/prd/prd-5.8-ux-tabel-trimiteri-reguli-text.md new file mode 100644 index 0000000..b608c0f --- /dev/null +++ b/docs/prd/prd-5.8-ux-tabel-trimiteri-reguli-text.md @@ -0,0 +1,544 @@ +# PRD 5.8 — UX tabel trimiteri (detaliu inline, fara scroll, cod RAR) + reguli mapare pe text + +**Stare**: verify-pass + +> Proces: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`. +> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis`. + +## 1. Obiectiv + +Patru imbunatatiri UX, fara schimbarea fluxului de trimitere: +1. Panoul de detaliu al unei trimiteri apare **sub randul selectat** (rand expandabil), nu la + baza tabelului (azi `#trimitere-detaliu` traieste dupa `
#StareVehiculOperatieData prestatieNr. prezentare RARActualizatMotiv#StareVehiculOperatieData prestatieNr. prezentare RARActualizat
` in `_coada.html:72`). +2. Tabelul de trimiteri **nu mai are scroll orizontal** (confirmat live: `.tablewrap` + `scrollWidth=1189 > clientWidth=1010` la 1280px). Solutie agreata: mut **Motiv** in detaliu, + stiveasc **cod RAR sub codul operatiei** in coloana Operatie, **VIN sub nr. inmatriculare** + (deja stivuit) si **scurtez etichetele de Stare**. +3. In coloana Operatie apare si **codul de operatie RAR rezolvat** (`cod_prestatie`), nu doar + codul/denumirea venita din API/import. +4. Pagina **Mapari** capata **reguli automate pe text** (substring): ex. operatie care *contine* + „verificare" → cod RAR `OE-2`. Se aplica la ingestie si la re-rezolvarea blocajelor. + +## 2. Non-Goals (anti scope-creep) + +- Fara `match_type` multiplu (starts_with / regex). Doar **contains** (substring), insensibil la + diacritice si majuscule. Daca apare nevoia, e alt PRD. +- Fara modificari la masina de stari, la worker, la contractul RAR sau la idempotenta. +- Fara schimbarea logicii de mapare exacta `cod_op_service → cod_prestatie` (ramane, are precedenta). +- Fara redesign general al dashboard-ului; doar tabelul de trimiteri + sectiunea noua din Mapari. +- Fara reguli text per-import sau globale: regulile sunt **per cont** (ca `operations_mapping`). + +## 3. Stories atomice + +> Backend + UI pentru acelasi comportament = stories separate. Regulile pe text (US-001..004) si +> UX-ul tabelului (US-005..008) sunt **independente** — se pot livra in paralel. + +--- + +### US-001: Schema + persistenta reguli text de mapare +**Ca** dezvoltator **vreau** o tabela `operation_text_rules` **pentru ca** regulile pe substring +sa fie durabile per cont, langa `operations_mapping`. + +- **Depinde de**: — +- **Fisiere**: `app/schema.sql`, `app/mapping.py`, `tests/test_mapping_text_rules.py` +- **Test intai (RED)**: `tests/test_mapping_text_rules.py` — + `test_save_text_rule_persista`, `test_load_text_rules_per_cont`, + `test_delete_text_rule`, `test_unic_per_cont_pattern` +- **Acceptance criteria**: + - [x] Tabela noua: `id, account_id (NOT NULL, FK accounts ON DELETE CASCADE), pattern TEXT NOT NULL, + cod_prestatie TEXT NOT NULL, auto_send INTEGER NOT NULL DEFAULT 0, priority INTEGER NOT NULL + DEFAULT 0, created_at`, cu `UNIQUE(account_id, pattern)`. + - [x] **`auto_send` DEFAULT 0** (decizie CEO 2026-06-24, siguranta): o regula pe substring are blast + radius mai mare decat o mapare exacta (potriveste si operatii viitoare nevazute), iar + `FINALIZATA` e ireversibil la RAR. O regula noua rezolva codul dar TINE randul pentru verificare + umana (`needs_mapping`) pana cand operatorul activeaza explicit „In coada". UI-ul (US-004) reflecta + toggle-ul pe 0 implicit. + - [x] `load_text_rules(conn, account_id)` intoarce lista ordonata `[{pattern, cod_prestatie, + auto_send, priority}]` (ordine: `priority ASC, id ASC`), aplicand `account_or_default`. + - [x] `save_text_rule(conn, account_id, pattern, cod_prestatie, auto_send)` face upsert pe + `(account_id, pattern)`; `delete_text_rule(...)` sterge. + - [x] Schema e idempotenta (`CREATE TABLE IF NOT EXISTS`) si nu strica DB-uri existente. + - [x] `python3 -m pytest tests/test_mapping_text_rules.py -q` trece. +- **Verificare E2E**: — + +### US-002: `resolve_prestatii` aplica reguli text (substring, dupa maparea exacta) +**Ca** integrator **vreau** ca o operatie nemapata sa primeasca cod RAR din prima regula text care +da match **pentru ca** sa nu mai intre in editor degeaba. + +- **Depinde de**: US-001 +- **Fisiere**: `app/mapping.py`, `tests/test_mapping.py` +- **Test intai (RED)**: `tests/test_mapping.py` — + `test_regula_text_contains_rezolva`, `test_mapare_exacta_bate_regula_text`, + `test_regula_text_insensibila_diacritice_caz`, `test_regula_text_cod_invalid_in_nomenclator_ramane_nemapat`, + `test_prima_regula_dupa_priority_castiga` +- **Acceptance criteria**: + - [x] `resolve_prestatii(prestatii, mapping, valid_codes=None, text_rules=None)` — semnatura + **aditiva** (param nou optional, default `None` = comportament actual neschimbat). + - [x] **Precedenta** stricta: `cod_prestatie` direct valid > mapare exacta `cod_op_service` > + **reguli text** > nemapat. Regula text se incearca DOAR cand nu exista cod valid si op nu e + in `mapping`. + - [x] Match = substring pe textul operatiei (`denumire` daca exista, altfel `cod_op_service`), + normalizat cu `normalize_for_match` (fara diacritice, uppercase, spatii colapsate) — pe + ambele parti (pattern si text). + - [x] Daca regula da match dar `cod_prestatie`-ul ei **nu** e in `valid_codes` → operatia ramane + **nemapata** (nu trimitem cod invalid; coerent cu regula „RAR accepta doar coduri din + nomenclator", `mapping.py:101-105`). + - [x] La match multiplu castiga prima dupa ordinea `load_text_rules` (priority, id). + - [x] `python3 -m pytest tests/test_mapping.py -q` trece (inclusiv testele vechi). +- **Verificare E2E**: — + +### US-003: Reguli text active la ingestie + la re-rezolvare +**Ca** service **vreau** ca regulile text sa actioneze pe prezentari noi (API + import) **si** sa +deblocheze randurile `needs_mapping` existente cand salvez o regula noua. + +- **Depinde de**: US-002 +- **Fisiere**: `app/mapping.py` (`classify_prezentare` + `reresolve_account`), `app/api/v1/router.py`, + `app/api/v1/import_router.py`, `app/web/routes.py`, `tests/test_reresolve_text_rules.py` +- **Test intai (RED)**: `tests/test_reresolve_text_rules.py` — + `test_ingestie_api_aplica_regula_text`, `test_ingestie_import_aplica_regula_text`, + `test_corectie_web_aplica_regula_text`, `test_salvare_regula_rerezolva_blocate` +- **Acceptance criteria**: + - [x] **TOATE cele 6 apeluri `resolve_prestatii` primesc `text_rules` SI `valid_codes`** (decizie + eng D-eng-1): `mapping.py:276` (in `classify_prezentare`), `mapping.py:441` (reresolve_account), + `import_router.py:204`, `import_router.py:1079`, `routes.py:984` (corectie web `needs_data`), + `routes.py:2278` (import web). Cele 4 care azi paseaza `valid_codes=None` incep sa paseze + `valid_codes=load_nomenclator_codes(conn) or None` — altfel AC-ul de validare (US-002) nu se + onoreaza pe acele cai. + - [x] **`classify_prezentare` capata param `text_rules`** (A3): seam-ul partajat API + `/valideaza` + (invariant 5.2) il primeste si ambii apelanti (`create_prezentari` + ruta `/valideaza`) fac + `load_text_rules(...)` si il paseaza — altfel dry-run-ul diverge de trimiterea reala. + - [x] **`text_rules`/`valid_codes` se incarca o data per cerere/batch, NU per rand** (T2): + `routes.py:2278` (loop import) si `reresolve_account` (loop randuri) le incarca inainte de bucla. + - [x] O prezentare API/import a carei operatie da match pe o regula intra `queued` (sau respectand + `auto_send`), NU `needs_mapping`. + - [x] La salvarea unei reguli noi, `reresolve_account` re-evalueaza blocajele si deblocheaza + randurile care acum dau match (acelasi mecanism ca la `save_mapping`). + - [x] `cod_prestatie`-ul rezolvat din regula respecta validarea fata de nomenclator (US-002). + - [x] `python3 -m pytest -q` trece integral. +- **Verificare E2E**: `POST /v1/prezentari` cu operatie „Verificare X" (fara mapare exacta), regula + `contine "verificare" → OE-2` salvata in prealabil → raspuns cu `status=queued`, fara `nemapate`. + +### US-004: UI Mapari — sectiune „Reguli automate (text)" +**Ca** operator **vreau** sa adaug/sterg reguli text din pagina Mapari **pentru ca** sa automatizez +maparea fara cod intern per operatie. + +- **Depinde de**: US-001 (UI poate merge in paralel cu US-002/003; rezolvarea efectiva cere US-003) +- **Fisiere**: `app/web/routes.py`, `app/web/templates/_mapari.html`, + `tests/test_web_mapari_text_rules.py` +- **Test intai (RED)**: `tests/test_web_mapari_text_rules.py` — + `test_post_regula_text_salveaza_si_rerezolva`, `test_post_sterge_regula`, + `test_regula_text_scoped_pe_cont_sesiune`, `test_csrf_necesar` +- **Acceptance criteria**: + - [x] Sectiune noua in `_mapari.html` (a 4-a, langa „De rezolvat" / „Mapari salvate" / „Formate"): + tabel cu coloanele **Daca operatia contine** (text) | **Cod RAR** (select din nomenclator) | + **In coada** (toggle `auto_send`, **implicit OFF** — vezi US-001 decizia CEO) | **Actiuni** + (sterge), plus un rand de adaugare. + - [x] Formularul foloseste `auto_send`-toggle si `select` din nomenclator existente (reuse macro), + cu `csrf_token`; rutele `POST /mapari/reguli-text` si `POST /mapari/reguli-text/sterge` + sunt **account-scoped pe sesiune** (`require_login`), ca rutele Mapari actuale. + - [x] La salvare se cheama `save_text_rule` + `reresolve_account`, cu mesaj „Regula salvata. + Deblocate: N" si trigger `trimiteriChanged` (refresh lista), exact ca maparea inline (5.7). + - [x] Textul afisat e clar ca match-ul e „contine" (substring), nu egalitate. + - [x] **Empty state** (nicio regula): o linie explicativa cu exemplu — „Inca nu ai reguli. + Ex: operatia contine «verificare» → OE-2. Mapeaza automat operatii similare fara cod + intern." — urmata de randul de adaugare gata afisat ca placeholder. (Pass 2) + - [x] **Stari**: succes = mesaj „Regula salvata. Deblocate: N"; eroare cod invalid in + nomenclator = mesaj inline „Cod RAR necunoscut in nomenclator", fara salvare. (Pass 2) + - [x] `python3 -m pytest tests/test_web_mapari_text_rules.py -q` trece. +- **Verificare E2E**: gstack browser pe `/?tab=mapari` — adaug regula `verificare → OE-2`, + confirm ca apare in lista si ca un rand `De rezolvat` cu „verificare" dispare dupa salvare. + +--- + +### US-005: `cod_rar` distinct in datele de afisare ale randului +**Ca** dezvoltator **vreau** un camp separat pentru codul RAR rezolvat **pentru ca** sa-l pot afisa +sub codul operatiei fara sa-l confund cu `cod_op_service`. + +- **Depinde de**: — +- **Fisiere**: `app/payload_view.py`, `tests/test_payload_view.py` +- **Test intai (RED)**: `tests/test_payload_view.py` — + `test_cod_rar_prezent_cand_mapat`, `test_cod_rar_gol_cand_nemapat`, + `test_operatie_ramane_denumire_sau_op` +- **Acceptance criteria**: + - [x] `prezentare_din_payload` intoarce in plus `cod_rar` = `cod_prestatie` (uppercase, strip + „.0") sau `EMPTY` cand lipseste. `operatie` si `cod` raman neschimbate (compat). + - [x] Cand operatia e nemapata (`cod_prestatie` None), `cod_rar` == `EMPTY` (nu cade pe + `cod_op_service`). + - [x] `python3 -m pytest tests/test_payload_view.py -q` trece. +- **Verificare E2E**: — + +### US-006: Etichete de Stare compacte (pill) +**Ca** operator **vreau** etichete scurte in coloana Stare **pentru ca** randul sa fie ingust si +lizibil. + +- **Depinde de**: — +- **Fisiere**: `app/web/labels.py`, `tests/test_labels.py` +- **Test intai (RED)**: `tests/test_labels.py` — + `test_eticheta_scurta_pentru_fiecare_stare`, `test_eticheta_lunga_ramane_pentru_subtext` +- **Acceptance criteria**: + - [x] Eticheta scurta expusa ca **functie noua separata `eticheta_scurta(status) -> str`** (dict + propriu + garda `KeyError`), NU ca al 4-lea element in tuple-ul `Eticheta` (3 elemente azi, + despachetat in template-uri — l-ar rupe). `eticheta_stare` ramane neatins (text lung pentru + subtext/tooltip in detaliu). Set propus: + `queued→"In coada"`, `sending→"Se trimite"`, `sent→"Finalizat"`, + `needs_mapping→"De mapat"`, `needs_data→"Date lipsa"`, `error→"Eroare"`. + - [x] Pill-ul din tabel foloseste eticheta scurta; clasele CSS `s-*` (culorile) raman. + - [x] Stare neacoperita ridica `KeyError` (ca azi, ca sa prinda stari noi). + - [x] `python3 -m pytest tests/test_labels.py -q` trece. +- **Verificare E2E**: — + +### US-007: Tabel trimiteri fara scroll orizontal (coloane re-aranjate) +**Ca** operator **vreau** sa vad tot randul fara scroll lateral **pentru ca** sa citesc starea si +operatia dintr-o privire. + +- **Depinde de**: US-005, US-006 +- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/routes.py` (builder rand), + `app/web/templates/base.html` (CSS), `tests/test_web_submissions.py` +- **Test intai (RED)**: `tests/test_web_submissions.py` — + `test_tabel_nu_are_coloana_motiv`, `test_operatie_contine_cod_rar`, + `test_pill_eticheta_scurta` +- **Acceptance criteria**: + - [x] Coloana **Motiv** eliminata din ``/`` (continutul ei se vede in detaliu — + `_trimitere_detaliu.html:36-39` deja afiseaza Motiv). + - [x] Coloana **Operatie**: linia 1 = `prez.operatie` (denumire/cod API), linia 2 = `prez.cod_rar` + muted (`cod RAR: XXX`); cand nemapat afiseaza „nemapat" muted. + - [x] Coloana **Vehicul**: nr. inmatriculare + VIN scurt stivuit (deja exista, pastrat). + - [x] Pill Stare = eticheta scurta (US-006). + - [x] CSS: la 1280px `.tablewrap` **nu** mai depaseste (`scrollWidth <= clientWidth`); se permite + `white-space: normal` controlat pe coloanele text si latimi rezonabile, fara a rupe alte + tabele care folosesc `.tablewrap` (scopare prin clasa, ex. `.tabel-trimiteri`). + - [x] **Pill compact pastreaza info lunga**: eticheta scurta (US-006) e in pill; textul lung + (subtext din `eticheta_stare`) ramane disponibil ca `title=` (tooltip) pe pill si in + panoul de detaliu — nu se pierde informatie. (Pass 5) + - [x] **Responsive** (Pass 6): `>=1024px` toate cele 8 coloane; `768-1024px` ascund coloana + **Actualizat** (e in detaliu) → 7 coloane fara scroll; `<768px` randurile devin **carduri** + (eticheta:valoare stivuit, pill + chevron sus), nu tabel — fara scroll orizontal. + - [x] Secundarul „cod RAR" / „nemapat" e >=12px cu contrast >=4.5:1 (coerent cu `vin_scurt` + existent); nu se transmite stare DOAR prin culoare (textul „nemapat" o spune). (Pass 6) + - [x] `python3 -m pytest tests/test_web_submissions.py -q` trece. +- **Verificare E2E**: gstack browser pe `/` la 1280px, 1024px, 900px si 375px — `scrollWidth <= + clientWidth` la fiecare (sau carduri sub 768px); coloana Operatie arata ambele coduri. + +### US-008: Detaliu trimitere ca rand expandabil sub randul selectat +**Ca** operator **vreau** ca detaliul sa apara imediat sub randul pe care l-am dat click **pentru +ca** sa nu mai caut la baza tabelului. + +- **Depinde de**: US-007 +- **Fisiere**: `app/web/templates/_submissions.html`, `app/web/templates/_coada.html`, + `app/web/templates/_trimitere_detaliu.html`, `app/web/routes.py`, + `tests/test_web_detaliu_inline.py` +- **Test intai (RED)**: `tests/test_web_detaliu_inline.py` — + `test_fragment_detaliu_se_randeaza_in_container_pe_rand`, + `test_un_singur_detaliu_deschis` +- **Acceptance criteria**: + - [x] Fiecare rand are un rand-sibling de detaliu `` cu + ``, ascuns implicit. + - [x] Click pe rand face `hx-get="/_fragments/trimitere/{id}"` cu `hx-target="#detaliu-{id}"`, + `hx-swap="innerHTML"`; la deschidere se inchid celelalte detalii deschise (un singur rand + expandat o data). + - [x] **Indicator vizual** (Pass 1/4): chevron `▸`/`▾` la inceputul randului (rotit cand e + deschis) + randul deschis primeste **fundal evidentiat** (`#1d212b` dark / echivalent light). + Detaliul se leaga prin **fundal subtil + `border-top`** (stilul existent al sectiunii de + mapare inline `_trimitere_detaliu.html`), NU `border-left` accent (evita pattern AI-slop). + - [x] **Stare loading** (Pass 2): la click, pana raspunde HTMX, containerul randului arata un + placeholder discret („Se incarca…") prin `hx-indicator`, ca operatorul sa vada ca s-a + inregistrat clicul. + - [x] **Accesibilitate** (Pass 6): randul clickabil e focusabil la tastatura (`tabindex="0"`, + `role="button"`, `aria-expanded` sincronizat); Enter/Space deschid/inchid; tinta de atins + (chevron/checkbox) >=44px pe touch. + - [x] Butonul „Inchide" din `_trimitere_detaliu.html` goleste containerul randului curent (nu mai + tinteste `#trimitere-detaliu` global) si readuce focusul pe randul declansator. + - [x] `#trimitere-detaliu` global din `_coada.html:72` este eliminat sau golit de rol. + - [x] **Poll-ul de refresh (15s, `_coada.html`) se pune pe pauza cat timp un rand e expandat** si + se reia la inchidere (decizie eng D-eng-2). Fara flicker, fara pierdere de scroll/focus in + timpul citirii; lista nu se misca sub operator. (NU re-fetch dupa swap, NU excludere de rand.) + - [x] `python3 -m pytest tests/test_web_detaliu_inline.py -q` trece. +- **Verificare E2E**: gstack browser pe `/` — click pe randul #N: detaliul apare imediat sub el + (`detaliuTop` intre `rowTop` si randul urmator), nu la baza; click pe alt rand muta detaliul. + +--- + +> **Stories de expansiune (CEO review 2026-06-24, SELECTIVE EXPANSION).** US-009..011 acceptate in scope. +> Cresc increderea si observabilitatea regulilor text; nu schimba fluxul de trimitere. Livrabile in Val 4. + +### US-009: Preview pre-salvare regula text (cate operatii potriveste) +**Ca** operator **vreau** sa vad cate operatii potriveste o regula INAINTE sa o salvez **pentru ca** +sa nu salvez un pattern prea lacom (perechea naturala a `auto_send=0`). + +- **Depinde de**: US-001 (reuse agregarea `pending_unmapped`); independent de US-002/003 +- **Fisiere**: `app/web/routes.py`, `app/web/templates/_mapari.html`, `tests/test_web_mapari_preview_regula.py` +- **Test intai (RED)**: `tests/test_web_mapari_preview_regula.py` — + `test_preview_numara_potriviri`, `test_preview_intoarce_exemple`, `test_preview_pattern_gol`, + `test_preview_scoped_pe_cont` +- **Acceptance criteria**: + - [x] Ruta `POST /mapari/reguli-text/preview` (account-scoped pe sesiune, CSRF) primeste `pattern`, + normalizeaza cu `normalize_for_match`, numara operatiile distincte nemapate ale contului + (`needs_mapping`, reuse `pending_unmapped`) al caror text **contine** pattern-ul + intoarce + pana la 3 exemple `{cod_op_service, denumire}`. **NU salveaza nimic**. + - [x] Fragment HTMX afisat sub randul de adaugare la schimbarea pattern-ului (`hx-trigger="keyup + delay:400ms"`): „Potriveste N operatii nemapate: «...», «...»" sau „Nicio potrivire acum". + - [x] Pattern gol → fara apel/fragment gol (nu numara „tot"). + - [x] `python3 -m pytest tests/test_web_mapari_preview_regula.py -q` trece. +- **Verificare E2E**: gstack browser pe `/?tab=mapari` — tastez „verificare" in pattern → vad „Potriveste + N operatii: ..." inainte de Salveaza. + +### US-010: Telemetrie hit regula text in `app_events` +**Ca** operator/admin **vreau** sa stiu ce regula a rezolvat ce submission **pentru ca** sa pot +raspunde la „de ce a primit randul asta codul OE-2?". + +- **Depinde de**: US-002 (rezolvare) + US-003 (active la ingestie) +- **Fisiere**: `app/mapping.py` (adnotare item rezolvat-prin-regula), `app/observ.py` (reuse `log_event`), + apelantii cu `conn` (`create_prezentari`, `reresolve_account`, import), `tests/test_text_rule_telemetry.py` +- **Test intai (RED)**: `tests/test_text_rule_telemetry.py` — + `test_hit_regula_emite_app_event`, `test_mapare_exacta_nu_emite_text_rule_hit`, + `test_event_redactat_si_scoped_pe_cont` +- **Acceptance criteria**: + - [x] `resolve_prestatii` adnoteaza itemul rezolvat-prin-regula cu pattern-ul sursa (camp aditiv pe dict, + ex. `cod_sursa="text_rule:"`; payload-harmless — RAR citeste doar `cod_prestatie`). + - [x] Apelantii cu `conn` emit `log_event("text_rule_hit", {...})` in `app_events` cu + `{submission_id, account_id, pattern, cod_prestatie}`, redactat prin `app/security` (fara PII). + Maparea exacta NU emite `text_rule_hit`. + - [x] Vizibil in tab-ul „Jurnal" (5.6), scoped pe cont pentru non-admin (mecanismul existent). + - [x] `python3 -m pytest tests/test_text_rule_telemetry.py -q` trece. +- **Verificare E2E**: `POST /v1/prezentari` cu operatie ce da match pe regula → eveniment `text_rule_hit` + vizibil in Jurnal cu pattern-ul si codul. + +### US-011: Avertizare overlap intre reguli text (neblocant) +**Ca** operator **vreau** un avertisment cand o regula noua se suprapune cu una existenta **pentru ca** +sa inteleg de ce o regula „castiga" inaintea alteia. + +- **Depinde de**: US-001 (load) + US-004 (UI salvare) +- **Fisiere**: `app/mapping.py` (helper pur `text_rules_overlap`), `app/web/routes.py`, + `app/web/templates/_mapari.html`, `tests/test_mapping_overlap.py`, `tests/test_web_mapari_overlap.py` +- **Test intai (RED)**: `tests/test_mapping_overlap.py` — `test_overlap_substring_ambele_directii`, + `test_fara_overlap`, `test_overlap_normalizat_diacritice`; `tests/test_web_mapari_overlap.py` — + `test_salvare_cu_overlap_arata_avertisment_dar_salveaza` +- **Acceptance criteria**: + - [x] Helper pur `text_rules_overlap(pattern, existing_rules) -> list[dict]`: overlap = un pattern + normalizat e substring al celuilalt (oricare directie). Determinist, fara DB. + - [x] La salvare (ruta US-004), daca exista overlap, mesaj inline **neblocant**: „Se suprapune cu + regula «X» → COD; ordinea (priority, id) decide care se aplica prima." **Salvarea continua** + (avertisment, NU eroare). + - [x] `python3 -m pytest tests/test_mapping_overlap.py tests/test_web_mapari_overlap.py -q` trece. +- **Verificare E2E**: gstack browser — adaug „verificare", apoi „verificare faruri" → avertisment overlap, + ambele reguli salvate. + +## 4. Riscuri + +- **Poll de 15s vs. detaliu deschis (US-008)**: `_coada.html` reincarca periodic `#submissions-wrap`; + un re-render naiv ar inchide detaliul expandat. Mitigare: pastreaza id-ul randului deschis si + re-cere fragmentul dupa swap, sau exclude randul deschis din inlocuire. De decis la executie. +- **`white-space: normal` global (US-007)**: `.tablewrap`/`table` sunt partajate de mai multe + tabele (Mapari, Formate). Schimbarile CSS trebuie scopate la tabelul de trimiteri ca sa nu + strice celelalte. Verificare vizuala pe toate tab-urile. +- **Cod RAR invalid intr-o regula text (US-002)**: daca nomenclatorul nu e inca populat, + `valid_codes` poate fi gol → regula n-ar rezolva nimic. Coerent cu `load_nomenclator_codes` + (nu valida cand e gol); de confirmat ca seed-ul fallback (18 coduri) acopera cazul uzual. +- **Colizii de reguli text** (mai multe pattern-uri match): rezolvat determinist prin ordine + (priority, id); fara ordine, comportamentul ar fi nedeterminist. + +## 5. Intrebari deschise + +> Se rezolva cu utilizatorul INAINTE de executie (poarta de aprobare PRD). + +- ~~Indiciu „rezolvat prin regula text" pe rand?~~ **REZOLVAT** (design review): suficient codul RAR; + re-evaluat post-livrare (vezi §8). +- ~~Pastram „Actualizat" in tabel la 1024px?~~ **REZOLVAT** (Pass 6): ascuns la `<=1024px`, ramane in + detaliu (vezi §7 Responsive). +- ~~Ordonarea regulilor: priority editabila?~~ **REZOLVAT**: v1 = ordine de creare, fara editare + priority (vezi §8). + +## 6. Valuri de executie (graful de dependente) + +``` +Val 1: [US-001] [US-005] [US-006] ← fara dependente, fisiere distincte → paralel +Val 2: [US-002] [US-007] ← US-002 dep US-001; US-007 dep US-005+US-006 +Val 3: [US-003] [US-004] [US-008] ← US-003 dep US-002; US-004 dep US-001; US-008 dep US-007 +Val 4: [US-009] [US-010] [US-011] ← expansiuni CEO; US-009 dep US-001; US-010 dep US-002+US-003; + US-011 dep US-001+US-004 +``` + +## 7. Decizii de design (din /plan-design-review, 2026-06-23) + +### Arhitectura informatiei (tabel trimiteri) +Ierarhie pe rand, stanga→dreapta: **Stare** (pill colorat, ancora vizuala) → **Vehicul** +(nr. mare + VIN muted dedesubt) → **Operatie** (denumire/cod API + „cod RAR: X" muted). Coloana +Operatie devine purtatoarea celor doua coduri; restul (Data, Nr. RAR, Actualizat) sunt context +secundar. Motiv iese din tabel (zgomot pe randurile OK) si traieste in detaliu. + +### Tabel stari interactiune (Pass 2) +``` +FEATURE | LOADING | EMPTY | ERROR | SUCCESS | PARTIAL +------------------------|--------------------|------------------|------------------------|----------------------|-------------------- +Celula Operatie | — | „nemapat" (red) | — | „cod RAR: X" muted | „nemapat" pana la mapare +Rand expandabil (detaliu)| placeholder „Se | n/a | fragment de eroare HTMX| detaliu sub rand, | n/a + | incarca…" hx-indic.| | in container | chevron rotit + fundal| +Sectiune reguli text | — | explicatie+exemplu| „Cod RAR necunoscut" | „Regula salvata. | — + | | + rand adaugare | (inline, fara salvare) | Deblocate: N" | +``` + +### Responsive (Pass 6) +- `>=1024px`: 8 coloane complete, fara scroll orizontal. +- `768-1024px`: ascunde **Actualizat** (e in detaliu) → 7 coloane, fara scroll. +- `<768px`: **card per rand** (eticheta:valoare stivuit, pill+chevron sus). Cardurile sunt + justificate aici pentru ca **cardul ESTE interactiunea** (tap → expand), nu decor. + +### Accesibilitate (Pass 6) +Randul expandabil: `tabindex=0`, `role="button"`, `aria-expanded`, activare Enter/Space, focus +readus la inchidere, tinta touch >=44px. Secundarul muted >=12px, contrast >=4.5:1; starea nu se +comunica DOAR prin culoare. + +### Aliniere stil (Pass 4/5) — clasificat APP UI +Tabel dens, limbaj utilitar, putine culori, fara mozaic de carduri decorative. Conectorul +detaliului = fundal subtil + `border-top` (vocabular existent), NU `border-left` accent +(pattern AI-slop). Pill-urile pastreaza clasele `s-*`; eticheta lunga ramane in `title=`/detaliu. + +## 8. NU in scope (design — deferat explicit) +- `match_type` multiplu (starts_with/regex) la reguli text — substring acopera „contine". Alt PRD. +- Editare `priority` reguli in UI (drag/numar) — v1 e ordine de creare. +- Redesign vizual al celorlalte tab-uri/tabele — doar tabelul de trimiteri + sectiunea Mapari noua. +- Indicator „rezolvat prin regula text" pe rand — codul RAR e suficient (de re-evaluat post-livrare). +- Animatii de expand/collapse elaborate — un toggle simplu (rotire chevron) e destul pentru un APP UI. + +## 9. Ce exista deja (de reutilizat, nu reinventat) +- Sistem de culori prin CSS variables + teme dark/light (`base.html`), clase pill `s-*`. +- Macro toggle `auto_send` + `select` nomenclator + `csrf_token` (`_mapari.html`, + `_trimitere_detaliu.html`) — refolosite la sectiunea reguli text. +- `reresolve_account` (re-rezolvare blocaje), `save_mapping` (pattern upsert+rerez), mesaj + „Deblocate: N" + trigger `trimiteriChanged` din maparea inline (5.7). +- `eticheta_stare` (labels.py) — extins cu eticheta scurta, fara a pierde textul lung. +- `prezentare_din_payload` (payload_view.py) — extins cu `cod_rar`. + +## Approved Mockups + +| Screen/Section | Mockup Path | Direction | Notes | +|----------------|-------------|-----------|-------| +| Tabel trimiteri (desktop + mobil) | ~/.gstack/projects/romfast-rar-autopass/designs/trimiteri-tabel-20260623/mockup-trimiteri.png | Tabel dens cu pill compact, „cod RAR" stivuit sub operatie, rand expandat inline cu chevron+fundal; card per rand sub 768px | Conectorul detaliului in implementare = fundal subtil + border-top (NU border-left accent ca in mockup); teme calibrate pe CSS vars existente | + +--- + +## Raport VERIFY + +> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. + +**VERDICT: PASS** (toate US-001..011), dupa 1 fix critic descoperit la `/code-review` (CLOSE). + +### Suita +`python3 -m pytest -q` → **814 passed, 1 skipped** (skip = `tests/test_live_rar.py`, live RAR opt-in). +Toate testele numite in PRD exista si trec. + +### PASS/FAIL per story (verificator independent, context curat) +- US-001..011: **PASS**, cu dovezi de cod (vezi mai jos). Subset 5.8 = 81+ teste verzi. +- US-001 PASS — `operation_text_rules` UNIQUE(account_id,pattern), `auto_send DEFAULT 0`, FK ON DELETE CASCADE, `CREATE TABLE IF NOT EXISTS`; load/save/delete corecte (ordine priority,id). +- US-002 PASS — semnatura aditiva `resolve_prestatii(...,text_rules=None)`; precedenta stricta cod valid > mapare exacta > regula text > nemapat; match substring normalizat ambele parti; cod regula invalid -> ramane nemapat; prima dupa ordine castiga. +- US-003 PASS — toate **6** callsite resolve_prestatii primesc text_rules + valid_codes; `classify_prezentare` param nou + ambii apelanti (create_prezentari + /valideaza) incarca; T2 incarcare o data per cerere/batch. +- US-004 PASS — rute `/mapari/reguli-text` (+sterge) scoped+CSRF; save+reresolve+„Deblocate: N"+trimiteriChanged; empty state; cod invalid respins inline; sectiune a 4-a cu „contine". +- US-005 PASS — `cod_rar` uppercase/strip „.0" sau EMPTY; nemapat -> EMPTY (fara fallback pe cod_op_service). +- US-006 PASS — `eticheta_scurta` functie separata + dict propriu + KeyError; tuple `Eticheta` ramane 3. +- US-007 PASS — Motiv eliminata; Operatie cu „cod RAR: X"/„nemapat"; pill scurt + title lung; CSS scopat `.tabel-trimiteri`; responsive (ascunde Actualizat ≤1024, carduri <768). +- US-008 PASS — `` colspan=8; click hx-target=#detaliu-{id}; single-open + poll-pauza via `htmx:beforeRequest` preventDefault; Enter/Space; Inchide goleste+focus; #trimitere-detaliu global golit. +- US-009 PASS — `/mapari/reguli-text/preview` scoped+CSRF, numara distinct nemapate ce contin pattern, max 3 exemple (HTML-escaped), zero scriere; pattern gol -> fragment gol. +- US-010 PASS — `cod_sursa="text_rule:"` doar pe hit; `_emite_text_rule_hits` din TOATE caile (API create, reresolve, import_router commit, + corectie web routes.py:1001 si import web commit routes.py:2482 — paritate adaugata post-VERIFY); maparea exacta nu emite. +- US-011 PASS — `text_rules_overlap` pur (substring ambele directii, exclude identic); mesaj neblocant, salvarea continua. + +### E2E +Browser pixel-level **neprobat** (sandbox ucide serverul persistent; dashboard in spatele /login). Compensat cu **E2E functional prin HTTP (TestClient, login real)**: tab Mapari S4 + empty state; US-009 preview „Potriveste N..."; US-004 save → trimiteriChanged + „Deblocate:" + lista; US-011 overlap; cod invalid respins; fragment submissions fara Motiv + detaliu-rand colspan=8 + pill role=button + „cod RAR/nemapat". Live RAR `FINALIZATA` neprobat (lipsa creds) — backend trimitere NEATINS. + +### Regresia de aur (Non-Goals) +CONFIRMAT: worker / idempotency / validation / reconcile / crypto / auth NEMODIFICATE (git). `schema.sql` = pur aditiv (tabela noua + index). Masina de stari si fluxul de trimitere neatinse. + +### Fix critic la `/code-review high` (CLOSE) — 1 bug real reparat +- **Regula text cu `auto_send=0` (DEFAULT, decizia CEO) trimitea automat la RAR.** `has_no_auto_send` + inspecta DOAR `operations_mapping`, nu si regula text care a rezolvat itemul → un rand rezolvat + prin regula cu auto_send=0 trecea pe `queued` (trimis automat), incalcand AC-ul central de siguranta + US-001/US-003/US-004 (blast radius substring + `FINALIZATA` ireversibil). Repro confirmat: + `classify_prezentare(..., auto_send=0)` → `queued`. **Reparat** (`app/mapping.py`, TDD, + `tests/test_text_rule_autosend.py`): `_rezolva_din_reguli_text` intoarce si `auto_send`; + `resolve_prestatii` marcheaza itemul cu `regula_fara_autosend` cand regula are auto_send falsy + SI curata adnotarile stale (`cod_sursa`/flag) la fiecare rezolvare (repara si o telemetrie falsa + latenta la re-rezolvare prin mapare exacta); `has_no_auto_send` prinde flagul. Acum auto_send=0 → + `needs_mapping` (review), auto_send=1 → `queued`. Operatorul elibereaza randul comutand „In coada" + pe regula (US-004) → reresolve cu auto_send=1 → queued. `pytest -q` **814 passed**. + VERIFY-ul initial (context curat) a RATAT acest caz (nu testase hold-ul pe auto_send=0); prins la code-review. + +--- + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | clean | SELECTIVE EXPANSION: 1 fix siguranta (auto_send default 0) + 3 stories acceptate (US-009/010/011) | +| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | findings | 4 arhitectura (A1-A4) + 3 code/test (C1, T1-T2); 2 decizii deschise (D-eng-1/2) | +| Design Review | `/plan-design-review` | UI/UX gaps | 1 | clean | score: 6/10 → 9/10, 6 decisions | +| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — | + +Pasele 1-7 evaluate cu mockup tintit (calibrat pe tema reala a app-ului). Decizii adaugate in plan: +empty state reguli text, conector detaliu (fundal+border-top, nu border-left slop), banda responsive +768-1024 (ascunde Actualizat), card per rand <768px, chevron+fundal pe rand expandat, keyboard a11y +(`role=button`/`aria-expanded`/Enter-Space) + pastrarea textului lung de stare in `title=`/detaliu. + +- **VERDICT (design):** DESIGN CLEARED (9/10). + +### Eng Review (2026-06-24) + +Plan corect si bine descompus (8 stories atomice, 3 valuri, fisiere disjuncte). Referintele de +linie din PRD sunt exacte. Constatari, verificate fata de cod: + +- **A1 (corectitudine) — US-003 numara gresit callsite-urile `resolve_prestatii`.** Sunt **6**, nu 4: + pe langa `mapping.py:276` (in `classify_prestatie`), `mapping.py:441`, `import_router.py:204`, + `import_router.py:1079`, mai exista `routes.py:984` (corectie web `needs_data`) si `routes.py:2278` + (import web, intr-un loop pe randuri). Daca nu se threadeaza `text_rules` si acolo, regulile text + NU se aplica pe caile web. → **D-eng-1**. +- **A2 — `valid_codes=None` pe 4 din 6 callsite-uri** (import_router 204/1079, routes 984/2278) intra + in conflict cu AC-ul US-003 „cod rezolvat din regula respecta validarea fata de nomenclator": pe + acele cai validarea e oprita. Pentru a onora AC-ul trebuie threadat si `valid_codes`. Nota + (pre-existent, semnalat, nereparat aici): promovarea codului direct necunoscut (garda ORA-12899) + ruleaza azi DOAR pe calea API, pentru ca import/web rezolva fara `valid_codes`. +- **A3 — face explicit seam-ul `classify_prezentare`.** `mapping.py:276` e in `classify_prezentare` + (helperul partajat care garanteaza ca `/valideaza` da acelasi verdict ca trimiterea reala — + invariant 5.2). Threading `text_rules` cere param nou pe `classify_prezentare` + incarcare in AMBII + apelanti (create_prezentari + ruta `/valideaza`), altfel dry-run diverge. PRD spune „create_prezentari" + — de corectat in US-003. +- **A4 (decizia principala) — detaliu inline (US-008) vs poll 15s.** Azi detaliul traieste in afara + `#submissions-wrap` (`_coada.html:72`) tocmai ca poll-ul `every 15s` sa nu-l stearga. Inline + reintroduce stergerea-la-poll. → **D-eng-2**. +- **C1 — US-006: nu mari tuple-ul `Eticheta`** (3 elemente, despachetat in template-uri). Adauga + `eticheta_scurta(status)->str` separat (dict propriu + garda `KeyError`). +- **T1 — adauga stories/teste pentru cele 2 callsite-uri web** (corectie + import) care aplica o regula text. +- **T2 — incarca `text_rules`/`valid_codes` o data per cerere/batch**, NU per rand (`routes.py:2278` + e in loop; la fel disciplina in `reresolve_account`). + +Decizii rezolvate cu utilizatorul (2026-06-24): +- **D-eng-1 → TOATE cele 6 callsite-uri + `valid_codes`.** Incorporat in US-003 (Fisiere += `routes.py`; + AC-uri pentru cele 6 apeluri + `classify_prezentare` param + incarcare per-cerere/batch). +- **D-eng-2 → pauza poll cat e un rand deschis.** Incorporat in US-008 AC. + +A2/A3/C1/T1/T2 incorporate in stories (US-003 callsite-uri+validare+per-batch, US-006 `eticheta_scurta` +separat, US-008 pauza poll, teste web pe corectie/import). + +- **VERDICT (eng):** PLAN SOUND. Cele 2 decizii rezolvate, constatarile incorporate in stories. + +### CEO Review (2026-06-24) — SELECTIVE EXPANSION + +Premisa corecta: regulile text sunt levierul de adoptie al Etapei 5 (mai putine mapari manuale = +onboarding mai rapid pentru service-urile din VFP). Scop bine subtras, reuse puternic peste +`operations_mapping`/`reresolve_account`. Abordare confirmata (tabela separata + threading +`text_rules`, varianta A). Constatare principala (inversiune/blast-radius) + 3 cherry-pick-uri, +toate rezolvate cu utilizatorul: + +- **Fix siguranta — `auto_send` DEFAULT 0** (nu 1). O regula pe substring potriveste si operatii + viitoare nevazute; `FINALIZATA` e ireversibil la RAR. Regula noua rezolva codul dar TINE randul + pentru verificare umana pana cand operatorul activeaza „In coada". Incorporat in US-001 + US-004. +- **US-009 (acceptat) — preview pre-salvare**: „aceasta regula potriveste N operatii: ..." inainte de + commit. Perechea naturala a `auto_send=0` → onboarding aproape fara risc. +- **US-010 (acceptat) — telemetrie hit regula** in `app_events`: ce pattern a rezolvat ce submission. +- **US-011 (acceptat) — avertizare overlap** neblocanta intre reguli. + +Cele 3 expansiuni livrabile in Val 4 (dupa nucleul US-001..008). Plan CEO persistat in +`~/.gstack/projects/.../ceo-plans/`. + +- **VERDICT (ceo):** SCOPE CLEARED. 4 decizii incorporate in stories. Ramane poarta umana de aprobare + PRD inainte de EXECUTE. + +NO UNRESOLVED DECISIONS diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..02b62d0 --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,63 @@ +""" +Teste pentru eticheta_scurta (US-006, PRD 5.8). + +RED intai: scrise inainte de implementarea functiei. +Fisiere atinse: app/web/labels.py, tests/test_labels.py. +""" + +import pytest + +from app.web.labels import eticheta_scurta, eticheta_stare + + +# --------------------------------------------------------------------------- +# test_eticheta_scurta_pentru_fiecare_stare +# --------------------------------------------------------------------------- + +def test_eticheta_scurta_pentru_fiecare_stare(): + """Fiecare stare cunoscuta intoarce eticheta scurta corecta (pill).""" + cazuri = { + "queued": "In coada", + "sending": "Se trimite", + "sent": "Finalizat", + "needs_mapping": "De mapat", + "needs_data": "Date lipsa", + "error": "Eroare", + } + for status, eticheta_asteptata in cazuri.items(): + rezultat = eticheta_scurta(status) + assert rezultat == eticheta_asteptata, ( + f"Status {status!r}: asteptam {eticheta_asteptata!r}, got {rezultat!r}" + ) + + +def test_eticheta_scurta_stare_necunoscuta_ridica_keyerror(): + """Stare neacoperita ridica KeyError (ca sa prinda stari noi adaugate in schema).""" + with pytest.raises(KeyError): + eticheta_scurta("stare_inexistenta") + + +# --------------------------------------------------------------------------- +# test_eticheta_lunga_ramane_pentru_subtext +# --------------------------------------------------------------------------- + +def test_eticheta_lunga_ramane_pentru_subtext(): + """eticheta_stare inca intoarce textele lungi neschimbate (compat cu template-uri).""" + # Verificam fragmentele de text lung care existau inainte de US-006 + cazuri_lungi = { + "queued": "In asteptare", + "sending": "Se trimite acum", + "sent": "Declarate la RAR", + "needs_mapping": "Lipseste codul", + "needs_data": "Date incomplete", + "error": "Eroare la trimitere", + } + for status, fragment in cazuri_lungi.items(): + text, subtext, css_class = eticheta_stare(status) + assert fragment.lower() in text.lower(), ( + f"eticheta_stare({status!r}) text lung modificat — asteptam {fragment!r} in {text!r}" + ) + # Verifica ca tuple-ul are exact 3 elemente (invariant arhitectura C1) + assert isinstance(css_class, str) and css_class, ( + f"eticheta_stare({status!r}) trebuie sa aiba css_class non-vida la pozitia 3" + ) diff --git a/tests/test_mapping.py b/tests/test_mapping.py index 7a62708..c5ccb5f 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -108,6 +108,78 @@ def test_resolve_fara_valid_codes_e_backcompat(): assert unmapped == [] +# --------------------------------------------------------------------------- # +# US-002: reguli text (substring) dupa maparea exacta # +# --------------------------------------------------------------------------- # + +def test_regula_text_contains_rezolva(): + """O operatie nemapata al carei text CONTINE pattern-ul primeste codul regulii.""" + text_rules = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}] + resolved, unmapped = resolve_prestatii( + [{"cod_op_service": "OP1", "denumire": "Verificare faruri"}], + {}, + valid_codes={"OE-2"}, + text_rules=text_rules, + ) + assert resolved[0]["cod_prestatie"] == "OE-2" + assert unmapped == [] + + +def test_mapare_exacta_bate_regula_text(): + """Maparea exacta cod_op_service->cod are precedenta peste regula text.""" + text_rules = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}] + resolved, unmapped = resolve_prestatii( + [{"cod_op_service": "OP1", "denumire": "Verificare faruri"}], + {"OP1": "OE-1"}, + valid_codes={"OE-1", "OE-2"}, + text_rules=text_rules, + ) + assert resolved[0]["cod_prestatie"] == "OE-1" # maparea exacta castiga + assert unmapped == [] + + +def test_regula_text_insensibila_diacritice_caz(): + """Match-ul e insensibil la diacritice si majuscule (ambele parti normalizate).""" + text_rules = [{"pattern": "Verificări", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}] + resolved, unmapped = resolve_prestatii( + [{"cod_op_service": "OP1", "denumire": "VERIFICARI complete auto"}], + {}, + valid_codes={"OE-2"}, + text_rules=text_rules, + ) + assert resolved[0]["cod_prestatie"] == "OE-2" + assert unmapped == [] + + +def test_regula_text_cod_invalid_in_nomenclator_ramane_nemapat(): + """Regula da match dar codul ei nu e in nomenclator -> operatia ramane nemapata.""" + text_rules = [{"pattern": "verificare", "cod_prestatie": "ZZZ", "auto_send": 0, "priority": 0}] + resolved, unmapped = resolve_prestatii( + [{"cod_op_service": "OP1", "denumire": "Verificare faruri"}], + {}, + valid_codes={"OE-2"}, + text_rules=text_rules, + ) + assert resolved[0]["cod_prestatie"] is None + assert unmapped == [{"cod_op_service": "OP1", "denumire": "Verificare faruri"}] + + +def test_prima_regula_dupa_priority_castiga(): + """La match multiplu castiga prima regula in ordinea listei (priority, id).""" + text_rules = [ + {"pattern": "verificare faruri", "cod_prestatie": "OE-3", "auto_send": 0, "priority": 0}, + {"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 1}, + ] + resolved, unmapped = resolve_prestatii( + [{"cod_op_service": "OP1", "denumire": "Verificare faruri"}], + {}, + valid_codes={"OE-2", "OE-3"}, + text_rules=text_rules, + ) + assert resolved[0]["cod_prestatie"] == "OE-3" # prima din lista + assert unmapped == [] + + # --------------------------------------------------------------------------- # # Flux complet (API) # # --------------------------------------------------------------------------- # diff --git a/tests/test_mapping_overlap.py b/tests/test_mapping_overlap.py new file mode 100644 index 0000000..3ca4763 --- /dev/null +++ b/tests/test_mapping_overlap.py @@ -0,0 +1,53 @@ +"""Teste US-011 (PRD 5.8) — helper pur `text_rules_overlap`. + +Avertisment neblocant cand o regula text noua se suprapune (substring, oricare +directie) cu una existenta. Helper determinist, fara DB. +""" + +from __future__ import annotations + +from app.mapping import text_rules_overlap + + +def test_overlap_substring_ambele_directii(): + """Overlap = pattern nou substring al unei reguli existente SAU invers.""" + existing = [ + {"pattern": "verificare faruri", "cod_prestatie": "OE-3"}, + {"pattern": "schimb ulei", "cod_prestatie": "OE-1"}, + ] + # nou „verificare" e substring al „verificare faruri" -> overlap + hits = text_rules_overlap("verificare", existing) + assert len(hits) == 1 + assert hits[0]["pattern"] == "verificare faruri" + + # invers: nou „verificare faruri spate" CONTINE „verificare faruri" -> overlap + hits2 = text_rules_overlap("verificare faruri spate", existing) + assert len(hits2) == 1 + assert hits2[0]["pattern"] == "verificare faruri" + + +def test_fara_overlap(): + """Pattern fara nicio relatie de substring -> lista goala. + + Pattern IDENTIC (dupa normalizare) cu unul existent NU e overlap: e un upsert + (update al codului), nu o suprapunere care merita avertisment. + """ + existing = [ + {"pattern": "verificare", "cod_prestatie": "OE-2"}, + ] + assert text_rules_overlap("schimb ulei", existing) == [] + # identic dupa normalizare -> update, nu overlap + assert text_rules_overlap("VERIFICARE", existing) == [] + # fara reguli existente + assert text_rules_overlap("verificare", []) == [] + + +def test_overlap_normalizat_diacritice(): + """Normalizarea (diacritice + caz) se aplica pe ambele parti la match.""" + existing = [ + {"pattern": "verificare completa", "cod_prestatie": "OE-2"}, + ] + # „Verificăre" -> „VERIFICARE", substring al „VERIFICARE COMPLETA" -> overlap + hits = text_rules_overlap("Verificăre", existing) + assert len(hits) == 1 + assert hits[0]["pattern"] == "verificare completa" diff --git a/tests/test_mapping_text_rules.py b/tests/test_mapping_text_rules.py new file mode 100644 index 0000000..626ea1a --- /dev/null +++ b/tests/test_mapping_text_rules.py @@ -0,0 +1,147 @@ +"""US-001: Schema + persistenta reguli text de mapare (operation_text_rules). + +Teste RED-first: verifica tabela, functiile de persistenta si unicitatea per cont. +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + + +# --------------------------------------------------------------------------- # +# Fixtures # +# --------------------------------------------------------------------------- # + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "text_rules.db")) + 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() + + +# --------------------------------------------------------------------------- # +# Teste # +# --------------------------------------------------------------------------- # + +def test_save_text_rule_persista(conn): + """save_text_rule insereaza un rand; load_text_rules il returneaza.""" + from app.mapping import save_text_rule, load_text_rules + + save_text_rule(conn, account_id=1, pattern="verificare", cod_prestatie="OE-2", auto_send=False) + conn.commit() + + rules = load_text_rules(conn, account_id=1) + assert len(rules) == 1 + r = rules[0] + assert r["pattern"] == "verificare" + assert r["cod_prestatie"] == "OE-2" + assert r["auto_send"] == 0 + assert r["priority"] == 0 + + +def test_load_text_rules_per_cont(conn): + """load_text_rules returneaza DOAR regulile contului dat, nu ale altor conturi.""" + from app.mapping import save_text_rule, load_text_rules + + # Creeaza un cont suplimentar (id=2) + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'cont2')") + conn.commit() + + save_text_rule(conn, account_id=1, pattern="revizie", cod_prestatie="OE-1", auto_send=False) + save_text_rule(conn, account_id=2, pattern="vopsitorie", cod_prestatie="OE-3", auto_send=False) + conn.commit() + + rules1 = load_text_rules(conn, account_id=1) + rules2 = load_text_rules(conn, account_id=2) + + assert len(rules1) == 1 + assert rules1[0]["pattern"] == "revizie" + assert len(rules2) == 1 + assert rules2[0]["pattern"] == "vopsitorie" + + +def test_delete_text_rule(conn): + """delete_text_rule sterge regula specificata; load_text_rules returneaza lista vida.""" + from app.mapping import save_text_rule, load_text_rules, delete_text_rule + + save_text_rule(conn, account_id=1, pattern="schimb ulei", cod_prestatie="OE-2", auto_send=True) + conn.commit() + + assert len(load_text_rules(conn, account_id=1)) == 1 + + delete_text_rule(conn, account_id=1, pattern="schimb ulei") + conn.commit() + + assert load_text_rules(conn, account_id=1) == [] + + +def test_unic_per_cont_pattern(conn): + """save_text_rule face upsert: al doilea apel cu acelasi (account_id, pattern) + actualizeaza cod_prestatie si auto_send, nu creeaza un rand nou.""" + from app.mapping import save_text_rule, load_text_rules + + save_text_rule(conn, account_id=1, pattern="inspectie", cod_prestatie="OE-1", auto_send=False) + conn.commit() + + # Al doilea apel = upsert: schimba codul si auto_send + save_text_rule(conn, account_id=1, pattern="inspectie", cod_prestatie="OE-2", auto_send=True) + conn.commit() + + rules = load_text_rules(conn, account_id=1) + assert len(rules) == 1 # nu s-a dublat + assert rules[0]["cod_prestatie"] == "OE-2" + assert rules[0]["auto_send"] == 1 + + +def test_load_text_rules_ordine(conn): + """load_text_rules returneaza regulile ordonate priority ASC, id ASC.""" + from app.mapping import save_text_rule, load_text_rules + + # Inseram direct cu priority diferit ca sa testam ordinea + conn.execute( + "INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send, priority) " + "VALUES (1, 'gamma', 'OE-3', 0, 10)" + ) + conn.execute( + "INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send, priority) " + "VALUES (1, 'alfa', 'OE-1', 0, 0)" + ) + conn.execute( + "INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send, priority) " + "VALUES (1, 'beta', 'OE-2', 0, 0)" + ) + conn.commit() + + rules = load_text_rules(conn, account_id=1) + # priority 0 inainte de priority 10; la egalitate de priority: id ASC (alfa < beta < gamma) + assert rules[0]["pattern"] == "alfa" + assert rules[1]["pattern"] == "beta" + assert rules[2]["pattern"] == "gamma" + + +def test_account_or_default_none_equals_1(conn): + """load_text_rules cu account_id=None aplica account_or_default -> returneaza regulile contului 1.""" + from app.mapping import save_text_rule, load_text_rules + + save_text_rule(conn, account_id=None, pattern="reparatie", cod_prestatie="OE-1", auto_send=False) + conn.commit() + + rules = load_text_rules(conn, account_id=None) + assert len(rules) == 1 + assert rules[0]["pattern"] == "reparatie" diff --git a/tests/test_payload_view.py b/tests/test_payload_view.py index 71a02d2..23d7fd5 100644 --- a/tests/test_payload_view.py +++ b/tests/test_payload_view.py @@ -80,3 +80,69 @@ def test_operatie_fallback_la_cod(): d = prezentare_din_payload({"prestatii": [{"cod_op_service": "OP-77"}]}) assert d["cod"] == "OP-77" assert d["operatie"] == "OP-77" + + +# --- US-005: cod_rar distinct --- + +def test_cod_rar_prezent_cand_mapat(): + """cod_prestatie prezent -> cod_rar e uppercase + strip '.0'.""" + d = prezentare_din_payload({ + "prestatii": [{"cod_prestatie": "r-frane", "denumire": "Reparatie frane"}] + }) + assert d["cod_rar"] == "R-FRANE" + + # defensiv ca odometru: strip '.0' chiar daca codurile RAR nu au zecimale + d2 = prezentare_din_payload({ + "prestatii": [{"cod_prestatie": "oe-2.0"}] + }) + assert d2["cod_rar"] == "OE-2" + + # deja uppercase + fara zecimale -> trece neschimbat + d3 = prezentare_din_payload({ + "prestatii": [{"cod_prestatie": "OE-2", "denumire": "Verificare"}] + }) + assert d3["cod_rar"] == "OE-2" + + +def test_cod_rar_gol_cand_nemapat(): + """cod_prestatie absent/None -> cod_rar == EMPTY; NU cade pe cod_op_service.""" + # doar cod_op_service prezent + d = prezentare_din_payload({ + "prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}] + }) + assert d["cod_rar"] == EMPTY + + # fara prestatii deloc + d2 = prezentare_din_payload({"vin": "WVWZZZ1JZXW000001"}) + assert d2["cod_rar"] == EMPTY + + # cod_prestatie explicit None + d3 = prezentare_din_payload({ + "prestatii": [{"cod_prestatie": None, "cod_op_service": "OP-99"}] + }) + assert d3["cod_rar"] == EMPTY + + +def test_operatie_ramane_denumire_sau_op(): + """operatie si cod raman neschimbate (compatibilitate cu apelantii existenti).""" + # cu ambele coduri -> operatie=denumire, cod=cod_prestatie (neschimbat) + d = prezentare_din_payload({ + "prestatii": [{"cod_prestatie": "R-FRANE", "cod_op_service": "OP-1", + "denumire": "Reparatie frane"}] + }) + assert d["operatie"] == "Reparatie frane" + assert d["cod"] == "R-FRANE" + + # fara denumire, doar cod_op_service -> operatie=cod intern, cod=cod intern + d2 = prezentare_din_payload({ + "prestatii": [{"cod_op_service": "OP-77"}] + }) + assert d2["operatie"] == "OP-77" + assert d2["cod"] == "OP-77" + + # cod_rar nu afecteaza cod si operatie + d3 = prezentare_din_payload({ + "prestatii": [{"cod_prestatie": "oe-2", "denumire": "Verificare"}] + }) + assert d3["operatie"] == "Verificare" + assert d3["cod"] == "oe-2" # cod ramas neschimbat (nu uppercase) diff --git a/tests/test_reresolve_text_rules.py b/tests/test_reresolve_text_rules.py new file mode 100644 index 0000000..aaa91eb --- /dev/null +++ b/tests/test_reresolve_text_rules.py @@ -0,0 +1,267 @@ +"""US-003: Reguli text active la ingestie (API + import + corectie web) si la +re-rezolvarea blocajelor (`reresolve_account`). + +Verifica ca o regula text salvata in prealabil rezolva o operatie fara mapare +exacta pe TOATE caile de ingestie, in loc sa o lase `needs_mapping`, si ca la +re-rezolvare un rand `needs_mapping` care acum da match pe o regula se deblocheaza. + +Codul rezolvat din regula respecta validarea fata de nomenclator (US-002): folosim +`OE-2`, cod valid din seed-ul nomenclatorului. +""" + +from __future__ import annotations + +import io +import json +import os +import re +import tempfile + +import openpyxl +import pytest +from fastapi.testclient import TestClient + + +# --------------------------------------------------------------------------- # +# Fixtures # +# --------------------------------------------------------------------------- # + +@pytest.fixture() +def api_env(monkeypatch): + """Client API + get_connection, DB temporara izolata (fara web-auth).""" + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rr.db")) + from app.config import get_settings + get_settings.cache_clear() + from app.db import get_connection + from app.main import app + with TestClient(app) as c: + yield c, get_connection + get_settings.cache_clear() + + +@pytest.fixture() +def web_env(monkeypatch): + """Client web (auth pornit) + get_connection, DB temporara izolata.""" + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rrw.db")) + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true") + from app.config import get_settings + get_settings.cache_clear() + from app.web import ratelimit + ratelimit._hits.clear() + from app.db import get_connection + from app.main import app + with TestClient(app, follow_redirects=False) as c: + yield c, get_connection + ratelimit._hits.clear() + get_settings.cache_clear() + + +# --------------------------------------------------------------------------- # +# Helpere # +# --------------------------------------------------------------------------- # + +def _seed_text_rule(get_connection, account_id, pattern, cod, auto_send=True): + from app.mapping import save_text_rule + conn = get_connection() + try: + save_text_rule(conn, account_id, pattern, cod, auto_send=auto_send) + conn.commit() + finally: + conn.close() + + +def _make_xlsx(rows): + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Sheet1" + for row in rows: + ws.append(row) + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def _row_status_cod(get_connection, sub_id): + conn = get_connection() + try: + r = conn.execute( + "SELECT status, payload_json FROM submissions WHERE id=?", (sub_id,) + ).fetchone() + payload = json.loads(r["payload_json"]) if r["payload_json"] else {} + cod = (payload.get("prestatii") or [{}])[0].get("cod_prestatie") + return r["status"], cod + finally: + conn.close() + + +# --------------------------------------------------------------------------- # +# 1. Ingestie API (router.py -> classify_prezentare) # +# --------------------------------------------------------------------------- # + +def test_ingestie_api_aplica_regula_text(api_env): + """POST /v1/prezentari cu operatie fara mapare exacta dar match pe regula text + -> queued (cod din regula), nu needs_mapping.""" + client, get_connection = api_env + _seed_text_rule(get_connection, 1, "verificare", "OE-2") + + body = { + "rar_credentials": {"email": "x@y.ro", "password": "s"}, + "prezentari": [{ + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [{"cod_op_service": "Verificare frane", "denumire": "Verificare frane"}], + }], + } + r = client.post("/v1/prezentari", json=body) + assert r.status_code == 200, r.text + res = r.json()["results"][0] + assert res["status"] == "queued", res + assert not res.get("nemapate") + + status, cod = _row_status_cod(get_connection, res["submission_id"]) + assert status == "queued" + assert cod == "OE-2" + + +# --------------------------------------------------------------------------- # +# 2. Ingestie import (import_router.py preview + commit) # +# --------------------------------------------------------------------------- # + +def test_ingestie_import_aplica_regula_text(api_env): + """Import xlsx cu operatie fara mapare exacta dar match pe regula text: + preview o marcheaza 'ok' si commit o pune 'queued' (cod din regula).""" + client, get_connection = api_env + _seed_text_rule(get_connection, 1, "verificare", "OE-2") + + header = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"] + row = ["WVWZZZ1KZAW001111", "B100TST", "2026-06-15", "123456", "Verificare frane"] + data = _make_xlsx([header, row]) + + r = client.post( + "/v1/import", + files={"file": ("t.xlsx", io.BytesIO(data), "application/octet-stream")}, + ) + assert r.status_code == 200, r.text + import_id = r.json()["import_id"] + + rc = client.post(f"/v1/import/{import_id}/column-mapping", json={"json_mapare": { + "VIN": "vin", + "Nr inmatriculare": "nr_inmatriculare", + "Data prestatie": "data_prestatie", + "Odometru final": "odometru_final", + "Operatie": "operatie", + }}) + assert rc.status_code == 200, rc.text + + rp = client.get(f"/v1/import/{import_id}/preview") + assert rp.status_code == 200, rp.text + assert rp.json()["summary"].get("ok", 0) == 1, rp.json()["summary"] + + rcommit = client.post(f"/v1/import/{import_id}/commit", json={ + "n_confirmat": 1, "reviewed_rows": [], + }) + assert rcommit.status_code == 200, rcommit.text + assert rcommit.json()["enqueued"] == 1 + sub_id = rcommit.json()["submissions"][0]["submission_id"] + + status, cod = _row_status_cod(get_connection, sub_id) + assert status == "queued" + assert cod == "OE-2" + + +# --------------------------------------------------------------------------- # +# 3. Corectie web (routes.py -> post_corectie_trimitere) # +# --------------------------------------------------------------------------- # + +def _create_account_user(get_connection, email, name="Service", password="parolasecreta10"): + from app.accounts import create_account + from app.users import create_user + conn = get_connection() + try: + acct_id = create_account(conn, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email, password="parolasecreta10"): + resp = client.get("/login") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _csrf(client): + resp = client.get("/?tab=acasa") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m + return m.group(1) + + +def _insert_needs_mapping(get_connection, acct, op, denumire=None, batch_id=None): + conn = get_connection() + try: + payload = { + "vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B123ABC", + "data_prestatie": "2026-06-10", "odometru_final": "159004", + "prestatii": [{"cod_prestatie": None, "cod_op_service": op, "denumire": denumire or op}], + } + k = f"k-{os.urandom(6).hex()}" + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, batch_id) " + "VALUES (?, ?, 'needs_mapping', ?, ?, ?)", + (k, acct, json.dumps(payload), json.dumps({"unmapped": [{"cod_op_service": op}]}), batch_id), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +def test_corectie_web_aplica_regula_text(web_env): + """POST /trimitere/{id}/corecteaza pe un needs_mapping a carui operatie acum da + match pe o regula text -> randul intra 'queued' (cod din regula).""" + client, get_connection = web_env + acct = _create_account_user(get_connection, "cor@test.com") + sid = _insert_needs_mapping(get_connection, acct, op="Verificare frane") + _seed_text_rule(get_connection, acct, "verificare", "OE-2") + + _login(client, "cor@test.com") + csrf = _csrf(client) + resp = client.post(f"/trimitere/{sid}/corecteaza", data={"csrf_token": csrf}) + assert resp.status_code == 200, resp.text + + status, cod = _row_status_cod(get_connection, sid) + assert status == "queued" + assert cod == "OE-2" + + +# --------------------------------------------------------------------------- # +# 4. Re-rezolvare blocaje (mapping.reresolve_account) # +# --------------------------------------------------------------------------- # + +def test_salvare_regula_rerezolva_blocate(api_env): + """Dupa salvarea unei reguli noi, reresolve_account deblocheaza randurile + needs_mapping care acum dau match (acelasi mecanism ca la save_mapping).""" + client, get_connection = api_env + sid = _insert_needs_mapping(get_connection, 1, op="Verificare frane") + _seed_text_rule(get_connection, 1, "verificare", "OE-2") + + from app.mapping import reresolve_account + conn = get_connection() + try: + stats = reresolve_account(conn, 1) + conn.commit() + finally: + conn.close() + + assert stats["requeued"] == 1, stats + status, cod = _row_status_cod(get_connection, sid) + assert status == "queued" + assert cod == "OE-2" diff --git a/tests/test_text_rule_autosend.py b/tests/test_text_rule_autosend.py new file mode 100644 index 0000000..f010460 --- /dev/null +++ b/tests/test_text_rule_autosend.py @@ -0,0 +1,81 @@ +"""FIX (code-review 5.8): o regula text cu auto_send=0 (DEFAULT, decizia CEO) trebuie +sa TINA randul pentru verificare umana (needs_mapping/review), NU sa-l trimita automat. + +`has_no_auto_send` trebuie sa prinda si itemii rezolvati-prin-regula-text cu auto_send=0, +nu doar maparile exacte din operations_mapping. Adnotarile (cod_sursa/regula_fara_autosend) +trebuie curatate la fiecare rezolvare (anti-staleness). + +Functii pure -> teste fara DB. +""" + +from __future__ import annotations + +from app.mapping import classify_prezentare, has_no_auto_send, resolve_prestatii + + +VALID = {"OE-2"} +_CONTENT = { + "vin": "WDB123456789012AB", + "nr_inmatriculare": "B123ABC", + "data_prestatie": "2026-06-22", + "odometru_final": "1000", +} + + +def _content_cu(op_denumire="Verificare faruri"): + return {**_CONTENT, "prestatii": [{"cod_op_service": "X99", "denumire": op_denumire}]} + + +def test_regula_auto_send_0_tine_randul(): + """Regula text auto_send=0 + continut valid -> needs_mapping (review), NU queued.""" + tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}] + cl = classify_prezentare(_content_cu(), {}, {}, VALID, tr) + assert cl["status"] == "needs_mapping", f"asteptat needs_mapping (held), got {cl['status']}" + + +def test_regula_auto_send_1_trece_in_coada(): + """Regula text auto_send=1 + continut valid -> queued (trimite automat).""" + tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 1, "priority": 0}] + cl = classify_prezentare(_content_cu(), {}, {}, VALID, tr) + assert cl["status"] == "queued", f"asteptat queued, got {cl['status']}" + + +def test_has_no_auto_send_prinde_flagul_regula(): + """has_no_auto_send=True cand un item poarta regula_fara_autosend; codul e tot rezolvat.""" + tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}] + resolved, unmapped = resolve_prestatii( + [{"cod_op_service": "X99", "denumire": "Verificare faruri"}], {}, VALID, tr + ) + assert unmapped == [] + assert resolved[0]["cod_prestatie"] == "OE-2" + assert resolved[0].get("regula_fara_autosend") is True + assert has_no_auto_send(resolved, {}) is True + + +def test_has_no_auto_send_fals_cand_regula_auto_send_1(): + """Regula auto_send=1 -> fara flag -> has_no_auto_send False.""" + tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 1, "priority": 0}] + resolved, _ = resolve_prestatii( + [{"cod_op_service": "X99", "denumire": "Verificare faruri"}], {}, VALID, tr + ) + assert resolved[0].get("regula_fara_autosend") is None + assert has_no_auto_send(resolved, {}) is False + + +def test_adnotari_stale_curatate_la_mapare_exacta(): + """Un item venit cu cod_sursa/regula_fara_autosend stale dar re-rezolvat acum prin + mapare EXACTA cu auto_send=1 -> adnotarile sunt curatate; randul NU mai e tinut.""" + item_stale = { + "cod_op_service": "X99", + "denumire": "Verificare faruri", + "cod_sursa": "text_rule:verificare", + "regula_fara_autosend": True, + } + # Acum X99 are mapare exacta cu auto_send=1. + mapping = {"X99": "OE-2"} + mapping_meta = {"X99": {"cod_prestatie": "OE-2", "auto_send": True}} + resolved, _ = resolve_prestatii([item_stale], mapping, VALID, text_rules=None) + assert resolved[0]["cod_prestatie"] == "OE-2" + assert "cod_sursa" not in resolved[0], "cod_sursa stale nu a fost curatat" + assert "regula_fara_autosend" not in resolved[0], "flag stale nu a fost curatat" + assert has_no_auto_send(resolved, mapping_meta) is False diff --git a/tests/test_text_rule_telemetry.py b/tests/test_text_rule_telemetry.py new file mode 100644 index 0000000..b2c91c0 --- /dev/null +++ b/tests/test_text_rule_telemetry.py @@ -0,0 +1,251 @@ +"""US-010: Telemetrie hit regula text in `app_events`. + +Cand o operatie nemapata primeste cod RAR dintr-o regula text (substring), apelantii +cu `conn` (ingestie API, import, re-rezolvare) emit un eveniment `text_rule_hit` in +`app_events` cu {submission_id, account_id, pattern, cod_prestatie}. Maparea exacta +(cod_op_service -> cod_prestatie) NU emite acest eveniment. Evenimentul e redactat +(fara PII) si scoped pe cont. + +Folosim `OE-2`, cod valid din seed-ul nomenclatorului. +""" + +from __future__ import annotations + +import io +import json +import os +import re +import tempfile + +import openpyxl +import pytest +from fastapi.testclient import TestClient + + +# --------------------------------------------------------------------------- # +# Fixtures # +# --------------------------------------------------------------------------- # + +@pytest.fixture() +def api_env(monkeypatch): + """Client API + get_connection, DB temporara izolata (fara web-auth).""" + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rr.db")) + from app.config import get_settings + get_settings.cache_clear() + from app.db import get_connection + from app.main import app + with TestClient(app) as c: + yield c, get_connection + get_settings.cache_clear() + + +# --------------------------------------------------------------------------- # +# Helpere # +# --------------------------------------------------------------------------- # + +def _seed_text_rule(get_connection, account_id, pattern, cod, auto_send=True): + from app.mapping import save_text_rule + conn = get_connection() + try: + save_text_rule(conn, account_id, pattern, cod, auto_send=auto_send) + conn.commit() + finally: + conn.close() + + +def _seed_mapping(get_connection, account_id, op, cod, auto_send=True): + from app.mapping import save_mapping + conn = get_connection() + try: + save_mapping(conn, account_id, op, cod, auto_send=auto_send) + conn.commit() + finally: + conn.close() + + +def _text_rule_hits(get_connection): + conn = get_connection() + try: + rows = conn.execute( + "SELECT account_id, context_json FROM app_events WHERE tip='text_rule_hit' ORDER BY id" + ).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +_VIN = "WVWZZZ1KZAW000123" + + +def _post_prezentare(client, op, denumire=None): + body = { + "rar_credentials": {"email": "x@y.ro", "password": "parola-secreta"}, + "prezentari": [{ + "vin": _VIN, + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [{"cod_op_service": op, "denumire": denumire or op}], + }], + } + return client.post("/v1/prezentari", json=body) + + +# --------------------------------------------------------------------------- # +# 1. Hit regula -> eveniment # +# --------------------------------------------------------------------------- # + +def test_hit_regula_emite_app_event(api_env): + """O operatie rezolvata prin regula text emite `text_rule_hit` cu pattern + cod.""" + client, get_connection = api_env + _seed_text_rule(get_connection, 1, "verificare", "OE-2") + + r = _post_prezentare(client, "Verificare frane") + assert r.status_code == 200, r.text + res = r.json()["results"][0] + assert res["status"] == "queued", res + sub_id = res["submission_id"] + + hits = _text_rule_hits(get_connection) + assert len(hits) == 1, hits + ctx = json.loads(hits[0]["context_json"]) + assert ctx["submission_id"] == sub_id + assert ctx["account_id"] == 1 + assert ctx["pattern"] == "verificare" + assert ctx["cod_prestatie"] == "OE-2" + + +# --------------------------------------------------------------------------- # +# 2. Maparea exacta NU emite # +# --------------------------------------------------------------------------- # + +def test_mapare_exacta_nu_emite_text_rule_hit(api_env): + """O operatie rezolvata prin mapare exacta (cod_op_service) nu emite text_rule_hit.""" + client, get_connection = api_env + _seed_mapping(get_connection, 1, "Verificare frane", "OE-2") + + r = _post_prezentare(client, "Verificare frane") + assert r.status_code == 200, r.text + res = r.json()["results"][0] + assert res["status"] == "queued", res + + hits = _text_rule_hits(get_connection) + assert hits == [], hits + + +# --------------------------------------------------------------------------- # +# 3. Eveniment redactat + scoped pe cont # +# --------------------------------------------------------------------------- # + +def test_event_redactat_si_scoped_pe_cont(api_env): + """Evenimentul e scoped pe cont (coloana account_id) si nu contine PII (VIN integral).""" + client, get_connection = api_env + _seed_text_rule(get_connection, 1, "verificare", "OE-2") + + r = _post_prezentare(client, "Verificare frane") + assert r.status_code == 200, r.text + + hits = _text_rule_hits(get_connection) + assert len(hits) == 1, hits + assert hits[0]["account_id"] == 1 + # Fara PII: VIN-ul integral nu apare in context. + assert _VIN not in (hits[0]["context_json"] or "") + + +# --------------------------------------------------------------------------- # +# 4. Calea web import commit (routes.py -> web_confirma_import) # +# --------------------------------------------------------------------------- # + +@pytest.fixture() +def web_env(monkeypatch): + """Client web (mod dev) + get_connection + cont creat; require_login fixat pe cont.""" + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rrw.db")) + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false") + from app.config import get_settings + get_settings.cache_clear() + from app.web import ratelimit + ratelimit._hits.clear() + from app.accounts import create_account + from app.db import get_connection + from app.main import app + with TestClient(app, follow_redirects=False) as c: + conn = get_connection() + acct = create_account(conn, "Cont Web Telemetrie", active=True) + conn.commit() + conn.close() + monkeypatch.setattr("app.web.routes.require_login", lambda r: acct) + yield c, get_connection, acct + ratelimit._hits.clear() + get_settings.cache_clear() + + +def _make_xlsx(rows): + wb = openpyxl.Workbook() + ws = wb.active + if rows: + ws.append(list(rows[0].keys())) + for r in rows: + ws.append(list(r.values())) + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def _csrf_from(html): + m = re.search(r'name="csrf_token" value="([^"]*)"', html) + return m.group(1) if m else "" + + +def test_web_import_commit_emite_text_rule_hit(web_env): + """Import web commit pe un rand a carui operatie da match pe o regula text + emite `text_rule_hit` in app_events cu submission_id-ul randului creat.""" + client, get_connection, acct = web_env + _seed_text_rule(get_connection, acct, "verificare", "OE-2") + + rows = [{ + "vin": "WVWZZZ1KZAW001111", "nr_inmatriculare": "B100TST", + "data_prestatie": "2026-06-15", "odometru_final": "123456", + "operatie": "Verificare frane", + }] + r_up = client.post( + "/_import/upload", + files={"file": ("t.xlsx", _make_xlsx(rows), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, + ) + assert r_up.status_code == 200, r_up.text + csrf = _csrf_from(r_up.text) + batch_id = int(re.search(r"/_import/(\d+)/mapare-coloane", r_up.text).group(1)) + + r_map = client.post( + f"/_import/{batch_id}/mapare-coloane", + data={ + "csrf_token": csrf, + "colname": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"], + "canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"], + }, + ) + assert r_map.status_code == 200, r_map.text + csrf = _csrf_from(r_map.text) or csrf + + r_commit = client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf}) + assert r_commit.status_code == 200, r_commit.text + + conn = get_connection() + try: + sub = conn.execute( + "SELECT id, status FROM submissions WHERE account_id=? AND batch_id=?", (acct, batch_id) + ).fetchone() + finally: + conn.close() + assert sub is not None, "submission-ul nu a fost creat" + assert sub["status"] == "queued" + + hits = _text_rule_hits(get_connection) + assert len(hits) == 1, hits + ctx = json.loads(hits[0]["context_json"]) + assert ctx["submission_id"] == sub["id"] + assert ctx["account_id"] == acct + assert ctx["pattern"] == "verificare" + assert ctx["cod_prestatie"] == "OE-2" diff --git a/tests/test_web_corectie.py b/tests/test_web_corectie.py index 7830230..1ac96ec 100644 --- a/tests/test_web_corectie.py +++ b/tests/test_web_corectie.py @@ -73,7 +73,7 @@ def _row(sid: int): def _payload(vin: str, *, odo: str = "55000") -> dict: return { "vin": vin, "nr_inmatriculare": "B100AAA", "data_prestatie": "2026-06-10", - "odometru_final": odo, "prestatii": [{"cod_prestatie": "R-X"}], + "odometru_final": odo, "prestatii": [{"cod_prestatie": "OE-1"}], } diff --git a/tests/test_web_detaliu_inline.py b/tests/test_web_detaliu_inline.py new file mode 100644 index 0000000..aaca624 --- /dev/null +++ b/tests/test_web_detaliu_inline.py @@ -0,0 +1,145 @@ +"""Teste PRD 5.8 US-008: detaliul trimiterii apare ca rand expandabil SUB randul +selectat (nu in panoul global de la baza tabelului). + +Verificam markup-ul server-side: fiecare rand de date are un rand-sibling de detaliu +`` cu container per-rand `#detaliu-{id}`, randul clickabil +tinteste acel container, iar fragmentul de detaliu (Inchide + forme) tinteste tot +containerul per-rand — NU `#trimitere-detaliu` global. Single-open + pauza poll sunt +logica JS in base.html (verificam prezenta hook-urilor). +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"): + from app.accounts import create_account + from app.users import create_user + from app.db import get_connection + + conn = get_connection() + try: + acct_id = create_account(conn, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> None: + resp = client.get("/login") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \ + re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text) + assert m + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _insert_submission(acct: int, status: str = "sent", *, payload: dict | None = None) -> int: + from app.db import get_connection + conn = get_connection() + try: + p = payload if payload is not None else { + "vin": "WVWZZZ1JZXW000777", + "nr_inmatriculare": "B777ZZZ", + "data_prestatie": "2026-06-18", + "odometru_final": "55000", + "prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}], + } + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES (?, ?, ?, ?)", + (f"k-{status}-{os.urandom(4).hex()}", acct, status, json.dumps(p)), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "subm.db")) + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true") + from app.config import get_settings + get_settings.cache_clear() + from app.web import ratelimit + ratelimit._hits.clear() + from app.main import app + with TestClient(app, follow_redirects=False) as c: + yield c + ratelimit._hits.clear() + get_settings.cache_clear() + + +def test_fragment_detaliu_se_randeaza_in_container_pe_rand(client): + """Tabelul are un rand-sibling de detaliu per rand (#detaliu-{id}), iar fragmentul + de detaliu tinteste acel container, nu panoul global #trimitere-detaliu.""" + acct = _create_account_user("inl@test.com") + sid = _insert_submission(acct, "needs_data") + _login(client, "inl@test.com") + + # 1. Tabelul: rand-sibling de detaliu + retargeting pe randul clickabil + lista = client.get("/_fragments/submissions") + assert lista.status_code == 200 + h = lista.text + assert 'class="detaliu-rand"' in h, "lipseste randul-sibling de detaliu" + assert f'id="detaliu-{sid}"' in h, "lipseste containerul per-rand" + assert 'colspan="8"' in h, "td-ul de detaliu trebuie sa acopere cele 8 coloane" + assert f'hx-target="#detaliu-{sid}"' in h, "randul de date trebuie sa tinteasca containerul per-rand" + # randul de date NU mai tinteste panoul global + assert 'hx-target="#trimitere-detaliu"' not in h + + # 2. Fragmentul de detaliu: Inchide + forme tintesc containerul per-rand + det = client.get(f"/_fragments/trimitere/{sid}") + assert det.status_code == 200 + d = det.text + # butonul Inchide opereaza pe containerul randului curent (nu pe panoul global) + assert f"detaliu-{sid}" in d + assert "getElementById('trimitere-detaliu')" not in d + # formele de corectie/mapare tintesc containerul per-rand + assert f'hx-target="#detaliu-{sid}"' in d + assert 'hx-target="#trimitere-detaliu"' not in d + + +def test_un_singur_detaliu_deschis(client): + """Logica JS din base.html asigura un singur detaliu deschis (inchide celelalte la + deschidere) si pune poll-ul pe pauza cat un rand e expandat (D-eng-2).""" + _create_account_user("one@test.com") + _login(client, "one@test.com") + + pagina = client.get("/") + assert pagina.status_code == 200 + js = pagina.text + # randul clickabil e accesibil (role/aria pentru toggle) + assert 'class="trimitere-row"' not in js or True # markup-ul randului traieste in fragment + # hook-uri de single-open: inchiderea altor detalii + sincronizarea starii aria + assert "closeAllDetalii" in js, "lipseste logica de inchidere a celorlalte detalii" + assert "detaliu-rand" in js, "logica trebuie sa opereze pe randurile de detaliu" + assert "aria-expanded" in js, "starea expandata trebuie sincronizata" + # pauza poll cat un rand e deschis: anuleaza request-ul periodic pe #submissions-wrap + assert "submissions-wrap" in js + assert "preventDefault" in js + + +def test_rand_clickabil_accesibil(client): + """Randul de date e focusabil la tastatura (role=button, tabindex, aria-expanded).""" + acct = _create_account_user("a11y@test.com") + sid = _insert_submission(acct, "sent") + _login(client, "a11y@test.com") + h = client.get("/_fragments/submissions").text + # randul de date + m = re.search(r'' % sid, h, re.S) + assert m, "lipseste randul de date" + rand = m.group(0) + assert 'role="button"' in rand + assert 'tabindex="0"' in rand + assert 'aria-expanded="false"' in rand diff --git a/tests/test_web_mapari_overlap.py b/tests/test_web_mapari_overlap.py new file mode 100644 index 0000000..37a6c04 --- /dev/null +++ b/tests/test_web_mapari_overlap.py @@ -0,0 +1,110 @@ +"""Teste US-011 (PRD 5.8) — avertisment overlap NEBLOCANT la salvarea regulii text. + +Cand regula noua se suprapune (substring) cu una existenta, mesajul de succes +„Regula salvata. Deblocate: N" capata si un avertisment neblocant care numeste +regula suprapusa. Salvarea CONTINUA (regula se salveaza oricum). +""" + +from __future__ import annotations + +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"): + from app.accounts import create_account + from app.users import create_user + from app.db import get_connection + + conn = get_connection() + try: + acct_id = create_account(conn, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> None: + resp = client.get("/login") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _csrf(client) -> str: + resp = client.get("/?tab=mapari") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m, "csrf_token negasit" + return m.group(1) + + +def _seed_text_rule(acct: int, pattern: str, cod: str, auto_send: int = 0) -> None: + from app.db import get_connection + conn = get_connection() + try: + conn.execute( + "INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send) " + "VALUES (?, ?, ?, ?)", + (acct, pattern, cod, auto_send), + ) + conn.commit() + finally: + conn.close() + + +def _text_rules(acct: int) -> list[dict]: + from app.db import get_connection + from app.mapping import load_text_rules + conn = get_connection() + try: + return load_text_rules(conn, acct) + finally: + conn.close() + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "overlap.db")) + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true") + from app.config import get_settings + get_settings.cache_clear() + from app.web import ratelimit + ratelimit._hits.clear() + from app.main import app + with TestClient(app, follow_redirects=False) as c: + yield c + ratelimit._hits.clear() + get_settings.cache_clear() + + +def test_salvare_cu_overlap_arata_avertisment_dar_salveaza(client): + """Regula noua care se suprapune cu una existenta -> avertisment + salvare. + + Exista „verificare" -> OE-2; salvam „verificare faruri" -> OE-3. „verificare" + e substring al „verificare faruri" -> avertisment neblocant; ambele se salveaza. + """ + acct = _create_account_user("ov@test.com") + _seed_text_rule(acct, "verificare", "OE-2") + + _login(client, "ov@test.com") + csrf = _csrf(client) + resp = client.post("/mapari/reguli-text", data={ + "pattern": "verificare faruri", "cod_prestatie": "OE-3", "csrf_token": csrf, + }) + assert resp.status_code == 200 + # mesaj de succes pastrat + assert "Regula salvata" in resp.text + # avertisment neblocant care numeste regula suprapusa + assert "suprapune" in resp.text.lower() + assert "verificare" in resp.text + + # ambele reguli persistate + patterns = {r["pattern"] for r in _text_rules(acct)} + assert patterns == {"verificare", "verificare faruri"} diff --git a/tests/test_web_mapari_preview_regula.py b/tests/test_web_mapari_preview_regula.py new file mode 100644 index 0000000..5878c23 --- /dev/null +++ b/tests/test_web_mapari_preview_regula.py @@ -0,0 +1,180 @@ +"""Teste US-009 (PRD 5.8) — preview pre-salvare regula text. + +POST /mapari/reguli-text/preview primeste `pattern`, normalizeaza cu +normalize_for_match, numara operatiile DISTINCTE nemapate ale contului +(needs_mapping, reuse pending_unmapped) al caror text contine pattern-ul si +intoarce pana la 3 exemple. NU salveaza nimic (zero scriere DB). Pattern gol -> +fragment gol (nu numara „tot"). Scoped pe contul sesiunii (require_login + CSRF). +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"): + from app.accounts import create_account + from app.users import create_user + from app.db import get_connection + + conn = get_connection() + try: + acct_id = create_account(conn, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> None: + resp = client.get("/login") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _csrf(client) -> str: + resp = client.get("/?tab=mapari") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m, "csrf_token negasit" + return m.group(1) + + +def _seed_needs_mapping(acct: int, *, op: str, denumire: str | None = None) -> int: + """Submission needs_mapping pe canal API (batch_id NULL) cu o operatie nemapata.""" + from app.db import get_connection + conn = get_connection() + try: + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) " + "VALUES (?, ?, 'needs_mapping', ?, ?)", + ( + f"k-{os.urandom(6).hex()}", + acct, + json.dumps({ + "vin": "WVWZZZ1JZXW000111", + "nr_inmatriculare": "B11AAA", + "data_prestatie": "2026-06-18", + "odometru_final": "12345", + "prestatii": [{"cod_prestatie": None, "cod_op_service": op, "denumire": denumire or op}], + }), + json.dumps({"unmapped": [{"cod_op_service": op}]}), + ), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +def _count_submissions() -> int: + from app.db import get_connection + conn = get_connection() + try: + return int(conn.execute("SELECT COUNT(*) AS c FROM submissions").fetchone()["c"]) + finally: + conn.close() + + +def _count_text_rules() -> int: + from app.db import get_connection + conn = get_connection() + try: + return int(conn.execute("SELECT COUNT(*) AS c FROM operation_text_rules").fetchone()["c"]) + finally: + conn.close() + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "preview_regula.db")) + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true") + from app.config import get_settings + get_settings.cache_clear() + from app.web import ratelimit + ratelimit._hits.clear() + from app.main import app + with TestClient(app, follow_redirects=False) as c: + yield c + ratelimit._hits.clear() + get_settings.cache_clear() + + +def test_preview_numara_potriviri(client): + """Numara operatiile DISTINCTE nemapate al caror text contine pattern-ul.""" + acct = _create_account_user("p1@test.com") + _seed_needs_mapping(acct, op="VERIFICARE FARURI", denumire="Verificare faruri") + _seed_needs_mapping(acct, op="VERIFICARE FRANE", denumire="Verificare frane") + _seed_needs_mapping(acct, op="SCHIMB ULEI", denumire="Schimb ulei") + + _login(client, "p1@test.com") + csrf = _csrf(client) + before = _count_submissions() + resp = client.post("/mapari/reguli-text/preview", data={ + "pattern": "verificare", "csrf_token": csrf, + }) + assert resp.status_code == 200 + # Cele doua operatii „verificare", nu si „schimb ulei". + assert "2" in resp.text + assert "potriveste" in resp.text.lower() + # NU salveaza nimic: nici submission, nici regula. + assert _count_submissions() == before + assert _count_text_rules() == 0 + + +def test_preview_intoarce_exemple(client): + """Fragmentul include exemple (denumirea/operatia care potrivesc).""" + acct = _create_account_user("p2@test.com") + _seed_needs_mapping(acct, op="VERIFICARE FARURI", denumire="Verificare faruri") + _seed_needs_mapping(acct, op="VERIFICARE FRANE", denumire="Verificare frane") + + _login(client, "p2@test.com") + csrf = _csrf(client) + resp = client.post("/mapari/reguli-text/preview", data={ + "pattern": "verificare", "csrf_token": csrf, + }) + assert resp.status_code == 200 + text = resp.text.lower() + assert "faruri" in text or "frane" in text + + +def test_preview_pattern_gol(client): + """Pattern gol -> fragment gol; nu numara „tot".""" + acct = _create_account_user("p3@test.com") + _seed_needs_mapping(acct, op="VERIFICARE FARURI", denumire="Verificare faruri") + + _login(client, "p3@test.com") + csrf = _csrf(client) + resp = client.post("/mapari/reguli-text/preview", data={ + "pattern": " ", "csrf_token": csrf, + }) + assert resp.status_code == 200 + # Nu raporteaza nicio potrivire numerica pentru pattern gol. + assert "potriveste" not in resp.text.lower() + assert resp.text.strip() == "" or "1" not in resp.text + + +def test_preview_scoped_pe_cont(client): + """Numara DOAR operatiile contului sesiunii, nu ale altui cont.""" + acct_a = _create_account_user("a@test.com", name="Cont A") + acct_b = _create_account_user("b@test.com", name="Cont B") + # Contul A are 2 potriviri, contul B niciuna. + _seed_needs_mapping(acct_a, op="VERIFICARE FARURI", denumire="Verificare faruri") + _seed_needs_mapping(acct_a, op="VERIFICARE FRANE", denumire="Verificare frane") + + _login(client, "b@test.com") + csrf = _csrf(client) + resp = client.post("/mapari/reguli-text/preview", data={ + "pattern": "verificare", "csrf_token": csrf, + }) + assert resp.status_code == 200 + # Contul B nu are nicio operatie nemapata -> nicio potrivire. + assert "Nicio potrivire" in resp.text diff --git a/tests/test_web_mapari_text_rules.py b/tests/test_web_mapari_text_rules.py new file mode 100644 index 0000000..d20e969 --- /dev/null +++ b/tests/test_web_mapari_text_rules.py @@ -0,0 +1,201 @@ +"""Teste US-004 (PRD 5.8) — sectiunea „Reguli automate (text)" din pagina Mapari. + +Adaugare/stergere reguli text (substring) din UI: +POST /mapari/reguli-text salveaza regula (save_text_rule) + re-rezolva blocajele +(reresolve_account) -> mesaj „Regula salvata. Deblocate: N" + trigger trimiteriChanged. +POST /mapari/reguli-text/sterge sterge regula. Ambele scoped pe contul sesiunii +(require_login), CSRF obligatoriu. Cod absent din nomenclator -> respins inline. +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"): + from app.accounts import create_account + from app.users import create_user + from app.db import get_connection + + conn = get_connection() + try: + acct_id = create_account(conn, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> None: + resp = client.get("/login") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _csrf(client) -> str: + resp = client.get("/?tab=mapari") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m, "csrf_token negasit" + return m.group(1) + + +def _seed_needs_mapping(acct: int, *, op: str, denumire: str | None = None) -> int: + """Submission needs_mapping pe canal API (batch_id NULL) cu o operatie nemapata.""" + from app.db import get_connection + conn = get_connection() + try: + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) " + "VALUES (?, ?, 'needs_mapping', ?, ?)", + ( + f"k-{os.urandom(6).hex()}", + acct, + json.dumps({ + "vin": "WVWZZZ1JZXW000111", + "nr_inmatriculare": "B11AAA", + "data_prestatie": "2026-06-18", + "odometru_final": "12345", + "prestatii": [{"cod_prestatie": None, "cod_op_service": op, "denumire": denumire or op}], + }), + json.dumps({"unmapped": [{"cod_op_service": op}]}), + ), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +def _status_of(sid: int) -> str: + from app.db import get_connection + conn = get_connection() + try: + return conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()["status"] + finally: + conn.close() + + +def _text_rules(acct: int) -> list[dict]: + from app.db import get_connection + from app.mapping import load_text_rules + conn = get_connection() + try: + return load_text_rules(conn, acct) + finally: + conn.close() + + +def _seed_text_rule(acct: int, pattern: str, cod: str, auto_send: int = 0) -> None: + from app.db import get_connection + conn = get_connection() + try: + conn.execute( + "INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send) " + "VALUES (?, ?, ?, ?)", + (acct, pattern, cod, auto_send), + ) + conn.commit() + finally: + conn.close() + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "reguli_text.db")) + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true") + from app.config import get_settings + get_settings.cache_clear() + from app.web import ratelimit + ratelimit._hits.clear() + from app.main import app + with TestClient(app, follow_redirects=False) as c: + yield c + ratelimit._hits.clear() + get_settings.cache_clear() + + +def test_post_regula_text_salveaza_si_rerezolva(client): + """POST salveaza regula + re-rezolva blocajele; mesaj „Deblocate: N" + trigger.""" + acct = _create_account_user("rt@test.com") + sid = _seed_needs_mapping(acct, op="DIVERSE VERIFICARI 159004") + assert _status_of(sid) == "needs_mapping" + + _login(client, "rt@test.com") + csrf = _csrf(client) + resp = client.post("/mapari/reguli-text", data={ + "pattern": "verificari", "cod_prestatie": "OE-2", + "auto_send": "true", "csrf_token": csrf, + }) + assert resp.status_code == 200 + assert "Regula salvata" in resp.text + assert "Deblocate" in resp.text + assert resp.headers.get("HX-Trigger") == "trimiteriChanged" + + rules = _text_rules(acct) + assert any(r["pattern"] == "verificari" and r["cod_prestatie"] == "OE-2" for r in rules) + # randul a fost deblocat (nu mai e needs_mapping) + assert _status_of(sid) != "needs_mapping" + + +def test_post_sterge_regula(client): + """POST sterge regula existenta; dispare din load_text_rules.""" + acct = _create_account_user("del@test.com") + _seed_text_rule(acct, "verificare", "OE-2") + assert len(_text_rules(acct)) == 1 + + _login(client, "del@test.com") + csrf = _csrf(client) + resp = client.post("/mapari/reguli-text/sterge", data={ + "pattern": "verificare", "csrf_token": csrf, + }) + assert resp.status_code == 200 + assert _text_rules(acct) == [] + + +def test_regula_text_scoped_pe_cont_sesiune(client): + """Salvarea creeaza regula DOAR pe contul sesiunii, nu pe alt cont.""" + acct_a = _create_account_user("a@test.com", name="Cont A") + acct_b = _create_account_user("b@test.com", name="Cont B") + + _login(client, "b@test.com") + csrf = _csrf(client) + resp = client.post("/mapari/reguli-text", data={ + "pattern": "schimb ulei", "cod_prestatie": "OE-1", "csrf_token": csrf, + }) + assert resp.status_code == 200 + + assert any(r["pattern"] == "schimb ulei" for r in _text_rules(acct_b)) + assert _text_rules(acct_a) == [] + + +def test_csrf_necesar(client): + """Fara token CSRF valid -> 403, fara regula salvata.""" + acct = _create_account_user("cf@test.com") + _login(client, "cf@test.com") + resp = client.post("/mapari/reguli-text", data={ + "pattern": "verificare", "cod_prestatie": "OE-2", "csrf_token": "gresit", + }) + assert resp.status_code == 403 + assert _text_rules(acct) == [] + + +def test_cod_invalid_respins(client): + """Cod absent din nomenclator -> mesaj inline, fara salvare.""" + acct = _create_account_user("ci@test.com") + _login(client, "ci@test.com") + csrf = _csrf(client) + resp = client.post("/mapari/reguli-text", data={ + "pattern": "verificare", "cod_prestatie": "NU-EXISTA", "csrf_token": csrf, + }) + assert resp.status_code == 200 + assert "necunoscut" in resp.text.lower() + assert _text_rules(acct) == [] diff --git a/tests/test_web_submissions.py b/tests/test_web_submissions.py index 99e34b9..b6922bc 100644 --- a/tests/test_web_submissions.py +++ b/tests/test_web_submissions.py @@ -86,8 +86,8 @@ def test_submissions_coloane_umane(client): resp = client.get("/_fragments/submissions") assert resp.status_code == 200 html = resp.text - # Antete romanesti - for antet in ("Stare", "Vehicul", "Operatie", "Data prestatie", "Nr. prezentare RAR", "Motiv"): + # Antete romanesti (Motiv a iesit din tabel in PRD 5.8 US-007 -> traieste in detaliu) + for antet in ("Stare", "Vehicul", "Operatie", "Data prestatie", "Nr. prezentare RAR"): assert antet in html, f"Lipseste antetul '{antet}'" # "HTTP RAR" NU mai e antet principal de coloana assert "" not in html @@ -112,18 +112,75 @@ def test_tab_eticheta_trimiteri(client): def test_motiv_needs_data_afisat(client): - """Pentru needs_data, coloana Motiv arata motivul (nu gol cand exista rar_error).""" + """Pentru needs_data, motivul apare in detaliu (PRD 5.8 US-007: Motiv a iesit din tabel).""" acct = _create_account_user("motiv@test.com") - _insert_submission( + sid = _insert_submission( acct, "needs_data", rar_error=json.dumps([{"field": "odometru_final", "message": "lipsa odometru"}]), ) _login(client, "motiv@test.com") - resp = client.get("/_fragments/submissions") + resp = client.get(f"/_fragments/trimitere/{sid}") assert resp.status_code == 200 assert "lipsa odometru" in resp.text +def test_tabel_nu_are_coloana_motiv(client): + """PRD 5.8 US-007: coloana Motiv eliminata din thead/tbody (e in detaliu).""" + acct = _create_account_user("nomotiv@test.com") + _insert_submission( + acct, "needs_data", + rar_error=json.dumps([{"field": "odometru_final", "message": "lipsa odometru xyz"}]), + ) + _login(client, "nomotiv@test.com") + resp = client.get("/_fragments/submissions") + assert resp.status_code == 200 + html = resp.text + assert "" not in html + # continutul Motiv nu mai apare in tabel (a fost mutat in detaliu) + assert "lipsa odometru xyz" not in html + + +def test_operatie_contine_cod_rar(client): + """PRD 5.8 US-007: coloana Operatie arata 'cod RAR: XXX' cand mapat, 'nemapat' cand nu.""" + acct = _create_account_user("codrar@test.com") + # mapat: are cod_prestatie -> cod RAR vizibil + _insert_submission(acct, "sent", payload={ + "vin": "WVWZZZ1JZXW000111", + "nr_inmatriculare": "B111AAA", + "data_prestatie": "2026-06-18", + "odometru_final": "10000", + "prestatii": [{"cod_prestatie": "OE-2", "denumire": "Verificare X"}], + }) + # nemapat: doar cod_op_service -> "nemapat" + _insert_submission(acct, "needs_mapping", payload={ + "vin": "WVWZZZ1JZXW000222", + "nr_inmatriculare": "B222BBB", + "data_prestatie": "2026-06-18", + "odometru_final": "20000", + "prestatii": [{"cod_op_service": "INTERN9", "denumire": "Spalare auto"}], + }) + _login(client, "codrar@test.com") + resp = client.get("/_fragments/submissions") + assert resp.status_code == 200 + html = resp.text + assert "cod RAR: OE-2" in html + assert "nemapat" in html + + +def test_pill_eticheta_scurta(client): + """PRD 5.8 US-007/US-006: pill-ul de Stare foloseste eticheta scurta; textul lung in title.""" + acct = _create_account_user("pill@test.com") + _insert_submission(acct, "sent", id_prezentare=70001) + _login(client, "pill@test.com") + resp = client.get("/_fragments/submissions") + assert resp.status_code == 200 + html = resp.text + # eticheta scurta in pill + assert ">Finalizat<" in html + # textul lung pastrat ca tooltip (title) + assert 'title="Declarate la RAR' in html + + def test_detaliu_trimitere(client): """/_fragments/trimitere/{id} intoarce detaliul complet scoped pe cont.""" acct = _create_account_user("det@test.com")
HTTP RARMotiv