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) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-08 15:45:34 +00:00
parent 0bc618763e
commit 56ebe1f7cb
20 changed files with 2839 additions and 552 deletions

View File

@@ -2,6 +2,44 @@
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare. Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare.
## Din /autoplan PRD 5.23 (2026-07-07)
- [ ] **Bulk in preview: "aplica maparea la toate cele N randuri identice"** — o decizie de mapare
repetata per rand e adevaratul consumator de timp; editorul unificat nu o rezolva. Ce: dupa o
mapare in preview/modal, ofera aplicarea la toate randurile din batch cu aceeasi operatie
nemapata. De ce: reduce deschiderile de modal (drumul 10x — vocea externa CEO). Pro: taie direct
timpul de corectie pe importuri mari. Contra: atinge panoul de preview (non-goal in 5.23).
Context: `reresolve_account` exista deja pentru re-rezolvare dupa `save_mapping` — de verificat
daca nu acopera deja cazul la nivel de cont; daca da, TODO-ul devine doar UI de confirmare.
Effort: M→S (CC). Prioritate: P2. Depinde de: 5.23 livrat.
- [ ] **Metrici de baza pentru bucla de mapare** — cate deschideri de modal per import, % randuri
auto-rezolvate, % sugestii acceptate. De ce: fara ele nu stim daca editorul (5.23) sau motorul
(5.14/5.18) merita urmatoarea investitie. Pro: decizie de produs pe date. Contra: telemetrie
noua. Context: contorizare in `/form-chips` + import commit; expunere in /metrics existent.
Effort: S. Prioritate: P3.
- [ ] **A11y touch targets pe chips (× sub 44px)** — UI-ul compact 11-12px face tintele de atins
mici pe mobil. De ce: operare de pe telefon in service. Pro: mai putine mis-tap-uri. Contra:
chips mai inalti = mai putine pe rand. Context: decizie 5.23 = consecventa cu designul compact
existent; de revizitat la un audit a11y global. Effort: S. Prioritate: P3.
## Din executie + code-review 5.23 (2026-07-08)
- [ ] **Curata calea legacy `/trimitere/{id}/mapeaza`** — formular per-operatie separat, cu propriile
"Sugestii:", care coexista cu editorul unificat de chips. Nu interfereaza (dispare cand tinta e
setata), dar e cod mort de UX vechi. Context: semnalat de worker la fix-ul de randare initiala.
Effort: S. Prioritate: P3.
- [ ] **Autocomplete/cautare in selectul de coduri RAR** — non-goal explicit in 5.23 (select nativ
pastrat). De reevaluat daca nomenclatorul creste peste ~18 coduri fixe. Effort: M. Prioritate: P3.
- [ ] **Race buton Salveaza in timpul unui `/form-chips` in zbor** — sectiunea chips e inerta pe
`.htmx-request`, dar butonul Salveaza al formularului parinte nu e blocat; un submit foarte rapid
ar putea trimite un `chips_state` neactualizat. Improbabil (dedup + fereastra mica). Context:
finding PLAUSIBLE din code-review. Effort: S. Prioritate: P3.
- [ ] **Conflict mapare din tab Mapari vs item marcat `exclus` din chips** — un item marcat exclus
explicit din editor domina o mapare salvata ulterior din tab-ul Mapari (`resolve_prestatii`
precedenta item-exclus > mapare cont); utilizatorul poate sa nu inteleaga de ce maparea "nu tine".
De clarificat semantica sau de adaugat un semnal in UI. Context: finding PLAUSIBLE din code-review.
Effort: S. Prioritate: P3.
## Din /autoplan PRD 5.21 (2026-07-06) ## Din /autoplan PRD 5.21 (2026-07-06)
- [ ] **CLI administrare cache embeddings (`python3 -m tools.embcache stats|clear|rebuild`)** — v1 foloseste - [ ] **CLI administrare cache embeddings (`python3 -m tools.embcache stats|clear|rebuild`)** — v1 foloseste

View File

