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:
226
app/mapping.py
226
app/mapping.py
@@ -87,27 +87,127 @@ def suggest_codes(
|
||||
]
|
||||
|
||||
|
||||
# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text (US-010).
|
||||
# Forma: "text_rule:<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=?",
|
||||
|
||||
Reference in New Issue
Block a user