From 56ebe1f7cb4028719df9d2b656821372182b3015 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 8 Jul 2026 15:45:34 +0000 Subject: [PATCH] feat(editor): editor prestatii unificat in modal - un select, un rand de chips (PRD 5.23) Inlocuieste cele 3 controale de asociere cod RAR cu un singur rand de chips + un singur select care adauga instant la change. Stare = un hidden chips_state JSON versionat; post_form_chips redus la 2 actiuni (add/remove). Optgroup Sugestii (fuzzy/k-NN) + optiune "Nu se declara la RAR" in select, cu tinta implicita evidentiata si placeholder care o numeste. exclus persistat prin payload_json (treapta noua de precedenta in resolve_prestatii, round-trip complet). Siguranta: itemii exclusi sunt scosi din payload la momentul trimiterii (worker split_prestatii_excluse inainte de build_rar_payload + filtru defensiv), ca sa nu ajunga NICIODATA la RAR ca codPrestatie:null. Suita: 1680 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODOS.md | 38 + app/api/v1/import_router.py | 38 +- app/mapping.py | 29 +- app/payload.py | 11 + app/web/routes.py | 615 +++++++++++---- app/web/templates/_chips_prestatii.html | 378 ++++----- app/web/templates/base.html | 29 +- app/worker/__main__.py | 7 +- docs/ROADMAP.md | 4 +- .../prd/prd-5.23-editor-prestatii-unificat.md | 514 ++++++++++++ tests/test_chips_target_robustete.py | 78 ++ tests/test_exclude_declarare.py | 70 +- tests/test_payload.py | 32 + tests/test_web_chips.py | 745 ++++++++++++++++++ tests/test_web_corectie_prestatii.py | 157 ++-- tests/test_web_editare_op_rar.py | 10 +- tests/test_web_editare_rand.py | 421 ++++++++++ tests/test_web_form_editare_slim.py | 103 ++- tests/test_web_mapare_din_chip.py | 15 +- tests/test_worker_exclude_prestatii.py | 97 +++ 20 files changed, 2839 insertions(+), 552 deletions(-) create mode 100644 docs/prd/prd-5.23-editor-prestatii-unificat.md create mode 100644 tests/test_chips_target_robustete.py create mode 100644 tests/test_web_chips.py create mode 100644 tests/test_web_editare_rand.py create mode 100644 tests/test_worker_exclude_prestatii.py diff --git a/TODOS.md b/TODOS.md index 3fa7c57..4cc4104 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,44 @@ Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare. +## Din /autoplan PRD 5.23 (2026-07-07) + +- [ ] **Bulk in preview: "aplica maparea la toate cele N randuri identice"** — o decizie de mapare + repetata per rand e adevaratul consumator de timp; editorul unificat nu o rezolva. Ce: dupa o + mapare in preview/modal, ofera aplicarea la toate randurile din batch cu aceeasi operatie + nemapata. De ce: reduce deschiderile de modal (drumul 10x — vocea externa CEO). Pro: taie direct + timpul de corectie pe importuri mari. Contra: atinge panoul de preview (non-goal in 5.23). + Context: `reresolve_account` exista deja pentru re-rezolvare dupa `save_mapping` — de verificat + daca nu acopera deja cazul la nivel de cont; daca da, TODO-ul devine doar UI de confirmare. + Effort: M→S (CC). Prioritate: P2. Depinde de: 5.23 livrat. +- [ ] **Metrici de baza pentru bucla de mapare** — cate deschideri de modal per import, % randuri + auto-rezolvate, % sugestii acceptate. De ce: fara ele nu stim daca editorul (5.23) sau motorul + (5.14/5.18) merita urmatoarea investitie. Pro: decizie de produs pe date. Contra: telemetrie + noua. Context: contorizare in `/form-chips` + import commit; expunere in /metrics existent. + Effort: S. Prioritate: P3. +- [ ] **A11y touch targets pe chips (× sub 44px)** — UI-ul compact 11-12px face tintele de atins + mici pe mobil. De ce: operare de pe telefon in service. Pro: mai putine mis-tap-uri. Contra: + chips mai inalti = mai putine pe rand. Context: decizie 5.23 = consecventa cu designul compact + existent; de revizitat la un audit a11y global. Effort: S. Prioritate: P3. + +## Din executie + code-review 5.23 (2026-07-08) + +- [ ] **Curata calea legacy `/trimitere/{id}/mapeaza`** — formular per-operatie separat, cu propriile + "Sugestii:", care coexista cu editorul unificat de chips. Nu interfereaza (dispare cand tinta e + setata), dar e cod mort de UX vechi. Context: semnalat de worker la fix-ul de randare initiala. + Effort: S. Prioritate: P3. +- [ ] **Autocomplete/cautare in selectul de coduri RAR** — non-goal explicit in 5.23 (select nativ + pastrat). De reevaluat daca nomenclatorul creste peste ~18 coduri fixe. Effort: M. Prioritate: P3. +- [ ] **Race buton Salveaza in timpul unui `/form-chips` in zbor** — sectiunea chips e inerta pe + `.htmx-request`, dar butonul Salveaza al formularului parinte nu e blocat; un submit foarte rapid + ar putea trimite un `chips_state` neactualizat. Improbabil (dedup + fereastra mica). Context: + finding PLAUSIBLE din code-review. Effort: S. Prioritate: P3. +- [ ] **Conflict mapare din tab Mapari vs item marcat `exclus` din chips** — un item marcat exclus + explicit din editor domina o mapare salvata ulterior din tab-ul Mapari (`resolve_prestatii` + precedenta item-exclus > mapare cont); utilizatorul poate sa nu inteleaga de ce maparea "nu tine". + De clarificat semantica sau de adaugat un semnal in UI. Context: finding PLAUSIBLE din code-review. + Effort: S. Prioritate: P3. + ## Din /autoplan PRD 5.21 (2026-07-06) - [ ] **CLI administrare cache embeddings (`python3 -m tools.embcache stats|clear|rebuild`)** — v1 foloseste diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index 95dfe30..34e94f7 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -233,8 +233,10 @@ def _resolve_row_for_preview( "flags": all_flags, } - # Operatii excluse de la declarare: ies din prestatiile trimise (si din cheia - # de idempotenta). Toate excluse -> rand 'excluded' (nu se trimite, nu blocheaza). + # Operatii excluse de la declarare: ies DOAR din payload-ul trimis la RAR (si din + # cheia de idempotenta), NU din `mapped["prestatii"]` persistat/afisat — editorul + # (chips modal) trebuie sa arate chip-ul Nedeclarat la redeschidere (round-trip). + # Toate excluse -> rand 'excluded' (nu se trimite, nu blocheaza). declarabile, excluse = split_prestatii_excluse(resolved) if excluse and not declarabile: return { @@ -243,11 +245,10 @@ def _resolve_row_for_preview( "errors": [], "flags": all_flags, } - if excluse: - mapped["prestatii"] = declarabile - # Validare continut - errors = validate_prezentare(mapped) + # Validare continut: doar partea declarabila conteaza (validate_prezentare + # ignora oricum itemii fara cod_prestatie, dar `mapped` ramane intact pentru afisare). + errors = validate_prezentare({**mapped, "prestatii": declarabile} if excluse else mapped) if all_flags and not reviewed: # needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala. @@ -852,7 +853,15 @@ def preview_import( key = None if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"): try: - key = _build_idempotency_key(account_id, resolved_info["resolved"], preview_env) + # Cheia foloseste DOAR partea declarabila (itemii exclusi nu intra in + # hash) — identic cu cheia calculata la commit (linia cu + # split_prestatii_excluse din commit_import), altfel preview-ul + # arata alt duplicat decat cel real. + _key_resolved = resolved_info["resolved"] + _decl_key, _exc_key = split_prestatii_excluse(_key_resolved.get("prestatii")) + if _exc_key: + _key_resolved = {**_key_resolved, "prestatii": _decl_key} + key = _build_idempotency_key(account_id, _key_resolved, preview_env) keys_for_lookup.append(key) if key not in key_to_index: key_to_index[key] = [] @@ -1204,16 +1213,19 @@ 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, valid_codes, text_rules, excluded_ops) - # Prestatiile excluse de la declarare NU pleaca la RAR (ca in preview); + # Prestatiile excluse de la declarare NU pleaca la RAR si nu intra in + # cheia de idempotenta, dar RAMAN in payload_json persistat (round-trip + # in editor, ca in preview) — ingustarea la declarabile se face doar + # local, pentru canon/cheie, si la worker, la momentul trimiterii. + declarabile, _excluse = split_prestatii_excluse(resolved) # rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip. - resolved, _excluse = split_prestatii_excluse(resolved) - if not resolved: + if not declarabile: continue mapped["prestatii"] = resolved # Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie; # cheia IDENTICA cu preview: _build_idempotency_key = canonicalize_row + build_key). - canon = canonicalize_row(mapped) + canon = canonicalize_row({**mapped, "prestatii": declarabile}) mapped.update({ "vin": canon["vin"], "nr_inmatriculare": canon["nr_inmatriculare"], @@ -1223,13 +1235,13 @@ def commit_import( # Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env) key = build_key(account_id, canon, env) - # Hash row pentru atestare (valori rezolvate) + # Hash row pentru atestare (valori rezolvate, doar partea declarabila) rows_for_hash.append(json.dumps({ "row_index": row_index, "vin": mapped.get("vin"), "data_prestatie": mapped.get("data_prestatie"), "odometru_final": mapped.get("odometru_final"), - "prestatii": [str(p.get("cod_prestatie") or p.get("cod_op_service") or "") for p in resolved], + "prestatii": [str(p.get("cod_prestatie") or p.get("cod_op_service") or "") for p in declarabile], }, sort_keys=True, ensure_ascii=False)) payload_json = json.dumps(mapped, ensure_ascii=False) diff --git a/app/mapping.py b/app/mapping.py index 7c478b0..ec579ac 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -266,11 +266,16 @@ def resolve_prestatii( 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 > exclus de la declarare > - mapare exacta `cod_op_service` in `mapping` > reguli text > nemapat. Un cod ales - explicit pe rand (editor) bate regula de excludere; regula de excludere bate - regulile text. Regulile text se incearca DOAR cand nu exista cod valid SI op - nu e in `mapping`. + Precedenta (stricta): item-exclus EXPLICIT (`exclus=1` venit pe item, ex. din + editorul de chips) > `cod_prestatie` direct valid > exclus de la declarare + (regula de cont) > mapare exacta `cod_op_service` in `mapping` > reguli text > + nemapat. Un item marcat explicit `exclus` de utilizator (editor) domina TOT — + inclusiv un `cod_prestatie` deja valid pe rand — pentru ca alegerea explicita + "Nu se declara la RAR" e intentia cea mai recenta si trebuie sa supravietuiasca + re-rezolvarii (altfel un item exclus ar reveni needs_mapping sau, mai rau, ar fi + declarat la RAR). Restul precedentei (cod valid > exclus de cont > mapare > + reguli text) e NESCHIMBAT. Regulile text se incearca DOAR cand nu exista cod + valid SI op nu e in `mapping`. `valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece @@ -295,11 +300,21 @@ def resolve_prestatii( # un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit. it.pop("cod_sursa", None) it.pop("regula_fara_autosend", None) - it.pop("exclus", None) + exclus_explicit = bool(it.pop("exclus", None)) cod = (it.get("cod_prestatie") or "").strip().upper() op = (it.get("cod_op_service") or "").strip() cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes) - if cod_valid: + if exclus_explicit: + # Treapta noua (precedenta maxima): alegerea explicita "Nu se declara la + # RAR" (editor de chips) supravietuieste re-rezolvarii, chiar daca ramane + # (sau a ramas) un cod_prestatie valid pe rand. + if cod and not op: + op = cod + if op and not it.get("cod_op_service"): + it["cod_op_service"] = op + it["cod_prestatie"] = None + it["exclus"] = True + elif cod_valid: it["cod_prestatie"] = cod else: # cod lipsa SAU necunoscut in nomenclator -> ruta de mapare. diff --git a/app/payload.py b/app/payload.py index 9d9feda..a8b2483 100644 --- a/app/payload.py +++ b/app/payload.py @@ -33,6 +33,16 @@ def _cod(p: object) -> str | None: return str(cod).strip().upper() if cod else None +def _declarabil(p: object) -> bool: + """Un item pleaca la RAR doar daca are cod si nu e adnotat exclus. + + Filtru no-op pentru payload deja ingustat la declarabile; sarit ca ultima + plasa de siguranta — un item fara cod nu produce niciodata {codPrestatie: null}. + """ + exclus = p.get("exclus") if isinstance(p, dict) else getattr(p, "exclus", None) + return not exclus and _cod(p) is not None + + def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]: """Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare.""" payload: dict[str, Any] = { @@ -46,6 +56,7 @@ def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]: "prestatii": [ {"codPrestatie": _cod(p), "idPrezentare": None} for p in (prezentare.get("prestatii") or []) + if _declarabil(p) ], "sistemReparat": prezentare.get("sistem_reparat") or "null", "status": "FINALIZATA", diff --git a/app/web/routes.py b/app/web/routes.py index bb10381..15720a8 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -120,6 +120,7 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem # Expune parse_erori si eticheta_env in toate template-urile templates.env.globals["parse_erori"] = parse_erori templates.env.globals["eticheta_env"] = eticheta_env +templates.env.globals["EXCLUDE_SENTINEL"] = EXCLUDE_SENTINEL def _mediu_instanta() -> str: @@ -1336,8 +1337,10 @@ def _payload_form_values(payload_json) -> dict: def _prestatii_chips_from_payload(payload_json) -> list[dict]: """Extrage lista de chips prestatii din payload_json pentru _form_editare.html. - Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire}. + Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire, exclus}. Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''. + `exclus` propaga adnotarea din payload (chip Nedeclarat la redeschidere); + default 0 pentru payload-uri vechi fara camp. """ try: data = json.loads(payload_json) if payload_json else {} @@ -1353,6 +1356,7 @@ def _prestatii_chips_from_payload(payload_json) -> list[dict]: "cod_prestatie": (item.get("cod_prestatie") or "").strip().upper(), "cod_op_service": (item.get("cod_op_service") or "").strip(), "denumire": (item.get("denumire") or "").strip(), + "exclus": 1 if item.get("exclus") else 0, }) return chips @@ -1501,6 +1505,15 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, ctx["prestatii_chips"] = prestatii_chips ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips) ctx["form_chips_url"] = "/form-chips" + # Tinta implicita + sugestii la randarea INITIALA (inainte de orice /form-chips) — + # helper partajat cu post_form_chips: cat exista operatii nemapate, EXISTA tinta. + # Fara conn nu se pot calcula sugestiile (enrich_suggestions). + ctx["chips_target_index"] = None + ctx["sugestii_tinta"] = [] + if conn is not None: + ctx["chips_target_index"], ctx["sugestii_tinta"] = _chips_target_si_sugestii( + conn, prestatii_chips, nomenclator_rar + ) # submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html. # Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste # → butonul nu apare (corect: /form-chips nu are scop de submission). @@ -1656,73 +1669,108 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR if isinstance(obs_val, str): content["obs"] = obs_val.strip() - # Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii. - # form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din - # prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite - # TOATE itemele (inclusiv "" pentru nemapate), permitand 1-1 aliniere by-index - # chiar cand un item de mijloc ramane nemapat. - # Cod necunoscut in nomenclator -> respins imediat (invariant ORA-12899). - codes_raw = form.getlist("cod_prestatie") - # Acceptam lista cu "" pentru pozitii nemapate; filtrare doar pt detectia - # "fara niciun cod trimis" (cazul in care form-ul nu a inclus deloc cod_prestatie). - codes_positional = [ - c.strip().upper() if isinstance(c, str) else "" - for c in codes_raw - ] - # Codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare. - # Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit. - # Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional. - # Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899). - _flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper() - if _flat_picker and _flat_picker not in codes_positional: - codes_positional.append(_flat_picker) - for _i in range(len(codes_positional)): - if not codes_positional[_i]: - _op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper() - if _op_val: - codes_positional[_i] = _op_val - # Verifica daca cel putin un cod non-gol a fost trimis - codes_nonempty = [c for c in codes_positional if c] - if codes_nonempty: - # Valideaza FIECARE cod non-gol fata de nomenclator (ORA-12899: RAR accepta NUMAI coduri valide) - for cod in codes_nonempty: - exists_nom = conn.execute( - "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) - ).fetchone() - if not exists_nom: - return templates.TemplateResponse( - "_trimitere_detaliu.html", - _detaliu_ctx( - request, row, conn=conn, account_id=account_id, error=True, - message=f"Cod RAR necunoscut in nomenclator: {cod}. " - "Alege un cod valid din lista.", - ), - ) - # Pereche operatie<->cod (E4): fiecare cod se ataseaza itemului by index. - # Itemii existenti cu cod_op_service/denumire sunt PASTRATI (D7, E1 IRON RULE). - # Coduri "" (pozitii nemapate) lasa itemul fara cod_prestatie -> needs_mapping. - existing = content.get("prestatii") or [] - new_prestatii = [] - for i, cod in enumerate(codes_positional): - if i >= len(existing) and not cod: - continue # extra pozitii goale fara item corespondent — sarite - item = dict(existing[i]) if i < len(existing) else {} - if cod: - item["cod_prestatie"] = cod - # E1: cod_op_service/denumire NU se sterg; perechea op<->cod ramane intacta - new_prestatii.append(item) - # Dedup per-item (E4): (cod_op_service, cod_prestatie) identice -> pastreaza primul. - # Doua operatii DIFERITE cu acelasi cod RAR sunt legitime si NU se dedupeaza. - seen_pairs: set = set() - deduped: list = [] - for item in new_prestatii: - pair = (item.get("cod_op_service"), item.get("cod_prestatie")) - if pair not in seen_pairs: - seen_pairs.add(pair) - deduped.append(item) - content["prestatii"] = deduped - # else: fara coduri trimise -> content["prestatii"] neatins; resolve_prestatii - # detecteaza operatii nemapate si randul ramane needs_mapping. + # Stare chips: editorul unificat trimite UN SINGUR hidden `chips_state` + # (JSON versionat, cu `exclus` per item — vezi _chips_state_from_form). Cand e + # prezent, INLOCUIESTE integral prestatiile, cu round-trip complet pe exclus. + # Cod necunoscut in nomenclator -> respins (invariant ORA-12899); + # sentinelul __NEDECLARAT__ nu e niciodata in nomenclator, deci pica in aceeasi + # verificare daca ajunge (fabricat) direct ca cod_prestatie in starea hidden. + _chips_state_raw = form.get("chips_state") + _are_chips_state = _chips_state_raw is not None and str(_chips_state_raw).strip() != "" + if _are_chips_state: + _chips_din_stare = _chips_from_state_json(str(_chips_state_raw)) + if _chips_din_stare is None: + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx( + request, row, conn=conn, account_id=account_id, error=True, + message="Stare chips invalida — reincarca formularul si reintroduce codurile.", + ), + ) + valid_codes_now = load_nomenclator_codes(conn) or set() + necunoscute = sorted( + {c["cod_prestatie"] for c in _chips_din_stare if c["cod_prestatie"]} - valid_codes_now + ) + if necunoscute: + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx( + request, row, conn=conn, account_id=account_id, error=True, + message=f"Cod RAR necunoscut in nomenclator: {', '.join(necunoscute)}. " + "Alege un cod valid din lista.", + ), + ) + content["prestatii"] = _chips_din_stare + else: + # Compat: formular vechi (peste deploy) cu listele paralele + # cod_prestatie/chip_op_service/chip_denumire + pickere ne-aprobate. + # Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii. + # form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din + # prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite + # TOATE itemele (inclusiv "" pentru nemapate), permitand 1-1 aliniere by-index + # chiar cand un item de mijloc ramane nemapat. + # Cod necunoscut in nomenclator -> respins imediat (invariant ORA-12899). + codes_raw = form.getlist("cod_prestatie") + # Acceptam lista cu "" pentru pozitii nemapate; filtrare doar pt detectia + # "fara niciun cod trimis" (cazul in care form-ul nu a inclus deloc cod_prestatie). + codes_positional = [ + c.strip().upper() if isinstance(c, str) else "" + for c in codes_raw + ] + # Codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare. + # Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit. + # Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional. + # Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899). + _flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper() + if _flat_picker and _flat_picker not in codes_positional: + codes_positional.append(_flat_picker) + for _i in range(len(codes_positional)): + if not codes_positional[_i]: + _op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper() + if _op_val: + codes_positional[_i] = _op_val + # Verifica daca cel putin un cod non-gol a fost trimis + codes_nonempty = [c for c in codes_positional if c] + if codes_nonempty: + # Valideaza FIECARE cod non-gol fata de nomenclator (ORA-12899: RAR accepta NUMAI coduri valide) + for cod in codes_nonempty: + exists_nom = conn.execute( + "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) + ).fetchone() + if not exists_nom: + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx( + request, row, conn=conn, account_id=account_id, error=True, + message=f"Cod RAR necunoscut in nomenclator: {cod}. " + "Alege un cod valid din lista.", + ), + ) + # Pereche operatie<->cod (E4): fiecare cod se ataseaza itemului by index. + # Itemii existenti cu cod_op_service/denumire sunt PASTRATI (D7, E1 IRON RULE). + # Coduri "" (pozitii nemapate) lasa itemul fara cod_prestatie -> needs_mapping. + existing = content.get("prestatii") or [] + new_prestatii = [] + for i, cod in enumerate(codes_positional): + if i >= len(existing) and not cod: + continue # extra pozitii goale fara item corespondent — sarite + item = dict(existing[i]) if i < len(existing) else {} + if cod: + item["cod_prestatie"] = cod + # E1: cod_op_service/denumire NU se sterg; perechea op<->cod ramane intacta + new_prestatii.append(item) + # Dedup per-item (E4): (cod_op_service, cod_prestatie) identice -> pastreaza primul. + # Doua operatii DIFERITE cu acelasi cod RAR sunt legitime si NU se dedupeaza. + seen_pairs: set = set() + deduped: list = [] + for item in new_prestatii: + pair = (item.get("cod_op_service"), item.get("cod_prestatie")) + if pair not in seen_pairs: + seen_pairs.add(pair) + deduped.append(item) + content["prestatii"] = deduped + # else: fara coduri trimise -> content["prestatii"] neatins; resolve_prestatii + # detecteaza operatii nemapate si randul ramane needs_mapping. # Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune # niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil @@ -1733,19 +1781,23 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR text_rules = load_text_rules(conn, account_id) excluded_ops = load_excluded_ops(conn, account_id) resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops) + # Persistam TOATE itemii (inclusiv cei exclusi, adnotati exclus=1) — round-trip + # complet in editor (chip Nedeclarat reapare la redeschidere, x il readuce la + # warning). Excluderea din payload-ul RAR/cheia idempotentei se face DOAR local, + # mai jos, prin canon; content["prestatii"] NU se ingusteaza niciodata. content["prestatii"] = resolved # telemetrie pentru itemii rezolvati prin regula text (calea corectie web). _emite_text_rule_hits(conn, account_id, row["id"], resolved) - # Prestatiile excluse de la declarare ies din payload-ul trimis (ca in - # reresolve_account); toate excluse -> needs_data cu motiv explicit mai jos. + # Prestatiile excluse de la declarare NU intra in payload-ul trimis la RAR nici + # in cheia de idempotenta (ca in reresolve_account) — dar RAMAN in payload_json + # persistat mai sus. Toate excluse -> needs_data cu motiv explicit mai jos. declarabile, excluse = split_prestatii_excluse(resolved) - if not unmapped and excluse and declarabile: - content["prestatii"] = declarabile # Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie. - canon = canonicalize_row(content) + # Foloseste DOAR partea declarabila pentru cheie (exclusii nu schimba hash-ul). + canon = canonicalize_row({**content, "prestatii": declarabile}) content.update({ "vin": canon["vin"], "nr_inmatriculare": canon["nr_inmatriculare"], @@ -2392,6 +2444,71 @@ async def post_bulk_fix(request: Request) -> HTMLResponse: conn.close() +def _sugestii_select_pentru_tinta(conn, denumire: str | None, nomenclator: list[dict]) -> list[dict]: + """Sugestii pentru optgroup "Sugestii" din selectul unic. + + Refoloseste EXACT sursele din `_preview_import.html:89-121` (fara drum nou de cod): + `sugestie_principala` (GOLD/SILVER/embedding, `enrich_suggestions`) prima, apoi + top-3 fuzzy (`suggest_codes`), dedupliate, pastrand doar coduri care exista in + nomenclator. Fara memoizare — se recalculeaza la fiecare re-render. + + Degradare gratioasa: motor de sugestii rece/dezactivat/exceptie -> lista goala + (selectul cade pe nomenclatorul complet, fara optgroup). `ensure_embeddings_corpus` + ruleaza cu `block=False` implicit — calea de request nu asteapta warmup-ul. + + Fara denumire (tinta neidentificata) fuzzy match-ul ar intoarce primele coduri + din nomenclator ca sugestii irelevante — golim explicit. + """ + if not (denumire or "").strip(): + return [] + try: + ensure_embeddings_corpus(conn, nomenclator) + by_cod = {n["cod_prestatie"]: n.get("nume_prestatie") for n in (nomenclator or [])} + out: list[dict] = [] + seen: set[str] = set() + + enriched = enrich_suggestions(conn, denumire) + principala = enriched.get("sugestie_principala") if enriched else None + if principala and principala.get("cod_prestatie") in by_cod: + cod = principala["cod_prestatie"] + out.append({"cod_prestatie": cod, "nume_prestatie": by_cod.get(cod)}) + seen.add(cod) + + for s in suggest_codes(denumire, nomenclator, limit=3): + cod = s.get("cod_prestatie") + if cod and cod not in seen and cod in by_cod: + out.append({"cod_prestatie": cod, "nume_prestatie": by_cod.get(cod)}) + seen.add(cod) + return out + except Exception: + return [] # degradare gratioasa: selectul ramane cu nomenclatorul complet + + +def _chips_target_si_sugestii( + conn, chips: list[dict], nomenclator: list[dict], +) -> tuple[int | None, list[dict]]: + """Tinta implicita (prima operatie nemapata ne-exclusa, identificabila) + sugestii. + + Sursa unica pentru `post_form_chips` SI randarea INITIALA a modalului (inainte + de orice /form-chips): cat exista operatii nemapate, EXISTA tinta. Fara acest + helper partajat, cele doua cai ar putea diverge. + + Necesita `cod_op_service` truthy: template-ul randeaza chip-ul warning (tinta + vizibila) doar pentru operatii identificabile; un item raw fara cod_op_service + n-are ce sa arate ca tinta si ar ramane invizibil. + """ + target_index = next( + (i for i, c in enumerate(chips) + if not c.get("cod_prestatie") and not c.get("exclus") and c.get("cod_op_service")), + None, + ) + sugestii: list[dict] = [] + if target_index is not None: + _den_tinta = chips[target_index].get("denumire") or chips[target_index].get("cod_op_service") + sugestii = _sugestii_select_pentru_tinta(conn, _den_tinta, nomenclator) + return target_index, sugestii + + # =========================================================================== # # Endpoint /form-chips — re-randare chips prestatii. Preia starea curenta din # # form + actiunea (add/remove) si re-randeaza _chips_prestatii.html. Fara # @@ -2403,37 +2520,25 @@ async def post_bulk_fix(request: Request) -> HTMLResponse: async def post_form_chips(request: Request) -> HTMLResponse: """Re-randeaza sectiunea chips prestatii (HTMX server-driven). - Primeste starea curenta a chip-urilor (3 liste paralele: cod_prestatie, - chip_op_service, chip_denumire) + actiunea (add/remove) si returneaza - _chips_prestatii.html actualizat. Fara scriere in DB (stateless mid-edit). - Auth: sesiune activa; CSRF verificat. + Stare unica: hidden `chips_state` (JSON versionat, vezi _chips_state_from_form). + DOUA actiuni: add (leaga codul de operatia tinta prin chips_target_index, sau + adauga cod liber fara tinta; sentinelul __NEDECLARAT__ + tinta marcheaza + exclus=1 pe item) si remove (chips_remove_index, index unic — acopera si fostul + remove_flat). Fara scriere in DB (stateless mid-edit). Auth: sesiune activa; + CSRF verificat. """ account_id = require_login(request) form = await request.form() verify_csrf(request, str(form.get("csrf_token") or "")) - # Reconstruct current chips state from parallel hidden inputs (emise de _chips_prestatii.html). - # Toate cele 3 liste sunt aceeasi lungime (emise index-by-index in template). - cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")] - op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")] - den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")] - - # Aliniaza listele la lungimea maxima (defensive) - n = max(len(cod_list), len(op_list), len(den_list)) if (cod_list or op_list or den_list) else 0 - chips: list[dict] = [] - for i in range(n): - chips.append({ - "cod_prestatie": cod_list[i] if i < len(cod_list) else "", - "cod_op_service": op_list[i] if i < len(op_list) else "", - "denumire": den_list[i] if i < len(den_list) else "", - }) - + chips, chips_error = _chips_state_from_form(form) + if chips is None: + chips = [] action = str(form.get("chips_action") or "").strip() # Orice adaugare are un efect vizibil: mesaj de refuz (chips_error) sau - # confirmare + chip evidentiat (chips_ok / chips_added_cod). Fara no-op silentios. - chips_error = "" + # confirmare + chip evidentiat (chips_ok / chips_added_index). Fara no-op silentios. chips_ok = "" - chips_added_cod = "" + chips_added_index: int | None = None conn = get_connection() @@ -2442,71 +2547,93 @@ async def post_form_chips(request: Request) -> HTMLResponse: "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) ).fetchone() is not None + def _target_valid(idx: int) -> bool: + return 0 <= idx < len(chips) and not chips[idx].get("cod_prestatie") and not chips[idx].get("exclus") + try: - if action == "add": - # Adauga cod la operatia specificata prin chips_add_op_index - try: - op_idx = int(str(form.get("chips_add_op_index") or 0)) - except (ValueError, TypeError): - op_idx = 0 - add_cod = str(form.get(f"chips_add_cod_{op_idx}") or "").strip().upper() - if not add_cod: - chips_error = "Selecteaza un cod RAR din lista inainte de a adauga." - elif not (0 <= op_idx < len(chips)): - chips_error = "Operatia nu mai exista in formular — reincarca editarea." - elif not _cod_valid(add_cod): - chips_error = f"Cod necunoscut in nomenclator: {add_cod}." - else: - chips[op_idx]["cod_prestatie"] = add_cod - chips_ok = f"{add_cod} adaugat." - chips_added_cod = add_cod + if not chips_error and action == "add": + raw_target = str(form.get("chips_target_index") or "").strip() + target_idx: int | None = None + if raw_target != "": + try: + target_idx = int(raw_target) + except (ValueError, TypeError): + target_idx = -1 # index nenumeric -> forteaza "tinta invalida" mai jos - elif action in ("add_flat", "add_extra"): - # Adauga cod RAR liber (fara op_service): add_flat = mod plat, - # add_extra = mod operatii. Acelasi select (chips_add_cod_flat), - # aceeasi validare si acelasi dedup per-item (E4). - add_cod_flat = str(form.get("chips_add_cod_flat") or "").strip().upper() - if not add_cod_flat: + # Mai multe - - - {% endfor %} + {# ===== Stare: UN SINGUR hidden JSON versionat + tinta + submission id ===== #} + + +
- + - {% if _has_ops %} - {# ===== Mod operatii: UN picker PE operatie (E4 binding) ===== #} - {% for chip in _chips %} - {% if chip.cod_op_service %} - {% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %} - {% set _nemapat = not chip.cod_prestatie %} - {% set _e_nou = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %} -
- - {{ chip.cod_op_service }} - {% if chip.denumire and chip.denumire != chip.cod_op_service %} - — {{ chip.denumire }} - {% endif %} - {% if _nemapat %} - · lipsa cod - {% endif %} - - - {% if chip.cod_prestatie %} - {# ===== Operatie mapata: chip cu × ===== #} - - {{ chip.cod_prestatie }} - - - {# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut - (in modalul de detaliu, nu la re-randarea stateless via /form-chips). - Reuse EXACT save_mapping + reresolve_account via endpoint dedicat. - hx-include="closest form" propaga csrf_token din form-ul parinte. #} - - {% if _sub_id and chip.cod_op_service and chip.cod_prestatie %} - - {% endif %} - - {% else %} - {# ===== Operatie nemapata: picker galben cu "alege cod RAR" ===== #} - - - {% endif %} - -
- {% endif %} - {% endfor %} +
+ {% for chip in _chips %} + {% set _idx = loop.index0 %} + {% set _is_target = _target_idx is not none and _idx == _target_idx %} - {# ===== Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #} - {# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat). - Containerul .chips se randeaza DOAR cand exista chips extra — altfel ramanea - un chenar gol nefinisat sub randurile de operatie. #} - {% set _extra_chips = _chips | rejectattr('cod_op_service') | selectattr('cod_prestatie') | list %} - {% if _extra_chips %} -
- {% for chip in _extra_chips %} - {% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %} - {% set _e_nou_extra = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %} - - {{ chip.cod_prestatie }} + {% if chip.cod_op_service and not chip.cod_prestatie and not chip.exclus %} + {# ===== Chip warning: operatie nemapata — click comuta tinta client-side ===== #} + {% set _label = chip.cod_op_service %} + {% set _label_scurt = (_label[:18] ~ '…') if _label | length > 18 else _label %} + {% set _title = chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '') %} + + + {% elif chip.exclus %} + {# ===== Chip Nedeclarat: vizibil (line-through), x readuce warning-ul ===== #} + + Nedeclarat + hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}' + aria-label="Anuleaza Nedeclarat pentru {{ chip.cod_op_service or 'cod liber' }} (readuce avertismentul)"> + × + + + {% elif chip.cod_prestatie %} + {# ===== Chip cod: legat de operatie sau liber ===== #} + {% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %} + {% set _e_nou = (chips_added_index is defined) and chips_added_index is not none and _idx == chips_added_index %} + {% set _tooltip = (chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '')) if chip.cod_op_service else 'cod adaugat manual' %} + + COD {{ chip.cod_prestatie }} + + + {# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut + (modalul de detaliu, nu la re-randarea stateless via /form-chips fara ecou). #} + {% if _sub_id and chip.cod_op_service and chip.cod_prestatie %} + + + + {% endif %} + {% endif %} {% endfor %} -
- {% endif %} - {% if nomenclator_rar %} - - + + {% if _target_idx is not none %} + {% set _sugestii = sugestii_tinta if (sugestii_tinta is defined and sugestii_tinta) else [] %} + {% if _sugestii %} + + {% for s in _sugestii %} + + {% endfor %} + + {% endif %} + + {% endif %} + {% for n in (nomenclator_rar or []) %} {% endfor %} - - - {% else %} - {# T-D1/T-E5 (5.16): empty state in mod operatii cand nomenclatorul lipseste #} -
- Nomenclator indisponibil — adaugarea de coduri suplimentare nu e posibila. -
- {% endif %} - {% else %} - {# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #} -
- {% for chip in _chips %} - {% if chip.cod_prestatie %} - {% set _is_warn_flat = chip.cod_prestatie in ('R-ODO', 'I-ODO') %} - {% set _e_nou_flat = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %} - - {{ chip.cod_prestatie }} - - - {% endif %} - {% endfor %} - {# Picker adaugare cod nou in mod plat #} - {% if nomenclator_rar %} - - - - {% else %} - {# T-D1/T-E5 (5.16): empty state in mod plat cand nomenclatorul lipseste #} + {# T-D1/T-E5 (5.16): empty state cand nomenclatorul lipseste #}
- Nomenclator indisponibil — nu se pot adauga coduri RAR momentan. + Nomenclator indisponibil — adaugarea de coduri RAR nu e posibila.
{% endif %}
- {% endif %} - {# Rezultatul ultimei adaugari, in ambele moduri: refuz (rol=alert) sau confirmare. + {# Rezultatul ultimei adaugari: refuz (rol=alert) sau confirmare. Clasa chips-extra-error pastrata pe eroare (selector stabil pentru teste/QA). #} {% if chips_error is defined and chips_error %} {% endif %} - - {# Hint discret fara chips (debut) #} - {% if not _chips %} -
- Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare. -
- {% endif %}
+ + diff --git a/app/web/templates/base.html b/app/web/templates/base.html index f841faf..bc90a1a 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -828,19 +828,22 @@ .chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); } /* Chip abia adaugat via /form-chips — confirmare vizibila a actiunii "+" */ .chip-nou { outline:2px solid color-mix(in srgb, var(--ok) 45%, transparent); outline-offset:1px; } - /* .add-code — buton dashed pentru adaugare cod in chipbox */ - .add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent; - border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line)); - border-radius:5px; color:var(--accent); font:500 10px var(--font-ui); cursor:pointer; } - .add-code:hover, .add-code:focus-visible { border-style:solid; } - /* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */ - .op-row { display:flex; align-items:center; justify-content:space-between; gap:10px; - padding:8px 10px; border:1px solid var(--line); border-radius:6px; - background:var(--card2); margin-bottom:8px; } - /* Nume operatie emfatic (T-9 5.16): proeminent (bold) ca in mockup — e ancora - vizuala a randului de mapare op<->cod. */ - .op-row-name { font-size:var(--fs-sm); font-weight:700; color:var(--ink); } - .op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); } + /* Eticheta mono "COD" pe chip-ul de cod */ + .chip-cod-label { font-size:9px; font-weight:800; opacity:.65; letter-spacing:.03em; } + /* Chip warning operatie nemapata: dashed implicit; tinta activa = solid + inel — + singurul tratament "tare" din rand. */ + .chip-op-warn { background:color-mix(in srgb, var(--warn) 16%, transparent); color:var(--warn); + border:1px dashed var(--warn); cursor:pointer; font-weight:600; } + .chip-op-warn.is-target { border-style:solid; outline:2px solid color-mix(in srgb, var(--warn) 40%, transparent); + outline-offset:1px; } + .chip-op-warn:focus-visible { outline:2px solid var(--warn); outline-offset:1px; } + /* Chip Nedeclarat: vizibil (line-through), NU mut/invizibil — cea mai riscanta + actiune legala (exclude de la declararea RAR) nu poate fi cea mai discreta. */ + .chip-nedeclarat { background:var(--card2); color:var(--muted); border:1px dashed var(--line); + text-decoration:line-through; } + /* Sectiunea chips inerta pe durata unui request /form-chips in zbor: + click pe x/warning mid-flight nu se pierde, doar se ignora. */ + .chips-editor.htmx-request { pointer-events:none; opacity:.7; } /* Mobil: fara header, randul revine la 2 linii (placuta / cod·operatie·data), cu actiune + litera mediu + bulina status in dreapta. Grid-areas peste aceleasi celule; .c-meta redevine linie flex cu separatoare "·". */ diff --git a/app/worker/__main__.py b/app/worker/__main__.py index 849d31d..92b490b 100644 --- a/app/worker/__main__.py +++ b/app/worker/__main__.py @@ -37,7 +37,7 @@ from ..config import Settings, get_settings, load_test_credentials from ..crypto import decrypt_creds from ..db import get_connection, init_db, read_heartbeat, write_heartbeat from ..observ import log_event, set_source -from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator +from ..mapping import DEFAULT_ACCOUNT_ID, split_prestatii_excluse, upsert_nomenclator from ..payload import build_rar_payload from ..reconcile import match_finalizata from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env @@ -258,7 +258,10 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d sid = claimed["id"] account_id = claimed.get("account_id") content = claimed["content"] - payload = build_rar_payload(content) + # Prestatiile excluse de la declarare raman in content (persistat neatins) dar + # nu pleaca niciodata la RAR: ingustare la declarabile chiar la momentul trimiterii. + declarabile, _excluse = split_prestatii_excluse(content.get("prestatii")) + payload = build_rar_payload({**content, "prestatii": declarabile}) try: data = rar.post_prezentare(token, payload) mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id")) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8196257..4c2d05b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,7 +48,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". -**Ultima actualizare**: 2026-07-07 — **5.22 UI COMPACT TRIMITERI + FIX BUTON "+" EDITOR — LIVRAT** (PRD: [prd-5.22](prd/prd-5.22-ui-compact-trimiteri-reguli-fix-chips.md); mockup aprobat de utilizator inainte de executie). Trimiteri pe desktop = tabel grid cu header (Vehicul/Cod RAR/Operatie/Data/Status/Mediu), un rand per trimitere: status = bulina colorata cu tooltip (`.dot-stare`), mediu = litera `P` (fill --err, risc L.142) / `T` (contur), multi-cod = primul cod + badge `+N` cu toate codurile in title (`payload_view.coduri_rar` nou), sub-linie de eroare = textul BRUT (mesaj RAR, ex. ORA-12899) mono trunchiat doar pe error/needs_data (`labels.eroare_bruta` nou; needs_mapping exclus — "nemapat" e in coloana Cod RAR). Mobil (<=767px): fara header, rand pe 2 linii prin grid-areas, T/P + bulina in dreapta. Actiunile bulk ("Aplica cod"/"Sterge") mutate intr-o **bara contextuala** vizibila doar la selectie, cu contor si texte explicite ("Aplica acest cod la N randuri") + hint; JS delegat + sync la `htmx:afterSwap`. Mapari: descrierea sectiunii "Reguli automate (text)" inlocuita cu un exemplu concret formatat. **Fix bug "+" editor prestatii** — cauza-radacina gasita LA VERIFY E2E (nu in analiza statica): `hx-disabled-elt="find button"` de pe form-urile parinte se mosteneste pe butoanele chips → `find button` relativ la "+" = null → TypeError intern htmx 1.9.12 INAINTE de request (nicio cerere, nicio eroare) → fix `hx-disabled-elt="this"` explicit pe toate butoanele hx-post din `_chips_prestatii.html`; separat, no-op-urile silentioase server-side din `post_form_chips` (cod invalid pe add/add_flat, duplicat pe toate ramurile) au acum mesaje explicite (`chips_error`) si confirmare la succes (chip `.chip-nou` evidentiat + "X adaugat."). Verify E2E in browser pe desktop 1280 + mobil 390 (screenshots), bulk-fix aplicat live. Suita completa: **1622 passed, 1 skipped** (+26 teste noi). Observatii deferate: `bulk_message` e suprascris imediat de reload-ul `trimiteriChanged` (pre-existent); operatia trunchiata agresiv pe mobil pe randurile cu buton "Trimite". +**Ultima actualizare**: 2026-07-08 — **5.23 EDITOR PRESTATII UNIFICAT IN MODAL — LIVRAT** (PRD: [prd-5.23](prd/prd-5.23-editor-prestatii-unificat.md); executie echipa agenti Sonnet TDD in valuri, lead orchestreaza). Cele 3 controale suprapuse pentru asocierea codurilor RAR (rand-per-operatie + container chips libere + select "+ Adauga") inlocuite cu **UN singur rand de chips + UN singur select** care adauga instant la `change` (fara butoane "Adauga"/"+"). Starea chips = UN hidden `chips_state` JSON versionat (`{"v":1,"items":[{cod,op,den,exclus}]}`, `_chips_state_from_form` unic + fallback tranzitoriu pe listele vechi), `post_form_chips` redus la 2 actiuni (`add`/`remove`, validare semantica tinta). Selectul primeste optgroup "Sugestii" (fuzzy/k-NN pentru operatia tinta, aceleasi surse ca preview-ul, degradare gratioasa non-blocanta pe motor rece) + optiune "Nu se declara la RAR" (`__NEDECLARAT__` → chip `Nedeclarat` line-through, × readuce warning). Tinta implicita (prima operatie nemapata) evidentiata + placeholder-ul o numeste ("Cod pentru OP-X — alege"), comutare tinta client-side fara round-trip; helper partajat `_chips_target_si_sugestii` pe TOATE caile de randare (initiala + /form-chips + erori). `exclus` persistat prin salvare (round-trip complet): treapta noua de precedenta in `resolve_prestatii` (item-exclus explicit domina, `it.pop` inlocuit, test de regresie blocant pe precedenta), itemii exclusi RAMAN in `payload_json` adnotati. **CLOSE `/code-review high` (8 unghiuri) — 1 BLOCKER de siguranta prins + reparat**: itemii exclusi (cod null) ajungeau la RAR via worker (`build_rar_payload` fara filtru) → risc ORA-12899/FINALIZATA partial ireversibil; fix la chokepoint unic (worker `split_prestatii_excluse` inainte de `build_rar_payload`, acopera toate canalele) + filtru defensiv + teste care dovedesc; plus consistenta round-trip la import (`web_confirma_import`/`commit_import` pastreaza full, cheie=declarabile) + bug latent idempotenta preview↔commit reparat + curatare comentarii (conventie CLAUDE.md). VERIFY context curat PASS (E2E browser Playwright ambele fluxuri: import preview + Trimiteri corectie; regresia de aur RAR live NEPROBAT — cont QA fara creds). Suita completa: **1680 passed, 1 skipped** (+45 teste noi fata de 1635). Deferate in TODOS: cale legacy `/trimitere/{id}/mapeaza` (formular per-operatie separat), autocomplete in select, bulk-apply mapare pe randuri identice. Backend worker/masina stari NEATINS (exceptie: split la trimitere); fara migrare schema (`exclus` in `payload_json`). + +> 2026-07-07 — **5.22 UI COMPACT TRIMITERI + FIX BUTON "+" EDITOR — LIVRAT** (PRD: [prd-5.22](prd/prd-5.22-ui-compact-trimiteri-reguli-fix-chips.md); mockup aprobat de utilizator inainte de executie). Trimiteri pe desktop = tabel grid cu header (Vehicul/Cod RAR/Operatie/Data/Status/Mediu), un rand per trimitere: status = bulina colorata cu tooltip (`.dot-stare`), mediu = litera `P` (fill --err, risc L.142) / `T` (contur), multi-cod = primul cod + badge `+N` cu toate codurile in title (`payload_view.coduri_rar` nou), sub-linie de eroare = textul BRUT (mesaj RAR, ex. ORA-12899) mono trunchiat doar pe error/needs_data (`labels.eroare_bruta` nou; needs_mapping exclus — "nemapat" e in coloana Cod RAR). Mobil (<=767px): fara header, rand pe 2 linii prin grid-areas, T/P + bulina in dreapta. Actiunile bulk ("Aplica cod"/"Sterge") mutate intr-o **bara contextuala** vizibila doar la selectie, cu contor si texte explicite ("Aplica acest cod la N randuri") + hint; JS delegat + sync la `htmx:afterSwap`. Mapari: descrierea sectiunii "Reguli automate (text)" inlocuita cu un exemplu concret formatat. **Fix bug "+" editor prestatii** — cauza-radacina gasita LA VERIFY E2E (nu in analiza statica): `hx-disabled-elt="find button"` de pe form-urile parinte se mosteneste pe butoanele chips → `find button` relativ la "+" = null → TypeError intern htmx 1.9.12 INAINTE de request (nicio cerere, nicio eroare) → fix `hx-disabled-elt="this"` explicit pe toate butoanele hx-post din `_chips_prestatii.html`; separat, no-op-urile silentioase server-side din `post_form_chips` (cod invalid pe add/add_flat, duplicat pe toate ramurile) au acum mesaje explicite (`chips_error`) si confirmare la succes (chip `.chip-nou` evidentiat + "X adaugat."). Verify E2E in browser pe desktop 1280 + mobil 390 (screenshots), bulk-fix aplicat live. Suita completa: **1622 passed, 1 skipped** (+26 teste noi). Observatii deferate: `bulk_message` e suprascris imediat de reload-ul `trimiteriChanged` (pre-existent); operatia trunchiata agresiv pe mobil pe randurile cu buton "Trimite". > 2026-07-06 — **5.21 CACHE PERSISTENT EMBEDDINGS IN SQLITE — LIVRAT** (PRD: [prd-5.21](prd/prd-5.21-embedding-cache-sqlite.md); executie echipa de agenti Sonnet: cache-core T1+T2, wiring T3+T5, tests T4+T6, reviewer adversarial). Vectorii corpusului k-NN (17.181 exemple SILVER) persista in tabela noua `embedding_cache` (PK `(model, text_hash)`, blob float32 LE 384, `array('f')` end-to-end); la warmup se vectorizeaza DOAR miss-urile (hash sha256 pe `denumire_normalizata` din lista FILTRATA, identica cu ce intra in `index_corpus`), restarturile cu corpus neschimbat = `embed=0`. Modul nou `app/embedding_cache.py` (fara dependinte noi): `sync_corpus_vectors(conn, model, texts, embed_fn)` hash->load->embed(miss)->save; scrieri/stergeri in chunk-uri de 500 cu `BEGIN`/`COMMIT` EXPLICIT (conexiunile sunt autocommit — `conn.commit()` e no-op); validare dimensiune la scriere SI citire (blob corupt = miss re-vectorizat); purjare orfane + modele vechi DOAR dupa indexare reusita; toate erorile SQLite -> `log.warning` + fallback embed complet (degradare gratioasa, nimic propagat). `EmbeddingEngine.index_corpus(vectors=)` valideaza alinierea `len(vectors)==len(items)` + absenta None (mismatch -> fallback embed complet). Secventa protejata de `_embeddings_lock` (mapping.py); pe calea de request (`block=False`) acquire NE-blocant — daca warmup-ul tine lock-ul, return imediat (fix MAJOR din review: altfel primul request in fereastra de cold start ingheta ~1-2 min). Log observabilitate: `embeddings: warmup ok cache=N embed=M in Xs` (eticheta `corpus reindexat` pe calea de request). Semnatura corpusului (`_corpus_signature_silver`) = mecanism de decizie NESCHIMBAT; worker-ul nu atinge cache-ul. Review adversarial: 1 MAJOR + 2 MINOR + 1 NIT, toate reparate. Suita completa: **1596 passed, 1 skipped** (+31 teste noi: 15/15 cai din diagrama de acoperire, toate cu `AUTOPASS_EMBEDDINGS_ENABLED=1` explicit, backend mock). Crestere DB ~26-30MB acceptata; deferate in TODOS.md: CLI embcache, precompute la build, graceful reload, numpy la >50k randuri. **De verificat pe instanta reala (US-006)**: doua restarturi `./start.sh`, al doilea cu `embed=0` si sugestia "similaritate" in <30s. diff --git a/docs/prd/prd-5.23-editor-prestatii-unificat.md b/docs/prd/prd-5.23-editor-prestatii-unificat.md new file mode 100644 index 0000000..1fd4d4d --- /dev/null +++ b/docs/prd/prd-5.23-editor-prestatii-unificat.md @@ -0,0 +1,514 @@ + +# PRD 5.23 — Editor prestatii unificat in modalul "Editare rand" (un select, un rand de chips) + +**Stare**: inchis (2026-07-08; executie echipa agenti Sonnet TDD, VERIFY context curat PASS, +code-review CLOSE = 1 blocker de siguranta prins + reparat. Vezi Anexa /autoplan, GSTACK REVIEW +REPORT, Raport VERIFY; mockup final aprobat: select cu placeholder-tinta) + +> Proces: `docs/ROADMAP.md` §5. Contract RAR: `docs/api-rar-contract.md`. +> Context vizual: screenshot `2026-07-07-003.jpg` (modal "Editare rand 4", badge "Cod RAR lipsa"). + +## 1. Obiectiv + +Modalul "Editare rand" (import preview + corectie Trimiteri) are azi TREI controale pentru aceeasi +sarcina — asocierea codurilor RAR la prestatii: (1) rand per operatie cu select "— alege cod RAR —" ++ buton "Adauga", (2) container separat de chips pentru coduri libere (ex. `OE-1 ×`), (3) select +"+ Adauga alta operatie / cod RAR" cu buton "+". Utilizatorul nu stie care control face ce, iar +chips-urile apar in containere diferite, pe randuri diferite. + +Livrabila: **un singur control** — un rand unic de chips (toate codurile, legate de operatie sau +libere) + **un singur select** care adauga chip instant la selectie (fara butoane "Adauga"/"+"). +Selectul primeste sugestii fuzzy/k-NN pentru operatia nemapata si optiunea "Nu se declara la RAR". + +## 2. Non-Goals (anti scope-creep) + +- NU se schimba panoul de mapare (`_mapari.html`, `_preview_import.html`). EXCEPTIE confirmata in + review (eng, 2026-07-07): `resolve_prestatii` primeste O SINGURA treapta noua de precedenta — + `exclus=1` explicit pe item (din override/corectie) supravietuieste re-rezolvarii (azi + `it.pop("exclus", None)` la `mapping.py:298` il sterge, ceea ce face US-004 imposibil altfel). + Restul precedentei ramane neatins. +- NU se schimba contractul API `POST /v1/prezentari`. `override_json["prestatii"]` castiga campul + `exclus` pe item; itemii exclusi RAMAN in `payload_json` (adnotati), nu mai sunt eliminati la + salvare — `split_prestatii_excluse` ii scoate doar din payload-ul trimis la RAR. +- NU se schimba validarea codului fata de nomenclator la salvare (invariant ORA-12899) — ramane. +- NU se adauga cautare/autocomplete text in select (ramane `` nativ cu tot nomenclatorul — **acceptat**; autocomplete ramane + non-goal. + +## 7. Valuri de executie (graful de dependente) + +``` +Val 1: [US-001] ← fara dependente +Val 2: [US-002] [US-004] ← deblocate de US-001, fisiere distincte → paralel +Val 3: [US-003] ← deblocat de US-002 +Val 4: [US-005] ← verificare finala E2E +``` + +--- + +## Anexa /autoplan — Review CEO (Faza 1, 2026-07-07, mod SELECTIVE EXPANSION, voci: subagent-only) + +### NOT in scope (decizii din review) +- Autocomplete/cautare in picker — non-goal confirmat de utilizator. +- Schimbarea panoului de mapare / precedentei `resolve_prestatii` — non-goal PRD. +- Badge-uri sursa sugestie (GOLD/SILVER) in select — respins (P5): optgroup "Sugestii" e suficient. +- "Bulk: aplica maparea la N randuri identice" in preview — deferat TODOS (in afara blast radius). +- Metrici de baza (deschideri modal / % randuri mapate manual) — deferat TODOS. + +### What already exists (refolosit, nu reconstruit) +- Stare chips in hidden inputs + `/form-chips` stateless (`_chips_prestatii.html`, `routes.py:2402`). +- Validare cod vs nomenclator la add si la save (c6e29aa); mesaje `chips_error`/`chips_ok` (5.22). +- Sugestii: `suggest_codes` + `sugestie_principala` (`mapping.py:117,868`) — aceleasi surse ca preview. +- `EXCLUDE_SENTINEL`/`split_prestatii_excluse` (0bc6187); `apply_row_override` (c6e29aa). +- Empty state nomenclator lipsa; `has_r_odo`/reveal odometru; buton "salveaza ca regula" (Trimiteri). + +### Dream state delta +Planul muta editorul de la 3 controale/4 actiuni la 1 control/2 actiuni si aduce sugestiile in +modal — pas direct spre idealul 12 luni (o singura componenta de mapare peste tot). Ramase pe +traiectorie: autocomplete (daca nomenclatorul creste), componenta partajata cu panoul de mapare. + +### Arhitectura (Sectiunea 1) +``` +[modal Editare rand]──include──▶[_form_editare]──include──▶[_chips_prestatii (REWRITE)] + │ │ hx-post change/× + ▼ ▼ +POST editeaza (import) ─┐ POST /form-chips (add|remove) +POST corecteaza (Trim.) ─┴─▶ _chips_state_from_form ──▶ apply_row_override(prestatii+exclus) + │ (commit) + suggest_codes/mapping.py ◀──────┘ resolve + split_prestatii_excluse +``` +Cuplaj NOU: /form-chips → motorul de sugestii (pana acum doar preview-ul) — justificat (reuse), +cu conditia caii NE-blocante pe request (lock embeddings non-blocking, PRD 5.21). Fara migrare DB +(`exclus` traieste in override_json). Rollback: git revert simplu. Fereastra de deploy: un modal +deschis peste deploy trimite campuri vechi (`chips_add_cod_{i}`) pe care handlerul nou le ignora +— utilizatorul redeschide editarea; acceptat (sesiune de editare scurta). + +### Registru erori & rescue (Sectiunea 2) +``` +CODEPATH | CE POATE ESUA | TRATARE | USER VEDE +--------------------------|------------------------------------|--------------------------------|----------- +/form-chips add | cod gol / necunoscut | chips_error (exista) | mesaj rosu +/form-chips add | index tinta iesit din forma | chips_error (exista) | mesaj rosu +/form-chips add NED | __NEDECLARAT__ fara tinta | GAP -> chips_error nou | mesaj rosu +/form-chips render | motor sugestii rece/dezactivat | GAP -> degradare la nomenclator| select fara optgroup +/form-chips | nomenclator gol | empty state (exista) | avertisment +save (ambele) | cod necunoscut in stare | re-render cu eroare (exista) | modal + eroare +save (ambele) | stare veche fara chip_exclus | default exclus=0 (PRD risc) | nimic (compat) +save (ambele) | CSRF invalid/expirat | 403 (existent) | eroare form +enqueue | toate operatiile excluse | excluded / needs_data (exista) | stare explicita +``` +GAP-uri de inchis in implementare: NED-fara-tinta si degradarea sugestiilor (ambele = criterii noi). + +### Securitate (Sectiunea 3) +Fara endpoint nou. Input nou `chip_exclus`: parsare stricta ("1" -> 1, orice altceva -> 0). +Sentinelul `__NEDECLARAT__` nu ajunge NICIODATA in payload RAR (split inainte de enqueue — +invariant existent; test explicit). XSS: `denumire` in options/title — autoescape Jinja (exista). +Sugestii cross-account: politica 5.14 (suggestion-only) neschimbata. CSRF pe /form-chips: exista. +Nicio amenintare High nemitigata. + +### Edge cases date/interactiune (Sectiunea 4) +- Dublu `change` in zbor: `hx-disabled-elt="this"` pe select (criteriu US-002). +- Focus pierdut dupa swap `outerHTML`: focusul revine pe select dupa add (criteriu nou US-002). +- Navigare cu tastatura prin select declanseaza change pe unele browsere: acceptat (PRD risc), + chip-ul gresit se scoate cu un click. +- Iesire din modal mid-edit: stare pierduta (comportament existent, form-local) — neschimbat. +- Dedup (operatie,cod) si cod liber duplicat: exista, se pastreaza. +- Mod plat + `has_r_odo`: pastrate explicit (spec actualizat). + +### Calitate (Sectiunea 5) +4 actiuni -> 2 reduce ramificarea din `post_form_chips` (~120 linii azi). `_chips_state_from_form` +ramane UNICUL parser, partajat de ambele endpoint-uri de save (risc PRD; test comun). +Alternativa "un singur camp hidden JSON" in loc de 4 liste paralele — decizie de gust (vezi gate). + +### Teste (Sectiunea 6) — completari la RED-urile din PRD +- `test_add_nedeclarat_fara_tinta_da_eroare` (US-001) +- `test_sugestii_indisponibile_degradare_fara_optgroup` (US-003) +- `test_mod_plat_fara_operatii_select_doar_coduri_libere` (US-002) +- `test_has_r_odo_pastrat_dupa_actiuni` (US-002) +- `test_sentinel_nedeclarat_nu_ajunge_in_payload` (US-004) +- Template: `test_select_are_hx_disabled_elt_this` (capcana 5.22) + +### Performanta (Sectiunea 7) +Sugestiile se calculeaza la FIECARE re-render /form-chips pentru operatia tinta — bounded +(matvec numpy 5.21, corpus 17k, <100ms tipic); calea request NU asteapta warmup (lock non-blocking). +18 optiuni in select — trivial. Fara probleme. + +### Observabilitate (Sectiunea 8) +Starea randurilor e deja vizibila in dashboard (needs_mapping/excluded). Gap acceptat si deferat +TODOS: metrica "deschideri modal / sugestie acceptata" (leaga de decizia de produs, nu de 5.23). + +### Deploy (Sectiunea 9) +Fara migrare, fara feature flag (UI inlocuit atomic in acelasi template). Worker neatins. +Post-deploy: smoke = deschide un rand needs_mapping, adauga un cod, salveaza. + +### Traiectorie (Sectiunea 10) +Reversibilitate 4/5 (revert curat; starile cu exclus persistate raman valide — split exista +independent). Datorie REDUSA (actiuni duplicate eliminate). Comentariul de header din +`_chips_prestatii.html` se rescrie (diagrama noua a starii — parte din US-002). + +### Design/UX la nivel CEO (Sectiunea 11) +Ierarhie: chips (starea) inaintea selectului (actiunea) — corect. Stari acoperite: gol (hint), +nomenclator lipsa, eroare, succes, exclus. Mobil: rand cu wrap; latimea selectului pe 390px = +criteriu in faza Design. Accesibilitate: aria-labels existente se pastreaza; focus dupa swap. +Riscul de mis-binding (tinta implicita + fara text permanent) — ridicat de vocea externa, +decizie la gate. Detaliu complet in Faza 2 (plan-design-review). + +### Decision Audit Trail (/autoplan) + +| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins | +|---|------|---------|-------------|-----------|-------------|---------| +| 1 | CEO | Abordare A (rescriere template + unificare actiuni) | Mecanica | P1,P5 | B pastreaza confuzia; C incalca non-goal aprobat | B, C | +| 2 | CEO | A11y tastatura + focus dupa swap → criterii US-002 | Mecanica | P1 | in blast radius, 2 fisiere | — | +| 3 | CEO | Badge sursa sugestie in select → respins | Mecanica | P5 | optgroup "Sugestii" spune deja asta | badge-uri | +| 4 | CEO | Bulk-apply mapare pe randuri identice → TODOS | Mecanica | P2,P3 | in afara blast radius (panou preview = non-goal) | — | +| 5 | CEO | Metrici deschideri-modal/sugestii → TODOS | Mecanica | P3 | produs, nu 5.23 | — | +| 6 | CEO | GAP-uri erori (NED-fara-tinta, degradare sugestii) → criterii noi | Mecanica | P1 | zero silent failures | — | +| 7 | CEO | Auto-advance tinta + fara eticheta pe select | TASTE (gate) | — | voce externa: risc mis-binding pe declaratii ireversibile | — | +| 8 | CEO | Paleta click 18 coduri vs select nativ | TASTE (gate) | — | premisa "tot nomenclatorul" e falsa (18 coduri fixe) | — | +| 9 | CEO | Stare chips: 4 liste hidden vs un camp JSON | TASTE (gate) | — | a 3-a regresie in zona; JSON = sursa unica | — | +| 10 | CEO | Test cu operator real in US-005 | Mecanica | P1 | verifica direct riscul de mis-binding; cost ~0 | — | +| 11 | Design | Scara de dominanta vizuala (tabel §3.1) | Mecanica | P1,P5 | 3 tratamente concurente fara ierarhie = confuzia initiala reambalata | — | +| 12 | Design | Stari loading/eroare/gol/complet specificate (tabel §3.1) | Mecanica | P1 | eroarea de retea lasa selectul disabled permanent altfel | — | +| 13 | Design | Tinta invarianta (exista cat sunt nemapate; click pe tinta = no-op) | Mecanica | P5 | interzice starea "cod liber cu warnings inca pe rand" tacuta | — | +| 14 | Design | Comutare tinta CLIENT-SIDE (fara round-trip) | Mecanica | P3 | round-trip pe click de highlight = lent + race cu add-ul in zbor | server-side | +| 15 | Design | Ordine chips stabila + focus pe select dupa swap | Mecanica | P1 | reflow-ul + focusul pierdut rup fluxul de tastatura pe 10 randuri | — | +| 16 | Design | Optiuni `COD — denumire`; chip warning ellipsis 18ch | Mecanica | P5 | 18 coduri criptice fara denumire = memorare fortata | — | +| 17 | Design | Nedeclarat vizibil (line-through, nu mut) + mesaj cu numele operatiei | Mecanica | P1 | cea mai riscanta actiune legala nu poate fi cea mai discreta | stil mut | +| 18 | Design | Mesaje chips_ok/err raman pattern 5.22 (persista pana la actiune) | Mecanica | P4 | timer de auto-dismiss = JS nou fara valoare | timer 4s | +| 19 | Design | Auto-advance tinta: pastrat cu emfaza vs eliminat | TASTE (gate) | — | CEO-voice: elimina (mis-binding); Design-voice: pastreaza (momentum) + emfaza | — | +| 20 | Design | Touch targets × pe chips sub 44px (UI compact 11px existent) | Mecanica | P4 | consecvent cu design-ul compact existent; nota in TODOS a11y | 44px | +| 21 | Eng | `exclus` supravietuieste `resolve_prestatii` (treapta noua, non-goal amendat) | Mecanica | P1 | P1 confirmat: `it.pop("exclus")` mapping.py:298 face US-004 imposibil; alternativa (regula pe cont) schimba semantica aprobata | regula per-cont; taierea US-004 | +| 22 | Eng | Itemii exclusi raman in payload_json + `exclus` in toti constructorii de chips | Mecanica | P1 | P1 confirmat: routes.py:1744 ii elimina → × pe Nedeclarat imposibil dupa salvare | — | +| 23 | Eng | `chips_submission_id` hidden + ecou in /form-chips ("salveaza ca regula" persistent) | Mecanica | P1,P3 | butonul dispare azi la prima re-randare; fluxul nou trece TOT prin /form-chips | — | +| 24 | Eng | `chips_pick` reset dupa orice raspuns; valoarea NEaplicata la Salveaza | Mecanica | P5 | clasa de bug c6e29aa; sentinel NED pozitional pe calea corectie | aplicare implicita | +| 25 | Eng | Validare semantica tinta + chip_exclus strict + sectiune inerta in request | Mecanica | P1 | POST fabricat / index stale / race mid-flight leaga codul de alt item | — | +| 26 | Eng | `chips_present=1` — golirea listei se persista (nu no-op) | Mecanica | P1 | `_chips_state_from_form` intoarce None pe form fara chips → golire imposibila | no-op actual | +| 27 | Eng | Actualizarea testelor existente pe actiunile vechi in acelasi val | Mecanica | P6 | altfel suita e rosie intre Val 1 si Val 2 | — | +| 28 | Eng | Emfaza chip nou pe index, nu pe cod | Mecanica | P5 | doua operatii cu acelasi cod = dubla evidentiere falsa | — | +| 29 | Eng | Fara memoizare sugestii per re-render | Mecanica | P3 | rapidfuzz pe 18 randuri + k-NN gardat = bounded; memoizarea = complexitate fara castig | memoizare | +| D2 | Gate | Auto-advance pastrat + placeholder-ul selectului numeste tinta | USER | — | leaga explicit codul de operatie la momentul alegerii, fara text sub control | doar emfaza; fara auto-advance | +| D3 | Gate | Select nativ pastrat (mockup v1); paleta v2 vazuta si RESPINSA de utilizator | USER | — | directia utilizatorului; paleta ramane alternativa daca selectul se dovedeste lent (US-005 cu operator) | paleta 18 coduri | +| D4 | Gate | Stare chips = UN hidden `chips_state` JSON versionat + fallback tranzitoriu liste vechi | USER | — | elimina clasa de regresii de aliniere pe index | 4 liste paralele | +| D5 | Gate | Plan revizuit APROBAT de utilizator | USER | — | — | — | + +### Anexa Eng (Faza 3) — diagrama de acoperire teste + +``` +CODE PATHS USER FLOWS +[+] post_form_chips (rescris, 2 actiuni) [+] Corectie rand cu N operatii nemapate + ├── add cu tinta valida [RED planificat] ├── sugestie→chip instant [RED + E2E US-005] + ├── add fara tinta (liber) [RED planificat] ├── comutare tinta client [E2E US-005] + ├── add NED cu/fara tinta [RED planificat] ├── × pe cod → warning [RED planificat] + ├── tinta semantica invalida [RED planificat] ├── NED → Nedeclarat → × [RED planificat] + ├── remove index/liber [RED planificat] ├── eroare retea la add [→E2E] [GAP inchis §3.1] + └── dedup + cod necunoscut [exista, se muta] └── golire totala + salvare [RED chips_present] +[+] resolve_prestatii treapta item-exclus [RED planificat — REGRESIUNE potentiala: IRON RULE, + test obligatoriu ca exclus NU e sters si ca restul precedentei ramane identic] +[+] _chips_state_from_form (4 liste + compat vechi + liste scurte) [RED planificat] +[+] save x2 (import/corectie): exclus round-trip, NED respins, chips_pick ignorat [RED planificat] +[+] template: hx-atribute, focus, sectiune inerta, mod plat, has_r_odo [RED planificat] +COVERAGE planificata: 100% din ramurile noi au RED numit in stories. Fara eval-uri LLM (nu se +ating prompturi). Regresiune-cheie: precedenta resolve_prestatii → test dedicat blocant. +``` + +ENG DUAL VOICES — CONSENS (subagent-only): +``` + Dimensiune Claude Codex Consens + 1. Arhitectura solida? DA* N/A OK (directia 4→2 actiuni corecta; * dupa fix P1-uri) + 2. Acoperire teste suficienta? NU→DA N/A FLAGGED→INCHIS (6 teste noi adaugate in stories) + 3. Riscuri de performanta? NU N/A OK (18 coduri, k-NN gardat, bounded) + 4. Securitate acoperita? PARTIAL→DA N/A INCHIS (validare semantica tinta, chip_exclus strict, NED respins la save) + 5. Drumuri de eroare tratate? PARTIAL→DA N/A INCHIS (§3.1 + criterii noi) + 6. Risc de deploy gestionabil? DA N/A OK (fara migrare; fereastra modal-peste-deploy acceptata) +``` + +--- + +## Raport VERIFY + +Verificator independent (subagent context curat, doar PRD + §5.6), 2026-07-08. **VERDICT: PASS** +pe toate criteriile testabile; regresia de aur RAR live = NEPROBAT (contul QA fara creds RAR). + +- **Suita**: `python3 -m pytest -q` → verde. La VERIFY: 1672 passed, 1 skipped. Dupa fix-urile + de code-review CLOSE: **1680 passed, 1 skipped** (+45 teste noi fata de baseline 1635). +- **US-001..US-004** PASS cu dovezi de cod + teste (chips_state JSON unic, 2 actiuni add/remove, + exclus supravietuieste resolve_prestatii cu test de regresie blocant pe precedenta, sentinel + respins pe ambele cai). +- **US-005 E2E browser** (import preview + Trimiteri corectie, Playwright): PASS pe (a) un singur + rand de chips cu tinta evidentiata + placeholder-tinta + optgroup Sugestii + optiune "Nu se + declara la RAR" la deschiderea initiala; (b) sugestie→chip cod instant fara "+"; (c) Nedeclarat + → salvare → rand "Nedeclarat", redeschidere arata chipul, × readuce warning; (d) acelasi control + la Trimiteri, "salveaza ca regula" persistent; (e) fara controale duble, zero erori JS de flux. +- **Gap prins in executie** (dupa US-003, reparat inainte de VERIFY): tinta implicita + sugestiile + nu se calculau la randarea initiala a modalului — helper partajat `_chips_target_si_sugestii` + in toate caile de randare. + +### Code-review CLOSE (high, 8 unghiuri + verify) — 1 BLOCKER de siguranta + 2 findings, toate reparate + +- **BLOCKER (CONFIRMED, reparat)**: itemii exclusi (`exclus=1`, cod null) ramaneau in `payload_json` + pe calea de corectie, iar worker-ul ii trimitea la RAR ca `{codPrestatie: null}` → risc + ORA-12899 / FINALIZATA partial ireversibil. Nimic nu facea `split_prestatii_excluse` la MOMENTUL + trimiterii. Fix la chokepoint unic (worker, inainte de `build_rar_payload`) — acopera toate + canalele (API/import/corectie) — + filtru defensiv in `build_rar_payload` (sare itemii exclusi / + fara cod) + teste care dovedesc ca itemul exclus NU apare in payload-ul RAR. +- **Consistenta round-trip (reparat)**: `commit_import`/`web_confirma_import` pastreaza acum + prestatiile complete in `payload_json` (cheia idempotentei = doar declarabile, identica cu + preview); bonus: bug latent de idempotenta preview↔commit in `_web_compute_preview` reparat. +- **Conventii CLAUDE.md (reparat)**: referinte PRD/US-xxx/decizii scoase din comentariile noi. +- **Robustete tinta (reparat)**: un item fara `cod_op_service` nu mai poate deveni tinta + invizibila; sugestiile intorc gol pe denumire goala. + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | CLEAR (PLAN via /autoplan) | 5 proposals, 2 accepted, 3 deferred; 8 constatari voce externa | +| Codex Review | `/codex review` | Independent 2nd opinion | 0 | UNAVAILABLE (usage limit pana 2026-07-18) | voci = subagent-only | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (PLAN via /autoplan) | 8 issues (4×P1 confirmate in cod), 0 critical gaps ramase | +| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (FULL via /autoplan) | score: 6/10 → 9/10, 10 decizii in §3.1 | +| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | SKIPPED | fara scope developer-facing | + +- **CROSS-MODEL:** indisponibil (Codex la limita de utilizare); toate vocile externe = subagenti + Claude independenti (context curat, fara review-ul anterior). +- **VERDICT:** CEO + ENG + DESIGN CLEARED — gata de implementare. Decizii gate aplicate: + D2 placeholder-tinta, D3 select (paleta respinsa), D4 stare JSON, D5 aprobat. + +NO UNRESOLVED DECISIONS + diff --git a/tests/test_chips_target_robustete.py b/tests/test_chips_target_robustete.py new file mode 100644 index 0000000..944b201 --- /dev/null +++ b/tests/test_chips_target_robustete.py @@ -0,0 +1,78 @@ +"""Robustete selectie tinta chips: doar operatii nemapate IDENTIFICABILE (cu +cod_op_service) pot deveni tinta. Un item fara cod_op_service (chip liber, fara +nicio operatie asociata) nu are cum sa fie afisat ca tinta in template (chip-ul +warning se randeaza doar cand cod_op_service e truthy) — daca ar fi ales tinta, +ar deveni invizibil: userul nu vede niciun highlight, dar selectul tot arata +sugestii/NEDECLARAT pentru un "cui" pe care nu-l poate vedea. +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + + +@pytest.fixture() +def conn(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) + from app.config import get_settings + get_settings.cache_clear() + from app.db import get_connection, init_db + init_db() + c = get_connection() + yield c + c.close() + get_settings.cache_clear() + + +def _seed_cod(conn, cod: str, nume: str) -> None: + conn.execute( + "INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)", + (cod, nume), + ) + conn.commit() + + +def test_item_fara_cod_op_service_nu_devine_tinta(conn): + """Chip liber (fara cod_op_service) nemapat nu poate fi tinta — e invizibil in template.""" + from app.web.routes import _chips_target_si_sugestii + _seed_cod(conn, "OE-1", "Schimb ulei") + from app.mapping import load_nomenclator + nomenclator = load_nomenclator(conn) + + chips = [{"cod_prestatie": "", "cod_op_service": "", "denumire": "", "exclus": 0}] + target_index, sugestii = _chips_target_si_sugestii(conn, chips, nomenclator) + + assert target_index is None + assert sugestii == [] + + +def test_operatie_identificabila_ramane_tinta(conn): + """Regresie: o operatie nemapata cu cod_op_service ramane tinta normal.""" + from app.web.routes import _chips_target_si_sugestii + _seed_cod(conn, "OE-1", "Schimb ulei motor") + from app.mapping import load_nomenclator + nomenclator = load_nomenclator(conn) + + chips = [{"cod_prestatie": "", "cod_op_service": "Op-A", "denumire": "Schimb ulei motor", "exclus": 0}] + target_index, sugestii = _chips_target_si_sugestii(conn, chips, nomenclator) + + assert target_index == 0 + assert any(s.get("cod_prestatie") == "OE-1" for s in sugestii) + + +def test_sugestii_pentru_tinta_fara_denumire_intoarce_gol(): + """Denumire (si cod_op_service) goala pe tinta -> lista goala, fara sugestii irelevante.""" + from app.web.routes import _sugestii_select_pentru_tinta + + class _FakeConn: + pass + + nomenclator = [{"cod_prestatie": "OE-1", "nume_prestatie": "Schimb ulei"}] + out = _sugestii_select_pentru_tinta(_FakeConn(), "", nomenclator) + assert out == [] + out_none = _sugestii_select_pentru_tinta(_FakeConn(), None, nomenclator) + assert out_none == [] diff --git a/tests/test_exclude_declarare.py b/tests/test_exclude_declarare.py index 3766274..ad1854a 100644 --- a/tests/test_exclude_declarare.py +++ b/tests/test_exclude_declarare.py @@ -8,8 +8,10 @@ Un cod ales explicit pe rand bate regula de excludere. from __future__ import annotations +import io import json import os +import re import tempfile import pytest @@ -191,8 +193,72 @@ def test_rand_mixt_exclude_doar_operatia_exclusa(client): finally: conn.close() payload = json.loads(sub["payload_json"]) - coduri = [p.get("cod_prestatie") for p in payload["prestatii"]] - assert coduri == ["OE-2"], f"Operatia exclusa nu trebuie sa plece la RAR: {coduri}" + # Round-trip: itemul exclus RAMANE in payload_json (persistat), adnotat exclus, + # dar fara cod_prestatie — nu pleaca niciodata la RAR (vezi build_rar_payload). + by_op = {p.get("cod_op_service"): p for p in payload["prestatii"] if p.get("cod_op_service")} + assert by_op["OP-ITP"]["exclus"] is True + assert not by_op["OP-ITP"].get("cod_prestatie") + coduri_declarabile = [p.get("cod_prestatie") for p in payload["prestatii"] if not p.get("exclus")] + assert coduri_declarabile == ["OE-2"], f"Operatia exclusa nu trebuie sa plece la RAR: {coduri_declarabile}" + + +def test_reupload_rand_cu_exclus_deja_comis_e_already_sent(client): + """Cheia de idempotenta la commit (doar declarabile) e IDENTICA cu preview. + + Re-incarcarea aceluiasi rand (dupa ce a fost deja comis cu un item exclus + adnotat in payload) trebuie recunoscuta drept duplicat — nu un rand nou. + """ + _seed() + rows = [{ + "VIN": "WVWZZZ1KZAW000789", + "Nr": "B003TST", + "Data": "2026-06-12", + "KM": "90000", + "Operatie": "OP-ITP", + }] + iid = _upload_and_preview(client, rows=rows) + csrf = _get_csrf(client) + client.post(f"/_import/{iid}/mapare-operatii", data={ + "cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf, + }) + client.post(f"/_import/{iid}/rand/0/editeaza", data={ + "cod_prestatie": ["", "OE-2"], + "chip_op_service": ["OP-ITP", ""], + "chip_denumire": ["", ""], + }) + csrf = _get_csrf(client) + rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf}) + assert rc.status_code == 200, rc.text + + # Re-incarca acelasi rand + acelasi cod ales pe editorul de rand. Maparea de + # coloane e deja memorata din primul upload -> raspunsul sare direct la preview + # (fara link mapare-coloane); extragem import_id din hx-get pe #import-section. + csv_data = _csv_bytes(rows) + csrf = _get_csrf(client) + r2 = client.post( + "/_import/upload", + files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")}, + data={"csrf_token": csrf}, + ) + assert r2.status_code == 200, r2.text + m2 = re.search(r"/_import/(\d+)/preview", r2.text) + assert m2, f"import_id negasit in raspunsul de reupload: {r2.text[:300]}" + iid2 = int(m2.group(1)) + csrf = _get_csrf(client) + client.post(f"/_import/{iid2}/mapare-operatii", data={ + "cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf, + }) + client.post(f"/_import/{iid2}/rand/0/editeaza", data={ + "cod_prestatie": ["", "OE-2"], + "chip_op_service": ["OP-ITP", ""], + "chip_denumire": ["", ""], + }) + # already_sent se calculeaza doar la refresh-ul complet de preview (lookup pe + # tot batch-ul), nu la editarea unui singur rand — reincarcam preview-ul. + rp = client.get(f"/_import/{iid2}/preview") + assert rp.status_code == 200 + assert "Deja trimis" in rp.text, \ + "Randul re-incarcat trebuie recunoscut deja trimis (cheia de idempotenta neschimbata)" def test_cod_explicit_pe_operatie_bate_regula_de_excludere(client): diff --git a/tests/test_payload.py b/tests/test_payload.py index 0e56109..a67baad 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -100,3 +100,35 @@ def test_normalizare_vin_nrinm_in_payload(): def test_sistem_reparat_default_null(): assert build_rar_payload(_internal())["sistemReparat"] == "null" + + +def test_item_exclus_nu_apare_in_payload(): + """Un item adnotat exclus=True nu produce niciodata {codPrestatie: null} la RAR.""" + internal = { + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [ + {"cod_prestatie": "OE-1"}, + {"cod_prestatie": None, "cod_op_service": "200", "denumire": "Spalare", "exclus": True}, + ], + } + p = build_rar_payload(internal) + assert p["prestatii"] == [{"codPrestatie": "OE-1", "idPrezentare": None}] + + +def test_item_fara_cod_prestatie_nu_apare_in_payload(): + """Defense-in-depth: orice item fara cod (chiar neadnotat exclus) e sarit, niciodata null.""" + internal = { + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [ + {"cod_prestatie": "OE-1"}, + {"cod_prestatie": None, "cod_op_service": "200"}, + ], + } + p = build_rar_payload(internal) + assert p["prestatii"] == [{"codPrestatie": "OE-1", "idPrezentare": None}] diff --git a/tests/test_web_chips.py b/tests/test_web_chips.py new file mode 100644 index 0000000..79cc34a --- /dev/null +++ b/tests/test_web_chips.py @@ -0,0 +1,745 @@ +"""US-001: stare chips extinsa (JSON versionat, D4) + actiuni add/remove unificate. + +Contract nou /form-chips: +- Stare curenta: UN SINGUR hidden `chips_state` = JSON {"v":1,"items":[{"cod","op","den","exclus"}]}. +- chips_action=add + chips_pick (codul ales) + chips_target_index optional: + cu tinta -> leaga codul de operatia de la acel index; fara tinta -> chip liber. + chips_pick=__NEDECLARAT__ + tinta valida -> item exclus=1, fara cod_prestatie. +- chips_action=remove + chips_remove_index: chip legat de operatie -> revine warning + (cod_prestatie="", exclus=0); chip liber -> iese din lista. +- Actiunile vechi (add_flat/add_extra/remove_flat/chips_add_cod_{i}/chips_add_cod_flat + ca actiuni) sunt ELIMINATE. +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +# --------------------------------------------------------------------------- # +# Fixtures # +# --------------------------------------------------------------------------- # + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "chips.db")) + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true") + from app.config import get_settings + get_settings.cache_clear() + from app.web import ratelimit + ratelimit._hits.clear() + from app.main import app + with TestClient(app, follow_redirects=False) as c: + yield c + ratelimit._hits.clear() + get_settings.cache_clear() + + +def _create_account_user(email: str, password: str = "parolasecreta10"): + from app.accounts import create_account + from app.users import create_user + from app.db import get_connection + conn = get_connection() + try: + acct_id = create_account(conn, "Service", active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> None: + resp = client.get("/login") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \ + re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text) + assert m, "csrf_token nu gasit in login" + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _csrf(client) -> str: + resp = client.get("/?tab=coada") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m, "csrf_token nu gasit in dashboard" + return m.group(1) + + +def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None: + from app.db import get_connection + conn = get_connection() + try: + conn.execute( + "INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)", + (cod, denumire), + ) + conn.commit() + finally: + conn.close() + + +def _state(items: list[dict]) -> str: + """Construieste JSON-ul hidden chips_state din itemi scurti {cod,op,den,exclus}.""" + return json.dumps({"v": 1, "items": items}, ensure_ascii=False) + + +def _login_seed(client, email: str) -> None: + _create_account_user(email) + _login(client, email) + + +# --------------------------------------------------------------------------- # +# add cu tinta -> leaga codul de operatia respectiva # +# --------------------------------------------------------------------------- # + +def test_add_cu_tinta_leaga_de_operatie(client): + _login_seed(client, "add.tinta@test.com") + _seed_cod("OE-1", "Schimb ulei") + csrf = _csrf(client) + + state = _state([ + {"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0}, + ]) + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": state, + "chips_action": "add", + "chips_target_index": "0", + "chips_pick": "OE-1", + }) + assert resp.status_code == 200, resp.text[:400] + html = resp.text + assert "OE-1" in html + + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html) + assert m, f"hidden chips_state lipsa din raspuns: {html[:400]}" + payload = json.loads(m.group(1).replace(""", '"')) + assert payload["v"] == 1 + items = payload["items"] + assert len(items) == 1 + assert items[0]["cod"] == "OE-1" + assert items[0]["op"] == "Op-A" + assert items[0]["exclus"] == 0 + + +# --------------------------------------------------------------------------- # +# add fara tinta -> chip liber # +# --------------------------------------------------------------------------- # + +def test_add_fara_tinta_adauga_cod_liber(client): + _login_seed(client, "add.liber@test.com") + _seed_cod("FRN1", "Sistem de franare") + csrf = _csrf(client) + + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": _state([]), + "chips_action": "add", + "chips_pick": "FRN1", + }) + assert resp.status_code == 200, resp.text[:400] + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + assert m + payload = json.loads(m.group(1).replace(""", '"')) + items = payload["items"] + assert len(items) == 1 + assert items[0]["cod"] == "FRN1" + assert items[0]["op"] == "" + + +# --------------------------------------------------------------------------- # +# add __NEDECLARAT__ cu tinta -> marcheaza exclus # +# --------------------------------------------------------------------------- # + +def test_add_nedeclarat_marcheaza_exclus(client): + _login_seed(client, "add.ned@test.com") + csrf = _csrf(client) + + state = _state([ + {"cod": "", "op": "OP-ITP", "den": "Inspectie tehnica", "exclus": 0}, + ]) + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": state, + "chips_action": "add", + "chips_target_index": "0", + "chips_pick": "__NEDECLARAT__", + }) + assert resp.status_code == 200, resp.text[:400] + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + assert m + payload = json.loads(m.group(1).replace(""", '"')) + items = payload["items"] + assert len(items) == 1 + assert items[0]["exclus"] == 1 + assert items[0]["cod"] == "", f"item exclus nu trebuie sa aiba cod_prestatie: {items[0]}" + assert items[0]["op"] == "OP-ITP" + assert "nu se va declara" in resp.text.lower() + + +def test_add_nedeclarat_fara_tinta_da_eroare(client): + """Sentinelul __NEDECLARAT__ fara tinta valida -> chips_error explicit, fara modificare.""" + _login_seed(client, "add.ned.notinta@test.com") + csrf = _csrf(client) + + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": _state([]), + "chips_action": "add", + "chips_pick": "__NEDECLARAT__", + # fara chips_target_index + }) + assert resp.status_code == 200, resp.text[:400] + assert "chips-extra-error" in resp.text, ( + f"Eroare vizibila lipsa pentru NEDECLARAT fara tinta: {resp.text[:400]}" + ) + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + assert m + payload = json.loads(m.group(1).replace(""", '"')) + assert payload["items"] == [], "NEDECLARAT fara tinta nu trebuie sa modifice starea" + + +def test_add_nedeclarat_tinta_deja_mapata_da_eroare(client): + """Tinta trebuie sa fie o operatie NEMAPATA — index spre operatie deja rezolvata -> eroare.""" + _login_seed(client, "add.ned.mapata@test.com") + _seed_cod("OE-1", "Schimb ulei") + csrf = _csrf(client) + + state = _state([ + {"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}, + ]) + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": state, + "chips_action": "add", + "chips_target_index": "0", + "chips_pick": "__NEDECLARAT__", + }) + assert resp.status_code == 200 + assert "chips-extra-error" in resp.text + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + payload = json.loads(m.group(1).replace(""", '"')) + assert payload["items"][0]["cod"] == "OE-1", "Tinta invalida nu trebuie sa strice starea existenta" + + +# --------------------------------------------------------------------------- # +# remove -> readuce operatia nemapata (warning) # +# --------------------------------------------------------------------------- # + +def test_remove_cod_readuce_operatia_nemapata(client): + _login_seed(client, "remove.readuce@test.com") + csrf = _csrf(client) + + state = _state([ + {"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}, + ]) + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": state, + "chips_action": "remove", + "chips_remove_index": "0", + }) + assert resp.status_code == 200, resp.text[:400] + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + assert m + payload = json.loads(m.group(1).replace(""", '"')) + items = payload["items"] + assert len(items) == 1, "Operatia trebuie sa RAMANA (doar codul dispare)" + assert items[0]["cod"] == "" + assert items[0]["op"] == "Op-A" + assert items[0]["exclus"] == 0 + + +def test_remove_pe_nedeclarat_readuce_warning(client): + """x pe un chip Nedeclarat readuce operatia la warning (nu se sterge operatia).""" + _login_seed(client, "remove.nedeclarat@test.com") + csrf = _csrf(client) + + state = _state([ + {"cod": "", "op": "OP-ITP", "den": "Inspectie", "exclus": 1}, + ]) + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": state, + "chips_action": "remove", + "chips_remove_index": "0", + }) + assert resp.status_code == 200 + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + payload = json.loads(m.group(1).replace(""", '"')) + items = payload["items"] + assert len(items) == 1 + assert items[0]["exclus"] == 0 + assert items[0]["cod"] == "" + assert items[0]["op"] == "OP-ITP" + + +def test_remove_cod_liber_il_sterge_complet(client): + """Chip liber (fara operatie sursa) e eliminat integral din lista la remove.""" + _login_seed(client, "remove.liber@test.com") + csrf = _csrf(client) + + state = _state([ + {"cod": "FRN1", "op": "", "den": "", "exclus": 0}, + ]) + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": state, + "chips_action": "remove", + "chips_remove_index": "0", + }) + assert resp.status_code == 200 + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + payload = json.loads(m.group(1).replace(""", '"')) + assert payload["items"] == [] + + +# --------------------------------------------------------------------------- # +# dedup (operatie, cod) # +# --------------------------------------------------------------------------- # + +def test_dedup_operatie_cod(client): + """Acelasi cod liber nu se adauga de doua ori (pereche op='',cod deja existenta).""" + _login_seed(client, "dedup.op.cod@test.com") + _seed_cod("OE-1", "Schimb ulei") + csrf = _csrf(client) + + state = _state([ + {"cod": "OE-1", "op": "", "den": "", "exclus": 0}, + ]) + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": state, + "chips_action": "add", + "chips_pick": "OE-1", + }) + assert resp.status_code == 200 + assert "chips-extra-error" in resp.text or "deja in lista" in resp.text + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + payload = json.loads(m.group(1).replace(""", '"')) + assert len(payload["items"]) == 1, f"Dedup a esuat, itemi: {payload['items']}" + + +# --------------------------------------------------------------------------- # +# actiuni vechi eliminate # +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("actiune", ["add_flat", "add_extra", "remove_flat"]) +def test_actiuni_vechi_eliminate_sunt_no_op(client, actiune): + """Actiunile eliminate nu mai au ramura dedicata — cad prin fara sa schimbe starea.""" + _login_seed(client, f"vechi.{actiune}@test.com") + _seed_cod("OE-1", "Schimb ulei") + csrf = _csrf(client) + + state = _state([{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}]) + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": state, + "chips_action": actiune, + "chips_add_cod_flat": "FRN1", + "chips_remove_cod": "OE-1", + }) + assert resp.status_code == 200 + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + payload = json.loads(m.group(1).replace(""", '"')) + assert payload["items"] == [{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}] + + +# --------------------------------------------------------------------------- # +# chips_state prezent mereu, inclusiv gol # +# --------------------------------------------------------------------------- # + +def test_chips_state_gol_ramane_prezent_in_raspuns(client): + _login_seed(client, "state.gol@test.com") + csrf = _csrf(client) + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": _state([]), + "chips_action": "", + }) + assert resp.status_code == 200 + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + assert m, "hidden chips_state trebuie sa fie mereu prezent, chiar cu lista goala" + payload = json.loads(m.group(1).replace(""", '"')) + assert payload == {"v": 1, "items": []} + + +# --------------------------------------------------------------------------- # +# JSON invalid -> eroare vizibila, nu stare goala silentioasa # +# --------------------------------------------------------------------------- # + +def test_chips_state_json_invalid_da_eroare_vizibila(client): + _login_seed(client, "state.corupt@test.com") + csrf = _csrf(client) + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + "chips_state": "{not valid json", + "chips_action": "", + }) + assert resp.status_code == 200 + assert "chips-extra-error" in resp.text or "invalid" in resp.text.lower(), ( + f"JSON corupt trebuie sa dea eroare vizibila: {resp.text[:400]}" + ) + + +# --------------------------------------------------------------------------- # +# compat tranzitorie: chips_state lipsa -> fallback pe listele vechi # +# --------------------------------------------------------------------------- # + +def test_compat_fallback_pe_liste_vechi_cand_chips_state_lipseste(client): + _login_seed(client, "compat.fallback@test.com") + _seed_cod("OE-1", "Schimb ulei") + csrf = _csrf(client) + + resp = client.post("/form-chips", data={ + "csrf_token": csrf, + # FARA chips_state -> fallback pe listele vechi + "cod_prestatie": [""], + "chip_op_service": ["Op-A"], + "chip_denumire": ["Schimb ulei motor"], + "chips_action": "add", + "chips_target_index": "0", + "chips_pick": "OE-1", + }) + assert resp.status_code == 200, resp.text[:400] + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + assert m + payload = json.loads(m.group(1).replace(""", '"')) + assert payload["items"][0]["cod"] == "OE-1" + assert payload["items"][0]["exclus"] == 0 + + +# =========================================================================== # +# US-002: template unificat -- un container .chips, un select, fara op-row # +# =========================================================================== # + + +def _post_chips(client, csrf, items, **extra): + data = { + "csrf_token": csrf, + "chips_state": _state(items), + "chips_action": "", + } + data.update(extra) + return client.post("/form-chips", data=data) + + +def test_render_un_singur_container_chips(client): + """UN singur container `.chips` -- indiferent de mixul de tipuri de chip.""" + _login_seed(client, "un.container@test.com") + _seed_cod("OE-1", "Schimb ulei") + csrf = _csrf(client) + + items = [ + {"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}, + {"cod": "", "op": "Op-B", "den": "Vulcanizare", "exclus": 0}, + {"cod": "FRN1", "op": "", "den": "", "exclus": 0}, + {"cod": "", "op": "Op-C", "den": "ITP anual", "exclus": 1}, + ] + _seed_cod("FRN1", "Sistem de franare") + resp = _post_chips(client, csrf, items) + assert resp.status_code == 200, resp.text[:400] + html = resp.text + + assert html.count('class="chips"') == 1, ( + f"Trebuie sa existe UN SINGUR container .chips, gasit {html.count('class=\"chips\"')}: {html[:800]}" + ) + + +def test_render_fara_op_row_si_fara_butoane_adauga(client): + """Fara .op-row, fara butoane Adauga/+, fara actiunile vechi in markup.""" + _login_seed(client, "fara.oprow@test.com") + _seed_cod("OE-1", "Schimb ulei") + csrf = _csrf(client) + + items = [ + {"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}, + {"cod": "", "op": "Op-B", "den": "Vulcanizare", "exclus": 0}, + ] + resp = _post_chips(client, csrf, items) + assert resp.status_code == 200, resp.text[:400] + html = resp.text + + assert "op-row" not in html + assert "Adauga" not in html + assert "add_flat" not in html + assert "chips_add_cod_" not in html + + +def test_chip_warning_pentru_operatie_nemapata(client): + """Operatie nemapata -> chip warning `warn OP-X . fara cod` cu title complet.""" + _login_seed(client, "chip.warning@test.com") + csrf = _csrf(client) + + items = [ + {"cod": "", "op": "OP-ITP", "den": "Inspectie tehnica periodica", "exclus": 0}, + ] + resp = _post_chips(client, csrf, items) + assert resp.status_code == 200, resp.text[:400] + html = resp.text + + assert "OP-ITP" in html + assert "fara cod" in html + assert 'title="OP-ITP' in html + + +def test_select_are_hx_disabled_elt_this(client): + """Selectul unic are hx-disabled-elt='this' EXPLICIT (capcana 5.22 -- nu mostenit de la form).""" + _login_seed(client, "select.disabled@test.com") + _seed_cod("OE-1", "Schimb ulei") + csrf = _csrf(client) + + resp = _post_chips(client, csrf, []) + assert resp.status_code == 200, resp.text[:400] + html = resp.text + + m = re.search(r']*name="chips_pick"[^>]*>', html) + assert m, f"selectul chips_pick lipseste: {html[:600]}" + select_tag = m.group(0) + assert 'id="chips-picker"' in select_tag + assert 'hx-trigger="change"' in select_tag + assert 'hx-post="/form-chips"' in select_tag + assert 'hx-target="#chips-section"' in select_tag + assert 'hx-swap="outerHTML"' in select_tag + assert 'hx-include="closest form"' in select_tag + assert 'hx-disabled-elt="this"' in select_tag, ( + f"hx-disabled-elt='this' lipseste de pe select (capcana 5.22): {select_tag}" + ) + + +def test_mod_plat_fara_operatii_select_doar_coduri_libere(client): + """Mod plat (zero operatii din import): doar chips libere + select placeholder generic.""" + _login_seed(client, "mod.plat@test.com") + _seed_cod("OE-1", "Schimb ulei") + csrf = _csrf(client) + + items = [ + {"cod": "OE-1", "op": "", "den": "", "exclus": 0}, + ] + resp = _post_chips(client, csrf, items) + assert resp.status_code == 200, resp.text[:400] + html = resp.text + + assert "⚠" not in html, "Mod plat nu trebuie sa aiba niciun chip warning" + m = re.search(r']*name="chips_pick"[^>]*>\s*', html) + assert m, f"select cu optiune placeholder lipseste: {html[:600]}" + assert "adauga cod RAR" in m.group(1) + + +def test_has_r_odo_pastrat_dupa_actiuni(client): + """has_r_odo / data-has-r-odo + chip-warn pe R-ODO/I-ODO raman neschimbate.""" + _login_seed(client, "r.odo@test.com") + _seed_cod("R-ODO", "Odometru initial") + csrf = _csrf(client) + + items = [ + {"cod": "R-ODO", "op": "", "den": "", "exclus": 0}, + ] + resp = _post_chips(client, csrf, items) + assert resp.status_code == 200, resp.text[:400] + html = resp.text + + assert 'data-has-r-odo="true"' in html + assert "chip-warn" in html + assert "R-ODO" in html + + +def test_chip_nedeclarat_are_x_functional(client): + """Item exclus=1 -> chip Nedeclarat (line-through) cu buton de sters functional (remove -> warning).""" + _login_seed(client, "nedeclarat.x@test.com") + csrf = _csrf(client) + + items = [ + {"cod": "", "op": "OP-ITP", "den": "Inspectie", "exclus": 1}, + ] + resp = _post_chips(client, csrf, items) + assert resp.status_code == 200, resp.text[:400] + html = resp.text + + assert "Nedeclarat" in html + assert 'hx-vals=\'{"chips_action":"remove","chips_remove_index":0}\'' in html + + +def test_chips_submission_id_ecouat(client): + """chips_submission_id trimis in form e ecouat ca hidden in raspunsul /form-chips.""" + _login_seed(client, "sub.id.ecou@test.com") + csrf = _csrf(client) + + resp = _post_chips(client, csrf, [], chips_submission_id="4242") + assert resp.status_code == 200, resp.text[:400] + assert re.search(r'name="chips_submission_id"\s+value="4242"', resp.text), ( + f"chips_submission_id nu a fost ecouat: {resp.text[:600]}" + ) + + +# =========================================================================== # +# US-003: sugestii fuzzy/k-NN + optiunea "Nu se declara la RAR" in select # +# =========================================================================== # + + +def _extrage_select(html: str) -> str: + """Extrage markup-ul complet al .""" + m = re.search(r']*name="chips_pick".*?', html, re.DOTALL) + assert m, f"select chips_pick lipseste din raspuns: {html[:600]}" + return m.group(0) + + +def test_select_are_optgroup_sugestii_pt_tinta(client): + """Cu tinta activa (operatie nemapata): optgroup 'Sugestii' + __NEDECLARAT__ + nomenclator.""" + _login_seed(client, "sugestii.tinta@test.com") + _seed_cod("OE-1", "Schimb ulei motor") + _seed_cod("FRN1", "Sistem de franare") + csrf = _csrf(client) + + items = [ + {"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0}, + ] + resp = _post_chips(client, csrf, items) + assert resp.status_code == 200, resp.text[:400] + select_html = _extrage_select(resp.text) + + assert '' in select_html, ( + f"optgroup Sugestii lipseste cu tinta activa: {select_html[:600]}" + ) + assert "OE-1" in select_html.split('')[1].split("")[0], ( + "sugestia fuzzy pentru 'Schimb ulei motor' trebuie sa contina OE-1" + ) + assert "__NEDECLARAT__" in select_html + assert "Nu se declara la RAR" in select_html + # Nomenclatorul complet ramane dupa Sugestii/NEDECLARAT. + assert "FRN1" in select_html + + +def test_select_are_nedeclarat_doar_cu_tinta(client): + """Optiunea __NEDECLARAT__ apare DOAR cand exista o tinta (operatie nemapata) activa.""" + _login_seed(client, "nedeclarat.doar.tinta@test.com") + _seed_cod("OE-1", "Schimb ulei") + csrf = _csrf(client) + + # Cu tinta (operatie nemapata) -> NEDECLARAT prezent. + resp_cu_tinta = _post_chips(client, csrf, [ + {"cod": "", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}, + ]) + assert "__NEDECLARAT__" in _extrage_select(resp_cu_tinta.text) + + # Fara tinta (doar cod liber, nicio operatie nemapata) -> NEDECLARAT absent. + resp_fara_tinta = _post_chips(client, csrf, [ + {"cod": "OE-1", "op": "", "den": "", "exclus": 0}, + ]) + assert "__NEDECLARAT__" not in _extrage_select(resp_fara_tinta.text) + + +def test_select_fara_tinta_fara_sugestii(client): + """Fara tinta: selectul listeaza DOAR nomenclatorul (fara optgroup Sugestii, fara NEDECLARAT).""" + _login_seed(client, "fara.tinta.fara.sugestii@test.com") + _seed_cod("OE-1", "Schimb ulei motor") + csrf = _csrf(client) + + items = [ + {"cod": "OE-1", "op": "", "den": "", "exclus": 0}, + ] + resp = _post_chips(client, csrf, items) + assert resp.status_code == 200, resp.text[:400] + select_html = _extrage_select(resp.text) + + assert " select DOAR cu nomenclatorul (fara optgroup), + fara eroare 500 si fara blocare (degradare gratioasa, GAP inchis din PRD 5.23).""" + _login_seed(client, "sugestii.indisponibile@test.com") + _seed_cod("OE-1", "Schimb ulei motor") + csrf = _csrf(client) + + from app.web import routes as _routes + + def _boom(*args, **kwargs): + raise RuntimeError("motor de sugestii indisponibil (simulat)") + + monkeypatch.setattr(_routes, "suggest_codes", _boom) + monkeypatch.setattr(_routes, "enrich_suggestions", _boom) + + items = [ + {"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0}, + ] + resp = _post_chips(client, csrf, items) + assert resp.status_code == 200, resp.text[:400] + select_html = _extrage_select(resp.text) + + assert " int: + from app.db import get_connection + conn = get_connection() + try: + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES (?, ?, ?, ?)", + (f"k-{os.urandom(6).hex()}", acct, status, json.dumps(payload)), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +def test_randare_initiala_are_tinta_si_optgroup(client): + """Deschiderea INITIALA a modalului de detaliu (needs_mapping, operatie nemapata), + FARA niciun /form-chips, trebuie sa aiba deja is-target pe chip-ul warning, + placeholder-ul D2 "Cod pentru OP-X — alege" si optgroup-ul Sugestii + NEDECLARAT.""" + acct = _login_seed_ret(client, "randare.initiala@test.com") + _seed_cod("OE-1", "Schimb ulei motor") + _seed_cod("FRN1", "Sistem de franare") + + op = "Schimb ulei motor" + sid = _insert_submission(acct, status="needs_mapping", payload={ + "vin": "WVWZZZ1JZXW0SC001", + "nr_inmatriculare": "B100AAA", + "data_prestatie": "2026-06-10", + "odometru_final": "50000", + "prestatii": [{"cod_op_service": op, "denumire": op}], + }) + + resp = client.get(f"/_fragments/trimitere/{sid}") + assert resp.status_code == 200, resp.text[:400] + html = resp.text + + assert "is-target" in html, ( + f"chip-ul warning al primei operatii nemapate trebuie sa fie tinta la deschiderea initiala: {html[:2000]}" + ) + assert f"Cod pentru {op} — alege" in html, ( + f"placeholder-ul D2 trebuie sa numeasca operatia tinta la deschiderea initiala: {html[:2000]}" + ) + select_html = _extrage_select(html) + assert '' in select_html, ( + f"optgroup Sugestii trebuie sa apara la deschiderea initiala (fara /form-chips): {select_html[:600]}" + ) + assert "OE-1" in select_html.split('')[1].split("")[0], ( + "sugestia fuzzy pentru 'Schimb ulei motor' trebuie sa contina OE-1 chiar la randarea initiala" + ) + assert "__NEDECLARAT__" in select_html + + +def _login_seed_ret(client, email: str) -> int: + """Ca `_login_seed`, dar intoarce account_id (necesar pentru inserarea directa a submission-ului).""" + acct = _create_account_user(email) + _login(client, email) + return acct diff --git a/tests/test_web_corectie_prestatii.py b/tests/test_web_corectie_prestatii.py index 33bcfaf..b2147da 100644 --- a/tests/test_web_corectie_prestatii.py +++ b/tests/test_web_corectie_prestatii.py @@ -535,8 +535,8 @@ def test_picker_flat_arata_cod_si_denumire(client): def test_adauga_cod_extra_in_mod_operatii(client): - """In mod operatii, actiunea add_extra adauga un cod RAR liber.""" - acct = _create_account_user("add.extra.ops@test.com") + """In mod operatii, actiunea unificata 'add' fara tinta adauga un cod RAR liber.""" + _create_account_user("add.extra.ops@test.com") _login(client, "add.extra.ops@test.com") _seed_cod("OE-1", "Schimb ulei motor") _seed_cod("FRN1", "Sistem de franare") @@ -547,11 +547,11 @@ def test_adauga_cod_extra_in_mod_operatii(client): "/form-chips", data={ "csrf_token": csrf, - "cod_prestatie": ["OE-1"], # chip existent (op mapata) - "chip_op_service": ["SchimbUlei"], - "chip_denumire": ["Schimb ulei motor"], - "chips_action": "add_extra", - "chips_add_cod_flat": "FRN1", # codul extra de adaugat + "chips_state": json.dumps({"v": 1, "items": [ + {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0}, + ]}), + "chips_action": "add", + "chips_pick": "FRN1", # codul extra de adaugat, fara tinta -> chip liber }, ) assert resp.status_code == 200, resp.text[:300] @@ -601,32 +601,36 @@ def test_extra_cod_persistat_la_salvare(client): def test_extra_cod_validat_nomenclator(client): - """add_extra respinge cod necunoscut in nomenclator (invariant ORA-12899).""" - acct = _create_account_user("extra.valid@test.com") + """'add' fara tinta respinge cod necunoscut in nomenclator (invariant ORA-12899).""" + _create_account_user("extra.valid@test.com") _login(client, "extra.valid@test.com") _seed_cod("OE-1", "Schimb ulei") csrf = _csrf(client) - # add_extra cu cod INVALID (XX-99 nu e in nomenclator) + # add fara tinta cu cod INVALID (XX-99 nu e in nomenclator) resp = client.post( "/form-chips", data={ "csrf_token": csrf, - "cod_prestatie": ["OE-1"], - "chip_op_service": ["SchimbUlei"], - "chip_denumire": ["Schimb ulei"], - "chips_action": "add_extra", - "chips_add_cod_flat": "XX-99", # cod necunoscut + "chips_state": json.dumps({"v": 1, "items": [ + {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0}, + ]}), + "chips_action": "add", + "chips_pick": "XX-99", # cod necunoscut }, ) assert resp.status_code == 200 html = resp.text - # XX-99 NU trebuie sa apara ca chip valid (hidden input cu valoarea XX-99) + # XX-99 NU trebuie sa apara in starea persistata (chips_state) import re as _re - hidden_xx99 = _re.search(r']+name="cod_prestatie"[^>]+value="XX-99"', html) - assert hidden_xx99 is None, ( - f"Codul invalid XX-99 a fost adaugat ca chip! HTML: {html[:500]}" + m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html) + assert m + payload = json.loads(m.group(1).replace(""", '"')) + coduri = [it["cod"] for it in payload["items"]] + assert "XX-99" not in coduri, ( + f"Codul invalid XX-99 a fost adaugat ca chip! items={payload['items']}" ) + assert "chips-extra-error" in html def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client): @@ -703,12 +707,12 @@ def test_salvare_fara_chip_explicit_nu_e_no_op(client): def test_picker_by_index_op2_nu_op1(client): - """Codul ales pe picker-ul op#2 aterizeaza pe op#2, NU pe op#1. + """Codul ales pentru op#2 aterizeaza pe op#2, NU pe op#1. - Verifica alinierea by-index in modul operatii: chips_add_op_index=1 + chips_add_cod_1 - actualizeaza chips[1] (op#2), nu chips[0] (op#1). + Verifica alinierea by-index in modul operatii: chips_target_index=1 + + chips_pick actualizeaza chips[1] (op#2), nu chips[0] (op#1). """ - acct = _create_account_user("byindex.op2@test.com") + _create_account_user("byindex.op2@test.com") _login(client, "byindex.op2@test.com") _seed_cod("OE-1", "Schimb ulei") _seed_cod("FRN1", "Sistem de franare") @@ -719,27 +723,25 @@ def test_picker_by_index_op2_nu_op1(client): "/form-chips", data={ "csrf_token": csrf, - "cod_prestatie": ["OE-1", ""], # idx 0=OE-1 (mapata), idx 1="" (nemapata) - "chip_op_service": ["Op-A", "Op-B"], - "chip_denumire": ["Prima", "A doua"], + "chips_state": json.dumps({"v": 1, "items": [ + {"cod": "OE-1", "op": "Op-A", "den": "Prima", "exclus": 0}, + {"cod": "", "op": "Op-B", "den": "A doua", "exclus": 0}, + ]}), "chips_action": "add", - "chips_add_op_index": "1", # adauga pe op#2 (idx=1) - "chips_add_cod_1": "FRN1", # picker-ul op#2 contine FRN1 + "chips_target_index": "1", # adauga pe op#2 (idx=1) + "chips_pick": "FRN1", }, ) assert resp.status_code == 200, resp.text[:300] html = resp.text import re as _re - hidden_vals = _re.findall(r']+name="cod_prestatie"[^>]+value="([^"]*)"', html) - assert "OE-1" in hidden_vals, f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {hidden_vals}" - assert "FRN1" in hidden_vals, f"FRN1 nu a aterizat pe op#2: {hidden_vals}" - # By-index: OE-1 trebuie sa fie INAINTE de FRN1 (idx 0 < idx 1) - oe1_pos = hidden_vals.index("OE-1") if "OE-1" in hidden_vals else -1 - frn1_pos = hidden_vals.index("FRN1") if "FRN1" in hidden_vals else -1 - assert oe1_pos < frn1_pos, ( - f"FRN1 (op#2, idx=1) trebuie dupa OE-1 (op#1, idx=0) by-index: {hidden_vals}" - ) + m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html) + assert m + payload = json.loads(m.group(1).replace(""", '"')) + items = payload["items"] + assert items[0]["cod"] == "OE-1", f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {items}" + assert items[1]["cod"] == "FRN1", f"FRN1 nu a aterizat pe op#2 (idx=1): {items}" def test_empty_state_picker_nomenclator_gol(client): @@ -774,25 +776,25 @@ def test_empty_state_picker_nomenclator_gol(client): def test_add_extra_semnal_vizibil_cod_invalid(client): - """add_extra cu cod invalid da semnal vizibil (nu esua silentios). + """'add' fara tinta cu cod invalid da semnal vizibil (nu esua silentios). div.chips-extra-error vizibil cand codul e invalid sau selectul e gol. """ - acct = _create_account_user("extra.err.signal@test.com") + _create_account_user("extra.err.signal@test.com") _login(client, "extra.err.signal@test.com") _seed_cod("OE-1", "Schimb ulei") csrf = _csrf(client) - # add_extra cu cod necunoscut in nomenclator + # add fara tinta cu cod necunoscut in nomenclator resp = client.post( "/form-chips", data={ "csrf_token": csrf, - "cod_prestatie": ["OE-1"], - "chip_op_service": ["SchimbUlei"], - "chip_denumire": ["Schimb ulei"], - "chips_action": "add_extra", - "chips_add_cod_flat": "XX-99", # cod inexistent + "chips_state": json.dumps({"v": 1, "items": [ + {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0}, + ]}), + "chips_action": "add", + "chips_pick": "XX-99", # cod inexistent }, ) assert resp.status_code == 200 @@ -802,7 +804,7 @@ def test_add_extra_semnal_vizibil_cod_invalid(client): def test_add_per_op_cod_invalid_afiseaza_mesaj(client): - """Actiunea 'add' (picker per operatie) cu cod invalid da semnal vizibil.""" + """Actiunea 'add' (picker per operatie, cu tinta) cu cod invalid da semnal vizibil.""" _create_account_user("add.op.err@test.com") _login(client, "add.op.err@test.com") _seed_cod("OE-1", "Schimb ulei") @@ -812,12 +814,12 @@ def test_add_per_op_cod_invalid_afiseaza_mesaj(client): "/form-chips", data={ "csrf_token": csrf, - "cod_prestatie": [""], - "chip_op_service": ["SchimbUlei"], - "chip_denumire": ["Schimb ulei"], + "chips_state": json.dumps({"v": 1, "items": [ + {"cod": "", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0}, + ]}), "chips_action": "add", - "chips_add_op_index": "0", - "chips_add_cod_0": "XX-99", # cod inexistent + "chips_target_index": "0", + "chips_pick": "XX-99", # cod inexistent }, ) assert resp.status_code == 200 @@ -837,21 +839,19 @@ def test_add_flat_cod_invalid_afiseaza_mesaj(client): "/form-chips", data={ "csrf_token": csrf, - "cod_prestatie": ["OE-1"], - "chip_op_service": [""], - "chip_denumire": [""], - "chips_action": "add_flat", - "chips_add_cod_flat": "XX-99", + "chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}), + "chips_action": "add", + "chips_pick": "XX-99", }, ) assert resp.status_code == 200 assert "chips-extra-error" in resp.text, ( - f"Semnal de eroare lipsa la add_flat cu cod invalid: {resp.text[:300]}" + f"Semnal de eroare lipsa la add cu cod invalid: {resp.text[:300]}" ) def test_add_flat_duplicat_afiseaza_mesaj(client): - """add_flat cu un cod deja prezent NU dubleaza chip-ul si afiseaza mesaj.""" + """'add' fara tinta cu un cod deja prezent NU dubleaza chip-ul si afiseaza mesaj.""" _create_account_user("add.flat.dup@test.com") _login(client, "add.flat.dup@test.com") _seed_cod("OE-1", "Schimb ulei") @@ -861,24 +861,24 @@ def test_add_flat_duplicat_afiseaza_mesaj(client): "/form-chips", data={ "csrf_token": csrf, - "cod_prestatie": ["OE-1"], - "chip_op_service": [""], - "chip_denumire": [""], - "chips_action": "add_flat", - "chips_add_cod_flat": "OE-1", # deja in lista + "chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}), + "chips_action": "add", + "chips_pick": "OE-1", # deja in lista }, ) assert resp.status_code == 200 import re as _re - hidden = _re.findall(r']+name="cod_prestatie"[^>]+value="OE-1"', resp.text) - assert len(hidden) == 1, f"Chip OE-1 duplicat: {len(hidden)} aparitii" + m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text) + assert m + payload = json.loads(m.group(1).replace(""", '"')) + assert len(payload["items"]) == 1, f"Chip OE-1 duplicat: {payload['items']}" assert "deja in lista" in resp.text, ( - f"Mesaj de duplicat lipsa la add_flat: {resp.text[:300]}" + f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}" ) def test_add_extra_duplicat_afiseaza_mesaj(client): - """add_extra cu chip extra identic existent afiseaza mesaj (nu no-op).""" + """'add' fara tinta cu chip liber identic existent afiseaza mesaj (nu no-op).""" _create_account_user("add.extra.dup@test.com") _login(client, "add.extra.dup@test.com") _seed_cod("OE-1", "Schimb ulei") @@ -889,16 +889,17 @@ def test_add_extra_duplicat_afiseaza_mesaj(client): "/form-chips", data={ "csrf_token": csrf, - "cod_prestatie": ["OE-1", "FRN1"], - "chip_op_service": ["SchimbUlei", ""], - "chip_denumire": ["Schimb ulei", ""], - "chips_action": "add_extra", - "chips_add_cod_flat": "FRN1", # chip extra identic deja existent + "chips_state": json.dumps({"v": 1, "items": [ + {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0}, + {"cod": "FRN1", "op": "", "den": "", "exclus": 0}, + ]}), + "chips_action": "add", + "chips_pick": "FRN1", # chip liber identic deja existent }, ) assert resp.status_code == 200 assert "deja in lista" in resp.text, ( - f"Mesaj de duplicat lipsa la add_extra: {resp.text[:300]}" + f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}" ) @@ -914,11 +915,11 @@ def test_add_reusit_marcheaza_chip_nou(client): "/form-chips", data={ "csrf_token": csrf, - "cod_prestatie": ["OE-1"], - "chip_op_service": ["SchimbUlei"], - "chip_denumire": ["Schimb ulei"], - "chips_action": "add_extra", - "chips_add_cod_flat": "FRN1", + "chips_state": json.dumps({"v": 1, "items": [ + {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0}, + ]}), + "chips_action": "add", + "chips_pick": "FRN1", }, ) assert resp.status_code == 200 diff --git a/tests/test_web_editare_op_rar.py b/tests/test_web_editare_op_rar.py index 4eb345f..2450c98 100644 --- a/tests/test_web_editare_op_rar.py +++ b/tests/test_web_editare_op_rar.py @@ -125,7 +125,8 @@ def client(monkeypatch): def test_editabil_arata_select_cod_rar(client): - """needs_data cu nomenclator populat → formularul de detaliu afiseaza + (picker-ul unificat de cod RAR; starea chips traieste in hidden name='chips_state', PRD 5.23).""" acct = _create_account_user("sel1@test.com") _ins_nomenclator("OE-1", "OE-2") sid = _ins(acct, status="needs_data") @@ -133,8 +134,11 @@ def test_editabil_arata_select_cod_rar(client): html = _detaliu(client, sid) - assert 'name="cod_prestatie"' in html, ( - "Formularul de detaliu needs_data trebuie sa contina un select cu name='cod_prestatie'" + assert 'name="chips_pick"' in html, ( + "Formularul de detaliu needs_data trebuie sa contina un select cu name='chips_pick'" + ) + assert 'name="chips_state"' in html, ( + "Starea chips trebuie persistata intr-un hidden name='chips_state'" ) assert " trebuie sa apara in detaliu pentru starea needs_data" diff --git a/tests/test_web_editare_rand.py b/tests/test_web_editare_rand.py new file mode 100644 index 0000000..f639b23 --- /dev/null +++ b/tests/test_web_editare_rand.py @@ -0,0 +1,421 @@ +"""US-004: persistarea `exclus` prin salvare (import + Trimiteri), round-trip complet. + +Acopera: +- resolve_prestatii: item-exclus EXPLICIT supravietuieste re-rezolvarii (treapta noua + de precedenta) FARA sa strice restul precedentei (IRON RULE, regresiune-cheie). +- Import (web_editeaza_rand / apply_row_override): `exclus` persistat in override_json, + chip Nedeclarat reapare la redeschiderea modalului, x il readuce la warning. +- Rand cu toate operatiile excluse -> stare preview 'excluded' ("Nedeclarat"). +- Trimiteri (post_corectie_trimitere): acelasi round-trip pe chips_state; toate + excluse -> needs_data cu motiv explicit. +- Sentinelul __NEDECLARAT__ NU ajunge in payload daca apare direct ca cod_prestatie + in starea hidden (nu doar ales din select), pe AMBELE cai. +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +# --------------------------------------------------------------------------- # +# Test unitar (fara client): precedenta resolve_prestatii # +# --------------------------------------------------------------------------- # + +def test_exclus_supravietuieste_resolve_chiar_cu_mapare_pe_cont(): + """IRON RULE: item cu `exclus=1` explicit pe item RAMANE exclus dupa resolve_prestatii, + chiar daca operatia are (a) o mapare exacta pe cont SAU (b) un cod_prestatie deja + valid pe rand. Regresiune-cheie: restul precedentei (cod valid > exclus de cont > + mapare exacta > reguli text > nemapat) trebuie sa ramana IDENTICA pentru itemii + FARA `exclus` explicit — altfel stricam maparile existente. + """ + from app.mapping import resolve_prestatii + + valid_codes = {"OE-1", "IG-1", "FRN1"} + mapping = {"Schimb-Ulei": "OE-1"} # mapare exacta pe cont pentru Schimb-Ulei + excluded_ops_cont = {"Filtru-Aer"} # regula de excludere PE CONT (alt mecanism) + + prestatii = [ + # (a) exclus explicit + mapare exacta pe cont exista -> exclus CASTIGA + {"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor", "exclus": 1}, + # (b) exclus explicit + cod_prestatie deja valid pe rand -> exclus CASTIGA + {"cod_op_service": "Frana", "denumire": "Reparatie frana", "cod_prestatie": "FRN1", "exclus": 1}, + # regresiune: FARA exclus explicit, mapare exacta trebuie sa functioneze ca inainte + {"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor 2"}, + # regresiune: FARA exclus explicit, regula de excludere PE CONT trebuie sa functioneze + {"cod_op_service": "Filtru-Aer", "denumire": "Filtru aer"}, + # regresiune: cod deja valid pe rand, FARA exclus -> pastrat ca atare (cod_valid) + {"cod_op_service": "", "denumire": "", "cod_prestatie": "IG-1"}, + # regresiune: nemapat ramane nemapat + {"cod_op_service": "Necunoscut-Op", "denumire": "Operatie necunoscuta"}, + ] + + resolved, unmapped = resolve_prestatii( + prestatii, mapping, valid_codes=valid_codes, text_rules=None, excluded_ops=excluded_ops_cont, + ) + + item_a = resolved[0] + assert item_a.get("exclus") is True, f"exclus explicit trebuia sa supravietuiasca: {item_a}" + assert not item_a.get("cod_prestatie"), f"item exclus explicit nu trebuie sa aiba cod_prestatie: {item_a}" + + item_b = resolved[1] + assert item_b.get("exclus") is True, f"exclus explicit trebuia sa domine cod_prestatie valid: {item_b}" + assert not item_b.get("cod_prestatie"), f"cod_prestatie valid trebuia sters de exclus explicit: {item_b}" + + # Regresiune: mapare exacta pe cont NESCHIMBATA pentru itemul fara exclus + item_c = resolved[2] + assert item_c.get("cod_prestatie") == "OE-1", f"mapare exacta pe cont stricata: {item_c}" + assert not item_c.get("exclus"), f"item fara exclus explicit nu trebuie marcat exclus: {item_c}" + + # Regresiune: regula de excludere PE CONT (mecanism vechi) NESCHIMBATA + item_d = resolved[3] + assert item_d.get("exclus") is True, f"regula de excludere pe cont stricata: {item_d}" + assert not item_d.get("cod_prestatie") + + # Regresiune: cod deja valid pastrat ca atare + item_e = resolved[4] + assert item_e.get("cod_prestatie") == "IG-1", f"cod valid pastrat stricat: {item_e}" + + # Regresiune: nemapat ramane nemapat + assert len(unmapped) == 1 + assert unmapped[0]["cod_op_service"] == "Necunoscut-Op" + + +def test_precedenta_neschimbata_pentru_itemi_fara_exclus(): + """Regresiune suplimentara: fara `exclus` explicit pe niciun item, comportamentul + lui resolve_prestatii e IDENTIC cu cel dinaintea treptei noi (cod valid > exclus + de cont > mapare exacta > reguli text > nemapat). + """ + from app.mapping import resolve_prestatii + + valid_codes = {"OE-1"} + mapping = {"Op-Mapata": "OE-1"} + text_rules = [{"pattern": "frana", "cod_prestatie": "OE-1", "auto_send": 1, "priority": 1}] + excluded_ops = {"Op-Exclusa"} + + prestatii = [ + {"cod_op_service": "Op-Mapata", "denumire": "Operatie mapata"}, + {"cod_op_service": "Op-Exclusa", "denumire": "Operatie exclusa"}, + {"cod_op_service": "Op-Text", "denumire": "Reparatie frana fata"}, + {"cod_op_service": "Op-Nemapat", "denumire": "Chestie noua"}, + ] + + resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops) + + assert resolved[0]["cod_prestatie"] == "OE-1" + assert not resolved[0].get("exclus") + assert resolved[1]["exclus"] is True + assert not resolved[1].get("cod_prestatie") + assert resolved[2]["cod_prestatie"] == "OE-1" # via regula text + assert resolved[2].get("cod_sursa") + assert len(unmapped) == 1 + assert unmapped[0]["cod_op_service"] == "Op-Nemapat" + + +# --------------------------------------------------------------------------- # +# Fixtures + helpere HTTP (import + Trimiteri, fara auth — cont 1 implicit) # +# --------------------------------------------------------------------------- # + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "editare_rand.db")) + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false") + from app.config import get_settings + get_settings.cache_clear() + from app.crypto import reset_cache + reset_cache() + from app.main import app + with TestClient(app) as c: + yield c + get_settings.cache_clear() + reset_cache() + + +def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None: + from app.db import get_connection + conn = get_connection() + try: + conn.execute( + "INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)", + (cod, denumire), + ) + conn.commit() + finally: + conn.close() + + +def _csv_bytes(rows: list[dict]) -> bytes: + import csv as _csv + import io + buf = io.StringIO() + writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";") + writer.writeheader() + writer.writerows(rows) + return buf.getvalue().encode("utf-8") + + +_MAP_COLS = { + "VIN": "vin", + "Nr": "nr_inmatriculare", + "Data": "data_prestatie", + "KM": "odometru_final", + "Operatie": "operatie", +} + + +def _upload_and_preview(client: TestClient, rows: list[dict]) -> int: + import io as _io + csv_data = _csv_bytes(rows) + r = client.post( + "/_import/upload", + files={"file": ("test.csv", _io.BytesIO(csv_data), "text/csv")}, + data={}, + ) + assert r.status_code == 200, r.text + m = re.search(r"/_import/(\d+)/mapare-coloane", r.text) + assert m, f"import_id negasit in raspuns: {r.text[:300]}" + iid = int(m.group(1)) + colnames = list(rows[0].keys()) + canons = [_MAP_COLS[c] for c in colnames] + r2 = client.post(f"/_import/{iid}/mapare-coloane", data={ + "colname": colnames, + "canon": canons, + "format_data": "YYYY-MM-DD", + }) + assert r2.status_code == 200, r2.text + return iid + + +def _chips_state_din_html(html: str) -> dict: + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html) + assert m, f"hidden chips_state lipsa din HTML: {html[:400]}" + return json.loads(m.group(1).replace(""", '"')) + + +# --- Trimiteri: helpere submissions direct in DB (cont 1, fara auth) --------- # + +def _insert_submission(*, status: str, payload: dict) -> int: + from app.db import get_connection + conn = get_connection() + try: + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES (?, ?, ?, ?)", + (f"k-{os.urandom(6).hex()}", 1, status, json.dumps(payload)), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +def _submission_row(sid: int): + from app.db import get_connection + conn = get_connection() + try: + return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone() + finally: + conn.close() + + +def _submission_payload(sid: int) -> dict: + from app.db import get_connection + conn = get_connection() + try: + r = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone() + return json.loads(r["payload_json"]) + finally: + conn.close() + + +def _payload_cu_ops(vin: str, ops: list[tuple[str, str]]) -> dict: + return { + "vin": vin, + "nr_inmatriculare": "B100AAA", + "data_prestatie": "2026-06-10", + "odometru_final": "50000", + "prestatii": [{"cod_op_service": op, "denumire": den} for op, den in ops], + } + + +# --------------------------------------------------------------------------- # +# Import: salvare + round-trip (web_editeaza_rand / apply_row_override) # +# --------------------------------------------------------------------------- # + +_SAMPLE_ROW_1OP = [{ + "VIN": "WVWZZZ1KZAW000999", + "Nr": "B009TST", + "Data": "2026-06-10", + "KM": "123456", + "Operatie": "Op-Nemapata", +}] + + +def test_salvare_pastreaza_exclus_in_override(client): + """Salvarea unui chip Nedeclarat in modalul de editare (import) persista `exclus=1` + in override_json — la reincarcarea preview-ului randul ramane 'Nedeclarat'.""" + iid = _upload_and_preview(client, _SAMPLE_ROW_1OP) + + chips_state = json.dumps({"v": 1, "items": [ + {"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1}, + ]}) + r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state}) + assert r.status_code == 200, r.text + trigger_settle = r.headers.get("HX-Trigger-After-Settle", "") + assert "inchideModal" in trigger_settle, f"Salvarea trebuia sa reuseasca: {r.text[:400]}" + + r2 = client.get(f"/_import/{iid}/rand/0/editare-modal") + assert r2.status_code == 200 + payload = _chips_state_din_html(r2.text) + item = payload["items"][0] + assert item.get("exclus") == 1, f"exclus nu a fost persistat: {payload}" + assert not item.get("cod"), f"item exclus nu trebuie sa aiba cod: {payload}" + + +def test_redeschidere_modal_arata_chip_nedeclarat_si_x_readuce_warning(client): + """Round-trip complet: dupa salvare, redeschiderea modalului arata chipul + 'Nedeclarat' (nu warning); actiunea x (/form-chips remove) il readuce la warning.""" + iid = _upload_and_preview(client, _SAMPLE_ROW_1OP) + + chips_state = json.dumps({"v": 1, "items": [ + {"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1}, + ]}) + r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state}) + assert r.status_code == 200 + + r2 = client.get(f"/_import/{iid}/rand/0/editare-modal") + assert r2.status_code == 200 + html = r2.text + assert "Nedeclarat" in html, f"Chipul Nedeclarat trebuia sa reapara la redeschidere: {html[:600]}" + assert "chip-nedeclarat" in html + + # x pe chip Nedeclarat -> /form-chips remove index 0 -> revine la warning. + payload = _chips_state_din_html(html) + r3 = client.post("/form-chips", data={ + "chips_state": json.dumps(payload), + "chips_action": "remove", + "chips_remove_index": "0", + }) + assert r3.status_code == 200, r3.text + assert "Nedeclarat" not in r3.text, "Dupa x, chipul Nedeclarat nu mai trebuie sa apara" + payload3 = _chips_state_din_html(r3.text) + item3 = payload3["items"][0] + assert not item3.get("exclus"), f"x trebuia sa readuca operatia la warning (exclus=0): {payload3}" + assert not item3.get("cod"), f"operatia trebuia sa ramana fara cod dupa x: {payload3}" + + +def test_rand_toate_operatiile_excluse_devine_excluded(client): + """Rand cu unica operatie exclusa -> stare preview 'excluded' ('Nedeclarat').""" + iid = _upload_and_preview(client, _SAMPLE_ROW_1OP) + + chips_state = json.dumps({"v": 1, "items": [ + {"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1}, + ]}) + r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state}) + assert r.status_code == 200, r.text + trigger = json.loads(r.headers.get("HX-Trigger", "{}")) + stare = trigger.get("randSalvat", {}).get("stare", "") + assert "Nedeclarat" in stare, f"randSalvat.stare trebuia sa arate Nedeclarat, got: {stare!r}" + + +# --------------------------------------------------------------------------- # +# Trimiteri: salvare + round-trip (post_corectie_trimitere) # +# --------------------------------------------------------------------------- # + +def test_salvare_trimitere_cu_exclus(client): + """/trimitere/{id}/corecteaza cu un item mapat + un item exclus -> queued, + payload_json pastreaza AMBELE (itemul exclus adnotat exclus=1, fara cod_prestatie).""" + _seed_cod("OE-1", "Schimb ulei") + sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops( + "WVWZZZ1JZXW0EX001", + [("SchimbUlei", "Schimb ulei motor"), ("Filtru-Aer", "Filtru aer")], + )) + + chips_state = json.dumps({"v": 1, "items": [ + {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0}, + {"cod": "", "op": "Filtru-Aer", "den": "Filtru aer", "exclus": 1}, + ]}) + r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state}) + assert r.status_code == 200, r.text[:500] + + row = _submission_row(sid) + assert row["status"] == "queued", f"status asteptat queued, got {row['status']}: {r.text[:500]}" + prestatii = _submission_payload(sid)["prestatii"] + assert len(prestatii) == 2, f"ambele itemi trebuiau pastrate in payload: {prestatii}" + by_op = {p.get("cod_op_service"): p for p in prestatii} + assert by_op["SchimbUlei"].get("cod_prestatie") == "OE-1" + assert by_op["Filtru-Aer"].get("exclus") is True, f"itemul exclus trebuia adnotat: {prestatii}" + assert not by_op["Filtru-Aer"].get("cod_prestatie"), f"itemul exclus nu trebuie sa aiba cod: {prestatii}" + + +def test_trimitere_toate_excluse_devine_needs_data(client): + """Toate operatiile excluse -> needs_data cu motiv explicit (nu se trimite la RAR).""" + sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops( + "WVWZZZ1JZXW0EX002", + [("Filtru-Aer", "Filtru aer")], + )) + + chips_state = json.dumps({"v": 1, "items": [ + {"cod": "", "op": "Filtru-Aer", "den": "Filtru aer", "exclus": 1}, + ]}) + r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state}) + assert r.status_code == 200, r.text[:500] + + row = _submission_row(sid) + assert row["status"] == "needs_data", f"status asteptat needs_data, got {row['status']}" + assert "excluse" in (row["rar_error"] or "").lower() + prestatii = _submission_payload(sid)["prestatii"] + assert prestatii[0].get("exclus") is True, "itemul exclus trebuie pastrat in payload pt afisare motiv" + + +# --------------------------------------------------------------------------- # +# Sentinel __NEDECLARAT__ respins explicit daca apare ca cod_prestatie # +# --------------------------------------------------------------------------- # + +def test_sentinel_nedeclarat_nu_ajunge_in_payload_import(client): + """Daca cineva fabrica direct chips_state cu cod=__NEDECLARAT__ (nu prin selectul + care seteaza exclus), salvarea trebuie respinsa — sentinelul nu e in nomenclator.""" + iid = _upload_and_preview(client, _SAMPLE_ROW_1OP) + + chips_state = json.dumps({"v": 1, "items": [ + {"cod": "__NEDECLARAT__", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 0}, + ]}) + r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state}) + assert r.status_code == 200 + assert "necunoscut" in r.text.lower(), f"Sentinelul trebuia respins ca cod necunoscut: {r.text[:400]}" + trigger_settle = r.headers.get("HX-Trigger-After-Settle", "") + assert "inchideModal" not in trigger_settle, "Salvarea NU trebuia sa reuseasca cu sentinelul in payload" + + # Confirma ca sentinelul NU a ajuns in override (redeschiderea nu-l arata ca si cod). + r2 = client.get(f"/_import/{iid}/rand/0/editare-modal") + payload2 = _chips_state_din_html(r2.text) + coduri = [it.get("cod") for it in payload2["items"]] + assert "__NEDECLARAT__" not in coduri, f"Sentinelul a ajuns in starea persistata: {payload2}" + + +def test_sentinel_nedeclarat_nu_ajunge_in_payload_trimitere(client): + """Aceeasi respingere pe calea Trimiteri (post_corectie_trimitere).""" + sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops( + "WVWZZZ1JZXW0SN001", + [("Op-Test", "Operatie test")], + )) + old_status = _submission_row(sid)["status"] + + chips_state = json.dumps({"v": 1, "items": [ + {"cod": "__NEDECLARAT__", "op": "Op-Test", "den": "Operatie test", "exclus": 0}, + ]}) + r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state}) + assert r.status_code == 200 + assert "necunoscut" in r.text.lower(), f"Sentinelul trebuia respins ca cod necunoscut: {r.text[:400]}" + + row = _submission_row(sid) + assert row["status"] == old_status, f"Statusul nu trebuia sa se schimbe: {row['status']}" + prestatii = _submission_payload(sid)["prestatii"] + coduri = [p.get("cod_prestatie") for p in prestatii] + assert "__NEDECLARAT__" not in coduri, f"Sentinelul a ajuns in payload: {prestatii}" diff --git a/tests/test_web_form_editare_slim.py b/tests/test_web_form_editare_slim.py index cad0025..ebccbf1 100644 --- a/tests/test_web_form_editare_slim.py +++ b/tests/test_web_form_editare_slim.py @@ -212,7 +212,7 @@ def test_camp_observatii_prezent(client): # --------------------------------------------------------------------------- # def test_chips_multi_select_prestatii(client): - """Submission cu cod_prestatie setat afiseaza chip cu hidden input.""" + """Submission cu cod_prestatie setat afiseaza chip cu OE-1 in starea hidden chips_state.""" acct = _create_account_user("chips.test@test.com") _login(client, "chips.test@test.com") _seed_cod("OE-1", "Schimb ulei motor") @@ -222,14 +222,13 @@ def test_chips_multi_select_prestatii(client): assert resp.status_code == 200 html = resp.text - # Trebuie sa existe un input (de obicei hidden) cu name="cod_prestatie" si valoarea "OE-1" - has_cod_prestatie_chip = bool( - re.search(r']+name="cod_prestatie"[^>]+value="OE-1"', html) or - re.search(r']+value="OE-1"[^>]+name="cod_prestatie"', html) - ) - assert has_cod_prestatie_chip, ( - "Formularul trebuie sa contina un hidden input cu name='cod_prestatie' value='OE-1' " - "reprezentand chip-ul de prestatie." + # Starea chips traieste intr-UN SINGUR hidden JSON versionat (D4, PRD 5.23). + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html) + assert m, "Formularul trebuie sa contina hidden input name='chips_state'" + payload = json.loads(m.group(1).replace(""", '"')) + coduri = [it["cod"] for it in payload["items"]] + assert "OE-1" in coduri, ( + f"chips_state trebuie sa contina codul OE-1 al prestatiei. items={payload['items']}" ) @@ -244,19 +243,19 @@ def test_adauga_sterge_chip(client): _seed_cod("OE-1", "Schimb ulei motor") csrf = _csrf(client) - # POST /form-chips: adauga OE-1 la prima operatie (index 0) + # POST /form-chips: adauga OE-1 la prima operatie (index 0), stare curenta = un + # singur hidden chips_state (JSON versionat, D4). + state = json.dumps({"v": 1, "items": [ + {"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0}, + ]}) resp = client.post( "/form-chips", data={ "csrf_token": csrf, - # Starea curenta: o operatie fara cod - "chip_op_service": ["Op-A"], - "chip_denumire": ["Schimb ulei motor"], - "cod_prestatie": [""], # nemaapat initial - # Actiunea + "chips_state": state, "chips_action": "add", - "chips_add_op_index": "0", - "chips_add_cod_0": "OE-1", + "chips_target_index": "0", + "chips_pick": "OE-1", }, ) assert resp.status_code == 200, f"/form-chips a returnat {resp.status_code}: {resp.text[:400]}" @@ -266,34 +265,31 @@ def test_adauga_sterge_chip(client): assert "OE-1" in html, ( f"Dupa add, OE-1 trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}" ) - # Si hidden input cu valoarea OE-1 - has_hidden = bool( - re.search(r']+name="cod_prestatie"[^>]+value="OE-1"', html) or - re.search(r']+value="OE-1"[^>]+name="cod_prestatie"', html) - ) - assert has_hidden, ( - "Dupa add, trebuie sa existe un input cu name='cod_prestatie' value='OE-1' " - f"in raspunsul /form-chips. html[:600]={html[:600]}" + # Si in starea hidden chips_state + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html) + assert m, f"hidden chips_state lipsa. html[:600]={html[:600]}" + payload = json.loads(m.group(1).replace(""", '"')) + assert payload["items"][0]["cod"] == "OE-1", ( + f"Dupa add, chips_state trebuie sa contina OE-1. items={payload['items']}" ) def test_sterge_chip(client): - """POST /form-chips cu action=remove sterge chip-ul la indexul dat.""" + """POST /form-chips cu action=remove sterge codul chip-ului la indexul dat.""" acct = _create_account_user("form.chips.del@test.com") _login(client, "form.chips.del@test.com") _seed_cod("OE-1", "Schimb ulei motor") csrf = _csrf(client) - # POST /form-chips: sterge chip-ul de la index 0 (OE-1 existent) + # POST /form-chips: sterge codul chip-ului de la index 0 (OE-1 existent, mapat pe Op-A) + state = json.dumps({"v": 1, "items": [ + {"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0}, + ]}) resp = client.post( "/form-chips", data={ "csrf_token": csrf, - # Starea curenta: OE-1 mapat pe Op-A - "chip_op_service": ["Op-A"], - "chip_denumire": ["Schimb ulei motor"], - "cod_prestatie": ["OE-1"], - # Actiunea: sterge indexul 0 + "chips_state": state, "chips_action": "remove", "chips_remove_index": "0", }, @@ -301,15 +297,15 @@ def test_sterge_chip(client): assert resp.status_code == 200, f"/form-chips remove a returnat {resp.status_code}: {resp.text[:400]}" html = resp.text - # Dupa remove, OE-1 nu mai apare ca chip (input hidden cu acea valoare) - has_oe1_chip = bool( - re.search(r']+name="cod_prestatie"[^>]+value="OE-1"', html) or - re.search(r']+value="OE-1"[^>]+name="cod_prestatie"', html) - ) - assert not has_oe1_chip, ( - "Dupa remove, OE-1 NU mai trebuie sa apara ca chip " - f"(hidden input cu cod_prestatie=OE-1). html[:500]={html[:500]}" + # Dupa remove, OE-1 nu mai apare ca cod in starea chips (operatia RAMANE, nemapata) + m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html) + assert m + payload = json.loads(m.group(1).replace(""", '"')) + coduri = [it["cod"] for it in payload["items"]] + assert "OE-1" not in coduri, ( + f"Dupa remove, OE-1 NU mai trebuie sa apara ca cod in chips_state. items={payload['items']}" ) + assert payload["items"][0]["op"] == "Op-A", "Operatia trebuie sa ramana (doar codul dispare)" # --------------------------------------------------------------------------- # @@ -437,11 +433,16 @@ def test_form_chips_reveal_r_odo(client): # --------------------------------------------------------------------------- # -# Test 8: Picker per operatie (E4 binding) -- format op-row # +# Test 8: Chip warning pentru operatie nemapata (control unificat, PRD 5.23) # # --------------------------------------------------------------------------- # def test_picker_per_operatie_in_form(client): - """Operatie nemapata (needs_mapping) -> formularul afiseaza picker pe operatie.""" + """Operatie nemapata (needs_mapping) -> formularul afiseaza chip warning + selectul unic. + + PRD 5.23 US-002: controlul unificat elimina `.op-row` (rand per operatie cu + picker propriu); operatia nemapata devine un chip warning in containerul + unic `.chips`, iar selectul e unul singur (`chips_pick`, id `chips-picker`). + """ acct = _create_account_user("picker.op@test.com") _login(client, "picker.op@test.com") # NU seed-uim nicio mapare -> operatia ramane nemapata @@ -455,17 +456,15 @@ def test_picker_per_operatie_in_form(client): assert resp.status_code == 200 html = resp.text - # Operatia REVIZIE PERIODICA trebuie sa apara in form (op-row cu clasa specifica) - # clasa "op-row" din CSS base.html e adaugata NUMAI de chips_prestatii.html - has_op_row = "op-row" in html - assert has_op_row, ( - "Formularul trebuie sa contina clasa 'op-row' " - "pentru picker-ul per-operatie. " - "Aceasta clasa e adaugata de _chips_prestatii.html. " + # Controlul unificat (PRD 5.23): fara .op-row, UN singur container .chips. + assert "op-row" not in html, ( + "op-row a fost eliminat de controlul unificat (PRD 5.23 US-002). " f"html[:600]={html[:600]}" ) - # Operatia REVIZIE PERIODICA trebuie sa apara in context op-row + assert 'class="chips"' in html + assert 'id="chips-picker"' in html + # Operatia REVIZIE PERIODICA trebuie sa apara ca chip warning assert "REVIZIE PERIODICA" in html, ( - "Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare (op-row). " - f"html[:500]={html[:500]}" + "Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare " + f"(chip warning). html[:500]={html[:500]}" ) diff --git a/tests/test_web_mapare_din_chip.py b/tests/test_web_mapare_din_chip.py index 07595c5..e804056 100644 --- a/tests/test_web_mapare_din_chip.py +++ b/tests/test_web_mapare_din_chip.py @@ -280,8 +280,8 @@ def test_optional_nu_forteaza(client): def test_fara_select_vechi_redundant(client): """Detaliu needs_data NU mai contine RAMANE in formularul /repune pentru starea error. """ @@ -302,15 +302,14 @@ def test_fara_select_vechi_redundant(client): assert resp.status_code == 200 html = resp.text - # Chipurile trebuie sa fie prezente (hidden input cu name="cod_prestatie") - has_chip_hidden = ( - re.search(r']+type=["\']hidden["\'][^>]+name=["\']cod_prestatie["\']', html) or - re.search(r']+name=["\']cod_prestatie["\'][^>]+type=["\']hidden["\']', html) - ) + # Chipurile trebuie sa fie prezente (hidden input cu name="chips_state", JSON versionat) + has_chip_hidden = re.search(r']+type=["\']hidden["\'][^>]+name=["\']chips_state["\']', html) assert has_chip_hidden, ( - "Chips: trebuie sa existe input hidden cu name='cod_prestatie' (din _chips_prestatii.html). " + "Chips: trebuie sa existe input hidden cu name='chips_state' (din _chips_prestatii.html). " f"html[:600]={html[:600]}" ) + # Cod-ul OE-1 trebuie sa apara in stare (JSON), nu ca input separat + assert "OE-1" in html, f"OE-1 trebuie sa apara in chips_state. html[:600]={html[:600]}" # Vechiul