@@ -233,8 +233,10 @@ def _resolve_row_for_preview(
"flags": all_flags, "flags": all_flags,
} }
# Operatii excluse de la declarare: ies din prestatiile trimise (si din cheia # Operatii excluse de la declarare: ies DOAR din payload-ul trimis la RAR (si din
# de idempotenta). Toate excluse -> rand 'excluded' (nu se trimite, nu blocheaza). # cheia de idempotenta), NU din `mapped["prestatii"]` persistat/afisat — editorul
# (chips modal) trebuie sa arate chip-ul Nedeclarat la redeschidere (round-trip).
# Toate excluse -> rand 'excluded' (nu se trimite, nu blocheaza).
declarabile, excluse = split_prestatii_excluse(resolved) declarabile, excluse = split_prestatii_excluse(resolved)
if excluse and not declarabile: if excluse and not declarabile:
return { return {
@@ -243,11 +245,10 @@ def _resolve_row_for_preview(
"errors": [], "errors": [],
"flags": all_flags, "flags": all_flags,
} }
if excluse:
mapped["prestatii"] = declarabile
# Validare continut # Validare continut: doar partea declarabila conteaza (validate_prezentare
errors = validate_prezentare(mapped) # ignora oricum itemii fara cod_prestatie, dar `mapped` ramane intact pentru afisare).
errors = validate_prezentare({**mapped, "prestatii": declarabile} if excluse else mapped)
if all_flags and not reviewed: if all_flags and not reviewed:
# needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala. # needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala.
@@ -852,7 +853,15 @@ def preview_import(
key = None key = None
if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"): if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"):
try: try:
key = _build_idempotency_key(account_id, resolved_info["resolved"], preview_env) # Cheia foloseste DOAR partea declarabila (itemii exclusi nu intra in
# hash) — identic cu cheia calculata la commit (linia cu
# split_prestatii_excluse din commit_import), altfel preview-ul
# arata alt duplicat decat cel real.
_key_resolved = resolved_info["resolved"]
_decl_key, _exc_key = split_prestatii_excluse(_key_resolved.get("prestatii"))
if _exc_key:
_key_resolved = {**_key_resolved, "prestatii": _decl_key}
key = _build_idempotency_key(account_id, _key_resolved, preview_env)
keys_for_lookup.append(key) keys_for_lookup.append(key)
if key not in key_to_index: if key not in key_to_index:
key_to_index[key] = [] key_to_index[key] = []
@@ -1204,16 +1213,19 @@ def commit_import(
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview) # Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
prestatii = mapped.get("prestatii") or [] prestatii = mapped.get("prestatii") or []
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops) resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
# Prestatiile excluse de la declarare NU pleaca la RAR (ca in preview); # Prestatiile excluse de la declarare NU pleaca la RAR si nu intra in
# cheia de idempotenta, dar RAMAN in payload_json persistat (round-trip
# in editor, ca in preview) — ingustarea la declarabile se face doar
# local, pentru canon/cheie, si la worker, la momentul trimiterii.
declarabile, _excluse = split_prestatii_excluse(resolved)
# rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip. # rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip.
resolved, _excluse = split_prestatii_excluse(resolved) if not declarabile:
if not resolved:
continue continue
mapped["prestatii"] = resolved mapped["prestatii"] = resolved
# Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie; # Canonicalizare (dupa rezolvare prestatii -> cod_prestatie inclus in cheie;
# cheia IDENTICA cu preview: _build_idempotency_key = canonicalize_row + build_key). # cheia IDENTICA cu preview: _build_idempotency_key = canonicalize_row + build_key).
canon = canonicalize_row(mapped) canon = canonicalize_row({**mapped, "prestatii": declarabile})
mapped.update({ mapped.update({
"vin": canon["vin"], "vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"], "nr_inmatriculare": canon["nr_inmatriculare"],
@@ -1223,13 +1235,13 @@ def commit_import(
# Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env) # Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env)
key = build_key(account_id, canon, env) key = build_key(account_id, canon, env)
# Hash row pentru atestare (valori rezolvate) # Hash row pentru atestare (valori rezolvate, doar partea declarabila)
rows_for_hash.append(json.dumps({ rows_for_hash.append(json.dumps({
"row_index": row_index, "row_index": row_index,
"vin": mapped.get("vin"), "vin": mapped.get("vin"),
"data_prestatie": mapped.get("data_prestatie"), "data_prestatie": mapped.get("data_prestatie"),
"odometru_final": mapped.get("odometru_final"), "odometru_final": mapped.get("odometru_final"),
"prestatii": [str(p.get("cod_prestatie") or p.get("cod_op_service") or "") for p in resolved], "prestatii": [str(p.get("cod_prestatie") or p.get("cod_op_service") or "") for p in declarabile],
}, sort_keys=True, ensure_ascii=False)) }, sort_keys=True, ensure_ascii=False))
payload_json = json.dumps(mapped, ensure_ascii=False) payload_json = json.dumps(mapped, ensure_ascii=False)

View File

@@ -266,11 +266,16 @@ def resolve_prestatii(
COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
la RAR (terminal) -> nu-l trimitem niciodata raw. la RAR (terminal) -> nu-l trimitem niciodata raw.
Precedenta (stricta): `cod_prestatie` direct valid > exclus de la declarare > Precedenta (stricta): item-exclus EXPLICIT (`exclus=1` venit pe item, ex. din
mapare exacta `cod_op_service` in `mapping` > reguli text > nemapat. Un cod ales editorul de chips) > `cod_prestatie` direct valid > exclus de la declarare
explicit pe rand (editor) bate regula de excludere; regula de excludere bate (regula de cont) > mapare exacta `cod_op_service` in `mapping` > reguli text >
regulile text. Regulile text se incearca DOAR cand nu exista cod valid SI op nemapat. Un item marcat explicit `exclus` de utilizator (editor) domina TOT —
nu e in `mapping`. inclusiv un `cod_prestatie` deja valid pe rand — pentru ca alegerea explicita
"Nu se declara la RAR" e intentia cea mai recenta si trebuie sa supravietuiasca
re-rezolvarii (altfel un item exclus ar reveni needs_mapping sau, mai rau, ar fi
declarat la RAR). Restul precedentei (cod valid > exclus de cont > mapare >
reguli text) e NESCHIMBAT. Regulile text se incearca DOAR cand nu exista cod
valid SI op nu e in `mapping`.
`valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e `valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e
None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece
@@ -295,11 +300,21 @@ def resolve_prestatii(
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit. # un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
it.pop("cod_sursa", None) it.pop("cod_sursa", None)
it.pop("regula_fara_autosend", None) it.pop("regula_fara_autosend", None)
it.pop("exclus", None) exclus_explicit = bool(it.pop("exclus", None))
cod = (it.get("cod_prestatie") or "").strip().upper() cod = (it.get("cod_prestatie") or "").strip().upper()
op = (it.get("cod_op_service") or "").strip() op = (it.get("cod_op_service") or "").strip()
cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes) cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes)
if cod_valid: if exclus_explicit:
# Treapta noua (precedenta maxima): alegerea explicita "Nu se declara la
# RAR" (editor de chips) supravietuieste re-rezolvarii, chiar daca ramane
# (sau a ramas) un cod_prestatie valid pe rand.
if cod and not op:
op = cod
if op and not it.get("cod_op_service"):
it["cod_op_service"] = op
it["cod_prestatie"] = None
it["exclus"] = True
elif cod_valid:
it["cod_prestatie"] = cod it["cod_prestatie"] = cod
else: else:
# cod lipsa SAU necunoscut in nomenclator -> ruta de mapare. # cod lipsa SAU necunoscut in nomenclator -> ruta de mapare.

View File

@@ -33,6 +33,16 @@ def _cod(p: object) -> str | None:
return str(cod).strip().upper() if cod else None return str(cod).strip().upper() if cod else None
def _declarabil(p: object) -> bool:
"""Un item pleaca la RAR doar daca are cod si nu e adnotat exclus.
Filtru no-op pentru payload deja ingustat la declarabile; sarit ca ultima
plasa de siguranta — un item fara cod nu produce niciodata {codPrestatie: null}.
"""
exclus = p.get("exclus") if isinstance(p, dict) else getattr(p, "exclus", None)
return not exclus and _cod(p) is not None
def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]: def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
"""Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare.""" """Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare."""
payload: dict[str, Any] = { payload: dict[str, Any] = {
@@ -46,6 +56,7 @@ def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
"prestatii": [ "prestatii": [
{"codPrestatie": _cod(p), "idPrezentare": None} {"codPrestatie": _cod(p), "idPrezentare": None}
for p in (prezentare.get("prestatii") or []) for p in (prezentare.get("prestatii") or [])
if _declarabil(p)
], ],
"sistemReparat": prezentare.get("sistem_reparat") or "null", "sistemReparat": prezentare.get("sistem_reparat") or "null",
"status": "FINALIZATA", "status": "FINALIZATA",

View File

@@ -120,6 +120,7 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
# Expune parse_erori si eticheta_env in toate template-urile # Expune parse_erori si eticheta_env in toate template-urile
templates.env.globals["parse_erori"] = parse_erori templates.env.globals["parse_erori"] = parse_erori
templates.env.globals["eticheta_env"] = eticheta_env templates.env.globals["eticheta_env"] = eticheta_env
templates.env.globals["EXCLUDE_SENTINEL"] = EXCLUDE_SENTINEL
def _mediu_instanta() -> str: def _mediu_instanta() -> str:
@@ -1336,8 +1337,10 @@ def _payload_form_values(payload_json) -> dict:
def _prestatii_chips_from_payload(payload_json) -> list[dict]: def _prestatii_chips_from_payload(payload_json) -> list[dict]:
"""Extrage lista de chips prestatii din payload_json pentru _form_editare.html. """Extrage lista de chips prestatii din payload_json pentru _form_editare.html.
Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire}. Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire, exclus}.
Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''. Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''.
`exclus` propaga adnotarea din payload (chip Nedeclarat la redeschidere);
default 0 pentru payload-uri vechi fara camp.
""" """
try: try:
data = json.loads(payload_json) if payload_json else {} data = json.loads(payload_json) if payload_json else {}
@@ -1353,6 +1356,7 @@ def _prestatii_chips_from_payload(payload_json) -> list[dict]:
"cod_prestatie": (item.get("cod_prestatie") or "").strip().upper(), "cod_prestatie": (item.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (item.get("cod_op_service") or "").strip(), "cod_op_service": (item.get("cod_op_service") or "").strip(),
"denumire": (item.get("denumire") or "").strip(), "denumire": (item.get("denumire") or "").strip(),
"exclus": 1 if item.get("exclus") else 0,
}) })
return chips return chips
@@ -1501,6 +1505,15 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
ctx["prestatii_chips"] = prestatii_chips ctx["prestatii_chips"] = prestatii_chips
ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips) ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips)
ctx["form_chips_url"] = "/form-chips" ctx["form_chips_url"] = "/form-chips"
# Tinta implicita + sugestii la randarea INITIALA (inainte de orice /form-chips) —
# helper partajat cu post_form_chips: cat exista operatii nemapate, EXISTA tinta.
# Fara conn nu se pot calcula sugestiile (enrich_suggestions).
ctx["chips_target_index"] = None
ctx["sugestii_tinta"] = []
if conn is not None:
ctx["chips_target_index"], ctx["sugestii_tinta"] = _chips_target_si_sugestii(
conn, prestatii_chips, nomenclator_rar
)
# submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html. # submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html.
# Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste # Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste
# → butonul nu apare (corect: /form-chips nu are scop de submission). # → butonul nu apare (corect: /form-chips nu are scop de submission).
@@ -1656,73 +1669,108 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
if isinstance(obs_val, str): if isinstance(obs_val, str):
content["obs"] = obs_val.strip() content["obs"] = obs_val.strip()
# Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii. # Stare chips: editorul unificat trimite UN SINGUR hidden `chips_state`
# form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din # (JSON versionat, cu `exclus` per item — vezi _chips_state_from_form). Cand e
# prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite # prezent, INLOCUIESTE integral prestatiile, cu round-trip complet pe exclus.
# TOATE itemele (inclusiv "" pentru nemapate), permitand 1-1 aliniere by-index # Cod necunoscut in nomenclator -> respins (invariant ORA-12899);
# chiar cand un item de mijloc ramane nemapat. # sentinelul __NEDECLARAT__ nu e niciodata in nomenclator, deci pica in aceeasi
# Cod necunoscut in nomenclator -> respins imediat (invariant ORA-12899). # verificare daca ajunge (fabricat) direct ca cod_prestatie in starea hidden.
codes_raw = form.getlist("cod_prestatie") _chips_state_raw = form.get("chips_state")
# Acceptam lista cu "" pentru pozitii nemapate; filtrare doar pt detectia _are_chips_state = _chips_state_raw is not None and str(_chips_state_raw).strip() != ""
# "fara niciun cod trimis" (cazul in care form-ul nu a inclus deloc cod_prestatie). if _are_chips_state:
codes_positional = [ _chips_din_stare = _chips_from_state_json(str(_chips_state_raw))
c.strip().upper() if isinstance(c, str) else "" if _chips_din_stare is None:
for c in codes_raw return templates.TemplateResponse(
] "_trimitere_detaliu.html",
# Codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare. _detaliu_ctx(
# Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit. request, row, conn=conn, account_id=account_id, error=True,
# Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional. message="Stare chips invalida — reincarca formularul si reintroduce codurile.",
# 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: valid_codes_now = load_nomenclator_codes(conn) or set()
codes_positional.append(_flat_picker) necunoscute = sorted(
for _i in range(len(codes_positional)): {c["cod_prestatie"] for c in _chips_din_stare if c["cod_prestatie"]} - valid_codes_now
if not codes_positional[_i]: )
_op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper() if necunoscute:
if _op_val: return templates.TemplateResponse(
codes_positional[_i] = _op_val "_trimitere_detaliu.html",
# Verifica daca cel putin un cod non-gol a fost trimis _detaliu_ctx(
codes_nonempty = [c for c in codes_positional if c] request, row, conn=conn, account_id=account_id, error=True,
if codes_nonempty: message=f"Cod RAR necunoscut in nomenclator: {', '.join(necunoscute)}. "
# Valideaza FIECARE cod non-gol fata de nomenclator (ORA-12899: RAR accepta NUMAI coduri valide) "Alege un cod valid din lista.",
for cod in codes_nonempty: ),
exists_nom = conn.execute( )
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) content["prestatii"] = _chips_din_stare
).fetchone() else:
if not exists_nom: # Compat: formular vechi (peste deploy) cu listele paralele
return templates.TemplateResponse( # cod_prestatie/chip_op_service/chip_denumire + pickere ne-aprobate.
"_trimitere_detaliu.html", # Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii.
_detaliu_ctx( # form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din
request, row, conn=conn, account_id=account_id, error=True, # prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite
message=f"Cod RAR necunoscut in nomenclator: {cod}. " # TOATE itemele (inclusiv "" pentru nemapate), permitand 1-1 aliniere by-index
"Alege un cod valid din lista.", # chiar cand un item de mijloc ramane nemapat.
), # Cod necunoscut in nomenclator -> respins imediat (invariant ORA-12899).
) codes_raw = form.getlist("cod_prestatie")
# Pereche operatie<->cod (E4): fiecare cod se ataseaza itemului by index. # Acceptam lista cu "" pentru pozitii nemapate; filtrare doar pt detectia
# Itemii existenti cu cod_op_service/denumire sunt PASTRATI (D7, E1 IRON RULE). # "fara niciun cod trimis" (cazul in care form-ul nu a inclus deloc cod_prestatie).
# Coduri "" (pozitii nemapate) lasa itemul fara cod_prestatie -> needs_mapping. codes_positional = [
existing = content.get("prestatii") or [] c.strip().upper() if isinstance(c, str) else ""
new_prestatii = [] for c in codes_raw
for i, cod in enumerate(codes_positional): ]
if i >= len(existing) and not cod: # Codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare.
continue # extra pozitii goale fara item corespondent — sarite # Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit.
item = dict(existing[i]) if i < len(existing) else {} # Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional.
if cod: # Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899).
item["cod_prestatie"] = cod _flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
# E1: cod_op_service/denumire NU se sterg; perechea op<->cod ramane intacta if _flat_picker and _flat_picker not in codes_positional:
new_prestatii.append(item) codes_positional.append(_flat_picker)
# Dedup per-item (E4): (cod_op_service, cod_prestatie) identice -> pastreaza primul. for _i in range(len(codes_positional)):
# Doua operatii DIFERITE cu acelasi cod RAR sunt legitime si NU se dedupeaza. if not codes_positional[_i]:
seen_pairs: set = set() _op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper()
deduped: list = [] if _op_val:
for item in new_prestatii: codes_positional[_i] = _op_val
pair = (item.get("cod_op_service"), item.get("cod_prestatie")) # Verifica daca cel putin un cod non-gol a fost trimis
if pair not in seen_pairs: codes_nonempty = [c for c in codes_positional if c]
seen_pairs.add(pair) if codes_nonempty:
deduped.append(item) # Valideaza FIECARE cod non-gol fata de nomenclator (ORA-12899: RAR accepta NUMAI coduri valide)
content["prestatii"] = deduped for cod in codes_nonempty:
# else: fara coduri trimise -> content["prestatii"] neatins; resolve_prestatii exists_nom = conn.execute(
# detecteaza operatii nemapate si randul ramane needs_mapping. "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 # Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune
# niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil # 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) text_rules = load_text_rules(conn, account_id)
excluded_ops = load_excluded_ops(conn, account_id) excluded_ops = load_excluded_ops(conn, account_id)
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops) resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
# Persistam TOATE itemii (inclusiv cei exclusi, adnotati exclus=1) — round-trip
# complet in editor (chip Nedeclarat reapare la redeschidere, x il readuce la
# warning). Excluderea din payload-ul RAR/cheia idempotentei se face DOAR local,
# mai jos, prin canon; content["prestatii"] NU se ingusteaza niciodata.
content["prestatii"] = resolved content["prestatii"] = resolved
# telemetrie pentru itemii rezolvati prin regula text (calea corectie web). # telemetrie pentru itemii rezolvati prin regula text (calea corectie web).
_emite_text_rule_hits(conn, account_id, row["id"], resolved) _emite_text_rule_hits(conn, account_id, row["id"], resolved)
# Prestatiile excluse de la declarare ies din payload-ul trimis (ca in # Prestatiile excluse de la declarare NU intra in payload-ul trimis la RAR nici
# reresolve_account); toate excluse -> needs_data cu motiv explicit mai jos. # in cheia de idempotenta (ca in reresolve_account) — dar RAMAN in payload_json
# persistat mai sus. Toate excluse -> needs_data cu motiv explicit mai jos.
declarabile, excluse = split_prestatii_excluse(resolved) declarabile, excluse = split_prestatii_excluse(resolved)
if not unmapped and excluse and declarabile:
content["prestatii"] = declarabile
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie. # Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
canon = canonicalize_row(content) # Foloseste DOAR partea declarabila pentru cheie (exclusii nu schimba hash-ul).
canon = canonicalize_row({**content, "prestatii": declarabile})
content.update({ content.update({
"vin": canon["vin"], "vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"], "nr_inmatriculare": canon["nr_inmatriculare"],
@@ -2392,6 +2444,71 @@ async def post_bulk_fix(request: Request) -> HTMLResponse:
conn.close() conn.close()
def _sugestii_select_pentru_tinta(conn, denumire: str | None, nomenclator: list[dict]) -> list[dict]:
"""Sugestii pentru optgroup "Sugestii" din selectul unic.
Refoloseste EXACT sursele din `_preview_import.html:89-121` (fara drum nou de cod):
`sugestie_principala` (GOLD/SILVER/embedding, `enrich_suggestions`) prima, apoi
top-3 fuzzy (`suggest_codes`), dedupliate, pastrand doar coduri care exista in
nomenclator. Fara memoizare — se recalculeaza la fiecare re-render.
Degradare gratioasa: motor de sugestii rece/dezactivat/exceptie -> lista goala
(selectul cade pe nomenclatorul complet, fara optgroup). `ensure_embeddings_corpus`
ruleaza cu `block=False` implicit — calea de request nu asteapta warmup-ul.
Fara denumire (tinta neidentificata) fuzzy match-ul ar intoarce primele coduri
din nomenclator ca sugestii irelevante — golim explicit.
"""
if not (denumire or "").strip():
return []
try:
ensure_embeddings_corpus(conn, nomenclator)
by_cod = {n["cod_prestatie"]: n.get("nume_prestatie") for n in (nomenclator or [])}
out: list[dict] = []
seen: set[str] = set()
enriched = enrich_suggestions(conn, denumire)
principala = enriched.get("sugestie_principala") if enriched else None
if principala and principala.get("cod_prestatie") in by_cod:
cod = principala["cod_prestatie"]
out.append({"cod_prestatie": cod, "nume_prestatie": by_cod.get(cod)})
seen.add(cod)
for s in suggest_codes(denumire, nomenclator, limit=3):
cod = s.get("cod_prestatie")
if cod and cod not in seen and cod in by_cod:
out.append({"cod_prestatie": cod, "nume_prestatie": by_cod.get(cod)})
seen.add(cod)
return out
except Exception:
return [] # degradare gratioasa: selectul ramane cu nomenclatorul complet
def _chips_target_si_sugestii(
conn, chips: list[dict], nomenclator: list[dict],
) -> tuple[int | None, list[dict]]:
"""Tinta implicita (prima operatie nemapata ne-exclusa, identificabila) + sugestii.
Sursa unica pentru `post_form_chips` SI randarea INITIALA a modalului (inainte
de orice /form-chips): cat exista operatii nemapate, EXISTA tinta. Fara acest
helper partajat, cele doua cai ar putea diverge.
Necesita `cod_op_service` truthy: template-ul randeaza chip-ul warning (tinta
vizibila) doar pentru operatii identificabile; un item raw fara cod_op_service
n-are ce sa arate ca tinta si ar ramane invizibil.
"""
target_index = next(
(i for i, c in enumerate(chips)
if not c.get("cod_prestatie") and not c.get("exclus") and c.get("cod_op_service")),
None,
)
sugestii: list[dict] = []
if target_index is not None:
_den_tinta = chips[target_index].get("denumire") or chips[target_index].get("cod_op_service")
sugestii = _sugestii_select_pentru_tinta(conn, _den_tinta, nomenclator)
return target_index, sugestii
# =========================================================================== # # =========================================================================== #
# Endpoint /form-chips — re-randare chips prestatii. Preia starea curenta din # # Endpoint /form-chips — re-randare chips prestatii. Preia starea curenta din #
# form + actiunea (add/remove) si re-randeaza _chips_prestatii.html. Fara # # form + actiunea (add/remove) si re-randeaza _chips_prestatii.html. Fara #
@@ -2403,37 +2520,25 @@ async def post_bulk_fix(request: Request) -> HTMLResponse:
async def post_form_chips(request: Request) -> HTMLResponse: async def post_form_chips(request: Request) -> HTMLResponse:
"""Re-randeaza sectiunea chips prestatii (HTMX server-driven). """Re-randeaza sectiunea chips prestatii (HTMX server-driven).
Primeste starea curenta a chip-urilor (3 liste paralele: cod_prestatie, Stare unica: hidden `chips_state` (JSON versionat, vezi _chips_state_from_form).
chip_op_service, chip_denumire) + actiunea (add/remove) si returneaza DOUA actiuni: add (leaga codul de operatia tinta prin chips_target_index, sau
_chips_prestatii.html actualizat. Fara scriere in DB (stateless mid-edit). adauga cod liber fara tinta; sentinelul __NEDECLARAT__ + tinta marcheaza
Auth: sesiune activa; CSRF verificat. exclus=1 pe item) si remove (chips_remove_index, index unic — acopera si fostul
remove_flat). Fara scriere in DB (stateless mid-edit). Auth: sesiune activa;
CSRF verificat.
""" """
account_id = require_login(request) account_id = require_login(request)
form = await request.form() form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or "")) verify_csrf(request, str(form.get("csrf_token") or ""))
# Reconstruct current chips state from parallel hidden inputs (emise de _chips_prestatii.html). chips, chips_error = _chips_state_from_form(form)
# Toate cele 3 liste sunt aceeasi lungime (emise index-by-index in template). if chips is None:
cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")] chips = []
op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")]
den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")]
# Aliniaza listele la lungimea maxima (defensive)
n = max(len(cod_list), len(op_list), len(den_list)) if (cod_list or op_list or den_list) else 0
chips: list[dict] = []
for i in range(n):
chips.append({
"cod_prestatie": cod_list[i] if i < len(cod_list) else "",
"cod_op_service": op_list[i] if i < len(op_list) else "",
"denumire": den_list[i] if i < len(den_list) else "",
})
action = str(form.get("chips_action") or "").strip() action = str(form.get("chips_action") or "").strip()
# Orice adaugare are un efect vizibil: mesaj de refuz (chips_error) sau # Orice adaugare are un efect vizibil: mesaj de refuz (chips_error) sau
# confirmare + chip evidentiat (chips_ok / chips_added_cod). Fara no-op silentios. # confirmare + chip evidentiat (chips_ok / chips_added_index). Fara no-op silentios.
chips_error = ""
chips_ok = "" chips_ok = ""
chips_added_cod = "" chips_added_index: int | None = None
conn = get_connection() conn = get_connection()
@@ -2442,71 +2547,93 @@ async def post_form_chips(request: Request) -> HTMLResponse:
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
).fetchone() is not None ).fetchone() is not None
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: try:
if action == "add": if not chips_error and action == "add":
# Adauga cod la operatia specificata prin chips_add_op_index raw_target = str(form.get("chips_target_index") or "").strip()
try: target_idx: int | None = None
op_idx = int(str(form.get("chips_add_op_index") or 0)) if raw_target != "":
except (ValueError, TypeError): try:
op_idx = 0 target_idx = int(raw_target)
add_cod = str(form.get(f"chips_add_cod_{op_idx}") or "").strip().upper() except (ValueError, TypeError):
if not add_cod: target_idx = -1 # index nenumeric -> forteaza "tinta invalida" mai jos
chips_error = "Selecteaza un cod RAR din lista inainte de a adauga."
elif not (0 <= op_idx < len(chips)):
chips_error = "Operatia nu mai exista in formular — reincarca editarea."
elif not _cod_valid(add_cod):
chips_error = f"Cod necunoscut in nomenclator: {add_cod}."
else:
chips[op_idx]["cod_prestatie"] = add_cod
chips_ok = f"{add_cod} adaugat."
chips_added_cod = add_cod
elif action in ("add_flat", "add_extra"): # Mai multe <select name="chips_pick"> pot coexista pe pagina (un picker
# Adauga cod RAR liber (fara op_service): add_flat = mod plat, # per operatie nemapata + unul flat); doar cel interactionat are valoare.
# add_extra = mod operatii. Acelasi select (chips_add_cod_flat), pick = next(
# aceeasi validare si acelasi dedup per-item (E4). (v.strip().upper() for v in form.getlist("chips_pick") if isinstance(v, str) and v.strip()),
add_cod_flat = str(form.get("chips_add_cod_flat") or "").strip().upper() "",
if not add_cod_flat: )
if not pick:
chips_error = "Selecteaza un cod RAR din lista inainte de a adauga." chips_error = "Selecteaza un cod RAR din lista inainte de a adauga."
elif not _cod_valid(add_cod_flat): elif pick == EXCLUDE_SENTINEL:
chips_error = f"Cod necunoscut in nomenclator: {add_cod_flat}." # "Nu se declara la RAR": cere OBLIGATORIU o tinta valida (operatie
else: # nemapata, ne-exclusa) — sentinelul nu are sens pe cod liber.
existing_pairs = { if target_idx is None or not _target_valid(target_idx):
(c.get("cod_op_service", ""), c.get("cod_prestatie", "")) chips_error = "\"Nu se declara la RAR\" cere o operatie tinta valida."
for c in chips
}
if ("", add_cod_flat) in existing_pairs:
chips_error = f"{add_cod_flat} este deja in lista — nu a fost adaugat inca o data."
else: else:
chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""}) chips[target_idx]["exclus"] = 1
chips_ok = f"{add_cod_flat} adaugat." chips[target_idx]["cod_prestatie"] = ""
chips_added_cod = add_cod_flat op_nume = chips[target_idx].get("cod_op_service") or "Operatia"
chips_ok = f"{op_nume} nu se va declara la RAR."
chips_added_index = target_idx
elif not _cod_valid(pick):
chips_error = f"Cod necunoscut in nomenclator: {pick}."
elif target_idx is not None and not _target_valid(target_idx):
chips_error = "Operatia tinta nu mai exista sau e deja rezolvata — reincarca editarea."
elif target_idx is not None:
# Leaga codul de operatia tinta (E4 binding).
op_tinta = chips[target_idx].get("cod_op_service")
dedup = any(c.get("cod_op_service") == op_tinta and c.get("cod_prestatie") == pick for c in chips)
if dedup:
chips_error = f"{pick} este deja in lista pentru aceasta operatie."
else:
chips[target_idx]["cod_prestatie"] = pick
chips[target_idx]["exclus"] = 0
chips_ok = f"{pick} adaugat."
chips_added_index = target_idx
else:
# Fara tinta -> chip liber (fara operatie sursa).
dedup = any(not c.get("cod_op_service") and c.get("cod_prestatie") == pick for c in chips)
if dedup:
chips_error = f"{pick} este deja in lista — nu a fost adaugat inca o data."
else:
chips.append({"cod_prestatie": pick, "cod_op_service": "", "denumire": "", "exclus": 0})
chips_ok = f"{pick} adaugat."
chips_added_index = len(chips) - 1
elif action == "remove": elif not chips_error and action == "remove":
# Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata) # Index unic (acopera si fostul remove_flat): chip legat de operatie ->
# codul se sterge (operatia redevine nemapata/warning, inclusiv Nedeclarat
# -> warning); chip liber -> iese complet din lista.
try: try:
remove_idx = int(str(form.get("chips_remove_index") or 0)) remove_idx = int(str(form.get("chips_remove_index") or ""))
except (ValueError, TypeError): except (ValueError, TypeError):
remove_idx = 0 remove_idx = -1
if 0 <= remove_idx < len(chips): if 0 <= remove_idx < len(chips):
chips[remove_idx]["cod_prestatie"] = "" item = chips[remove_idx]
if item.get("cod_op_service"):
item["cod_prestatie"] = ""
item["exclus"] = 0
else:
chips.pop(remove_idx)
elif action == "remove_flat":
# Sterge un chip plat dupa cod (in mod fara op_service)
remove_cod = str(form.get("chips_remove_cod") or "").strip().upper()
chips = [
c for c in chips
if not (not c.get("cod_op_service") and c.get("cod_prestatie") == remove_cod)
]
# Compute has_r_odo dupa actiune
has_r_odo = _has_r_odo_chips(chips) has_r_odo = _has_r_odo_chips(chips)
# Incarca nomenclatorul pentru picker
nomenclator_rar = load_nomenclator(conn) nomenclator_rar = load_nomenclator(conn)
# Tinta implicita dupa actiune (prima operatie nemapata) + sugestii pentru ea
# (optgroup "Sugestii" din select) — helper partajat cu randarea initiala.
chips_target_index, sugestii_tinta = _chips_target_si_sugestii(conn, chips, nomenclator_rar)
finally: finally:
conn.close() conn.close()
# Ecou (nu re-derivat): permite butonului "salveaza ca regula" sa ramana
# disponibil dupa re-randari succesive via /form-chips.
chips_submission_id = str(form.get("chips_submission_id") or "").strip()
return templates.TemplateResponse("_chips_prestatii.html", { return templates.TemplateResponse("_chips_prestatii.html", {
"request": request, "request": request,
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
@@ -2517,7 +2644,10 @@ async def post_form_chips(request: Request) -> HTMLResponse:
"chips_section_id": "chips-section", "chips_section_id": "chips-section",
"chips_error": chips_error, "chips_error": chips_error,
"chips_ok": chips_ok, "chips_ok": chips_ok,
"chips_added_cod": chips_added_cod, "chips_added_index": chips_added_index,
"chips_target_index": chips_target_index,
"chips_submission_id": chips_submission_id,
"sugestii_tinta": sugestii_tinta,
}) })
@@ -3235,7 +3365,12 @@ def _web_compute_preview(
key: str | None = None key: str | None = None
if info["resolved_status"] in ("ok", "needs_review", "needs_data"): if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
try: try:
key = _build_idempotency_key(account_id, info["resolved"], preview_env) # Cheia foloseste DOAR partea declarabila (itemii exclusi nu intra in
# hash) — identica cu cea calculata la commit, altfel un rand cu
# prestatii excluse arata alt duplicat decat cel real.
decl, _exc = split_prestatii_excluse(info["resolved"].get("prestatii"))
key_resolved = {**info["resolved"], "prestatii": decl} if _exc else info["resolved"]
key = _build_idempotency_key(account_id, key_resolved, preview_env)
keys_for_lookup.append(key) keys_for_lookup.append(key)
key_to_indices.setdefault(key, []).append(i) key_to_indices.setdefault(key, []).append(i)
except Exception: except Exception:
@@ -3796,12 +3931,16 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) ->
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(), "cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (p.get("cod_op_service") or "").strip(), "cod_op_service": (p.get("cod_op_service") or "").strip(),
"denumire": (p.get("denumire") or "").strip(), "denumire": (p.get("denumire") or "").strip(),
"exclus": 1 if p.get("exclus") else 0,
} }
for p in (res.get("prestatii") or []) for p in (res.get("prestatii") or [])
if isinstance(p, dict) if isinstance(p, dict)
] ]
_preview_has_r_odo = _has_r_odo_chips(_preview_chips) _preview_has_r_odo = _has_r_odo_chips(_preview_chips)
_preview_nomenclator = load_nomenclator(conn) _preview_nomenclator = load_nomenclator(conn)
_preview_target_idx, _preview_sugestii = _chips_target_si_sugestii(
conn, _preview_chips, _preview_nomenclator
)
return templates.TemplateResponse("_editare_preview_modal.html", { return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request, "request": request,
"import_id": import_id, "import_id": import_id,
@@ -3827,6 +3966,8 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) ->
"obs_val": (res.get("obs") or "").strip(), "obs_val": (res.get("obs") or "").strip(),
"nomenclator_rar": _preview_nomenclator, "nomenclator_rar": _preview_nomenclator,
"form_chips_url": "/form-chips", "form_chips_url": "/form-chips",
"chips_target_index": _preview_target_idx,
"sugestii_tinta": _preview_sugestii,
}) })
finally: finally:
conn.close() conn.close()
@@ -3859,25 +4000,77 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe
conn.close() conn.close()
def _chips_state_from_form(form) -> list[dict[str, str]] | None: CHIPS_STATE_VERSION = 1
"""Reconstruieste starea chips prestatii din inputurile paralele ale formularului.
Aceeasi conventie ca /form-chips (_chips_prestatii.html): 3 liste paralele
cod_prestatie / chip_op_service / chip_denumire. Codurile alese in pickere dar
ne-aprobate prin '+' (chips_add_cod_{i}, chips_add_cod_flat) se aplica implicit,
ca in post_corectie_trimitere. Dedup pe perechea (op, cod).
Intoarce None cand formularul nu contine deloc stare de chips (form fara def _parse_chip_exclus(raw) -> int:
sectiunea de prestatii) — apelantul lasa prestatiile neatinse. """Parsare stricta a flag-ului exclus: DOAR '1' -> 1, orice altceva -> 0."""
return 1 if str(raw).strip() == "1" else 0
def _chips_state_to_json(chips: list[dict]) -> str:
"""Serializeaza chips (lista interna) in JSON versionat pentru hidden `chips_state`."""
return json.dumps({
"v": CHIPS_STATE_VERSION,
"items": [
{
"cod": c.get("cod_prestatie") or "",
"op": c.get("cod_op_service") or "",
"den": c.get("denumire") or "",
"exclus": 1 if c.get("exclus") else 0,
}
for c in chips
],
}, ensure_ascii=False)
# Expune serializarea chips_state in template (randare initiala, fara trecere prin
# post_form_chips) -- sursa unica de adevar pentru hidden `chips_state`.
templates.env.globals["chips_state_json"] = _chips_state_to_json
def _chips_from_state_json(raw: str) -> list[dict] | None:
"""Decodeaza hidden `chips_state` (JSON versionat) in lista interna de chips.
Intoarce None cand JSON-ul e invalid sau structura nu se potriveste
(fara cheie 'items' lista) — apelantul trateaza asta ca eroare vizibila,
NU ca stare goala (altfel o golire accidentala trece neobservata).
"""
try:
parsed = json.loads(raw)
except (ValueError, TypeError):
return None
if not isinstance(parsed, dict) or not isinstance(parsed.get("items"), list):
return None
chips: list[dict] = []
for it in parsed["items"]:
if not isinstance(it, dict):
return None
chips.append({
"cod_prestatie": str(it.get("cod") or "").strip().upper(),
"cod_op_service": str(it.get("op") or "").strip(),
"denumire": str(it.get("den") or "").strip(),
"exclus": _parse_chip_exclus(it.get("exclus")),
})
return chips
def _chips_from_legacy_lists(form) -> list[dict]:
"""Compat tranzitorie: reconstruieste chips din listele paralele vechi.
cod_prestatie / chip_op_service / chip_denumire (+ chip_exclus optional,
parsat strict). Codurile alese in pickere dar ne-aprobate prin '+' (E.g.
chips_add_cod_{i}, chips_add_cod_flat) se aplica implicit — comportament
pastrat identic cu forma veche (form-uri deschise peste deploy). Dedup pe
perechea (op, cod). Se sterge in release-ul urmator.
""" """
flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper() flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
if "cod_prestatie" not in form and "chip_op_service" not in form and not flat_picker:
return None
cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")] cod_list = [c.strip().upper() if isinstance(c, str) else "" for c in form.getlist("cod_prestatie")]
op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")] op_list = [o.strip() if isinstance(o, str) else "" for o in form.getlist("chip_op_service")]
den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")] den_list = [d.strip() if isinstance(d, str) else "" for d in form.getlist("chip_denumire")]
excl_list = [str(e) for e in form.getlist("chip_exclus")]
n = max(len(cod_list), len(op_list), len(den_list)) n = max(len(cod_list), len(op_list), len(den_list))
chips: list[dict[str, str]] = [] chips: list[dict] = []
for i in range(n): for i in range(n):
cod = cod_list[i] if i < len(cod_list) else "" cod = cod_list[i] if i < len(cod_list) else ""
if not cod: if not cod:
@@ -3887,12 +4080,13 @@ def _chips_state_from_form(form) -> list[dict[str, str]] | None:
"cod_prestatie": cod, "cod_prestatie": cod,
"cod_op_service": op_list[i] if i < len(op_list) else "", "cod_op_service": op_list[i] if i < len(op_list) else "",
"denumire": den_list[i] if i < len(den_list) else "", "denumire": den_list[i] if i < len(den_list) else "",
"exclus": _parse_chip_exclus(excl_list[i]) if i < len(excl_list) else 0,
}) })
pairs = {(c["cod_op_service"], c["cod_prestatie"]) for c in chips} pairs = {(c["cod_op_service"], c["cod_prestatie"]) for c in chips}
if flat_picker and ("", flat_picker) not in pairs: if flat_picker and ("", flat_picker) not in pairs:
chips.append({"cod_prestatie": flat_picker, "cod_op_service": "", "denumire": ""}) chips.append({"cod_prestatie": flat_picker, "cod_op_service": "", "denumire": "", "exclus": 0})
seen: set = set() seen: set = set()
out: list[dict[str, str]] = [] out: list[dict] = []
for c in chips: for c in chips:
if not (c["cod_prestatie"] or c["cod_op_service"] or c["denumire"]): if not (c["cod_prestatie"] or c["cod_op_service"] or c["denumire"]):
continue continue
@@ -3904,6 +4098,33 @@ def _chips_state_from_form(form) -> list[dict[str, str]] | None:
return out return out
def _chips_state_from_form(form) -> tuple[list[dict] | None, str]:
"""Reconstruieste starea chips prestatii din formular.
Sursa unica: hidden `chips_state` (JSON versionat {"v":1,"items":[...]});
compat tranzitorie pe listele paralele vechi (cod_prestatie/chip_op_service/
chip_denumire) cand `chips_state` lipseste (form deschis peste deploy).
Intoarce (chips, error):
- error non-gol -> JSON invalid; apelantul arata chips_error si NU aplica
nicio schimbare (NU stare goala silentioasa).
- chips=None + error gol -> formularul nu are deloc sectiune de chips;
prestatiile raman neatinse.
- chips=[] + error gol -> golire intentionata (chips_state prezent, items:[]);
se persista la salvare.
"""
raw_state = form.get("chips_state")
if raw_state is not None and str(raw_state).strip() != "":
chips = _chips_from_state_json(str(raw_state))
if chips is None:
return None, "Stare chips invalida — reincarca formularul si reintroduce codurile."
return chips, ""
flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
if "cod_prestatie" not in form and "chip_op_service" not in form and not flat_picker:
return None, ""
return _chips_from_legacy_lists(form), ""
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse) @router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse: async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal. """Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal.
@@ -3924,9 +4145,55 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
for camp in EDIT_FIELDS for camp in EDIT_FIELDS
} }
# Chips prestatii din modal: None = form fara sectiune de chips (neatins). # Chips prestatii din modal: None = form fara sectiune de chips (neatins).
chips_state = _chips_state_from_form(form) chips_state, chips_parse_error = _chips_state_from_form(form)
conn = get_connection() conn = get_connection()
try: try:
if chips_parse_error:
# JSON chips_state corupt -> eroare vizibila, NU stare goala silentioasa;
# override NEATINS (nicio prestatie nu se schimba).
_nom_err = load_nomenclator(conn)
_cur_result, _cur_row = _preview_one_row(conn, import_id, account_id, row_index)
_cur_chips = (
(_cur_row.get("resolved") or {}).get("prestatii")
if _cur_row and not isinstance(_cur_result, str) else None
) or []
_cur_chips = [
{
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (p.get("cod_op_service") or "").strip(),
"denumire": (p.get("denumire") or "").strip(),
"exclus": 1 if p.get("exclus") else 0,
}
for p in _cur_chips if isinstance(p, dict)
]
_cur_target_idx, _cur_sugestii = _chips_target_si_sugestii(conn, _cur_chips, _nom_err)
return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request,
"import_id": import_id,
"row_index": row_index,
"csrf_token": get_csrf_token(request),
"vin": str(form.get("vin") or ""),
"stare_css": "",
"stare_eticheta": "",
"form_nr": str(form.get("nr_inmatriculare") or ""),
"form_vin": str(form.get("vin") or ""),
"form_data": str(form.get("data_prestatie") or ""),
"form_odo_final": str(form.get("odometru_final") or ""),
"form_odo_initial": str(form.get("odometru_initial") or ""),
"err_map": {},
"fix_map": {},
"vin_context": str(form.get("vin") or ""),
"btn_label": "Salveaza",
"message": chips_parse_error,
"chips_error": chips_parse_error,
"prestatii_chips": _cur_chips,
"has_r_odo": _has_r_odo_chips(_cur_chips),
"obs_val": str(form.get("obs") or "").strip(),
"nomenclator_rar": _nom_err,
"form_chips_url": "/form-chips",
"chips_target_index": _cur_target_idx,
"sugestii_tinta": _cur_sugestii,
})
# Invariant ORA-12899: orice cod trimis din form se valideaza fata de # Invariant ORA-12899: orice cod trimis din form se valideaza fata de
# nomenclator INAINTE de persistare (RAR accepta NUMAI coduri valide). # nomenclator INAINTE de persistare (RAR accepta NUMAI coduri valide).
if chips_state is not None: if chips_state is not None:
@@ -3936,6 +4203,7 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
) )
if necunoscute: if necunoscute:
_nom = load_nomenclator(conn) _nom = load_nomenclator(conn)
_nec_target_idx, _nec_sugestii = _chips_target_si_sugestii(conn, chips_state, _nom)
return templates.TemplateResponse("_editare_preview_modal.html", { return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request, "request": request,
"import_id": import_id, "import_id": import_id,
@@ -3960,6 +4228,8 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
"obs_val": str(form.get("obs") or "").strip(), "obs_val": str(form.get("obs") or "").strip(),
"nomenclator_rar": _nom, "nomenclator_rar": _nom,
"form_chips_url": "/form-chips", "form_chips_url": "/form-chips",
"chips_target_index": _nec_target_idx,
"sugestii_tinta": _nec_sugestii,
}) })
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error # Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
@@ -3992,11 +4262,13 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
"cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(), "cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(),
"cod_op_service": (p.get("cod_op_service") or "").strip(), "cod_op_service": (p.get("cod_op_service") or "").strip(),
"denumire": (p.get("denumire") or "").strip(), "denumire": (p.get("denumire") or "").strip(),
"exclus": 1 if p.get("exclus") else 0,
} }
for p in (res.get("prestatii") or []) for p in (res.get("prestatii") or [])
if isinstance(p, dict) if isinstance(p, dict)
] ]
_err_nomenclator = load_nomenclator(conn) _err_nomenclator = load_nomenclator(conn)
_err_target_idx, _err_sugestii = _chips_target_si_sugestii(conn, _err_chips, _err_nomenclator)
return templates.TemplateResponse("_editare_preview_modal.html", { return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request, "request": request,
"import_id": import_id, "import_id": import_id,
@@ -4021,6 +4293,8 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
"obs_val": str(form.get("obs") or res.get("obs") or "").strip(), "obs_val": str(form.get("obs") or res.get("obs") or "").strip(),
"nomenclator_rar": _err_nomenclator, "nomenclator_rar": _err_nomenclator,
"form_chips_url": "/form-chips", "form_chips_url": "/form-chips",
"chips_target_index": _err_target_idx,
"sugestii_tinta": _err_sugestii,
}) })
# Succes: reincarca preview-ul complet + toast + inchide modal (vezi helper). # Succes: reincarca preview-ul complet + toast + inchide modal (vezi helper).
@@ -4532,15 +4806,18 @@ async def web_confirma_import(
# Rezolva prestatii # Rezolva prestatii
prestatii = mapped.get("prestatii") or [] prestatii = mapped.get("prestatii") or []
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules, excluded_ops) resolved_p, _ = resolve_prestatii(prestatii, mapping_ops, valid_codes, text_rules, excluded_ops)
# Prestatiile excluse NU pleaca la RAR (ca in preview); rand fara nicio # Prestatiile excluse NU pleaca la RAR si nu intra in cheia de
# prestatie declarabila = 'excluded' in preview -> defensiv skip. # idempotenta, dar RAMAN in payload_json persistat (round-trip in
resolved_p, _excluse_p = split_prestatii_excluse(resolved_p) # editor) — ingustarea la declarabile se face doar local, pentru
if not resolved_p: # canon/cheie, si la worker, la momentul trimiterii.
declarabile_p, _excluse_p = split_prestatii_excluse(resolved_p)
# rand fara nicio prestatie declarabila = 'excluded' in preview -> defensiv skip.
if not declarabile_p:
continue continue
mapped["prestatii"] = resolved_p mapped["prestatii"] = resolved_p
# Canonicalizare (cheia identica cu preview: canonicalize_row + build_key) # Canonicalizare (cheia identica cu preview: canonicalize_row + build_key)
canon = canonicalize_row(mapped) canon = canonicalize_row({**mapped, "prestatii": declarabile_p})
mapped.update({ mapped.update({
"vin": canon["vin"], "vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"], "nr_inmatriculare": canon["nr_inmatriculare"],
@@ -4556,7 +4833,7 @@ async def web_confirma_import(
"odometru_final": mapped.get("odometru_final"), "odometru_final": mapped.get("odometru_final"),
"prestatii": [ "prestatii": [
str(p.get("cod_prestatie") or p.get("cod_op_service") or "") str(p.get("cod_prestatie") or p.get("cod_op_service") or "")
for p in resolved_p for p in declarabile_p
], ],
}, sort_keys=True, ensure_ascii=False)) }, sort_keys=True, ensure_ascii=False))

View File

@@ -1,233 +1,175 @@
{# {#
_chips_prestatii.html — sectiunea de prestatii chips (E4, server-driven via /form-chips). _chips_prestatii.html — control unificat chips prestatii.
Re-randata de endpoint-ul /form-chips la fiecare add/remove de chip. UN singur container `.chips`: chips cod (legate de operatie sau libere), chips
Inclusa si din _form_editare.html pentru randarea initiala. warning pentru operatii nemapate, chip `Nedeclarat` pentru itemi exclusi, si
selectul unic `chips_pick` la final. Fara `.op-row`, fara butoane Adauga/+ —
selectul adauga instant la `change` (POST /form-chips, chips_action=add).
Starea chip-urilor traieste in input-uri hidden din form (NU in DB mid-edit). Diagrama starii per item (chips_state.items[i]):
Fiecare operatie are un picker propriu cand e nemapata (E4 binding op<->cod). cod="" & exclus=0 -> chip warning (warn OP-X . fara cod), click = tinta
Reveal odometru initial semnalat prin data-has-r-odo="true" si chip-warn pe R-ODO/I-ODO. cod="" & exclus=1 -> chip Nedeclarat (line-through), x -> readuce warning
cod!="" (op="" sau op!="") -> chip cod (eticheta COD, tooltip = operatia sursa
sau "cod adaugat manual")
Stare persistata intr-UN SINGUR hidden `chips_state` (JSON versionat)
+ hidden `chips_target_index` (tinta curenta; comutata CLIENT-SIDE la click pe
un chip warning, fara round-trip — indexul pleaca la server abia la urmatorul
add) + hidden `chips_submission_id` (ecouat ca sa "salveaza ca regula" ramana
disponibil dupa re-randari via /form-chips).
Context vars (toate cu defaults): Context vars (toate cu defaults):
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire} prestatii_chips — list of {cod_prestatie, cod_op_service, denumire, exclus}
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru picker nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru select
has_r_odo — True daca orice chip e R-ODO sau I-ODO (server-computed) has_r_odo — True daca vreun chip e R-ODO/I-ODO (server-computed)
form_chips_url — URL pentru HTMX; default '/form-chips' chips_added_index — index-ul chip-ului tocmai adaugat (emfaza chip-nou)
chips_section_id — ID div (default 'chips-section') chips_target_index — index-ul operatiei tinta curente (sau none)
csrf_token — CSRF (trecut prin hx-include din form parinte) chips_submission_id — id submission cand chips sunt in modalul de detaliu
(activeaza butonul "salveaza ca regula")
form_chips_url — URL pentru HTMX; default '/form-chips'
chips_section_id — ID div (default 'chips-section')
csrf_token — CSRF (trecut prin hx-include din form parinte)
#} #}
{% set _chips_url = form_chips_url or '/form-chips' %} {% set _chips_url = form_chips_url or '/form-chips' %}
{% set _sec_id = chips_section_id or 'chips-section' %} {% set _sec_id = chips_section_id or 'chips-section' %}
{% set _chips = prestatii_chips or [] %} {% set _chips = prestatii_chips or [] %}
{% set _has_ops = _chips | selectattr('cod_op_service') | list | length > 0 %} {% set _sub_id = chips_submission_id if (chips_submission_id is defined and chips_submission_id) else '' %}
{# chips_submission_id e setat din _detaliu_ctx cand chips sunt randate in modalul de detaliu. {% set _target_idx = chips_target_index if (chips_target_index is defined and chips_target_index is not none) else none %}
Lipseste cand _chips_prestatii.html e rerandat via /form-chips (stateless, fara submission). #} {% set _target_op = (_chips[_target_idx].cod_op_service or '') if (_target_idx is not none and _target_idx < _chips | length) else '' %}
{% set _sub_id = chips_submission_id if chips_submission_id is defined else none %}
<div id="{{ _sec_id }}" data-has-r-odo="{{ 'true' if has_r_odo else 'false' }}" <div id="{{ _sec_id }}" class="chips-editor" data-has-r-odo="{{ 'true' if has_r_odo else 'false' }}"
aria-live="polite" aria-label="Prestatii cod RAR"> aria-live="polite" aria-label="Prestatii cod RAR">
{# ===== Input-uri hidden pentru starea curenta a chip-urilor ===== {# ===== Stare: UN SINGUR hidden JSON versionat + tinta + submission id ===== #}
TOATE itemele emit 3 hidden inputs (cod poate fi "" pentru unmapped). <input type="hidden" name="chips_state" value='{{ chips_state_json(_chips) }}'>
Paralele index-by-index: cod_prestatie[i], chip_op_service[i], chip_denumire[i]. <input type="hidden" name="chips_target_index" id="chips-target-index"
Filtrate la submit de post_corectie_trimitere (coduri goale = neschimbate). #} value="{{ _target_idx if _target_idx is not none else '' }}">
{% for chip in _chips %} <input type="hidden" name="chips_submission_id" value="{{ _sub_id }}">
<input type="hidden" name="cod_prestatie" value="{{ chip.cod_prestatie or '' }}">
<input type="hidden" name="chip_op_service" value="{{ chip.cod_op_service or '' }}">
<input type="hidden" name="chip_denumire" value="{{ chip.denumire or '' }}">
{% endfor %}
<div class="camp-slim" style="margin-bottom:8px;"> <div class="camp-slim" style="margin-bottom:8px;">
<label>Prestatii — cod RAR pe fiecare operatie</label> <label>Prestatii — cod RAR</label>
{% if _has_ops %} <div class="chips" role="group" aria-label="Prestatii cod RAR">
{# ===== Mod operatii: UN picker PE operatie (E4 binding) ===== #} {% for chip in _chips %}
{% for chip in _chips %} {% set _idx = loop.index0 %}
{% if chip.cod_op_service %} {% set _is_target = _target_idx is not none and _idx == _target_idx %}
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
{% set _nemapat = not chip.cod_prestatie %}
{% set _e_nou = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %}
<div class="op-row {% if _nemapat %}op-row-warn{% endif %}" style="margin-bottom:6px;">
<span class="op-row-name">
{{ chip.cod_op_service }}
{% if chip.denumire and chip.denumire != chip.cod_op_service %}
<span class="muted" style="font-weight:400;font-size:11px;"> — {{ chip.denumire }}</span>
{% endif %}
{% if _nemapat %}
<span style="color:var(--warn);font-size:10px;font-weight:400;"> · lipsa cod</span>
{% endif %}
</span>
<span style="display:flex;align-items:center;gap:8px;">
{% if chip.cod_prestatie %}
{# ===== Operatie mapata: chip cu × ===== #}
<span class="chip {% if _is_warn %}chip-warn{% endif %} {% if _e_nou %}chip-nou{% endif %}"
aria-label="Prestatie {{ chip.cod_prestatie }} adaugata pentru {{ chip.cod_op_service }}">
{{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove","chips_remove_index":{{ loop.index0 }}}'
aria-label="Sterge codul {{ chip.cod_prestatie }} pentru {{ chip.cod_op_service }}">
&times;
</button>
</span>
{# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
(in modalul de detaliu, nu la re-randarea stateless via /form-chips).
Reuse EXACT save_mapping + reresolve_account via endpoint dedicat.
hx-include="closest form" propaga csrf_token din form-ul parinte. #}
<span id="save-rule-slot-{{ loop.index0 }}" class="save-rule-slot">
{% if _sub_id and chip.cod_op_service and chip.cod_prestatie %}
<button type="button"
style="font-size:10px;color:var(--muted);background:none;border:none;cursor:pointer;text-decoration:underline;padding:0;margin-left:4px;line-height:1;"
hx-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-vals='{"salveaza_op":{{ chip.cod_op_service | tojson }},"salveaza_cod":{{ chip.cod_prestatie | tojson }}}'
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
salveaza ca regula
</button>
{% endif %}
</span>
{% else %}
{# ===== Operatie nemapata: picker galben cu "alege cod RAR" ===== #}
<select name="chips_add_cod_{{ loop.index0 }}"
id="picker-op-{{ loop.index0 }}"
aria-label="Alege cod RAR pentru {{ chip.cod_op_service }}"
style="min-width:160px;font-size:11px;height:26px;">
<option value="">— alege cod RAR —</option>
{% for n in (nomenclator_rar or []) %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add","chips_add_op_index":{{ loop.index0 }}}'
aria-label="Adauga cod RAR pentru {{ chip.cod_op_service }}">
+ Adauga
</button>
{% endif %}
</span>
</div>
{% endif %}
{% endfor %}
{# ===== Chips extra + picker '+ Adauga alta operatie / cod RAR' in mod operatii ===== #} {% if chip.cod_op_service and not chip.cod_prestatie and not chip.exclus %}
{# Chips extra: cod_op_service gol, cod_prestatie setat — afisate flat cu × (reuse remove_flat). {# ===== Chip warning: operatie nemapataclick comuta tinta client-side ===== #}
Containerul .chips se randeaza DOAR cand exista chips extra — altfel ramanea {% set _label = chip.cod_op_service %}
un chenar gol nefinisat sub randurile de operatie. #} {% set _label_scurt = (_label[:18] ~ '…') if _label | length > 18 else _label %}
{% set _extra_chips = _chips | rejectattr('cod_op_service') | selectattr('cod_prestatie') | list %} {% set _title = chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '') %}
{% if _extra_chips %} <button type="button"
<div class="chips" role="group" aria-label="Coduri RAR suplimentare" style="margin-top:4px;"> class="chip chip-op-warn {% if _is_target %}is-target{% endif %}"
{% for chip in _extra_chips %} data-chip-idx="{{ _idx }}"
{% set _is_warn_extra = chip.cod_prestatie in ('R-ODO', 'I-ODO') %} data-op-nume="{{ chip.cod_op_service }}"
{% set _e_nou_extra = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %} title="{{ _title }}"
<span class="chip {% if _is_warn_extra %}chip-warn{% endif %} {% if _e_nou_extra %}chip-nou{% endif %}" onclick="window.chipsSetTarget && window.chipsSetTarget(this, {{ _idx }})"
aria-label="Cod RAR suplimentar {{ chip.cod_prestatie }}"> aria-pressed="{{ 'true' if _is_target else 'false' }}"
{{ chip.cod_prestatie }} aria-label="Operatia {{ chip.cod_op_service }} nu are cod RAR{% if _is_target %} (tinta curenta){% endif %}">
&#9888; {{ _label_scurt }} &middot; fara cod
</button>
{% elif chip.exclus %}
{# ===== Chip Nedeclarat: vizibil (line-through), x readuce warning-ul ===== #}
<span class="chip chip-nedeclarat"
title="{{ chip.cod_op_service or 'Cod liber' }} — nu se declara la RAR"
aria-label="{{ chip.cod_op_service or 'Operatia' }} nu se declara la RAR">
Nedeclarat
<button type="button" class="chip-del" <button type="button" class="chip-del"
hx-post="{{ _chips_url }}" hx-post="{{ _chips_url }}"
hx-disabled-elt="this" hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form" hx-include="closest form"
hx-target="#{{ _sec_id }}" hx-target="#{{ _sec_id }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}' hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}'
aria-label="Sterge codul suplimentar {{ chip.cod_prestatie }}">&times;</button> aria-label="Anuleaza Nedeclarat pentru {{ chip.cod_op_service or 'cod liber' }} (readuce avertismentul)">
&times;
</button>
</span> </span>
{% elif chip.cod_prestatie %}
{# ===== Chip cod: legat de operatie sau liber ===== #}
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
{% set _e_nou = (chips_added_index is defined) and chips_added_index is not none and _idx == chips_added_index %}
{% set _tooltip = (chip.cod_op_service ~ (' — ' ~ chip.denumire if chip.denumire and chip.denumire != chip.cod_op_service else '')) if chip.cod_op_service else 'cod adaugat manual' %}
<span class="chip {% if _is_warn %}chip-warn{% endif %} {% if _e_nou %}chip-nou{% endif %}"
title="{{ _tooltip }}"
aria-label="Prestatie {{ chip.cod_prestatie }}{% if chip.cod_op_service %} pentru {{ chip.cod_op_service }}{% endif %}">
<span class="chip-cod-label">COD</span> {{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove","chips_remove_index":{{ _idx }}}'
aria-label="Sterge codul {{ chip.cod_prestatie }}{% if chip.cod_op_service %} pentru {{ chip.cod_op_service }}{% endif %}">
&times;
</button>
</span>
{# "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
(modalul de detaliu, nu la re-randarea stateless via /form-chips fara ecou). #}
{% if _sub_id and chip.cod_op_service and chip.cod_prestatie %}
<span id="save-rule-slot-{{ _idx }}" class="save-rule-slot">
<button type="button"
style="font-size:10px;color:var(--muted);background:none;border:none;cursor:pointer;text-decoration:underline;padding:0;margin-left:-2px;line-height:1;"
hx-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-include="closest form"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-vals='{"salveaza_op":{{ chip.cod_op_service | tojson }},"salveaza_cod":{{ chip.cod_prestatie | tojson }}}'
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
salveaza ca regula
</button>
</span>
{% endif %}
{% endif %}
{% endfor %} {% endfor %}
</div>
{% endif %} {# ===== Selectul unic: adauga instant la schimbare, placeholder numeste tinta ===== #}
{% if nomenclator_rar %} {% if nomenclator_rar %}
<span style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;"> <select name="chips_pick"
<select name="chips_add_cod_flat" id="chips-picker"
aria-label="Adauga cod RAR suplimentar" class="chips-picker"
style="min-width:160px;font-size:11px;height:26px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);"> hx-trigger="change"
<option value="">+ Adauga alta operatie / cod RAR</option> hx-post="{{ _chips_url }}"
{% for n in nomenclator_rar %} hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-include="closest form"
hx-disabled-elt="this"
hx-indicator="#{{ _sec_id }}"
hx-vals='{"chips_action":"add"}'
aria-label="{% if _target_op %}Alege cod RAR pentru {{ _target_op }}{% else %}Adauga cod RAR{% endif %}">
<option value="">{% if _target_op %}Cod pentru {{ _target_op }} — alege{% else %}— adauga cod RAR —{% endif %}</option>
{% if _target_idx is not none %}
{% set _sugestii = sugestii_tinta if (sugestii_tinta is defined and sugestii_tinta) else [] %}
{% if _sugestii %}
<optgroup label="Sugestii">
{% for s in _sugestii %}
<option value="{{ s.cod_prestatie }}">{{ s.cod_prestatie }} — {{ s.nume_prestatie }}</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="{{ EXCLUDE_SENTINEL }}">Nu se declara la RAR (exclude operatia)</option>
{% endif %}
{% for n in (nomenclator_rar or []) %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option> <option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %} {% endfor %}
</select> </select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add_extra"}'
aria-label="Adauga cod RAR suplimentar la trimitere">
+
</button>
</span>
{% else %}
{# T-D1/T-E5 (5.16): empty state in mod operatii cand nomenclatorul lipseste #}
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;margin-top:4px;">
Nomenclator indisponibil — adaugarea de coduri suplimentare nu e posibila.
</div>
{% endif %}
{% else %}
{# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #}
<div class="chips" role="group" aria-label="Coduri RAR selectate">
{% for chip in _chips %}
{% if chip.cod_prestatie %}
{% set _is_warn_flat = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
{% set _e_nou_flat = (chips_added_cod is defined) and chips_added_cod and chip.cod_prestatie == chips_added_cod %}
<span class="chip {% if _is_warn_flat %}chip-warn{% endif %} {% if _e_nou_flat %}chip-nou{% endif %}"
aria-label="Prestatie {{ chip.cod_prestatie }}">
{{ chip.cod_prestatie }}
<button type="button" class="chip-del"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
aria-label="Sterge codul {{ chip.cod_prestatie }}">&times;</button>
</span>
{% endif %}
{% endfor %}
{# Picker adaugare cod nou in mod plat #}
{% if nomenclator_rar %}
<span style="display:inline-flex;align-items:center;gap:4px;">
<select name="chips_add_cod_flat"
aria-label="Adauga cod RAR nou"
style="font-size:11px;height:22px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
<option value="">+ cod</option>
{% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
{% endfor %}
</select>
<button type="button"
class="add-code"
hx-post="{{ _chips_url }}"
hx-disabled-elt="this"
hx-include="closest form"
hx-target="#{{ _sec_id }}"
hx-swap="outerHTML"
hx-vals='{"chips_action":"add_flat"}'
aria-label="Adauga cod RAR selectat in lista">
+
</button>
</span>
{% else %} {% else %}
{# T-D1/T-E5 (5.16): empty state in mod plat cand nomenclatorul lipseste #} {# T-D1/T-E5 (5.16): empty state cand nomenclatorul lipseste #}
<div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;"> <div class="chips-nom-gol" style="font-size:11px;color:var(--warn);padding:4px 0;">
Nomenclator indisponibil — nu se pot adauga coduri RAR momentan. Nomenclator indisponibil — adaugarea de coduri RAR nu e posibila.
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{# Rezultatul ultimei adaugari, in ambele moduri: refuz (rol=alert) sau confirmare. {# Rezultatul ultimei adaugari: refuz (rol=alert) sau confirmare.
Clasa chips-extra-error pastrata pe eroare (selector stabil pentru teste/QA). #} Clasa chips-extra-error pastrata pe eroare (selector stabil pentru teste/QA). #}
{% if chips_error is defined and chips_error %} {% if chips_error is defined and chips_error %}
<div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert"> <div class="chips-extra-error" style="font-size:11px;color:var(--err);padding:2px 0;" role="alert">
@@ -238,12 +180,40 @@
{{ chips_ok }} {{ chips_ok }}
</div> </div>
{% endif %} {% endif %}
{# Hint discret fara chips (debut) #}
{% if not _chips %}
<div style="font-size:10px;color:var(--muted);padding:4px 0;">
Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare.
</div>
{% endif %}
</div> </div>
</div> </div>
<script>
(function () {
if (window.__chipsUnifiedBound) { return; }
window.__chipsUnifiedBound = true;
// Click pe chip warning -> comuta tinta CLIENT-SIDE (fara round-trip). Click
// pe tinta activa = no-op (nu se poate "deselecta" tinta cat exista warnings).
window.chipsSetTarget = function (btn, idx) {
var hidden = document.getElementById('chips-target-index');
if (!hidden) { return; }
if (String(idx) === hidden.value) { return; }
hidden.value = String(idx);
document.querySelectorAll('.chip-op-warn.is-target').forEach(function (el) {
el.classList.remove('is-target');
el.setAttribute('aria-pressed', 'false');
});
btn.classList.add('is-target');
btn.setAttribute('aria-pressed', 'true');
var picker = document.getElementById('chips-picker');
if (picker && picker.options.length) {
picker.options[0].textContent = 'Cod pentru ' + (btn.dataset.opNume || '') + ' — alege';
}
};
// Focus restaurat pe select dupa fiecare swap outerHTML al sectiunii.
document.body.addEventListener('htmx:afterSettle', function (evt) {
var t = evt.target;
if (t && t.classList && t.classList.contains('chips-editor')) {
var el = document.getElementById('chips-picker');
if (el) { el.focus(); }
}
});
})();
</script>

View File

@@ -828,19 +828,22 @@
.chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); } .chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); }
/* Chip abia adaugat via /form-chips — confirmare vizibila a actiunii "+" */ /* Chip abia adaugat via /form-chips — confirmare vizibila a actiunii "+" */
.chip-nou { outline:2px solid color-mix(in srgb, var(--ok) 45%, transparent); outline-offset:1px; } .chip-nou { outline:2px solid color-mix(in srgb, var(--ok) 45%, transparent); outline-offset:1px; }
/* .add-code — buton dashed pentru adaugare cod in chipbox */ /* Eticheta mono "COD" pe chip-ul de cod */
.add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent; .chip-cod-label { font-size:9px; font-weight:800; opacity:.65; letter-spacing:.03em; }
border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line)); /* Chip warning operatie nemapata: dashed implicit; tinta activa = solid + inel —
border-radius:5px; color:var(--accent); font:500 10px var(--font-ui); cursor:pointer; } singurul tratament "tare" din rand. */
.add-code:hover, .add-code:focus-visible { border-style:solid; } .chip-op-warn { background:color-mix(in srgb, var(--warn) 16%, transparent); color:var(--warn);
/* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */ border:1px dashed var(--warn); cursor:pointer; font-weight:600; }
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px; .chip-op-warn.is-target { border-style:solid; outline:2px solid color-mix(in srgb, var(--warn) 40%, transparent);
padding:8px 10px; border:1px solid var(--line); border-radius:6px; outline-offset:1px; }
background:var(--card2); margin-bottom:8px; } .chip-op-warn:focus-visible { outline:2px solid var(--warn); outline-offset:1px; }
/* Nume operatie emfatic (T-9 5.16): proeminent (bold) ca in mockup — e ancora /* Chip Nedeclarat: vizibil (line-through), NU mut/invizibil — cea mai riscanta
vizuala a randului de mapare op<->cod. */ actiune legala (exclude de la declararea RAR) nu poate fi cea mai discreta. */
.op-row-name { font-size:var(--fs-sm); font-weight:700; color:var(--ink); } .chip-nedeclarat { background:var(--card2); color:var(--muted); border:1px dashed var(--line);
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); } text-decoration:line-through; }
/* Sectiunea chips inerta pe durata unui request /form-chips in zbor:
click pe x/warning mid-flight nu se pierde, doar se ignora. */
.chips-editor.htmx-request { pointer-events:none; opacity:.7; }
/* Mobil: fara header, randul revine la 2 linii (placuta / cod·operatie·data), /* Mobil: fara header, randul revine la 2 linii (placuta / cod·operatie·data),
cu actiune + litera mediu + bulina status in dreapta. Grid-areas peste cu actiune + litera mediu + bulina status in dreapta. Grid-areas peste
aceleasi celule; .c-meta redevine linie flex cu separatoare "·". */ aceleasi celule; .c-meta redevine linie flex cu separatoare "·". */

View File

@@ -37,7 +37,7 @@ from ..config import Settings, get_settings, load_test_credentials
from ..crypto import decrypt_creds from ..crypto import decrypt_creds
from ..db import get_connection, init_db, read_heartbeat, write_heartbeat from ..db import get_connection, init_db, read_heartbeat, write_heartbeat
from ..observ import log_event, set_source from ..observ import log_event, set_source
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator from ..mapping import DEFAULT_ACCOUNT_ID, split_prestatii_excluse, upsert_nomenclator
from ..payload import build_rar_payload from ..payload import build_rar_payload
from ..reconcile import match_finalizata from ..reconcile import match_finalizata
from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env
@@ -258,7 +258,10 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d
sid = claimed["id"] sid = claimed["id"]
account_id = claimed.get("account_id") account_id = claimed.get("account_id")
content = claimed["content"] content = claimed["content"]
payload = build_rar_payload(content) # Prestatiile excluse de la declarare raman in content (persistat neatins) dar
# nu pleaca niciodata la RAR: ingustare la declarabile chiar la momentul trimiterii.
declarabile, _excluse = split_prestatii_excluse(content.get("prestatii"))
payload = build_rar_payload({**content, "prestatii": declarabile})
try: try:
data = rar.post_prezentare(token, payload) data = rar.post_prezentare(token, payload)
mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id")) mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id"))

View File

@@ -48,7 +48,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
**Ultima actualizare**: 2026-07-07**5.22 UI COMPACT TRIMITERI + FIX BUTON "+" EDITOR — LIVRAT** (PRD: [prd-5.22](prd/prd-5.22-ui-compact-trimiteri-reguli-fix-chips.md); mockup aprobat de utilizator inainte de executie). Trimiteri pe desktop = tabel grid cu header (Vehicul/Cod RAR/Operatie/Data/Status/Mediu), un rand per trimitere: status = bulina colorata cu tooltip (`.dot-stare`), mediu = litera `P` (fill --err, risc L.142) / `T` (contur), multi-cod = primul cod + badge `+N` cu toate codurile in title (`payload_view.coduri_rar` nou), sub-linie de eroare = textul BRUT (mesaj RAR, ex. ORA-12899) mono trunchiat doar pe error/needs_data (`labels.eroare_bruta` nou; needs_mapping exclus — "nemapat" e in coloana Cod RAR). Mobil (<=767px): fara header, rand pe 2 linii prin grid-areas, T/P + bulina in dreapta. Actiunile bulk ("Aplica cod"/"Sterge") mutate intr-o **bara contextuala** vizibila doar la selectie, cu contor si texte explicite ("Aplica acest cod la N randuri") + hint; JS delegat + sync la `htmx:afterSwap`. Mapari: descrierea sectiunii "Reguli automate (text)" inlocuita cu un exemplu concret formatat. **Fix bug "+" editor prestatii** — cauza-radacina gasita LA VERIFY E2E (nu in analiza statica): `hx-disabled-elt="find button"` de pe form-urile parinte se mosteneste pe butoanele chips → `find button` relativ la "+" = null → TypeError intern htmx 1.9.12 INAINTE de request (nicio cerere, nicio eroare) → fix `hx-disabled-elt="this"` explicit pe toate butoanele hx-post din `_chips_prestatii.html`; separat, no-op-urile silentioase server-side din `post_form_chips` (cod invalid pe add/add_flat, duplicat pe toate ramurile) au acum mesaje explicite (`chips_error`) si confirmare la succes (chip `.chip-nou` evidentiat + "X adaugat."). Verify E2E in browser pe desktop 1280 + mobil 390 (screenshots), bulk-fix aplicat live. Suita completa: **1622 passed, 1 skipped** (+26 teste noi). Observatii deferate: `bulk_message` e suprascris imediat de reload-ul `trimiteriChanged` (pre-existent); operatia trunchiata agresiv pe mobil pe randurile cu buton "Trimite". **Ultima actualizare**: 2026-07-08**5.23 EDITOR PRESTATII UNIFICAT IN MODAL — LIVRAT** (PRD: [prd-5.23](prd/prd-5.23-editor-prestatii-unificat.md); executie echipa agenti Sonnet TDD in valuri, lead orchestreaza). Cele 3 controale suprapuse pentru asocierea codurilor RAR (rand-per-operatie + container chips libere + select "+ Adauga") inlocuite cu **UN singur rand de chips + UN singur select** care adauga instant la `change` (fara butoane "Adauga"/"+"). Starea chips = UN hidden `chips_state` JSON versionat (`{"v":1,"items":[{cod,op,den,exclus}]}`, `_chips_state_from_form` unic + fallback tranzitoriu pe listele vechi), `post_form_chips` redus la 2 actiuni (`add`/`remove`, validare semantica tinta). Selectul primeste optgroup "Sugestii" (fuzzy/k-NN pentru operatia tinta, aceleasi surse ca preview-ul, degradare gratioasa non-blocanta pe motor rece) + optiune "Nu se declara la RAR" (`__NEDECLARAT__` → chip `Nedeclarat` line-through, × readuce warning). Tinta implicita (prima operatie nemapata) evidentiata + placeholder-ul o numeste ("Cod pentru OP-X — alege"), comutare tinta client-side fara round-trip; helper partajat `_chips_target_si_sugestii` pe TOATE caile de randare (initiala + /form-chips + erori). `exclus` persistat prin salvare (round-trip complet): treapta noua de precedenta in `resolve_prestatii` (item-exclus explicit domina, `it.pop` inlocuit, test de regresie blocant pe precedenta), itemii exclusi RAMAN in `payload_json` adnotati. **CLOSE `/code-review high` (8 unghiuri) — 1 BLOCKER de siguranta prins + reparat**: itemii exclusi (cod null) ajungeau la RAR via worker (`build_rar_payload` fara filtru) → risc ORA-12899/FINALIZATA partial ireversibil; fix la chokepoint unic (worker `split_prestatii_excluse` inainte de `build_rar_payload`, acopera toate canalele) + filtru defensiv + teste care dovedesc; plus consistenta round-trip la import (`web_confirma_import`/`commit_import` pastreaza full, cheie=declarabile) + bug latent idempotenta preview↔commit reparat + curatare comentarii (conventie CLAUDE.md). VERIFY context curat PASS (E2E browser Playwright ambele fluxuri: import preview + Trimiteri corectie; regresia de aur RAR live NEPROBAT — cont QA fara creds). Suita completa: **1680 passed, 1 skipped** (+45 teste noi fata de 1635). Deferate in TODOS: cale legacy `/trimitere/{id}/mapeaza` (formular per-operatie separat), autocomplete in select, bulk-apply mapare pe randuri identice. Backend worker/masina stari NEATINS (exceptie: split la trimitere); fara migrare schema (`exclus` in `payload_json`).
> 2026-07-07 — **5.22 UI COMPACT TRIMITERI + FIX BUTON "+" EDITOR — LIVRAT** (PRD: [prd-5.22](prd/prd-5.22-ui-compact-trimiteri-reguli-fix-chips.md); mockup aprobat de utilizator inainte de executie). Trimiteri pe desktop = tabel grid cu header (Vehicul/Cod RAR/Operatie/Data/Status/Mediu), un rand per trimitere: status = bulina colorata cu tooltip (`.dot-stare`), mediu = litera `P` (fill --err, risc L.142) / `T` (contur), multi-cod = primul cod + badge `+N` cu toate codurile in title (`payload_view.coduri_rar` nou), sub-linie de eroare = textul BRUT (mesaj RAR, ex. ORA-12899) mono trunchiat doar pe error/needs_data (`labels.eroare_bruta` nou; needs_mapping exclus — "nemapat" e in coloana Cod RAR). Mobil (<=767px): fara header, rand pe 2 linii prin grid-areas, T/P + bulina in dreapta. Actiunile bulk ("Aplica cod"/"Sterge") mutate intr-o **bara contextuala** vizibila doar la selectie, cu contor si texte explicite ("Aplica acest cod la N randuri") + hint; JS delegat + sync la `htmx:afterSwap`. Mapari: descrierea sectiunii "Reguli automate (text)" inlocuita cu un exemplu concret formatat. **Fix bug "+" editor prestatii** — cauza-radacina gasita LA VERIFY E2E (nu in analiza statica): `hx-disabled-elt="find button"` de pe form-urile parinte se mosteneste pe butoanele chips → `find button` relativ la "+" = null → TypeError intern htmx 1.9.12 INAINTE de request (nicio cerere, nicio eroare) → fix `hx-disabled-elt="this"` explicit pe toate butoanele hx-post din `_chips_prestatii.html`; separat, no-op-urile silentioase server-side din `post_form_chips` (cod invalid pe add/add_flat, duplicat pe toate ramurile) au acum mesaje explicite (`chips_error`) si confirmare la succes (chip `.chip-nou` evidentiat + "X adaugat."). Verify E2E in browser pe desktop 1280 + mobil 390 (screenshots), bulk-fix aplicat live. Suita completa: **1622 passed, 1 skipped** (+26 teste noi). Observatii deferate: `bulk_message` e suprascris imediat de reload-ul `trimiteriChanged` (pre-existent); operatia trunchiata agresiv pe mobil pe randurile cu buton "Trimite".
> 2026-07-06 — **5.21 CACHE PERSISTENT EMBEDDINGS IN SQLITE — LIVRAT** (PRD: [prd-5.21](prd/prd-5.21-embedding-cache-sqlite.md); executie echipa de agenti Sonnet: cache-core T1+T2, wiring T3+T5, tests T4+T6, reviewer adversarial). Vectorii corpusului k-NN (17.181 exemple SILVER) persista in tabela noua `embedding_cache` (PK `(model, text_hash)`, blob float32 LE 384, `array('f')` end-to-end); la warmup se vectorizeaza DOAR miss-urile (hash sha256 pe `denumire_normalizata` din lista FILTRATA, identica cu ce intra in `index_corpus`), restarturile cu corpus neschimbat = `embed=0`. Modul nou `app/embedding_cache.py` (fara dependinte noi): `sync_corpus_vectors(conn, model, texts, embed_fn)` hash->load->embed(miss)->save; scrieri/stergeri in chunk-uri de 500 cu `BEGIN`/`COMMIT` EXPLICIT (conexiunile sunt autocommit — `conn.commit()` e no-op); validare dimensiune la scriere SI citire (blob corupt = miss re-vectorizat); purjare orfane + modele vechi DOAR dupa indexare reusita; toate erorile SQLite -> `log.warning` + fallback embed complet (degradare gratioasa, nimic propagat). `EmbeddingEngine.index_corpus(vectors=)` valideaza alinierea `len(vectors)==len(items)` + absenta None (mismatch -> fallback embed complet). Secventa protejata de `_embeddings_lock` (mapping.py); pe calea de request (`block=False`) acquire NE-blocant — daca warmup-ul tine lock-ul, return imediat (fix MAJOR din review: altfel primul request in fereastra de cold start ingheta ~1-2 min). Log observabilitate: `embeddings: warmup ok cache=N embed=M in Xs` (eticheta `corpus reindexat` pe calea de request). Semnatura corpusului (`_corpus_signature_silver`) = mecanism de decizie NESCHIMBAT; worker-ul nu atinge cache-ul. Review adversarial: 1 MAJOR + 2 MINOR + 1 NIT, toate reparate. Suita completa: **1596 passed, 1 skipped** (+31 teste noi: 15/15 cai din diagrama de acoperire, toate cu `AUTOPASS_EMBEDDINGS_ENABLED=1` explicit, backend mock). Crestere DB ~26-30MB acceptata; deferate in TODOS.md: CLI embcache, precompute la build, graceful reload, numpy la >50k randuri. **De verificat pe instanta reala (US-006)**: doua restarturi `./start.sh`, al doilea cu `embed=0` si sugestia "similaritate" in <30s. > 2026-07-06 — **5.21 CACHE PERSISTENT EMBEDDINGS IN SQLITE — LIVRAT** (PRD: [prd-5.21](prd/prd-5.21-embedding-cache-sqlite.md); executie echipa de agenti Sonnet: cache-core T1+T2, wiring T3+T5, tests T4+T6, reviewer adversarial). Vectorii corpusului k-NN (17.181 exemple SILVER) persista in tabela noua `embedding_cache` (PK `(model, text_hash)`, blob float32 LE 384, `array('f')` end-to-end); la warmup se vectorizeaza DOAR miss-urile (hash sha256 pe `denumire_normalizata` din lista FILTRATA, identica cu ce intra in `index_corpus`), restarturile cu corpus neschimbat = `embed=0`. Modul nou `app/embedding_cache.py` (fara dependinte noi): `sync_corpus_vectors(conn, model, texts, embed_fn)` hash->load->embed(miss)->save; scrieri/stergeri in chunk-uri de 500 cu `BEGIN`/`COMMIT` EXPLICIT (conexiunile sunt autocommit — `conn.commit()` e no-op); validare dimensiune la scriere SI citire (blob corupt = miss re-vectorizat); purjare orfane + modele vechi DOAR dupa indexare reusita; toate erorile SQLite -> `log.warning` + fallback embed complet (degradare gratioasa, nimic propagat). `EmbeddingEngine.index_corpus(vectors=)` valideaza alinierea `len(vectors)==len(items)` + absenta None (mismatch -> fallback embed complet). Secventa protejata de `_embeddings_lock` (mapping.py); pe calea de request (`block=False`) acquire NE-blocant — daca warmup-ul tine lock-ul, return imediat (fix MAJOR din review: altfel primul request in fereastra de cold start ingheta ~1-2 min). Log observabilitate: `embeddings: warmup ok cache=N embed=M in Xs` (eticheta `corpus reindexat` pe calea de request). Semnatura corpusului (`_corpus_signature_silver`) = mecanism de decizie NESCHIMBAT; worker-ul nu atinge cache-ul. Review adversarial: 1 MAJOR + 2 MINOR + 1 NIT, toate reparate. Suita completa: **1596 passed, 1 skipped** (+31 teste noi: 15/15 cai din diagrama de acoperire, toate cu `AUTOPASS_EMBEDDINGS_ENABLED=1` explicit, backend mock). Crestere DB ~26-30MB acceptata; deferate in TODOS.md: CLI embcache, precompute la build, graceful reload, numpy la >50k randuri. **De verificat pe instanta reala (US-006)**: doua restarturi `./start.sh`, al doilea cu `embed=0` si sugestia "similaritate" in <30s.

View File

@@ -0,0 +1,514 @@
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260707-194818.md -->
# PRD 5.23 — Editor prestatii unificat in modalul "Editare rand" (un select, un rand de chips)
**Stare**: inchis (2026-07-08; executie echipa agenti Sonnet TDD, VERIFY context curat PASS,
code-review CLOSE = 1 blocker de siguranta prins + reparat. Vezi Anexa /autoplan, GSTACK REVIEW
REPORT, Raport VERIFY; mockup final aprobat: select cu placeholder-tinta)
> Proces: `docs/ROADMAP.md` §5. Contract RAR: `docs/api-rar-contract.md`.
> Context vizual: screenshot `2026-07-07-003.jpg` (modal "Editare rand 4", badge "Cod RAR lipsa").
## 1. Obiectiv
Modalul "Editare rand" (import preview + corectie Trimiteri) are azi TREI controale pentru aceeasi
sarcina — asocierea codurilor RAR la prestatii: (1) rand per operatie cu select "— alege cod RAR —"
+ buton "Adauga", (2) container separat de chips pentru coduri libere (ex. `OE-1 ×`), (3) select
"+ Adauga alta operatie / cod RAR" cu buton "+". Utilizatorul nu stie care control face ce, iar
chips-urile apar in containere diferite, pe randuri diferite.
Livrabila: **un singur control** — un rand unic de chips (toate codurile, legate de operatie sau
libere) + **un singur select** care adauga chip instant la selectie (fara butoane "Adauga"/"+").
Selectul primeste sugestii fuzzy/k-NN pentru operatia nemapata si optiunea "Nu se declara la RAR".
## 2. Non-Goals (anti scope-creep)
- NU se schimba panoul de mapare (`_mapari.html`, `_preview_import.html`). EXCEPTIE confirmata in
review (eng, 2026-07-07): `resolve_prestatii` primeste O SINGURA treapta noua de precedenta —
`exclus=1` explicit pe item (din override/corectie) supravietuieste re-rezolvarii (azi
`it.pop("exclus", None)` la `mapping.py:298` il sterge, ceea ce face US-004 imposibil altfel).
Restul precedentei ramane neatins.
- NU se schimba contractul API `POST /v1/prezentari`. `override_json["prestatii"]` castiga campul
`exclus` pe item; itemii exclusi RAMAN in `payload_json` (adnotati), nu mai sunt eliminati la
salvare — `split_prestatii_excluse` ii scoate doar din payload-ul trimis la RAR.
- NU se schimba validarea codului fata de nomenclator la salvare (invariant ORA-12899) — ramane.
- NU se adauga cautare/autocomplete text in select (ramane `<select>` nativ; imbunatatire separata).
- NU se atinge butonul "salveaza ca regula" de la Trimiteri — ramane, doar mutat pe chip-ul nou.
## 3. Comportamentul controlului unificat (spec)
```
Prestatii — coduri RAR
┌───────────────────────────────────────────────────────────────┐
│ [⚠ OP-ITP · fara cod] [OE-1 ×] [— alege cod RAR — ▾] │
└───────────────────────────────────────────────────────────────┘
```
- **Un singur container** `.chips` cu toate elementele: chips-uri cod (× sterge), chips-uri warning
pentru operatii nemapate, si selectul unic la capat. Wrap natural doar la lipsa de spatiu —
niciodata containere/chenare separate pe zone.
- **Chip cod legat de operatie**: eticheta `COD`, titlu/tooltip cu denumirea operatiei sursa
(ex. `OE-8 — Inlocuire anvelope`). `×` scoate codul; daca operatia ramane fara cod, reapare
chip-ul warning al operatiei.
- **Chip cod liber** (fara operatie sursa): identic vizual, tooltip "cod adaugat manual".
- **Chip warning** (`⚠ OP-ITP · fara cod`): reprezinta operatia din import nemapata. Click pe el
il face **tinta activa** (evidentiat); implicit tinta = prima operatie nemapata.
- **Selectul unic**, `hx-trigger="change"``POST /form-chips` cu `chips_action=add`:
- daca exista tinta activa (operatie nemapata) → codul se leaga de acea operatie
(chip warning → chip cod); tinta trece automat la urmatoarea operatie nemapata;
- **placeholder-ul selectului numeste tinta** (decizie gate D2, 2026-07-07): cu tinta activa →
`Cod pentru OP-X — alege`; fara tinta → `— adauga cod RAR —`. Legatura cod↔operatie e
explicita la momentul alegerii, fara text suplimentar sub control;
- fara operatii nemapate → codul se adauga ca chip liber;
- dupa adaugare selectul revine la placeholder; chip-ul nou primeste evidentierea `.chip-nou`.
- **Continutul selectului**, cand tinta e o operatie nemapata:
- `optgroup "Sugestii"` sus: sugestia principala + fuzzy/k-NN top-3 pentru denumirea operatiei
(aceleasi surse ca in `_preview_import.html:89-121`);
- optiunea `"Nu se declara la RAR (exclude operatia)"` = sentinel `__NEDECLARAT__`
(`EXCLUDE_SENTINEL`, `app/mapping.py:44`) → operatia primeste chip distinct `Nedeclarat`
(stil mut, ×-ul readuce chip-ul warning), item anotat `exclus` in starea chips;
- restul nomenclatorului, ca azi.
Fara tinta (doar cod liber): fara optgroup Sugestii si fara `__NEDECLARAT__`.
- **Dedup**: acelasi `(operatie, cod)` nu se adauga de doua ori (comportament existent pastrat).
- **Fara text explicativ permanent** sub control (decizie utilizator la aprobarea mockup-ului,
2026-07-07): starea tintei se comunica DOAR vizual (stilul chip-ului warning evidentiat);
mesajele `chips_ok`/`chips_error` raman exclusiv ca feedback tranzitoriu la actiuni.
- Mockup aprobat: artifact "Mockup — Editor prestatii unificat (PRD 5.23)".
- **Modul plat** (corectii fara operatii din import) foloseste ACELASI control: zero chips warning,
selectul adauga doar coduri libere. Logica `has_r_odo`/`data-has-r-odo` (reveal odometru initial
pe R-ODO/I-ODO) si stilul `chip-warn` pe aceste coduri se pastreaza neschimbate.
### 3.1 Specificatie vizuala si de stari (din review-ul de design, 2026-07-07)
**Scara de dominanta vizuala — UN SINGUR tratament puternic simultan:**
| Element | Fundal / chenar / text | Nota |
|---|---|---|
| Chip tinta activa | fundal warn-soft, chenar SOLID 1px `--warn` + inel 2px warn-transparent | singurul element "tare" din rand |
| Chip warning (ne-tinta) | fundal warn-soft, chenar DASHED 1px `--warn` | click → devine tinta |
| Chip cod | fundal `--card2`-albastru (`--accent` 15%), text `--accent`, chenar subtil | eticheta `COD` mono |
| Chip Nedeclarat | fundal gri mut, text taiat (line-through), chenar dashed gri | NU discret pana la invizibil |
| `.chip-nou` | animatie de emfaza >=1.5s (fade), tranzitorie | nu concureaza cu tinta |
| Chip R-ODO/I-ODO | `chip-warn` existent, neschimbat | reveal odometru pastrat |
**Invariante de interactiune:**
- Ordinea chips e STABILA: operatiile in ordinea din import, codurile libere la coada; o rezolvare
nu re-ordoneaza randul (warning → cod in acelasi slot).
- Cat exista operatii nemapate, EXISTA tinta (implicit prima nemapata); click pe tinta activa =
no-op (nu se poate "deselecta" — adaugarile libere sunt posibile doar fara warnings).
- Click pe chip warning = comutare de tinta CLIENT-SIDE (toggle clasa + hidden `chips_target_index`),
fara round-trip; indexul tintei pleaca la server la urmatorul add.
- Selectul are id stabil (`chips-picker`); dupa swap `outerHTML`, focusul revine pe select.
- Optiunile selectului: `COD — denumire nomenclator`; in optgroup "Sugestii" acelasi format
(optgroup-ul e singura marca de sugestie). Eticheta chip warning: max ~18ch + ellipsis,
text complet in `title`.
**Tabel de stari (ce VEDE utilizatorul):**
| Stare | Ce se vede |
|---|---|
| Loading (add in zbor) | selectul disabled (`hx-disabled-elt="this"`) + stil `.htmx-request` pe sectiune; dedup face inofensiv un dublu-fire |
| Eroare add (400/500/retea) | mesaj `chips_error` SUB control (pattern 5.22, persista pana la urmatoarea actiune); selectul re-activat; tinta neschimbata; `hx-on::response-error` acopera esecul de retea |
| Gol (mod plat, zero chips) | doar selectul cu placeholder "— adauga cod RAR —"; container cu min-height (modalul nu salta la primul chip) |
| Succes partial | chip-ul nou cu emfaza >=1.5s; tinta avanseaza (vezi decizia de la gate) |
| Complet (zero nemapate) | mesaj tranzitoriu "Toate operatiile au cod" (`chips_ok`); optgroup Sugestii si `__NEDECLARAT__` dispar din select |
| Nedeclarat | mesajul numeste operatia: "OP-X nu se va declara la RAR" |
| Nomenclator gol | empty state existent (pastrat) |
## 4. Stories atomice
### US-001: Stare chips extinsa cu `exclus` + actiuni add/remove unificate in backend
**Ca** dezvoltator **vreau** o singura pereche de actiuni `add`/`remove` pe starea chips (cu tinta
optionala si suport sentinel `__NEDECLARAT__`) **pentru ca** azi exista 4 actiuni suprapuse
(`add`, `add_flat`, `add_extra`, `remove_flat`) care dubleaza logica.
- **Depinde de**: —
- **Fisiere**: `app/web/routes.py` (`post_form_chips` :2402-2521, `_chips_state_from_form` :3862-3918), `tests/test_web_chips.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_chips.py``test_add_cu_tinta_leaga_de_operatie`,
`test_add_fara_tinta_adauga_cod_liber`, `test_add_nedeclarat_marcheaza_exclus`,
`test_remove_cod_readuce_operatia_nemapata`, `test_dedup_operatie_cod`
- **Acceptance criteria**:
- [x] Starea chips serializata intr-UN SINGUR hidden `chips_state` = JSON versionat
(`{"v":1,"items":[{"cod":"","op":"","den":"","exclus":0},...]}`) — decizie gate D4:
elimina structural clasa de bug-uri de aliniere pe index (2 regresii in 2 release-uri).
`_chips_state_from_form` parseaza JSON-ul; JSON invalid → eroare vizibila, nu stare goala.
- [x] Compat tranzitorie la deploy: daca `chips_state` lipseste dar exista listele vechi
(`cod_prestatie`/`chip_op_service`/`chip_denumire`), parserul cade inapoi pe ele cu
`exclus=0` (form-uri deschise peste deploy); fallback-ul se sterge in release-ul urmator.
- [x] `chips_state` prezent mereu (lista goala = `{"v":1,"items":[]}`): golirea intentionata a
chips se persista la salvare (inlocuieste santinela `chips_present`).
- [x] `chips_action=add` + `chips_target_index=i` leaga codul de operatia i; fara target →
chip liber; cod `__NEDECLARAT__` + target → item cu `exclus=1` si fara `cod_prestatie`.
- [x] `chips_action=remove` (index unic) acopera si fostul `remove_flat`; actiunile
`add_flat`/`add_extra`/`chips_add_cod_{i}`/`chips_add_cod_flat` sunt eliminate.
- [x] Tinta implicita dupa un add reusit = urmatoarea operatie nemapata (sau nici una).
- [x] Validare tinta SEMANTICA, nu doar in-range: `chips_target_index` trebuie sa arate spre o
operatie nemapata si ne-exclusa; altfel `chips_error` (acopera POST fabricat si index
stale dupa un remove).
- [x] `chip_exclus` parsat strict ("1"→1, orice altceva→0); `__NEDECLARAT__` fara tinta valida →
`chips_error` explicit.
- [x] Testele existente pe actiunile vechi (`tests/test_web_corectie_prestatii.py`,
`tests/test_web_form_editare_slim.py`) sunt ACTUALIZATE in acelasi val (suita nu ramane
rosie intre valuri).
- [x] Emfaza chip nou pe INDEX (`chips_added_index`), nu pe valoarea codului (doua operatii pot
avea legitim acelasi cod).
- [x] `python3 -m pytest -q` trece.
- **Verificare E2E**: acoperit de US-005.
### US-002: Template unificat — un container, un select, fara butoane Adauga/+
**Ca** operator **vreau** un singur rand de chips si un singur select care adauga instant
**pentru ca** azi nu stiu care din cele 3 controale trebuie folosit.
- **Depinde de**: US-001
- **Fisiere**: `app/web/templates/_chips_prestatii.html` (rescriere), `app/web/templates/base.html`
(CSS `.chips`/`.chip*` :815-843, eliminare `.op-row`), `tests/test_web_chips.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_chips.py``test_render_un_singur_container_chips`,
`test_render_fara_op_row_si_fara_butoane_adauga`, `test_chip_warning_pentru_operatie_nemapata`
- **Acceptance criteria**:
- [x] Un singur container `.chips` contine: chips cod (cu tooltip operatia sursa), chips warning
operatii nemapate, chip `Nedeclarat` pentru itemi exclusi, selectul unic la capat.
- [x] Selectul (nume camp: `chips_pick`, id stabil `chips-picker`) are `hx-trigger="change"`,
`hx-post="/form-chips"`, `hx-target="#chips-section"`, `hx-swap="outerHTML"`,
`hx-include="closest form"` (transporta CSRF + starea hidden — omiterea = 403) si
`hx-disabled-elt="this"` (capcana htmx din 5.22 — NU mosteni de la form).
- [x] Selectul revine la placeholder dupa ORICE raspuns (succes SI eroare); valoarea ramasa in
`chips_pick` NU e aplicata la Salveaza pe niciuna din cai (regresia c6e29aa nu reapare
sub alt nume — codul se adauga DOAR prin actiunea add).
- [x] Pe durata unui request /form-chips, INTREAGA sectiune e inerta (`pointer-events:none` pe
`.htmx-request` sau echivalent) — click pe × / chip warning mid-flight nu pierde actiuni.
- [x] Click pe chip warning comuta tinta CLIENT-SIDE (clasa + hidden `chips_target_index`),
fara round-trip; click pe tinta activa = no-op.
- [x] Focus restaurat pe `#chips-picker` dupa swap.
- [x] Zero aparitii `op-row`, `Adauga`, `add_flat`, `chips_add_cod_` in template.
- [x] `chips_submission_id` e emis ca hidden in sectiune si ecouat de `/form-chips`, astfel
incat "salveaza ca regula" ramane disponibil si DUPA re-randari (azi dispare la prima
interactiune — bug perpetuat altfel).
- **Verificare E2E**: acoperit de US-005.
### US-003: Sugestii fuzzy/k-NN + optiunea "Nu se declara la RAR" in selectul unic
**Ca** operator **vreau** sugestiile de mapare direct in selectul din modal **pentru ca** azi ele
exista doar in panoul de mapare, iar in modal caut manual prin tot nomenclatorul.
- **Depinde de**: US-001, US-002
- **Fisiere**: `app/web/routes.py` (`post_form_chips` — construire sugestii pentru operatia tinta,
refolosind sursele din preview), `app/web/templates/_chips_prestatii.html`, `tests/test_web_chips.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_chips.py``test_select_are_optgroup_sugestii_pt_tinta`,
`test_select_are_nedeclarat_doar_cu_tinta`, `test_select_fara_tinta_fara_sugestii`
- **Acceptance criteria**:
- [x] Cu tinta activa: `optgroup "Sugestii"` cu sugestia principala + top-3 fuzzy/k-NN pentru
`denumire`-a operatiei; sub el optiunea `__NEDECLARAT__`; apoi nomenclatorul complet.
- [x] Fara tinta: selectul listeaza doar nomenclatorul (fara Sugestii, fara `__NEDECLARAT__`).
- [x] Sugestiile folosesc aceleasi functii ca `_preview_import.html` (fara drum nou de cod).
- [x] Degradare gratioasa: motor de sugestii rece/dezactivat → select DOAR cu nomenclatorul
(fara optgroup), fara eroare si fara blocare pe warmup (lock non-blocking, PRD 5.21).
- [x] `python3 -m pytest -q` trece.
- **Verificare E2E**: acoperit de US-005.
### US-004: Persistarea `exclus` prin salvare (import + Trimiteri) — cu round-trip complet
**Ca** operator **vreau** ca excluderea aleasa in modal sa se pastreze la salvare, sa produca
starea corecta si sa fie REVERSIBILA la redeschidere **pentru ca** altfel optiunea din select ar fi
cosmetica sau, mai rau, operatia ar fi declarata contra intentiei mele (RAR e ireversibil).
> Corectie din review (eng, CONFIRMATA in cod): pipeline-ul actual sterge `exclus` per-item
> (`resolve_prestatii` face `it.pop("exclus", None)`, `mapping.py:298`) si elimina itemii exclusi
> din `payload_json` la corectie (`routes.py:1744`). Fara fix-urile de mai jos, un item exclus din
> modal ar reveni `needs_mapping` sau — daca operatia are mapare pe cont — ar fi DECLARAT la RAR.
- **Depinde de**: US-001
- **Fisiere**: `app/mapping.py` (`resolve_prestatii` — treapta noua: item-exclus explicit
supravietuieste), `app/web/routes.py` (`web_editeaza_rand`, `post_corectie_trimitere` :1619,
`_prestatii_chips_from_payload` :1336 + toti constructorii de context chips),
`app/api/v1/import_router.py` (`apply_row_override` :319-366), `tests/test_web_editare_rand.py` (~4 fisiere)
- **Test intai (RED)**: `tests/test_web_editare_rand.py``test_salvare_pastreaza_exclus_in_override`,
`test_exclus_supravietuieste_resolve_chiar_cu_mapare_pe_cont`,
`test_redeschidere_modal_arata_chip_nedeclarat_si_x_readuce_warning`,
`test_rand_toate_operatiile_excluse_devine_excluded`, `test_salvare_trimitere_cu_exclus`
- **Acceptance criteria**:
- [x] `resolve_prestatii` pastreaza `exclus=1` venit explicit pe item (precedenta: item-exclus >
cod explicit > regula exclus cont > mapare > reguli text); restul treptelor neschimbate.
- [x] Itemii exclusi RAMAN in `payload_json`/`override_json` adnotati cu `exclus=1`;
`split_prestatii_excluse` ii scoate DOAR din payload-ul trimis la RAR si din cheia idempotenta.
- [x] Toti constructorii de chips (`_prestatii_chips_from_payload`, preview, re-render eroare)
propaga `exclus` → chip `Nedeclarat` reapare la redeschidere; `×` il readuce la warning.
- [x] Rand cu toate operatiile excluse → stare preview `excluded` ("Nedeclarat"); la Trimiteri →
`needs_data` cu motiv explicit (invariantele existente).
- [x] Validarea cod-vs-nomenclator la salvare ramane; sentinelul `__NEDECLARAT__` e RESPINS
explicit daca apare ca `cod_prestatie` in starea hidden (nu doar in select), pe ambele cai.
- [x] `python3 -m pytest -q` trece.
- **Verificare E2E**: acoperit de US-005.
### US-005: Verificare E2E browser pe ambele fluxuri
**Ca** lead **vreau** dovada in browser ca editarea e acum un singur control coerent
**pentru ca** bug-ul raportat e de UX vizual, nu doar de logica.
- **Depinde de**: US-002, US-003, US-004
- **Fisiere**: — (doar verificare; eventuale fix-uri in fisierele de mai sus)
- **Test intai (RED)**: —
- **Acceptance criteria**:
- [x] Import preview: rand cu operatie nemapata + cod liber → modalul arata UN rand de chips
(warning + cod + select), fara chenare/zone separate; screenshot atasat la raport.
- [x] Selectarea unui cod din Sugestii transforma chip-ul warning in chip cod, instant, fara "+".
- [x] Alegerea "Nu se declara la RAR" produce chip `Nedeclarat`; salvare → rand "Nedeclarat".
- [x] Trimiteri (`/trimitere/{id}` corectie): acelasi control, "salveaza ca regula" functional.
- [x] Salvarea persista chips-urile (regresie c6e29aa) si nu reapar controale duble la re-deschidere.
- **Verificare E2E**: browser HTMX pe http://localhost:8010/ (import xlsx test + o trimitere
`needs_mapping`), conform pasilor de mai sus.
## 5. Riscuri
- **`hx-disabled-elt` mostenit de la form** a mai rupt butoanele chips o data (5.22) — selectul unic
primeste explicit `hx-disabled-elt="this"`; test de template pentru atribut.
- **`change` pe select nativ** se declanseaza si la navigarea cu tastatura pe unele browsere vechi;
acceptat (comportamentul modern e la confirmare), iar chip-ul gresit se sterge cu un click.
- **Compatibilitate stare veche**: submission-uri/override-uri existente fara `chip_exclus` trebuie
citite ca `exclus=0` (default la deserializare) — altfel salvarea de pe randuri vechi crapa.
- **Doua endpoint-uri de save** (import + Trimiteri) cu acelasi contract de campuri — orice
schimbare de nume de camp se face simultan in ambele, plus `_chips_state_from_form` unic partajat.
## 6. Intrebari deschise
> Rezolvate cu utilizatorul la aprobare (2026-07-07):
- `×` pe un chip `Nedeclarat`**readuce chip-ul warning** al operatiei (operatia NU se sterge).
- Selectul unic ramane `<select>` nativ cu tot nomenclatorul — **acceptat**; autocomplete ramane
non-goal.
## 7. Valuri de executie (graful de dependente)
```
Val 1: [US-001] ← fara dependente
Val 2: [US-002] [US-004] ← deblocate de US-001, fisiere distincte → paralel
Val 3: [US-003] ← deblocat de US-002
Val 4: [US-005] ← verificare finala E2E
```
---
## Anexa /autoplan — Review CEO (Faza 1, 2026-07-07, mod SELECTIVE EXPANSION, voci: subagent-only)
### NOT in scope (decizii din review)
- Autocomplete/cautare in picker — non-goal confirmat de utilizator.
- Schimbarea panoului de mapare / precedentei `resolve_prestatii` — non-goal PRD.
- Badge-uri sursa sugestie (GOLD/SILVER) in select — respins (P5): optgroup "Sugestii" e suficient.
- "Bulk: aplica maparea la N randuri identice" in preview — deferat TODOS (in afara blast radius).
- Metrici de baza (deschideri modal / % randuri mapate manual) — deferat TODOS.
### What already exists (refolosit, nu reconstruit)
- Stare chips in hidden inputs + `/form-chips` stateless (`_chips_prestatii.html`, `routes.py:2402`).
- Validare cod vs nomenclator la add si la save (c6e29aa); mesaje `chips_error`/`chips_ok` (5.22).
- Sugestii: `suggest_codes` + `sugestie_principala` (`mapping.py:117,868`) — aceleasi surse ca preview.
- `EXCLUDE_SENTINEL`/`split_prestatii_excluse` (0bc6187); `apply_row_override` (c6e29aa).
- Empty state nomenclator lipsa; `has_r_odo`/reveal odometru; buton "salveaza ca regula" (Trimiteri).
### Dream state delta
Planul muta editorul de la 3 controale/4 actiuni la 1 control/2 actiuni si aduce sugestiile in
modal — pas direct spre idealul 12 luni (o singura componenta de mapare peste tot). Ramase pe
traiectorie: autocomplete (daca nomenclatorul creste), componenta partajata cu panoul de mapare.
### Arhitectura (Sectiunea 1)
```
[modal Editare rand]──include──▶[_form_editare]──include──▶[_chips_prestatii (REWRITE)]
│ │ hx-post change/×
▼ ▼
POST editeaza (import) ─┐ POST /form-chips (add|remove)
POST corecteaza (Trim.) ─┴─▶ _chips_state_from_form ──▶ apply_row_override(prestatii+exclus)
│ (commit)
suggest_codes/mapping.py ◀──────┘ resolve + split_prestatii_excluse
```
Cuplaj NOU: /form-chips → motorul de sugestii (pana acum doar preview-ul) — justificat (reuse),
cu conditia caii NE-blocante pe request (lock embeddings non-blocking, PRD 5.21). Fara migrare DB
(`exclus` traieste in override_json). Rollback: git revert simplu. Fereastra de deploy: un modal
deschis peste deploy trimite campuri vechi (`chips_add_cod_{i}`) pe care handlerul nou le ignora
— utilizatorul redeschide editarea; acceptat (sesiune de editare scurta).
### Registru erori & rescue (Sectiunea 2)
```
CODEPATH | CE POATE ESUA | TRATARE | USER VEDE
--------------------------|------------------------------------|--------------------------------|-----------
/form-chips add | cod gol / necunoscut | chips_error (exista) | mesaj rosu
/form-chips add | index tinta iesit din forma | chips_error (exista) | mesaj rosu
/form-chips add NED | __NEDECLARAT__ fara tinta | GAP -> chips_error nou | mesaj rosu
/form-chips render | motor sugestii rece/dezactivat | GAP -> degradare la nomenclator| select fara optgroup
/form-chips | nomenclator gol | empty state (exista) | avertisment
save (ambele) | cod necunoscut in stare | re-render cu eroare (exista) | modal + eroare
save (ambele) | stare veche fara chip_exclus | default exclus=0 (PRD risc) | nimic (compat)
save (ambele) | CSRF invalid/expirat | 403 (existent) | eroare form
enqueue | toate operatiile excluse | excluded / needs_data (exista) | stare explicita
```
GAP-uri de inchis in implementare: NED-fara-tinta si degradarea sugestiilor (ambele = criterii noi).
### Securitate (Sectiunea 3)
Fara endpoint nou. Input nou `chip_exclus`: parsare stricta ("1" -> 1, orice altceva -> 0).
Sentinelul `__NEDECLARAT__` nu ajunge NICIODATA in payload RAR (split inainte de enqueue —
invariant existent; test explicit). XSS: `denumire` in options/title — autoescape Jinja (exista).
Sugestii cross-account: politica 5.14 (suggestion-only) neschimbata. CSRF pe /form-chips: exista.
Nicio amenintare High nemitigata.
### Edge cases date/interactiune (Sectiunea 4)
- Dublu `change` in zbor: `hx-disabled-elt="this"` pe select (criteriu US-002).
- Focus pierdut dupa swap `outerHTML`: focusul revine pe select dupa add (criteriu nou US-002).
- Navigare cu tastatura prin select declanseaza change pe unele browsere: acceptat (PRD risc),
chip-ul gresit se scoate cu un click.
- Iesire din modal mid-edit: stare pierduta (comportament existent, form-local) — neschimbat.
- Dedup (operatie,cod) si cod liber duplicat: exista, se pastreaza.
- Mod plat + `has_r_odo`: pastrate explicit (spec actualizat).
### Calitate (Sectiunea 5)
4 actiuni -> 2 reduce ramificarea din `post_form_chips` (~120 linii azi). `_chips_state_from_form`
ramane UNICUL parser, partajat de ambele endpoint-uri de save (risc PRD; test comun).
Alternativa "un singur camp hidden JSON" in loc de 4 liste paralele — decizie de gust (vezi gate).
### Teste (Sectiunea 6) — completari la RED-urile din PRD
- `test_add_nedeclarat_fara_tinta_da_eroare` (US-001)
- `test_sugestii_indisponibile_degradare_fara_optgroup` (US-003)
- `test_mod_plat_fara_operatii_select_doar_coduri_libere` (US-002)
- `test_has_r_odo_pastrat_dupa_actiuni` (US-002)
- `test_sentinel_nedeclarat_nu_ajunge_in_payload` (US-004)
- Template: `test_select_are_hx_disabled_elt_this` (capcana 5.22)
### Performanta (Sectiunea 7)
Sugestiile se calculeaza la FIECARE re-render /form-chips pentru operatia tinta — bounded
(matvec numpy 5.21, corpus 17k, <100ms tipic); calea request NU asteapta warmup (lock non-blocking).
18 optiuni in select trivial. Fara probleme.
### Observabilitate (Sectiunea 8)
Starea randurilor e deja vizibila in dashboard (needs_mapping/excluded). Gap acceptat si deferat
TODOS: metrica "deschideri modal / sugestie acceptata" (leaga de decizia de produs, nu de 5.23).
### Deploy (Sectiunea 9)
Fara migrare, fara feature flag (UI inlocuit atomic in acelasi template). Worker neatins.
Post-deploy: smoke = deschide un rand needs_mapping, adauga un cod, salveaza.
### Traiectorie (Sectiunea 10)
Reversibilitate 4/5 (revert curat; starile cu exclus persistate raman valide split exista
independent). Datorie REDUSA (actiuni duplicate eliminate). Comentariul de header din
`_chips_prestatii.html` se rescrie (diagrama noua a starii parte din US-002).
### Design/UX la nivel CEO (Sectiunea 11)
Ierarhie: chips (starea) inaintea selectului (actiunea) corect. Stari acoperite: gol (hint),
nomenclator lipsa, eroare, succes, exclus. Mobil: rand cu wrap; latimea selectului pe 390px =
criteriu in faza Design. Accesibilitate: aria-labels existente se pastreaza; focus dupa swap.
Riscul de mis-binding (tinta implicita + fara text permanent) ridicat de vocea externa,
decizie la gate. Detaliu complet in Faza 2 (plan-design-review).
### Decision Audit Trail (/autoplan)
| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins |
|---|------|---------|-------------|-----------|-------------|---------|
| 1 | CEO | Abordare A (rescriere template + unificare actiuni) | Mecanica | P1,P5 | B pastreaza confuzia; C incalca non-goal aprobat | B, C |
| 2 | CEO | A11y tastatura + focus dupa swap criterii US-002 | Mecanica | P1 | in blast radius, 2 fisiere | |
| 3 | CEO | Badge sursa sugestie in select respins | Mecanica | P5 | optgroup "Sugestii" spune deja asta | badge-uri |
| 4 | CEO | Bulk-apply mapare pe randuri identice TODOS | Mecanica | P2,P3 | in afara blast radius (panou preview = non-goal) | |
| 5 | CEO | Metrici deschideri-modal/sugestii TODOS | Mecanica | P3 | produs, nu 5.23 | |
| 6 | CEO | GAP-uri erori (NED-fara-tinta, degradare sugestii) criterii noi | Mecanica | P1 | zero silent failures | |
| 7 | CEO | Auto-advance tinta + fara eticheta pe select | TASTE (gate) | | voce externa: risc mis-binding pe declaratii ireversibile | |
| 8 | CEO | Paleta click 18 coduri vs select nativ | TASTE (gate) | | premisa "tot nomenclatorul" e falsa (18 coduri fixe) | |
| 9 | CEO | Stare chips: 4 liste hidden vs un camp JSON | TASTE (gate) | | a 3-a regresie in zona; JSON = sursa unica | |
| 10 | CEO | Test cu operator real in US-005 | Mecanica | P1 | verifica direct riscul de mis-binding; cost ~0 | |
| 11 | Design | Scara de dominanta vizuala (tabel §3.1) | Mecanica | P1,P5 | 3 tratamente concurente fara ierarhie = confuzia initiala reambalata | |
| 12 | Design | Stari loading/eroare/gol/complet specificate (tabel §3.1) | Mecanica | P1 | eroarea de retea lasa selectul disabled permanent altfel | |
| 13 | Design | Tinta invarianta (exista cat sunt nemapate; click pe tinta = no-op) | Mecanica | P5 | interzice starea "cod liber cu warnings inca pe rand" tacuta | |
| 14 | Design | Comutare tinta CLIENT-SIDE (fara round-trip) | Mecanica | P3 | round-trip pe click de highlight = lent + race cu add-ul in zbor | server-side |
| 15 | Design | Ordine chips stabila + focus pe select dupa swap | Mecanica | P1 | reflow-ul + focusul pierdut rup fluxul de tastatura pe 10 randuri | |
| 16 | Design | Optiuni `COD — denumire`; chip warning ellipsis 18ch | Mecanica | P5 | 18 coduri criptice fara denumire = memorare fortata | |
| 17 | Design | Nedeclarat vizibil (line-through, nu mut) + mesaj cu numele operatiei | Mecanica | P1 | cea mai riscanta actiune legala nu poate fi cea mai discreta | stil mut |
| 18 | Design | Mesaje chips_ok/err raman pattern 5.22 (persista pana la actiune) | Mecanica | P4 | timer de auto-dismiss = JS nou fara valoare | timer 4s |
| 19 | Design | Auto-advance tinta: pastrat cu emfaza vs eliminat | TASTE (gate) | | CEO-voice: elimina (mis-binding); Design-voice: pastreaza (momentum) + emfaza | |
| 20 | Design | Touch targets × pe chips sub 44px (UI compact 11px existent) | Mecanica | P4 | consecvent cu design-ul compact existent; nota in TODOS a11y | 44px |
| 21 | Eng | `exclus` supravietuieste `resolve_prestatii` (treapta noua, non-goal amendat) | Mecanica | P1 | P1 confirmat: `it.pop("exclus")` mapping.py:298 face US-004 imposibil; alternativa (regula pe cont) schimba semantica aprobata | regula per-cont; taierea US-004 |
| 22 | Eng | Itemii exclusi raman in payload_json + `exclus` in toti constructorii de chips | Mecanica | P1 | P1 confirmat: routes.py:1744 ii elimina × pe Nedeclarat imposibil dupa salvare | |
| 23 | Eng | `chips_submission_id` hidden + ecou in /form-chips ("salveaza ca regula" persistent) | Mecanica | P1,P3 | butonul dispare azi la prima re-randare; fluxul nou trece TOT prin /form-chips | |
| 24 | Eng | `chips_pick` reset dupa orice raspuns; valoarea NEaplicata la Salveaza | Mecanica | P5 | clasa de bug c6e29aa; sentinel NED pozitional pe calea corectie | aplicare implicita |
| 25 | Eng | Validare semantica tinta + chip_exclus strict + sectiune inerta in request | Mecanica | P1 | POST fabricat / index stale / race mid-flight leaga codul de alt item | |
| 26 | Eng | `chips_present=1` golirea listei se persista (nu no-op) | Mecanica | P1 | `_chips_state_from_form` intoarce None pe form fara chips golire imposibila | no-op actual |
| 27 | Eng | Actualizarea testelor existente pe actiunile vechi in acelasi val | Mecanica | P6 | altfel suita e rosie intre Val 1 si Val 2 | |
| 28 | Eng | Emfaza chip nou pe index, nu pe cod | Mecanica | P5 | doua operatii cu acelasi cod = dubla evidentiere falsa | |
| 29 | Eng | Fara memoizare sugestii per re-render | Mecanica | P3 | rapidfuzz pe 18 randuri + k-NN gardat = bounded; memoizarea = complexitate fara castig | memoizare |
| D2 | Gate | Auto-advance pastrat + placeholder-ul selectului numeste tinta | USER | | leaga explicit codul de operatie la momentul alegerii, fara text sub control | doar emfaza; fara auto-advance |
| D3 | Gate | Select nativ pastrat (mockup v1); paleta v2 vazuta si RESPINSA de utilizator | USER | | directia utilizatorului; paleta ramane alternativa daca selectul se dovedeste lent (US-005 cu operator) | paleta 18 coduri |
| D4 | Gate | Stare chips = UN hidden `chips_state` JSON versionat + fallback tranzitoriu liste vechi | USER | | elimina clasa de regresii de aliniere pe index | 4 liste paralele |
| D5 | Gate | Plan revizuit APROBAT de utilizator | USER | | | |
### Anexa Eng (Faza 3) — diagrama de acoperire teste
```
CODE PATHS USER FLOWS
[+] post_form_chips (rescris, 2 actiuni) [+] Corectie rand cu N operatii nemapate
├── add cu tinta valida [RED planificat] ├── sugestie→chip instant [RED + E2E US-005]
├── add fara tinta (liber) [RED planificat] ├── comutare tinta client [E2E US-005]
├── add NED cu/fara tinta [RED planificat] ├── × pe cod → warning [RED planificat]
├── tinta semantica invalida [RED planificat] ├── NED → Nedeclarat → × [RED planificat]
├── remove index/liber [RED planificat] ├── eroare retea la add [→E2E] [GAP inchis §3.1]
└── dedup + cod necunoscut [exista, se muta] └── golire totala + salvare [RED chips_present]
[+] resolve_prestatii treapta item-exclus [RED planificat — REGRESIUNE potentiala: IRON RULE,
test obligatoriu ca exclus NU e sters si ca restul precedentei ramane identic]
[+] _chips_state_from_form (4 liste + compat vechi + liste scurte) [RED planificat]
[+] save x2 (import/corectie): exclus round-trip, NED respins, chips_pick ignorat [RED planificat]
[+] template: hx-atribute, focus, sectiune inerta, mod plat, has_r_odo [RED planificat]
COVERAGE planificata: 100% din ramurile noi au RED numit in stories. Fara eval-uri LLM (nu se
ating prompturi). Regresiune-cheie: precedenta resolve_prestatii → test dedicat blocant.
```
ENG DUAL VOICES CONSENS (subagent-only):
```
Dimensiune Claude Codex Consens
1. Arhitectura solida? DA* N/A OK (directia 4→2 actiuni corecta; * dupa fix P1-uri)
2. Acoperire teste suficienta? NU→DA N/A FLAGGED→INCHIS (6 teste noi adaugate in stories)
3. Riscuri de performanta? NU N/A OK (18 coduri, k-NN gardat, bounded)
4. Securitate acoperita? PARTIAL→DA N/A INCHIS (validare semantica tinta, chip_exclus strict, NED respins la save)
5. Drumuri de eroare tratate? PARTIAL→DA N/A INCHIS (§3.1 + criterii noi)
6. Risc de deploy gestionabil? DA N/A OK (fara migrare; fereastra modal-peste-deploy acceptata)
```
---
## Raport VERIFY
Verificator independent (subagent context curat, doar PRD + §5.6), 2026-07-08. **VERDICT: PASS**
pe toate criteriile testabile; regresia de aur RAR live = NEPROBAT (contul QA fara creds RAR).
- **Suita**: `python3 -m pytest -q` verde. La VERIFY: 1672 passed, 1 skipped. Dupa fix-urile
de code-review CLOSE: **1680 passed, 1 skipped** (+45 teste noi fata de baseline 1635).
- **US-001..US-004** PASS cu dovezi de cod + teste (chips_state JSON unic, 2 actiuni add/remove,
exclus supravietuieste resolve_prestatii cu test de regresie blocant pe precedenta, sentinel
respins pe ambele cai).
- **US-005 E2E browser** (import preview + Trimiteri corectie, Playwright): PASS pe (a) un singur
rand de chips cu tinta evidentiata + placeholder-tinta + optgroup Sugestii + optiune "Nu se
declara la RAR" la deschiderea initiala; (b) sugestiechip 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 previewcommit 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

View File

@@ -0,0 +1,78 @@
"""Robustete selectie tinta chips: doar operatii nemapate IDENTIFICABILE (cu
cod_op_service) pot deveni tinta. Un item fara cod_op_service (chip liber, fara
nicio operatie asociata) nu are cum sa fie afisat ca tinta in template (chip-ul
warning se randeaza doar cand cod_op_service e truthy) — daca ar fi ales tinta,
ar deveni invizibil: userul nu vede niciun highlight, dar selectul tot arata
sugestii/NEDECLARAT pentru un "cui" pe care nu-l poate vedea.
"""
from __future__ import annotations
import os
import tempfile
import pytest
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _seed_cod(conn, cod: str, nume: str) -> None:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, nume),
)
conn.commit()
def test_item_fara_cod_op_service_nu_devine_tinta(conn):
"""Chip liber (fara cod_op_service) nemapat nu poate fi tinta — e invizibil in template."""
from app.web.routes import _chips_target_si_sugestii
_seed_cod(conn, "OE-1", "Schimb ulei")
from app.mapping import load_nomenclator
nomenclator = load_nomenclator(conn)
chips = [{"cod_prestatie": "", "cod_op_service": "", "denumire": "", "exclus": 0}]
target_index, sugestii = _chips_target_si_sugestii(conn, chips, nomenclator)
assert target_index is None
assert sugestii == []
def test_operatie_identificabila_ramane_tinta(conn):
"""Regresie: o operatie nemapata cu cod_op_service ramane tinta normal."""
from app.web.routes import _chips_target_si_sugestii
_seed_cod(conn, "OE-1", "Schimb ulei motor")
from app.mapping import load_nomenclator
nomenclator = load_nomenclator(conn)
chips = [{"cod_prestatie": "", "cod_op_service": "Op-A", "denumire": "Schimb ulei motor", "exclus": 0}]
target_index, sugestii = _chips_target_si_sugestii(conn, chips, nomenclator)
assert target_index == 0
assert any(s.get("cod_prestatie") == "OE-1" for s in sugestii)
def test_sugestii_pentru_tinta_fara_denumire_intoarce_gol():
"""Denumire (si cod_op_service) goala pe tinta -> lista goala, fara sugestii irelevante."""
from app.web.routes import _sugestii_select_pentru_tinta
class _FakeConn:
pass
nomenclator = [{"cod_prestatie": "OE-1", "nume_prestatie": "Schimb ulei"}]
out = _sugestii_select_pentru_tinta(_FakeConn(), "", nomenclator)
assert out == []
out_none = _sugestii_select_pentru_tinta(_FakeConn(), None, nomenclator)
assert out_none == []

View File

@@ -8,8 +8,10 @@ Un cod ales explicit pe rand bate regula de excludere.
from __future__ import annotations from __future__ import annotations
import io
import json import json
import os import os
import re
import tempfile import tempfile
import pytest import pytest
@@ -191,8 +193,72 @@ def test_rand_mixt_exclude_doar_operatia_exclusa(client):
finally: finally:
conn.close() conn.close()
payload = json.loads(sub["payload_json"]) payload = json.loads(sub["payload_json"])
coduri = [p.get("cod_prestatie") for p in payload["prestatii"]] # Round-trip: itemul exclus RAMANE in payload_json (persistat), adnotat exclus,
assert coduri == ["OE-2"], f"Operatia exclusa nu trebuie sa plece la RAR: {coduri}" # dar fara cod_prestatie — nu pleaca niciodata la RAR (vezi build_rar_payload).
by_op = {p.get("cod_op_service"): p for p in payload["prestatii"] if p.get("cod_op_service")}
assert by_op["OP-ITP"]["exclus"] is True
assert not by_op["OP-ITP"].get("cod_prestatie")
coduri_declarabile = [p.get("cod_prestatie") for p in payload["prestatii"] if not p.get("exclus")]
assert coduri_declarabile == ["OE-2"], f"Operatia exclusa nu trebuie sa plece la RAR: {coduri_declarabile}"
def test_reupload_rand_cu_exclus_deja_comis_e_already_sent(client):
"""Cheia de idempotenta la commit (doar declarabile) e IDENTICA cu preview.
Re-incarcarea aceluiasi rand (dupa ce a fost deja comis cu un item exclus
adnotat in payload) trebuie recunoscuta drept duplicat — nu un rand nou.
"""
_seed()
rows = [{
"VIN": "WVWZZZ1KZAW000789",
"Nr": "B003TST",
"Data": "2026-06-12",
"KM": "90000",
"Operatie": "OP-ITP",
}]
iid = _upload_and_preview(client, rows=rows)
csrf = _get_csrf(client)
client.post(f"/_import/{iid}/mapare-operatii", data={
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
})
client.post(f"/_import/{iid}/rand/0/editeaza", data={
"cod_prestatie": ["", "OE-2"],
"chip_op_service": ["OP-ITP", ""],
"chip_denumire": ["", ""],
})
csrf = _get_csrf(client)
rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
assert rc.status_code == 200, rc.text
# Re-incarca acelasi rand + acelasi cod ales pe editorul de rand. Maparea de
# coloane e deja memorata din primul upload -> raspunsul sare direct la preview
# (fara link mapare-coloane); extragem import_id din hx-get pe #import-section.
csv_data = _csv_bytes(rows)
csrf = _get_csrf(client)
r2 = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r2.status_code == 200, r2.text
m2 = re.search(r"/_import/(\d+)/preview", r2.text)
assert m2, f"import_id negasit in raspunsul de reupload: {r2.text[:300]}"
iid2 = int(m2.group(1))
csrf = _get_csrf(client)
client.post(f"/_import/{iid2}/mapare-operatii", data={
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
})
client.post(f"/_import/{iid2}/rand/0/editeaza", data={
"cod_prestatie": ["", "OE-2"],
"chip_op_service": ["OP-ITP", ""],
"chip_denumire": ["", ""],
})
# already_sent se calculeaza doar la refresh-ul complet de preview (lookup pe
# tot batch-ul), nu la editarea unui singur rand — reincarcam preview-ul.
rp = client.get(f"/_import/{iid2}/preview")
assert rp.status_code == 200
assert "Deja trimis" in rp.text, \
"Randul re-incarcat trebuie recunoscut deja trimis (cheia de idempotenta neschimbata)"
def test_cod_explicit_pe_operatie_bate_regula_de_excludere(client): def test_cod_explicit_pe_operatie_bate_regula_de_excludere(client):

View File

@@ -100,3 +100,35 @@ def test_normalizare_vin_nrinm_in_payload():
def test_sistem_reparat_default_null(): def test_sistem_reparat_default_null():
assert build_rar_payload(_internal())["sistemReparat"] == "null" assert build_rar_payload(_internal())["sistemReparat"] == "null"
def test_item_exclus_nu_apare_in_payload():
"""Un item adnotat exclus=True nu produce niciodata {codPrestatie: null} la RAR."""
internal = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [
{"cod_prestatie": "OE-1"},
{"cod_prestatie": None, "cod_op_service": "200", "denumire": "Spalare", "exclus": True},
],
}
p = build_rar_payload(internal)
assert p["prestatii"] == [{"codPrestatie": "OE-1", "idPrezentare": None}]
def test_item_fara_cod_prestatie_nu_apare_in_payload():
"""Defense-in-depth: orice item fara cod (chiar neadnotat exclus) e sarit, niciodata null."""
internal = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [
{"cod_prestatie": "OE-1"},
{"cod_prestatie": None, "cod_op_service": "200"},
],
}
p = build_rar_payload(internal)
assert p["prestatii"] == [{"codPrestatie": "OE-1", "idPrezentare": None}]

745
tests/test_web_chips.py Normal file
View File

@@ -0,0 +1,745 @@
"""US-001: stare chips extinsa (JSON versionat, D4) + actiuni add/remove unificate.
Contract nou /form-chips:
- Stare curenta: UN SINGUR hidden `chips_state` = JSON {"v":1,"items":[{"cod","op","den","exclus"}]}.
- chips_action=add + chips_pick (codul ales) + chips_target_index optional:
cu tinta -> leaga codul de operatia de la acel index; fara tinta -> chip liber.
chips_pick=__NEDECLARAT__ + tinta valida -> item exclus=1, fara cod_prestatie.
- chips_action=remove + chips_remove_index: chip legat de operatie -> revine warning
(cod_prestatie="", exclus=0); chip liber -> iese din lista.
- Actiunile vechi (add_flat/add_extra/remove_flat/chips_add_cod_{i}/chips_add_cod_flat
ca actiuni) sunt ELIMINATE.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "chips.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def _create_account_user(email: str, password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service", active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token nu gasit in login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _csrf(client) -> str:
resp = client.get("/?tab=coada")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token nu gasit in dashboard"
return m.group(1)
def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, denumire),
)
conn.commit()
finally:
conn.close()
def _state(items: list[dict]) -> str:
"""Construieste JSON-ul hidden chips_state din itemi scurti {cod,op,den,exclus}."""
return json.dumps({"v": 1, "items": items}, ensure_ascii=False)
def _login_seed(client, email: str) -> None:
_create_account_user(email)
_login(client, email)
# --------------------------------------------------------------------------- #
# add cu tinta -> leaga codul de operatia respectiva #
# --------------------------------------------------------------------------- #
def test_add_cu_tinta_leaga_de_operatie(client):
_login_seed(client, "add.tinta@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
state = _state([
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "add",
"chips_target_index": "0",
"chips_pick": "OE-1",
})
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "OE-1" in html
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m, f"hidden chips_state lipsa din raspuns: {html[:400]}"
payload = json.loads(m.group(1).replace("&#34;", '"'))
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("&#34;", '"'))
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("&#34;", '"'))
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("&#34;", '"'))
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("&#34;", '"'))
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("&#34;", '"'))
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("&#34;", '"'))
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("&#34;", '"'))
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("&#34;", '"'))
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("&#34;", '"'))
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("&#34;", '"'))
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("&#34;", '"'))
assert payload["items"][0]["cod"] == "OE-1"
assert payload["items"][0]["exclus"] == 0
# =========================================================================== #
# US-002: template unificat -- un container .chips, un select, fara op-row #
# =========================================================================== #
def _post_chips(client, csrf, items, **extra):
data = {
"csrf_token": csrf,
"chips_state": _state(items),
"chips_action": "",
}
data.update(extra)
return client.post("/form-chips", data=data)
def test_render_un_singur_container_chips(client):
"""UN singur container `.chips` -- indiferent de mixul de tipuri de chip."""
_login_seed(client, "un.container@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
{"cod": "", "op": "Op-B", "den": "Vulcanizare", "exclus": 0},
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
{"cod": "", "op": "Op-C", "den": "ITP anual", "exclus": 1},
]
_seed_cod("FRN1", "Sistem de franare")
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert html.count('class="chips"') == 1, (
f"Trebuie sa existe UN SINGUR container .chips, gasit {html.count('class=\"chips\"')}: {html[:800]}"
)
def test_render_fara_op_row_si_fara_butoane_adauga(client):
"""Fara .op-row, fara butoane Adauga/+, fara actiunile vechi in markup."""
_login_seed(client, "fara.oprow@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
{"cod": "", "op": "Op-B", "den": "Vulcanizare", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "op-row" not in html
assert "Adauga" not in html
assert "add_flat" not in html
assert "chips_add_cod_" not in html
def test_chip_warning_pentru_operatie_nemapata(client):
"""Operatie nemapata -> chip warning `warn OP-X . fara cod` cu title complet."""
_login_seed(client, "chip.warning@test.com")
csrf = _csrf(client)
items = [
{"cod": "", "op": "OP-ITP", "den": "Inspectie tehnica periodica", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "OP-ITP" in html
assert "fara cod" in html
assert 'title="OP-ITP' in html
def test_select_are_hx_disabled_elt_this(client):
"""Selectul unic are hx-disabled-elt='this' EXPLICIT (capcana 5.22 -- nu mostenit de la form)."""
_login_seed(client, "select.disabled@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
resp = _post_chips(client, csrf, [])
assert resp.status_code == 200, resp.text[:400]
html = resp.text
m = re.search(r'<select[^>]*name="chips_pick"[^>]*>', html)
assert m, f"selectul chips_pick lipseste: {html[:600]}"
select_tag = m.group(0)
assert 'id="chips-picker"' in select_tag
assert 'hx-trigger="change"' in select_tag
assert 'hx-post="/form-chips"' in select_tag
assert 'hx-target="#chips-section"' in select_tag
assert 'hx-swap="outerHTML"' in select_tag
assert 'hx-include="closest form"' in select_tag
assert 'hx-disabled-elt="this"' in select_tag, (
f"hx-disabled-elt='this' lipseste de pe select (capcana 5.22): {select_tag}"
)
def test_mod_plat_fara_operatii_select_doar_coduri_libere(client):
"""Mod plat (zero operatii din import): doar chips libere + select placeholder generic."""
_login_seed(client, "mod.plat@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "" not in html, "Mod plat nu trebuie sa aiba niciun chip warning"
m = re.search(r'<select[^>]*name="chips_pick"[^>]*>\s*<option value="">([^<]*)</option>', html)
assert m, f"select cu optiune placeholder lipseste: {html[:600]}"
assert "adauga cod RAR" in m.group(1)
def test_has_r_odo_pastrat_dupa_actiuni(client):
"""has_r_odo / data-has-r-odo + chip-warn pe R-ODO/I-ODO raman neschimbate."""
_login_seed(client, "r.odo@test.com")
_seed_cod("R-ODO", "Odometru initial")
csrf = _csrf(client)
items = [
{"cod": "R-ODO", "op": "", "den": "", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert 'data-has-r-odo="true"' in html
assert "chip-warn" in html
assert "R-ODO" in html
def test_chip_nedeclarat_are_x_functional(client):
"""Item exclus=1 -> chip Nedeclarat (line-through) cu buton de sters functional (remove -> warning)."""
_login_seed(client, "nedeclarat.x@test.com")
csrf = _csrf(client)
items = [
{"cod": "", "op": "OP-ITP", "den": "Inspectie", "exclus": 1},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "Nedeclarat" in html
assert 'hx-vals=\'{"chips_action":"remove","chips_remove_index":0}\'' in html
def test_chips_submission_id_ecouat(client):
"""chips_submission_id trimis in form e ecouat ca hidden in raspunsul /form-chips."""
_login_seed(client, "sub.id.ecou@test.com")
csrf = _csrf(client)
resp = _post_chips(client, csrf, [], chips_submission_id="4242")
assert resp.status_code == 200, resp.text[:400]
assert re.search(r'name="chips_submission_id"\s+value="4242"', resp.text), (
f"chips_submission_id nu a fost ecouat: {resp.text[:600]}"
)
# =========================================================================== #
# US-003: sugestii fuzzy/k-NN + optiunea "Nu se declara la RAR" in select #
# =========================================================================== #
def _extrage_select(html: str) -> str:
"""Extrage markup-ul complet al <select name="chips_pick">...</select>."""
m = re.search(r'<select[^>]*name="chips_pick".*?</select>', html, re.DOTALL)
assert m, f"select chips_pick lipseste din raspuns: {html[:600]}"
return m.group(0)
def test_select_are_optgroup_sugestii_pt_tinta(client):
"""Cu tinta activa (operatie nemapata): optgroup 'Sugestii' + __NEDECLARAT__ + nomenclator."""
_login_seed(client, "sugestii.tinta@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
items = [
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
select_html = _extrage_select(resp.text)
assert '<optgroup label="Sugestii">' in select_html, (
f"optgroup Sugestii lipseste cu tinta activa: {select_html[:600]}"
)
assert "OE-1" in select_html.split('<optgroup label="Sugestii">')[1].split("</optgroup>")[0], (
"sugestia fuzzy pentru 'Schimb ulei motor' trebuie sa contina OE-1"
)
assert "__NEDECLARAT__" in select_html
assert "Nu se declara la RAR" in select_html
# Nomenclatorul complet ramane dupa Sugestii/NEDECLARAT.
assert "FRN1" in select_html
def test_select_are_nedeclarat_doar_cu_tinta(client):
"""Optiunea __NEDECLARAT__ apare DOAR cand exista o tinta (operatie nemapata) activa."""
_login_seed(client, "nedeclarat.doar.tinta@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# Cu tinta (operatie nemapata) -> NEDECLARAT prezent.
resp_cu_tinta = _post_chips(client, csrf, [
{"cod": "", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
])
assert "__NEDECLARAT__" in _extrage_select(resp_cu_tinta.text)
# Fara tinta (doar cod liber, nicio operatie nemapata) -> NEDECLARAT absent.
resp_fara_tinta = _post_chips(client, csrf, [
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
])
assert "__NEDECLARAT__" not in _extrage_select(resp_fara_tinta.text)
def test_select_fara_tinta_fara_sugestii(client):
"""Fara tinta: selectul listeaza DOAR nomenclatorul (fara optgroup Sugestii, fara NEDECLARAT)."""
_login_seed(client, "fara.tinta.fara.sugestii@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
select_html = _extrage_select(resp.text)
assert "<optgroup" not in select_html
assert "__NEDECLARAT__" not in select_html
assert "OE-1" in select_html
def test_sugestii_indisponibile_degradare_fara_optgroup(client, monkeypatch):
"""Motor de sugestii care arunca exceptie -> select DOAR cu nomenclatorul (fara optgroup),
fara eroare 500 si fara blocare (degradare gratioasa, GAP inchis din PRD 5.23)."""
_login_seed(client, "sugestii.indisponibile@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
from app.web import routes as _routes
def _boom(*args, **kwargs):
raise RuntimeError("motor de sugestii indisponibil (simulat)")
monkeypatch.setattr(_routes, "suggest_codes", _boom)
monkeypatch.setattr(_routes, "enrich_suggestions", _boom)
items = [
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
select_html = _extrage_select(resp.text)
assert "<optgroup" not in select_html, (
f"optgroup nu trebuie sa apara cand motorul de sugestii arunca exceptie: {select_html[:600]}"
)
assert "OE-1" in select_html, "nomenclatorul complet trebuie sa ramana disponibil"
# =========================================================================== #
# FIX: tinta implicita + sugestii la RANDAREA INITIALA a modalului (fara #
# niciun round-trip /form-chips) — invariant PRD 5.23 §3.1. #
# =========================================================================== #
def _insert_submission(acct: int, *, status: str, payload: dict) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def test_randare_initiala_are_tinta_si_optgroup(client):
"""Deschiderea INITIALA a modalului de detaliu (needs_mapping, operatie nemapata),
FARA niciun /form-chips, trebuie sa aiba deja is-target pe chip-ul warning,
placeholder-ul D2 "Cod pentru OP-X — alege" si optgroup-ul Sugestii + NEDECLARAT."""
acct = _login_seed_ret(client, "randare.initiala@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare")
op = "Schimb ulei motor"
sid = _insert_submission(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0SC001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": op, "denumire": op}],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "is-target" in html, (
f"chip-ul warning al primei operatii nemapate trebuie sa fie tinta la deschiderea initiala: {html[:2000]}"
)
assert f"Cod pentru {op} — alege" in html, (
f"placeholder-ul D2 trebuie sa numeasca operatia tinta la deschiderea initiala: {html[:2000]}"
)
select_html = _extrage_select(html)
assert '<optgroup label="Sugestii">' in select_html, (
f"optgroup Sugestii trebuie sa apara la deschiderea initiala (fara /form-chips): {select_html[:600]}"
)
assert "OE-1" in select_html.split('<optgroup label="Sugestii">')[1].split("</optgroup>")[0], (
"sugestia fuzzy pentru 'Schimb ulei motor' trebuie sa contina OE-1 chiar la randarea initiala"
)
assert "__NEDECLARAT__" in select_html
def _login_seed_ret(client, email: str) -> int:
"""Ca `_login_seed`, dar intoarce account_id (necesar pentru inserarea directa a submission-ului)."""
acct = _create_account_user(email)
_login(client, email)
return acct

View File

@@ -535,8 +535,8 @@ def test_picker_flat_arata_cod_si_denumire(client):
def test_adauga_cod_extra_in_mod_operatii(client): def test_adauga_cod_extra_in_mod_operatii(client):
"""In mod operatii, actiunea add_extra adauga un cod RAR liber.""" """In mod operatii, actiunea unificata 'add' fara tinta adauga un cod RAR liber."""
acct = _create_account_user("add.extra.ops@test.com") _create_account_user("add.extra.ops@test.com")
_login(client, "add.extra.ops@test.com") _login(client, "add.extra.ops@test.com")
_seed_cod("OE-1", "Schimb ulei motor") _seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare") _seed_cod("FRN1", "Sistem de franare")
@@ -547,11 +547,11 @@ def test_adauga_cod_extra_in_mod_operatii(client):
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1"], # chip existent (op mapata) "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["SchimbUlei"], {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0},
"chip_denumire": ["Schimb ulei motor"], ]}),
"chips_action": "add_extra", "chips_action": "add",
"chips_add_cod_flat": "FRN1", # codul extra de adaugat "chips_pick": "FRN1", # codul extra de adaugat, fara tinta -> chip liber
}, },
) )
assert resp.status_code == 200, resp.text[:300] assert resp.status_code == 200, resp.text[:300]
@@ -601,32 +601,36 @@ def test_extra_cod_persistat_la_salvare(client):
def test_extra_cod_validat_nomenclator(client): def test_extra_cod_validat_nomenclator(client):
"""add_extra respinge cod necunoscut in nomenclator (invariant ORA-12899).""" """'add' fara tinta respinge cod necunoscut in nomenclator (invariant ORA-12899)."""
acct = _create_account_user("extra.valid@test.com") _create_account_user("extra.valid@test.com")
_login(client, "extra.valid@test.com") _login(client, "extra.valid@test.com")
_seed_cod("OE-1", "Schimb ulei") _seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client) csrf = _csrf(client)
# add_extra cu cod INVALID (XX-99 nu e in nomenclator) # add fara tinta cu cod INVALID (XX-99 nu e in nomenclator)
resp = client.post( resp = client.post(
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1"], "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["SchimbUlei"], {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
"chip_denumire": ["Schimb ulei"], ]}),
"chips_action": "add_extra", "chips_action": "add",
"chips_add_cod_flat": "XX-99", # cod necunoscut "chips_pick": "XX-99", # cod necunoscut
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# XX-99 NU trebuie sa apara ca chip valid (hidden input cu valoarea XX-99) # XX-99 NU trebuie sa apara in starea persistata (chips_state)
import re as _re import re as _re
hidden_xx99 = _re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="XX-99"', html) m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert hidden_xx99 is None, ( assert m
f"Codul invalid XX-99 a fost adaugat ca chip! HTML: {html[:500]}" payload = json.loads(m.group(1).replace("&#34;", '"'))
coduri = [it["cod"] for it in payload["items"]]
assert "XX-99" not in coduri, (
f"Codul invalid XX-99 a fost adaugat ca chip! items={payload['items']}"
) )
assert "chips-extra-error" in html
def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client): def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client):
@@ -703,12 +707,12 @@ def test_salvare_fara_chip_explicit_nu_e_no_op(client):
def test_picker_by_index_op2_nu_op1(client): def test_picker_by_index_op2_nu_op1(client):
"""Codul ales pe picker-ul op#2 aterizeaza pe op#2, NU pe op#1. """Codul ales pentru op#2 aterizeaza pe op#2, NU pe op#1.
Verifica alinierea by-index in modul operatii: chips_add_op_index=1 + chips_add_cod_1 Verifica alinierea by-index in modul operatii: chips_target_index=1 +
actualizeaza chips[1] (op#2), nu chips[0] (op#1). chips_pick actualizeaza chips[1] (op#2), nu chips[0] (op#1).
""" """
acct = _create_account_user("byindex.op2@test.com") _create_account_user("byindex.op2@test.com")
_login(client, "byindex.op2@test.com") _login(client, "byindex.op2@test.com")
_seed_cod("OE-1", "Schimb ulei") _seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Sistem de franare") _seed_cod("FRN1", "Sistem de franare")
@@ -719,27 +723,25 @@ def test_picker_by_index_op2_nu_op1(client):
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1", ""], # idx 0=OE-1 (mapata), idx 1="" (nemapata) "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["Op-A", "Op-B"], {"cod": "OE-1", "op": "Op-A", "den": "Prima", "exclus": 0},
"chip_denumire": ["Prima", "A doua"], {"cod": "", "op": "Op-B", "den": "A doua", "exclus": 0},
]}),
"chips_action": "add", "chips_action": "add",
"chips_add_op_index": "1", # adauga pe op#2 (idx=1) "chips_target_index": "1", # adauga pe op#2 (idx=1)
"chips_add_cod_1": "FRN1", # picker-ul op#2 contine FRN1 "chips_pick": "FRN1",
}, },
) )
assert resp.status_code == 200, resp.text[:300] assert resp.status_code == 200, resp.text[:300]
html = resp.text html = resp.text
import re as _re import re as _re
hidden_vals = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="([^"]*)"', html) m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert "OE-1" in hidden_vals, f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {hidden_vals}" assert m
assert "FRN1" in hidden_vals, f"FRN1 nu a aterizat pe op#2: {hidden_vals}" payload = json.loads(m.group(1).replace("&#34;", '"'))
# By-index: OE-1 trebuie sa fie INAINTE de FRN1 (idx 0 < idx 1) items = payload["items"]
oe1_pos = hidden_vals.index("OE-1") if "OE-1" in hidden_vals else -1 assert items[0]["cod"] == "OE-1", f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {items}"
frn1_pos = hidden_vals.index("FRN1") if "FRN1" in hidden_vals else -1 assert items[1]["cod"] == "FRN1", f"FRN1 nu a aterizat pe op#2 (idx=1): {items}"
assert oe1_pos < frn1_pos, (
f"FRN1 (op#2, idx=1) trebuie dupa OE-1 (op#1, idx=0) by-index: {hidden_vals}"
)
def test_empty_state_picker_nomenclator_gol(client): def test_empty_state_picker_nomenclator_gol(client):
@@ -774,25 +776,25 @@ def test_empty_state_picker_nomenclator_gol(client):
def test_add_extra_semnal_vizibil_cod_invalid(client): def test_add_extra_semnal_vizibil_cod_invalid(client):
"""add_extra cu cod invalid da semnal vizibil (nu esua silentios). """'add' fara tinta cu cod invalid da semnal vizibil (nu esua silentios).
div.chips-extra-error vizibil cand codul e invalid sau selectul e gol. div.chips-extra-error vizibil cand codul e invalid sau selectul e gol.
""" """
acct = _create_account_user("extra.err.signal@test.com") _create_account_user("extra.err.signal@test.com")
_login(client, "extra.err.signal@test.com") _login(client, "extra.err.signal@test.com")
_seed_cod("OE-1", "Schimb ulei") _seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client) csrf = _csrf(client)
# add_extra cu cod necunoscut in nomenclator # add fara tinta cu cod necunoscut in nomenclator
resp = client.post( resp = client.post(
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1"], "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["SchimbUlei"], {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
"chip_denumire": ["Schimb ulei"], ]}),
"chips_action": "add_extra", "chips_action": "add",
"chips_add_cod_flat": "XX-99", # cod inexistent "chips_pick": "XX-99", # cod inexistent
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -802,7 +804,7 @@ def test_add_extra_semnal_vizibil_cod_invalid(client):
def test_add_per_op_cod_invalid_afiseaza_mesaj(client): def test_add_per_op_cod_invalid_afiseaza_mesaj(client):
"""Actiunea 'add' (picker per operatie) cu cod invalid da semnal vizibil.""" """Actiunea 'add' (picker per operatie, cu tinta) cu cod invalid da semnal vizibil."""
_create_account_user("add.op.err@test.com") _create_account_user("add.op.err@test.com")
_login(client, "add.op.err@test.com") _login(client, "add.op.err@test.com")
_seed_cod("OE-1", "Schimb ulei") _seed_cod("OE-1", "Schimb ulei")
@@ -812,12 +814,12 @@ def test_add_per_op_cod_invalid_afiseaza_mesaj(client):
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": [""], "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["SchimbUlei"], {"cod": "", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
"chip_denumire": ["Schimb ulei"], ]}),
"chips_action": "add", "chips_action": "add",
"chips_add_op_index": "0", "chips_target_index": "0",
"chips_add_cod_0": "XX-99", # cod inexistent "chips_pick": "XX-99", # cod inexistent
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -837,21 +839,19 @@ def test_add_flat_cod_invalid_afiseaza_mesaj(client):
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1"], "chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}),
"chip_op_service": [""], "chips_action": "add",
"chip_denumire": [""], "chips_pick": "XX-99",
"chips_action": "add_flat",
"chips_add_cod_flat": "XX-99",
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "chips-extra-error" in resp.text, ( assert "chips-extra-error" in resp.text, (
f"Semnal de eroare lipsa la add_flat cu cod invalid: {resp.text[:300]}" f"Semnal de eroare lipsa la add cu cod invalid: {resp.text[:300]}"
) )
def test_add_flat_duplicat_afiseaza_mesaj(client): def test_add_flat_duplicat_afiseaza_mesaj(client):
"""add_flat cu un cod deja prezent NU dubleaza chip-ul si afiseaza mesaj.""" """'add' fara tinta cu un cod deja prezent NU dubleaza chip-ul si afiseaza mesaj."""
_create_account_user("add.flat.dup@test.com") _create_account_user("add.flat.dup@test.com")
_login(client, "add.flat.dup@test.com") _login(client, "add.flat.dup@test.com")
_seed_cod("OE-1", "Schimb ulei") _seed_cod("OE-1", "Schimb ulei")
@@ -861,24 +861,24 @@ def test_add_flat_duplicat_afiseaza_mesaj(client):
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1"], "chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}),
"chip_op_service": [""], "chips_action": "add",
"chip_denumire": [""], "chips_pick": "OE-1", # deja in lista
"chips_action": "add_flat",
"chips_add_cod_flat": "OE-1", # deja in lista
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200
import re as _re import re as _re
hidden = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', resp.text) m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert len(hidden) == 1, f"Chip OE-1 duplicat: {len(hidden)} aparitii" assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert len(payload["items"]) == 1, f"Chip OE-1 duplicat: {payload['items']}"
assert "deja in lista" in resp.text, ( assert "deja in lista" in resp.text, (
f"Mesaj de duplicat lipsa la add_flat: {resp.text[:300]}" f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}"
) )
def test_add_extra_duplicat_afiseaza_mesaj(client): def test_add_extra_duplicat_afiseaza_mesaj(client):
"""add_extra cu chip extra identic existent afiseaza mesaj (nu no-op).""" """'add' fara tinta cu chip liber identic existent afiseaza mesaj (nu no-op)."""
_create_account_user("add.extra.dup@test.com") _create_account_user("add.extra.dup@test.com")
_login(client, "add.extra.dup@test.com") _login(client, "add.extra.dup@test.com")
_seed_cod("OE-1", "Schimb ulei") _seed_cod("OE-1", "Schimb ulei")
@@ -889,16 +889,17 @@ def test_add_extra_duplicat_afiseaza_mesaj(client):
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1", "FRN1"], "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["SchimbUlei", ""], {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
"chip_denumire": ["Schimb ulei", ""], {"cod": "FRN1", "op": "", "den": "", "exclus": 0},
"chips_action": "add_extra", ]}),
"chips_add_cod_flat": "FRN1", # chip extra identic deja existent "chips_action": "add",
"chips_pick": "FRN1", # chip liber identic deja existent
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "deja in lista" in resp.text, ( assert "deja in lista" in resp.text, (
f"Mesaj de duplicat lipsa la add_extra: {resp.text[:300]}" f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}"
) )
@@ -914,11 +915,11 @@ def test_add_reusit_marcheaza_chip_nou(client):
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
"cod_prestatie": ["OE-1"], "chips_state": json.dumps({"v": 1, "items": [
"chip_op_service": ["SchimbUlei"], {"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
"chip_denumire": ["Schimb ulei"], ]}),
"chips_action": "add_extra", "chips_action": "add",
"chips_add_cod_flat": "FRN1", "chips_pick": "FRN1",
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200

View File

@@ -125,7 +125,8 @@ def client(monkeypatch):
def test_editabil_arata_select_cod_rar(client): def test_editabil_arata_select_cod_rar(client):
"""needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='cod_prestatie'>.""" """needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='chips_pick'>
(picker-ul unificat de cod RAR; starea chips traieste in hidden name='chips_state', PRD 5.23)."""
acct = _create_account_user("sel1@test.com") acct = _create_account_user("sel1@test.com")
_ins_nomenclator("OE-1", "OE-2") _ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="needs_data") sid = _ins(acct, status="needs_data")
@@ -133,8 +134,11 @@ def test_editabil_arata_select_cod_rar(client):
html = _detaliu(client, sid) html = _detaliu(client, sid)
assert 'name="cod_prestatie"' in html, ( assert 'name="chips_pick"' in html, (
"Formularul de detaliu needs_data trebuie sa contina un select cu name='cod_prestatie'" "Formularul de detaliu needs_data trebuie sa contina un select cu name='chips_pick'"
)
assert 'name="chips_state"' in html, (
"Starea chips trebuie persistata intr-un hidden name='chips_state'"
) )
assert "<select" in html, ( assert "<select" in html, (
"Elementul <select> trebuie sa apara in detaliu pentru starea needs_data" "Elementul <select> trebuie sa apara in detaliu pentru starea needs_data"

View File

@@ -0,0 +1,421 @@
"""US-004: persistarea `exclus` prin salvare (import + Trimiteri), round-trip complet.
Acopera:
- resolve_prestatii: item-exclus EXPLICIT supravietuieste re-rezolvarii (treapta noua
de precedenta) FARA sa strice restul precedentei (IRON RULE, regresiune-cheie).
- Import (web_editeaza_rand / apply_row_override): `exclus` persistat in override_json,
chip Nedeclarat reapare la redeschiderea modalului, x il readuce la warning.
- Rand cu toate operatiile excluse -> stare preview 'excluded' ("Nedeclarat").
- Trimiteri (post_corectie_trimitere): acelasi round-trip pe chips_state; toate
excluse -> needs_data cu motiv explicit.
- Sentinelul __NEDECLARAT__ NU ajunge in payload daca apare direct ca cod_prestatie
in starea hidden (nu doar ales din select), pe AMBELE cai.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Test unitar (fara client): precedenta resolve_prestatii #
# --------------------------------------------------------------------------- #
def test_exclus_supravietuieste_resolve_chiar_cu_mapare_pe_cont():
"""IRON RULE: item cu `exclus=1` explicit pe item RAMANE exclus dupa resolve_prestatii,
chiar daca operatia are (a) o mapare exacta pe cont SAU (b) un cod_prestatie deja
valid pe rand. Regresiune-cheie: restul precedentei (cod valid > exclus de cont >
mapare exacta > reguli text > nemapat) trebuie sa ramana IDENTICA pentru itemii
FARA `exclus` explicit — altfel stricam maparile existente.
"""
from app.mapping import resolve_prestatii
valid_codes = {"OE-1", "IG-1", "FRN1"}
mapping = {"Schimb-Ulei": "OE-1"} # mapare exacta pe cont pentru Schimb-Ulei
excluded_ops_cont = {"Filtru-Aer"} # regula de excludere PE CONT (alt mecanism)
prestatii = [
# (a) exclus explicit + mapare exacta pe cont exista -> exclus CASTIGA
{"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor", "exclus": 1},
# (b) exclus explicit + cod_prestatie deja valid pe rand -> exclus CASTIGA
{"cod_op_service": "Frana", "denumire": "Reparatie frana", "cod_prestatie": "FRN1", "exclus": 1},
# regresiune: FARA exclus explicit, mapare exacta trebuie sa functioneze ca inainte
{"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor 2"},
# regresiune: FARA exclus explicit, regula de excludere PE CONT trebuie sa functioneze
{"cod_op_service": "Filtru-Aer", "denumire": "Filtru aer"},
# regresiune: cod deja valid pe rand, FARA exclus -> pastrat ca atare (cod_valid)
{"cod_op_service": "", "denumire": "", "cod_prestatie": "IG-1"},
# regresiune: nemapat ramane nemapat
{"cod_op_service": "Necunoscut-Op", "denumire": "Operatie necunoscuta"},
]
resolved, unmapped = resolve_prestatii(
prestatii, mapping, valid_codes=valid_codes, text_rules=None, excluded_ops=excluded_ops_cont,
)
item_a = resolved[0]
assert item_a.get("exclus") is True, f"exclus explicit trebuia sa supravietuiasca: {item_a}"
assert not item_a.get("cod_prestatie"), f"item exclus explicit nu trebuie sa aiba cod_prestatie: {item_a}"
item_b = resolved[1]
assert item_b.get("exclus") is True, f"exclus explicit trebuia sa domine cod_prestatie valid: {item_b}"
assert not item_b.get("cod_prestatie"), f"cod_prestatie valid trebuia sters de exclus explicit: {item_b}"
# Regresiune: mapare exacta pe cont NESCHIMBATA pentru itemul fara exclus
item_c = resolved[2]
assert item_c.get("cod_prestatie") == "OE-1", f"mapare exacta pe cont stricata: {item_c}"
assert not item_c.get("exclus"), f"item fara exclus explicit nu trebuie marcat exclus: {item_c}"
# Regresiune: regula de excludere PE CONT (mecanism vechi) NESCHIMBATA
item_d = resolved[3]
assert item_d.get("exclus") is True, f"regula de excludere pe cont stricata: {item_d}"
assert not item_d.get("cod_prestatie")
# Regresiune: cod deja valid pastrat ca atare
item_e = resolved[4]
assert item_e.get("cod_prestatie") == "IG-1", f"cod valid pastrat stricat: {item_e}"
# Regresiune: nemapat ramane nemapat
assert len(unmapped) == 1
assert unmapped[0]["cod_op_service"] == "Necunoscut-Op"
def test_precedenta_neschimbata_pentru_itemi_fara_exclus():
"""Regresiune suplimentara: fara `exclus` explicit pe niciun item, comportamentul
lui resolve_prestatii e IDENTIC cu cel dinaintea treptei noi (cod valid > exclus
de cont > mapare exacta > reguli text > nemapat).
"""
from app.mapping import resolve_prestatii
valid_codes = {"OE-1"}
mapping = {"Op-Mapata": "OE-1"}
text_rules = [{"pattern": "frana", "cod_prestatie": "OE-1", "auto_send": 1, "priority": 1}]
excluded_ops = {"Op-Exclusa"}
prestatii = [
{"cod_op_service": "Op-Mapata", "denumire": "Operatie mapata"},
{"cod_op_service": "Op-Exclusa", "denumire": "Operatie exclusa"},
{"cod_op_service": "Op-Text", "denumire": "Reparatie frana fata"},
{"cod_op_service": "Op-Nemapat", "denumire": "Chestie noua"},
]
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
assert resolved[0]["cod_prestatie"] == "OE-1"
assert not resolved[0].get("exclus")
assert resolved[1]["exclus"] is True
assert not resolved[1].get("cod_prestatie")
assert resolved[2]["cod_prestatie"] == "OE-1" # via regula text
assert resolved[2].get("cod_sursa")
assert len(unmapped) == 1
assert unmapped[0]["cod_op_service"] == "Op-Nemapat"
# --------------------------------------------------------------------------- #
# Fixtures + helpere HTTP (import + Trimiteri, fara auth — cont 1 implicit) #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "editare_rand.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, denumire),
)
conn.commit()
finally:
conn.close()
def _csv_bytes(rows: list[dict]) -> bytes:
import csv as _csv
import io
buf = io.StringIO()
writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
_MAP_COLS = {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
def _upload_and_preview(client: TestClient, rows: list[dict]) -> int:
import io as _io
csv_data = _csv_bytes(rows)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", _io.BytesIO(csv_data), "text/csv")},
data={},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": "YYYY-MM-DD",
})
assert r2.status_code == 200, r2.text
return iid
def _chips_state_din_html(html: str) -> dict:
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m, f"hidden chips_state lipsa din HTML: {html[:400]}"
return json.loads(m.group(1).replace("&#34;", '"'))
# --- 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}"

View File

@@ -212,7 +212,7 @@ def test_camp_observatii_prezent(client):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_chips_multi_select_prestatii(client): def test_chips_multi_select_prestatii(client):
"""Submission cu cod_prestatie setat afiseaza chip cu hidden input.""" """Submission cu cod_prestatie setat afiseaza chip cu OE-1 in starea hidden chips_state."""
acct = _create_account_user("chips.test@test.com") acct = _create_account_user("chips.test@test.com")
_login(client, "chips.test@test.com") _login(client, "chips.test@test.com")
_seed_cod("OE-1", "Schimb ulei motor") _seed_cod("OE-1", "Schimb ulei motor")
@@ -222,14 +222,13 @@ def test_chips_multi_select_prestatii(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Trebuie sa existe un input (de obicei hidden) cu name="cod_prestatie" si valoarea "OE-1" # Starea chips traieste intr-UN SINGUR hidden JSON versionat (D4, PRD 5.23).
has_cod_prestatie_chip = bool( m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or assert m, "Formularul trebuie sa contina hidden input name='chips_state'"
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html) payload = json.loads(m.group(1).replace("&#34;", '"'))
) coduri = [it["cod"] for it in payload["items"]]
assert has_cod_prestatie_chip, ( assert "OE-1" in coduri, (
"Formularul trebuie sa contina un hidden input cu name='cod_prestatie' value='OE-1' " f"chips_state trebuie sa contina codul OE-1 al prestatiei. items={payload['items']}"
"reprezentand chip-ul de prestatie."
) )
@@ -244,19 +243,19 @@ def test_adauga_sterge_chip(client):
_seed_cod("OE-1", "Schimb ulei motor") _seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client) csrf = _csrf(client)
# POST /form-chips: adauga OE-1 la prima operatie (index 0) # POST /form-chips: adauga OE-1 la prima operatie (index 0), stare curenta = un
# singur hidden chips_state (JSON versionat, D4).
state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]})
resp = client.post( resp = client.post(
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
# Starea curenta: o operatie fara cod "chips_state": state,
"chip_op_service": ["Op-A"],
"chip_denumire": ["Schimb ulei motor"],
"cod_prestatie": [""], # nemaapat initial
# Actiunea
"chips_action": "add", "chips_action": "add",
"chips_add_op_index": "0", "chips_target_index": "0",
"chips_add_cod_0": "OE-1", "chips_pick": "OE-1",
}, },
) )
assert resp.status_code == 200, f"/form-chips a returnat {resp.status_code}: {resp.text[:400]}" assert resp.status_code == 200, f"/form-chips a returnat {resp.status_code}: {resp.text[:400]}"
@@ -266,34 +265,31 @@ def test_adauga_sterge_chip(client):
assert "OE-1" in html, ( assert "OE-1" in html, (
f"Dupa add, OE-1 trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}" f"Dupa add, OE-1 trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}"
) )
# Si hidden input cu valoarea OE-1 # Si in starea hidden chips_state
has_hidden = bool( m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or assert m, f"hidden chips_state lipsa. html[:600]={html[:600]}"
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html) payload = json.loads(m.group(1).replace("&#34;", '"'))
) assert payload["items"][0]["cod"] == "OE-1", (
assert has_hidden, ( f"Dupa add, chips_state trebuie sa contina OE-1. items={payload['items']}"
"Dupa add, trebuie sa existe un input cu name='cod_prestatie' value='OE-1' "
f"in raspunsul /form-chips. html[:600]={html[:600]}"
) )
def test_sterge_chip(client): def test_sterge_chip(client):
"""POST /form-chips cu action=remove sterge chip-ul la indexul dat.""" """POST /form-chips cu action=remove sterge codul chip-ului la indexul dat."""
acct = _create_account_user("form.chips.del@test.com") acct = _create_account_user("form.chips.del@test.com")
_login(client, "form.chips.del@test.com") _login(client, "form.chips.del@test.com")
_seed_cod("OE-1", "Schimb ulei motor") _seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client) csrf = _csrf(client)
# POST /form-chips: sterge chip-ul de la index 0 (OE-1 existent) # POST /form-chips: sterge codul chip-ului de la index 0 (OE-1 existent, mapat pe Op-A)
state = json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]})
resp = client.post( resp = client.post(
"/form-chips", "/form-chips",
data={ data={
"csrf_token": csrf, "csrf_token": csrf,
# Starea curenta: OE-1 mapat pe Op-A "chips_state": state,
"chip_op_service": ["Op-A"],
"chip_denumire": ["Schimb ulei motor"],
"cod_prestatie": ["OE-1"],
# Actiunea: sterge indexul 0
"chips_action": "remove", "chips_action": "remove",
"chips_remove_index": "0", "chips_remove_index": "0",
}, },
@@ -301,15 +297,15 @@ def test_sterge_chip(client):
assert resp.status_code == 200, f"/form-chips remove a returnat {resp.status_code}: {resp.text[:400]}" assert resp.status_code == 200, f"/form-chips remove a returnat {resp.status_code}: {resp.text[:400]}"
html = resp.text html = resp.text
# Dupa remove, OE-1 nu mai apare ca chip (input hidden cu acea valoare) # Dupa remove, OE-1 nu mai apare ca cod in starea chips (operatia RAMANE, nemapata)
has_oe1_chip = bool( m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or assert m
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html) payload = json.loads(m.group(1).replace("&#34;", '"'))
) coduri = [it["cod"] for it in payload["items"]]
assert not has_oe1_chip, ( assert "OE-1" not in coduri, (
"Dupa remove, OE-1 NU mai trebuie sa apara ca chip " f"Dupa remove, OE-1 NU mai trebuie sa apara ca cod in chips_state. items={payload['items']}"
f"(hidden input cu cod_prestatie=OE-1). html[:500]={html[:500]}"
) )
assert payload["items"][0]["op"] == "Op-A", "Operatia trebuie sa ramana (doar codul dispare)"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -437,11 +433,16 @@ def test_form_chips_reveal_r_odo(client):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Test 8: Picker per operatie (E4 binding) -- format op-row # # Test 8: Chip warning pentru operatie nemapata (control unificat, PRD 5.23) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_picker_per_operatie_in_form(client): def test_picker_per_operatie_in_form(client):
"""Operatie nemapata (needs_mapping) -> formularul afiseaza picker pe operatie.""" """Operatie nemapata (needs_mapping) -> formularul afiseaza chip warning + selectul unic.
PRD 5.23 US-002: controlul unificat elimina `.op-row` (rand per operatie cu
picker propriu); operatia nemapata devine un chip warning in containerul
unic `.chips`, iar selectul e unul singur (`chips_pick`, id `chips-picker`).
"""
acct = _create_account_user("picker.op@test.com") acct = _create_account_user("picker.op@test.com")
_login(client, "picker.op@test.com") _login(client, "picker.op@test.com")
# NU seed-uim nicio mapare -> operatia ramane nemapata # NU seed-uim nicio mapare -> operatia ramane nemapata
@@ -455,17 +456,15 @@ def test_picker_per_operatie_in_form(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Operatia REVIZIE PERIODICA trebuie sa apara in form (op-row cu clasa specifica) # Controlul unificat (PRD 5.23): fara .op-row, UN singur container .chips.
# clasa "op-row" din CSS base.html e adaugata NUMAI de chips_prestatii.html assert "op-row" not in html, (
has_op_row = "op-row" in html "op-row a fost eliminat de controlul unificat (PRD 5.23 US-002). "
assert has_op_row, (
"Formularul trebuie sa contina clasa 'op-row' "
"pentru picker-ul per-operatie. "
"Aceasta clasa e adaugata de _chips_prestatii.html. "
f"html[:600]={html[:600]}" f"html[:600]={html[:600]}"
) )
# Operatia REVIZIE PERIODICA trebuie sa apara in context op-row assert 'class="chips"' in html
assert 'id="chips-picker"' in html
# Operatia REVIZIE PERIODICA trebuie sa apara ca chip warning
assert "REVIZIE PERIODICA" in html, ( assert "REVIZIE PERIODICA" in html, (
"Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare (op-row). " "Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare "
f"html[:500]={html[:500]}" f"(chip warning). html[:500]={html[:500]}"
) )

View File

@@ -280,8 +280,8 @@ def test_optional_nu_forteaza(client):
def test_fara_select_vechi_redundant(client): def test_fara_select_vechi_redundant(client):
"""Detaliu needs_data NU mai contine <select name='cod_prestatie'>. """Detaliu needs_data NU mai contine <select name='cod_prestatie'>.
Formularul editabil foloseste NUMAI chips (hidden inputs cod_prestatie), Formularul editabil foloseste NUMAI chips (stare in hidden name='chips_state',
fara vechiul select dublu. Chips functioneaza ca singura sursa de cod_prestatie. JSON versionat, PRD 5.23), fara vechiul select dublu.
Nota: <select name="cod_prestatie"> RAMANE in formularul /repune pentru starea error. Nota: <select name="cod_prestatie"> RAMANE in formularul /repune pentru starea error.
""" """
@@ -302,15 +302,14 @@ def test_fara_select_vechi_redundant(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Chipurile trebuie sa fie prezente (hidden input cu name="cod_prestatie") # Chipurile trebuie sa fie prezente (hidden input cu name="chips_state", JSON versionat)
has_chip_hidden = ( has_chip_hidden = re.search(r'<input[^>]+type=["\']hidden["\'][^>]+name=["\']chips_state["\']', html)
re.search(r'<input[^>]+type=["\']hidden["\'][^>]+name=["\']cod_prestatie["\']', html) or
re.search(r'<input[^>]+name=["\']cod_prestatie["\'][^>]+type=["\']hidden["\']', html)
)
assert has_chip_hidden, ( assert has_chip_hidden, (
"Chips: trebuie sa existe input hidden cu name='cod_prestatie' (din _chips_prestatii.html). " "Chips: trebuie sa existe input hidden cu name='chips_state' (din _chips_prestatii.html). "
f"html[:600]={html[:600]}" f"html[:600]={html[:600]}"
) )
# Cod-ul OE-1 trebuie sa apara in stare (JSON), nu ca input separat
assert "OE-1" in html, f"OE-1 trebuie sa apara in chips_state. html[:600]={html[:600]}"
# Vechiul <select name="cod_prestatie"> NU trebuie sa existe in sectiunea editabila. # Vechiul <select name="cod_prestatie"> NU trebuie sa existe in sectiunea editabila.
# (needs_data nu are formular /repune, deci niciun select cu name="cod_prestatie" legal) # (needs_data nu are formular /repune, deci niciun select cu name="cod_prestatie" legal)

View File

@@ -0,0 +1,97 @@
"""Prestatiile excluse de la declarare nu ajung niciodata la RAR.
content['prestatii'] pastreaza itemii exclusi (round-trip in editor), dar
payload-ul trimis efectiv la RAR (build_rar_payload, apelat din process_one)
trebuie sa contina DOAR partea declarabila. Un item exclus/fara cod nu produce
niciodata {codPrestatie: null} in payload.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
settings = get_settings()
yield conn, settings
conn.close()
get_settings.cache_clear()
_CONTENT_CU_EXCLUS = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [
{"cod_prestatie": "OE-1", "cod_op_service": "100"},
{"cod_prestatie": None, "cod_op_service": "200", "denumire": "Spalare", "exclus": True},
],
"sistem_reparat": "null",
}
def _insert(conn, content):
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, retry_count) "
"VALUES (?, 'queued', ?, 0)",
(f"key-{os.urandom(4).hex()}", json.dumps(content)),
)
return int(cur.lastrowid)
class FakeRarCapturePayload:
"""RAR mock care retine payload-ul trimis efectiv la postPrezentare."""
def __init__(self):
self.sent_payload = None
def get_finalizate(self, token):
return []
def post_prezentare(self, token, payload):
self.sent_payload = payload
return {"id": 999}
def test_process_one_nu_trimite_item_exclus_la_rar(env):
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn, _CONTENT_CU_EXCLUS)
rar = FakeRarCapturePayload()
claimed = {"id": sid, "account_id": None, "content": _CONTENT_CU_EXCLUS}
status = process_one(conn, settings, rar, "tok", claimed)
assert status == "sent"
assert rar.sent_payload is not None
coduri = [p["codPrestatie"] for p in rar.sent_payload["prestatii"]]
assert None not in coduri
assert coduri == ["OE-1"]
def test_process_one_pastreaza_content_intact_in_db(env):
"""content['prestatii'] din DB nu se ingusteaza — doar payload-ul RAR."""
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn, _CONTENT_CU_EXCLUS)
rar = FakeRarCapturePayload()
claimed = {"id": sid, "account_id": None, "content": dict(_CONTENT_CU_EXCLUS)}
process_one(conn, settings, rar, "tok", claimed)
row = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone()
saved = json.loads(row["payload_json"])
# payload_json in DB nu e atins de process_one (doar payload-ul RAR e ingustat) —
# ramane cu ambele prestatii, inclusiv cea exclusa.
assert len(saved["prestatii"]) == 2