feat(mapari): regula 'exclude de la declarare' per operatie + stare preview Nedeclarat

Operatiile care nu se declara la RAR (ex. ITP facturat in service) primesc o
regula exclus=1 in operations_mapping, setabila din panoul de mapare al
preview-ului de import si din tab-ul Mapari (optiunea 'Nu se declara la RAR').

- resolve_prestatii(excluded_ops): item nemapat cu op exclusa -> adnotat exclus,
  nu mai e needs_mapping; precedenta: cod explicit > exclus > mapare > reguli text
- split_prestatii_excluse: itemii exclusi nu intra niciodata in payload/cheie
- preview import: rand cu toate operatiile excluse -> stare 'excluded'
  (eticheta Nedeclarat), necomis; operatia dispare din panoul de mapat
- reresolve/corectie/API: submission cu toate operatiile excluse -> needs_data
  cu motiv explicit; ingestia API trateaza excluderea la clasificare
- migrare: coloana operations_mapping.exclus + rebuild import_rows pentru
  CHECK-ul resolved_status cu 'excluded' (o singura data, gardat pe sqlite_master)
- fix flake: clamp similaritate embeddings la [-1,1] (float32 dadea 1.0000001)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-07 17:49:47 +00:00
parent c6e29aa65d
commit 0bc618763e
14 changed files with 616 additions and 37 deletions

View File

