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:
134
app/mapping.py
134
app/mapping.py
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user