feat(T5): editor web mapare operatii (hibrid + fuzzy + on-demand needs_mapping)
T5 reinterpretat: nu import DBF, ci editor web al maparii operatie ROAAUTO -> cod RAR, cu fuzzy lookup si validare de catre utilizator. - Contract hibrid: item prestatie accepta cod_prestatie (RAR direct, back-compat) SAU cod_op_service+denumire (mapat de gateway prin operations_mapping). - Ingestie: op intern necunoscut -> submission needs_mapping (nu pleaca la RAR); codul rezolvat se scrie inapoi in payload_json -> payload builder + worker neatinse. - Editor HTMX (_mapari.html + GET /_fragments/mapari, POST /mapari): listeaza op-urile nemapate, fuzzy preselecteaza codul RAR, save -> re-rezolvare automata (queued / needs_data). - Fuzzy: rapidfuzz.token_sort_ratio pe denumire normalizata (fara diacritice). - Nomenclator: seed fallback 18 coduri la boot (offline) + refresh live din worker. - Cont default id=1 cat timp auth API-key (CORE) nu exista (account_id NULL). - Endpointuri API: GET /v1/mapari/pending, POST /v1/mapari (respinge cod inexistent). - 15 teste noi (tests/test_mapping.py); 69 pass total. - Contract actualizat (docs/api-rar-contract.md), rapidfuzz==3.14.5 in requirements. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,9 +14,18 @@ from __future__ import annotations
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...db import get_connection
|
||||
from ...idempotency import idempotency_key
|
||||
from ...mapping import (
|
||||
account_or_default,
|
||||
load_mapping,
|
||||
pending_unmapped,
|
||||
reresolve_account,
|
||||
resolve_prestatii,
|
||||
save_mapping,
|
||||
)
|
||||
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
|
||||
from ...validation import validate_prezentare
|
||||
|
||||
@@ -35,9 +44,11 @@ def create_prezentari(req: PrezentareRequest) -> PrezentariResponse:
|
||||
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
|
||||
"""
|
||||
account_id = None # TODO(auth): din API key
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
results: list[SubmissionResult] = []
|
||||
try:
|
||||
mapping = load_mapping(conn, acct)
|
||||
for prez in req.prezentari:
|
||||
content = prez.model_dump()
|
||||
key = idempotency_key(account_id, content)
|
||||
@@ -56,17 +67,28 @@ def create_prezentari(req: PrezentareRequest) -> PrezentariResponse:
|
||||
)
|
||||
continue
|
||||
|
||||
# T3: validare de continut -> queued daca e curat, altfel needs_data + motiv.
|
||||
errors = validate_prezentare(content)
|
||||
if errors:
|
||||
status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False)
|
||||
# Mapare op->cod RAR (hibrid): codul RAR direct trece neatins; codul
|
||||
# intern ROAAUTO se traduce. Op nemapata -> needs_mapping (nu se trimite),
|
||||
# apare in editorul web. Codul rezolvat se scrie inapoi in payload, deci
|
||||
# validarea T3 + payload builder + worker raman code-driven.
|
||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
|
||||
content["prestatii"] = resolved
|
||||
|
||||
if unmapped:
|
||||
status = "needs_mapping"
|
||||
rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False)
|
||||
else:
|
||||
status, rar_error = "queued", None
|
||||
# T3: validare de continut -> queued daca e curat, altfel needs_data + motiv.
|
||||
errors = validate_prezentare(content)
|
||||
if errors:
|
||||
status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False)
|
||||
else:
|
||||
status, rar_error = "queued", None
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(key, account_id, status, json.dumps(content, ensure_ascii=False), rar_error),
|
||||
(key, acct, status, json.dumps(content, ensure_ascii=False), rar_error),
|
||||
)
|
||||
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status))
|
||||
finally:
|
||||
@@ -135,3 +157,46 @@ def get_mapari(account_id: int | None = None) -> dict:
|
||||
return {"mapari": [dict(r) for r in rows]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/mapari/pending")
|
||||
def get_mapari_pending() -> dict:
|
||||
"""Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy.
|
||||
|
||||
Alimenteaza editorul web. Fiecare intrare: {account_id, cod_op_service, denumire,
|
||||
blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
return {"pending": pending_unmapped(conn)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class MapareIn(BaseModel):
|
||||
account_id: int | None = None
|
||||
cod_op_service: str = Field(..., min_length=1)
|
||||
cod_prestatie: str = Field(..., min_length=1)
|
||||
auto_send: bool = True
|
||||
|
||||
|
||||
@router.post("/mapari")
|
||||
def create_mapare(req: MapareIn) -> dict:
|
||||
"""Salveaza/actualizeaza o mapare op->cod si re-rezolva submission-urile blocate.
|
||||
|
||||
Verifica intai ca `cod_prestatie` exista in nomenclator (nu lasam mapari catre
|
||||
coduri inexistente). Apoi upsert + re-rezolvare automata a `needs_mapping`.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cod = req.cod_prestatie.strip().upper()
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
||||
).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(status_code=422, detail=f"cod_prestatie '{cod}' nu exista in nomenclator")
|
||||
save_mapping(conn, req.account_id, req.cod_op_service, cod, req.auto_send)
|
||||
stats = reresolve_account(conn, req.account_id)
|
||||
return {"saved": {"cod_op_service": req.cod_op_service.strip(), "cod_prestatie": cod}, "reresolve": stats}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user