@@ -39,6 +39,10 @@ DEFAULT_ACCOUNT_ID = 1
# Sub acest scor (0..100) nu preselectam nicio sugestie — userul alege manual.
SUGGEST_MIN_SCORE = 60
# Sentinel pentru optiunea "nu se declara la RAR" din select-urile web de mapare.
# NU e cod RAR (codurile au max 5 caractere) — nu poate coliziona cu nomenclatorul.
EXCLUDE_SENTINEL = "__NEDECLARAT__"
# --------------------------------------------------------------------------- #
# Pur: normalizare + fuzzy + rezolvare #
@@ -243,11 +247,15 @@ def resolve_prestatii(
mapping: dict[str, str],
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
excluded_ops: set[str] | 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 `excluded_ops` -> adnotat `exclus=True`
(NU e nemapat; nu se declara la RAR — apelantii il scot din payload cu
`split_prestatii_excluse` inainte de enqueue).
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
- item fara cod, nemapat exact, dar al carui text da match pe o regula text
(substring) -> umplem cod_prestatie din prima regula care potriveste.
@@ -258,9 +266,11 @@ def resolve_prestatii(
COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
la RAR (terminal) -> nu-l trimitem niciodata raw.
Precedenta (stricta): `cod_prestatie` direct valid > 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`.
Precedenta (stricta): `cod_prestatie` direct valid > exclus de la declarare >
mapare exacta `cod_op_service` in `mapping` > reguli text > nemapat. Un cod ales
explicit pe rand (editor) bate regula de excludere; regula de excludere bate
regulile text. Regulile text se incearca DOAR cand nu exista cod valid SI op
nu e in `mapping`.
`valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e
None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece
@@ -285,6 +295,7 @@ def resolve_prestatii(
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
it.pop("cod_sursa", None)
it.pop("regula_fara_autosend", None)
it.pop("exclus", None)
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)
@@ -299,7 +310,11 @@ def resolve_prestatii(
it["cod_op_service"] = op
if not it.get("denumire"):
it["denumire"] = cod
if op and op in mapping:
if op and excluded_ops and op in excluded_ops:
# Exclus de la declarare: nu e nemapat, nu se trimite la RAR.
it["cod_prestatie"] = None
it["exclus"] = True
elif op and op in mapping:
it["cod_prestatie"] = mapping[op]
elif op:
# Mapare exacta absenta -> incearca regulile text (substring).
@@ -322,6 +337,17 @@ def resolve_prestatii(
return resolved, unmapped
def split_prestatii_excluse(prestatii: list[dict] | None) -> tuple[list[dict], list[dict]]:
"""Separa prestatiile declarabile de cele adnotate `exclus` de resolve_prestatii.
Payload-ul trimis la RAR (si cheia de idempotenta) se construieste NUMAI din
declarabile; cele excluse nu parasesc niciodata sistemul.
"""
declarabile = [p for p in (prestatii or []) if not p.get("exclus")]
excluse = [p for p in (prestatii or []) if p.get("exclus")]
return declarabile, excluse
# --------------------------------------------------------------------------- #
# Persistenta (conn) #
# --------------------------------------------------------------------------- #
@@ -404,10 +430,11 @@ def load_nomenclator_codes(conn) -> set[str]:
def load_mapping(conn, account_id: int | None) -> dict[str, str]:
"""{cod_op_service -> cod_prestatie} pentru un cont."""
"""{cod_op_service -> cod_prestatie} pentru un cont. Fara regulile de excludere."""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT cod_op_service, cod_prestatie FROM operations_mapping WHERE account_id=?",
"SELECT cod_op_service, cod_prestatie FROM operations_mapping "
"WHERE account_id=? AND exclus=0",
(acct,),
).fetchall()
return {r["cod_op_service"]: r["cod_prestatie"] for r in rows}
@@ -417,10 +444,13 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
Varianta extinsa care include si flagul auto_send per operatie.
Fara regulile de excludere (cod_prestatie gol) — consumatorii construiesc din ea
dict-uri de mapare op->cod; excluderile se incarca separat (load_excluded_ops).
"""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT cod_op_service, cod_prestatie, auto_send FROM operations_mapping WHERE account_id=?",
"SELECT cod_op_service, cod_prestatie, auto_send FROM operations_mapping "
"WHERE account_id=? AND exclus=0",
(acct,),
).fetchall()
return {
@@ -429,12 +459,23 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
}
def load_excluded_ops(conn, account_id: int | None) -> set[str]:
"""Setul de operatii excluse de la declarare pentru un cont."""
acct = account_or_default(account_id)
rows = conn.execute(
"SELECT cod_op_service FROM operations_mapping WHERE account_id=? AND exclus=1",
(acct,),
).fetchall()
return {r["cod_op_service"] for r in rows}
def classify_prezentare(
content: dict,
mapping: dict[str, str],
mapping_meta: dict[str, dict],
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
excluded_ops: set[str] | None = None,
) -> dict:
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
@@ -454,7 +495,7 @@ def classify_prezentare(
"odometru_final": canon["odometru_final"],
})
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules)
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
c["prestatii"] = resolved
if unmapped:
@@ -466,7 +507,20 @@ def classify_prezentare(
)
errors: list[dict] = []
else:
errors = validate_prezentare(c)
# Prestatiile excluse de la declarare NU intra in payload-ul trimis (nici in
# cheia de idempotenta). Toate excluse -> needs_data cu motiv explicit;
# payload-ul pastreaza itemii adnotati `exclus` ca detaliul sa arate operatiile.
declarabile, excluse = split_prestatii_excluse(resolved)
if not declarabile and excluse:
ops_excluse = ", ".join((p.get("cod_op_service") or "") for p in excluse)
errors = [{
"field": "prestatii",
"message": f"Toate operatiile sunt excluse de la declarare ({ops_excluse}) — randul nu se trimite la RAR.",
}]
else:
if excluse:
c["prestatii"] = declarabile
errors = validate_prezentare(c)
if errors:
status = "needs_data"
rar_error = json.dumps(errors, ensure_ascii=False)
@@ -522,6 +576,7 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
).fetchall()
agg: dict[tuple[int, str], dict[str, Any]] = {}
excluded_by_acct: dict[int, set[str]] = {}
for r in rows:
acct = r["account_id"] if r["account_id"] is not None else DEFAULT_ACCOUNT_ID
try:
@@ -536,6 +591,11 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
op = (item.get("cod_op_service") or "").strip()
if not op:
continue
# Operatiile excluse de la declarare nu sunt "de mapat" — nu apar in editor.
if acct not in excluded_by_acct:
excluded_by_acct[acct] = load_excluded_ops(conn, acct)
if op in excluded_by_acct[acct]:
continue
key = (acct, op)
entry = agg.setdefault(
key,
@@ -563,21 +623,44 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
def save_mapping(conn, account_id: int | None, cod_op_service: str, cod_prestatie: str, auto_send: bool) -> None:
"""Upsert o mapare op->cod (UNIQUE pe account_id+cod_op_service)."""
"""Upsert o mapare op->cod (UNIQUE pe account_id+cod_op_service).
Reseteaza `excluded=0`: maparea unei operatii excluse anterior o readuce
in fluxul de declarare (aceeasi cheie UNIQUE, o operatie = o regula).
"""
acct = account_or_default(account_id)
op = (cod_op_service or "").strip()
cod = (cod_prestatie or "").strip().upper()
if not op or not cod:
raise ValueError("cod_op_service si cod_prestatie sunt obligatorii")
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?) "
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send, exclus) "
"VALUES (?, ?, ?, ?, 0) "
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send",
"cod_prestatie=excluded.cod_prestatie, auto_send=excluded.auto_send, exclus=0",
(acct, op, cod, 1 if auto_send else 0),
)
def save_exclusion(conn, account_id: int | None, cod_op_service: str) -> None:
"""Upsert o regula 'nu se declara' pentru o operatie (UNIQUE pe account_id+op).
cod_prestatie ramane gol — regula nu mapeaza, doar exclude de la declarare.
Suprascrie o mapare existenta pe aceeasi operatie (o operatie = o regula).
"""
acct = account_or_default(account_id)
op = (cod_op_service or "").strip()
if not op:
raise ValueError("cod_op_service este obligatoriu")
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send, exclus) "
"VALUES (?, ?, '', 0, 1) "
"ON CONFLICT(account_id, cod_op_service) DO UPDATE SET "
"cod_prestatie='', auto_send=0, exclus=1",
(acct, op),
)
def load_text_rules(conn, account_id: int | None) -> list[dict]:
"""Returneaza regulile text ale unui cont, ordonate priority ASC, id ASC.
@@ -929,6 +1012,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
valid_codes = load_nomenclator_codes(conn) or None
# Incarca regulile text O DATA, inainte de bucla pe randuri.
text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
if batch_id is not None:
# Scope la batch-ul specificat (import commit explicit).
@@ -954,7 +1038,7 @@ 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, text_rules)
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules, excluded_ops)
content["prestatii"] = resolved
payload_json = json.dumps(content, ensure_ascii=False)
@@ -969,6 +1053,28 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
stats["still_blocked"] += 1
continue
# Prestatiile excluse ies din payload-ul trimis; toate excluse -> needs_data
# cu motiv explicit (randul nu se mai trimite; operatorul il poate sterge).
# Payload-ul pastreaza itemii adnotati `exclus` DOAR in ramura blocata, ca
# detaliul sa arate operatiile; la queued pleaca numai declarabilele.
declarabile, excluse = split_prestatii_excluse(resolved)
if not declarabile and excluse:
ops_excluse = ", ".join((p.get("cod_op_service") or "") for p in excluse)
motiv = [{
"field": "prestatii",
"message": f"Toate operatiile sunt excluse de la declarare ({ops_excluse}) — randul nu se trimite la RAR.",
}]
conn.execute(
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
"updated_at=datetime('now') WHERE id=?",
(payload_json, json.dumps(motiv, ensure_ascii=False), r["id"]),
)
stats["needs_data"] += 1
continue
if excluse:
content["prestatii"] = declarabile
payload_json = json.dumps(content, ensure_ascii=False)
# Ramura auto_send eliminata din reresolve.
# Un cod rezolvat -> queued direct (review_manual ramane 0).