feat(5.8): reguli mapare pe text (substring/cont) + UX tabel trimiteri (detaliu inline, fara scroll, cod RAR)

Reguli text per cont (operation_text_rules), resolve_prestatii cu param aditiv
text_rules + precedenta stricta, threadat pe toate cele 6 callsite-uri + valid_codes
+ seam classify_prezentare. UI Mapari: sectiune reguli + preview pre-salvare + overlap
+ telemetrie text_rule_hit. UX tabel: cod_rar sub operatie, pill eticheta scurta, fara
scroll orizontal (scopat .tabel-trimiteri + carduri <768px), detaliu inline expandabil
(a11y + pauza poll). code-review: reparat regula auto_send=0 care trimitea automat la RAR
in loc sa tina randul pentru review. 814 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-24 12:47:37 +00:00
parent c80c79462c
commit 51dc504f1d
28 changed files with 3023 additions and 61 deletions

View File

@@ -87,27 +87,127 @@ def suggest_codes(
]
# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text (US-010).
# Forma: "text_rule:<pattern original al regulii castigatoare>". Payload-harmless —
# RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect.
COD_SURSA_TEXT_RULE_PREFIX = "text_rule:"
def _rezolva_din_reguli_text(
item: dict,
text_rules: list[dict] | None,
valid_codes: set[str] | None,
) -> tuple[str | None, str | None, bool | None]:
"""Cauta prima regula text (in ordinea data) al carei pattern e substring al
textului operatiei. Intoarce (cod uppercase, pattern original, auto_send) daca e
valid, altfel (None, None, None).
Textul operatiei = `denumire` daca exista, altfel `cod_op_service`. Ambele parti
(text si pattern) se normalizeaza cu `normalize_for_match` (fara diacritice,
uppercase, spatii colapsate) -> match insensibil la caz/diacritice.
`text_rules` e deja ordonata (priority ASC, id ASC) de `load_text_rules`, deci
prima regula care da match castiga. Daca regula castigatoare are un cod absent din
`valid_codes` (cand `valid_codes` e setat), nu intoarcem un cod invalid ->
(None, None, None) (operatia ramane nemapata), coerent cu garda din `resolve_prestatii`.
Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie US-010), nu cel
normalizat folosit la match. `auto_send` = flagul regulii castigatoare: cand e
falsy (DEFAULT 0, decizia CEO de siguranta) randul trebuie TINUT pentru verificare
umana, nu trimis automat la RAR (blast radius substring + FINALIZATA ireversibil).
"""
if not text_rules:
return None, None, None
text = normalize_for_match(item.get("denumire") or item.get("cod_op_service"))
if not text:
return None, None, None
for rule in text_rules:
pat = normalize_for_match(rule.get("pattern"))
if not pat or pat not in text:
continue
# Prima regula care da match castiga.
cod = (rule.get("cod_prestatie") or "").strip().upper()
if not cod:
return None, None, None
if valid_codes is not None and cod not in valid_codes:
return None, None, None # cod invalid in nomenclator -> nu il punem; ramane nemapat
return cod, rule.get("pattern"), bool(rule.get("auto_send"))
return None, None, None
def text_rule_hits(resolved: list[dict] | None) -> list[dict]:
"""Extrage din itemii rezolvati cei care au primit cod dintr-o regula text (US-010).
Intoarce [{pattern, cod_prestatie}] pentru fiecare item al carui `cod_sursa`
incepe cu `COD_SURSA_TEXT_RULE_PREFIX`. Pur (fara DB); apelantii cu `conn` il
folosesc ca sa emita `log_event("text_rule_hit", ...)`.
"""
hits: list[dict] = []
for item in resolved or []:
sursa = item.get("cod_sursa")
if isinstance(sursa, str) and sursa.startswith(COD_SURSA_TEXT_RULE_PREFIX):
hits.append({
"pattern": sursa[len(COD_SURSA_TEXT_RULE_PREFIX):],
"cod_prestatie": item.get("cod_prestatie"),
})
return hits
def text_rules_overlap(pattern: str, existing_rules: list[dict] | None) -> list[dict]:
"""Reguli text existente care se SUPRAPUN cu `pattern` (US-011, avertisment neblocant).
Overlap = pattern-ul nou normalizat (`normalize_for_match`) e substring al unei
reguli existente SAU invers (oricare directie). Pur, determinist, fara DB.
Un pattern IDENTIC dupa normalizare NU e overlap: e un upsert (update al codului),
nu o suprapunere care merita avertisment. Intoarce dict-urile originale din
`existing_rules` care se suprapun (in ordinea data).
"""
pat = normalize_for_match(pattern)
if not pat:
return []
hits: list[dict] = []
for rule in existing_rules or []:
other = normalize_for_match(rule.get("pattern"))
if not other or other == pat:
continue # gol sau identic -> nu e overlap
if pat in other or other in pat:
hits.append(rule)
return hits
def resolve_prestatii(
prestatii: list[dict] | None,
mapping: dict[str, str],
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
) -> tuple[list[dict], list[dict]]:
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
Reguli (hibrid):
- item cu `cod_prestatie` valid (in nomenclator) -> pastrat ca atare.
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
- item fara cod si fara mapare -> ramane nemapat.
- item fara cod, nemapat exact, dar al carui text da match pe o regula text
(substring) -> umplem cod_prestatie din prima regula care potriveste.
- item fara cod, fara mapare si fara regula text -> ramane nemapat.
- item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de
mapat: il promovam la `cod_op_service` (daca nu exista deja) ca sa intre in
fluxul needs_mapping. Confirmat live (2026-06-23): RAR accepta NUMAI coduri
din nomenclator (coloana COD_PRESTATIE max 5 car.); un cod necunoscut da
HTTP 500 si RECORD PARTIAL la RAR (terminal) -> nu-l trimitem niciodata raw.
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service`
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista
cod valid SI op nu e in `mapping`.
`valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e
None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece
neatins"); rutele API il paseaza intotdeauna.
`text_rules` = lista de dict-uri ca cea intoarsa de `load_text_rules`
([{pattern, cod_prestatie, auto_send, priority}], ordonata priority ASC, id ASC).
Default None = comportament actual neschimbat (fara reguli text).
Intoarce (prestatii_rezolvate, nemapate). `prestatii_rezolvate` pastreaza
si campurile originale (cod_op_service/denumire) ca re-rezolvarea sa aiba
contextul; payload-ul RAR citeste doar cod_prestatie. `nemapate` =
@@ -117,6 +217,12 @@ def resolve_prestatii(
unmapped: list[dict] = []
for item in prestatii or []:
it = dict(item)
# Curata adnotarile aditive ale rezolvarii (cod_sursa US-010 + flagul de
# hold pe regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare.
# Altfel, un item re-rezolvat acum prin alta cale (ex. mapare exacta) ar pastra
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
it.pop("cod_sursa", None)
it.pop("regula_fara_autosend", None)
cod = (it.get("cod_prestatie") or "").strip().upper()
op = (it.get("cod_op_service") or "").strip()
cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes)
@@ -134,8 +240,22 @@ def resolve_prestatii(
if op and op in mapping:
it["cod_prestatie"] = mapping[op]
elif op:
it["cod_prestatie"] = None
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")})
# Mapare exacta absenta -> incearca regulile text (substring).
cod_regula, pattern_regula, auto_send_regula = _rezolva_din_reguli_text(
it, text_rules, valid_codes
)
if cod_regula is not None:
it["cod_prestatie"] = cod_regula
# Adnotare aditiva (US-010): marcheaza ca rezolvat-prin-regula cu
# pattern-ul sursa. Payload-harmless (RAR citeste doar cod_prestatie).
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
# Siguranta CEO (US-001): regula cu auto_send=0 rezolva codul dar
# TINE randul pentru verificare umana (has_no_auto_send -> True).
if not auto_send_regula:
it["regula_fara_autosend"] = True
else:
it["cod_prestatie"] = None
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")})
# item fara cod si fara op: il lasam asa; validarea de continut prinde
# "prestatii goale"/cod lipsa.
resolved.append(it)
@@ -254,6 +374,7 @@ def classify_prezentare(
mapping: dict[str, str],
mapping_meta: dict[str, dict],
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
) -> dict:
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
@@ -273,7 +394,7 @@ def classify_prezentare(
"odometru_final": canon["odometru_final"],
})
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes)
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules)
c["prestatii"] = resolved
if unmapped:
@@ -311,12 +432,17 @@ def classify_prezentare(
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
"""Verifica daca vreun item rezolvat via mapping are auto_send=0.
"""Verifica daca vreun item rezolvat are auto_send=0 (mapare exacta SAU regula text).
T6/OV-1: un cod nou-mapat cu auto_send=0 nu trebuie trimis automat.
Items cu cod_prestatie direct (nu via cod_op_service) nu sunt afectate.
T6/OV-1: un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat.
PRD 5.8 US-001 (decizia CEO): la fel pentru un item rezolvat printr-o REGULA TEXT cu
auto_send=0 — marcat de `resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri
randul ramane needs_mapping (review manual) pana cand operatorul activeaza „In coada".
Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate.
"""
for item in resolved:
if item.get("regula_fara_autosend"):
return True
op = (item.get("cod_op_service") or "").strip()
if op and op in mapping_meta and not mapping_meta[op]["auto_send"]:
return True
@@ -395,6 +521,85 @@ def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestati
)
def load_text_rules(conn, account_id: int | None) -> list[dict]:
"""Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC.
Fiecare element: {pattern, cod_prestatie, auto_send, priority}.
Aplica account_or_default (None == 1).
"""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT pattern, cod_prestatie, auto_send, priority "
"FROM operation_text_rules "
"WHERE account_id=? "
"ORDER BY priority ASC, id ASC",
(acct,),
).fetchall()
return [dict(r) for r in rows]
def save_text_rule(
conn,
account_id: int | None,
pattern: str,
cod_prestatie: str,
auto_send: bool,
) -> None:
"""Upsert o regula text pe (account_id, pattern).
auto_send boolean -> 0/1. Daca regula exista deja (acelasi cont + pattern),
actualizeaza cod_prestatie si auto_send.
"""
acct = account_or_default(account_id)
pat = (pattern or "").strip()
cod = (cod_prestatie or "").strip().upper()
if not pat or not cod:
raise ValueError("pattern si cod_prestatie sunt obligatorii")
conn.execute(
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(account_id, pattern) DO UPDATE SET "
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send",
(acct, pat, cod, 1 if auto_send else 0),
)
def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
"""Sterge regula cu (account_id, pattern) daca exista."""
acct = account_or_default(account_id)
pat = (pattern or "").strip()
conn.execute(
"DELETE FROM operation_text_rules WHERE account_id=? AND pattern=?",
(acct, pat),
)
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
US-010: telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event
inghite exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} —
fara PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import.
"""
hits = text_rule_hits(resolved)
if not hits:
return
from .observ import log_event # import local: best-effort, fara ciclu la import-time
for hit in hits:
log_event(
"text_rule_hit",
account_id=account_id,
cod=hit.get("cod_prestatie"),
conn=conn,
context={
"submission_id": submission_id,
"account_id": account_id,
"pattern": hit.get("pattern"),
"cod_prestatie": hit.get("cod_prestatie"),
},
)
def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) -> dict[str, int]:
"""Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare.
@@ -413,6 +618,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
# T2: incarca regulile text O DATA, inainte de bucla pe randuri.
text_rules = load_text_rules(conn, acct)
if batch_id is not None:
# T7: scope la batch-ul specificat (import commit explicit).
@@ -438,10 +645,13 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
content = json.loads(r["payload_json"])
except (ValueError, TypeError):
continue
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes)
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
content["prestatii"] = resolved
payload_json = json.dumps(content, ensure_ascii=False)
# US-010: telemetrie pentru itemii rezolvati prin regula text.
_emite_text_rule_hits(conn, acct, r["id"], resolved)
if unmapped:
conn.execute(
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",