feat(5.20): US-004/005/006/009 ingestie+API+worker+import pe mediu RAR
US-004: rezolva_rar_env (cerere>default cont>ancora globala) + MediuIndisponibil + cod RAR_MEDIU_INDISPONIBIL. US-005: camp rar_env pe POST /v1/prezentari + /valideaza (Literal), echo in SubmissionResult/ValidareResult/GET, build_key + INSERT env-aware. US-006: AccountSessions re-cheiat (account_id, rar_env); RarClient base_url per env; creds din slotul env; purge + recover_orphans scoped pe env (E1/1a, 1b/E6); claim_one propaga rar_env (1c/E8); keepalive pe ancora globala (M2). US-009: selector mediu la import (>=2 medii), eticheta la 1, banner la 0; commit seteaza rar_env pe submissions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,7 @@ from ...mapping import (
|
||||
resolve_prestatii,
|
||||
)
|
||||
from ...validation import validate_prezentare
|
||||
from ...rar_env import MediuIndisponibil, rar_env_efectiv_cont, rezolva_rar_env
|
||||
|
||||
router = APIRouter(prefix="/v1/import", tags=["import"])
|
||||
|
||||
@@ -260,10 +261,10 @@ def _resolve_row_for_preview(
|
||||
}
|
||||
|
||||
|
||||
def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) -> str:
|
||||
def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any], rar_env: str = "test") -> str:
|
||||
"""Construieste cheia de idempotenta pentru un rand rezolvat."""
|
||||
canon = canonicalize_row(resolved)
|
||||
return build_key(account_id, canon)
|
||||
return build_key(account_id, canon, rar_env)
|
||||
|
||||
|
||||
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
|
||||
@@ -767,6 +768,11 @@ def preview_import(
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
|
||||
# Mediul RAR efectiv al contului — folosit la calculul cheii de idempotenta
|
||||
# la preview (trebuie sa coincida cu ce va folosi commit-ul fara rar_env explicit).
|
||||
from ...config import get_settings as _get_settings_env
|
||||
preview_env = rar_env_efectiv_cont(conn, account_id) or _get_settings_env().rar_env or "test"
|
||||
|
||||
# Recalculam coercion_flags din valorile stocate (nu sunt persistate separat):
|
||||
# detectie simpla de VIN numeric.
|
||||
coercion_flags_map: dict[int, list[str]] = {}
|
||||
@@ -822,7 +828,7 @@ def preview_import(
|
||||
key = None
|
||||
if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
||||
try:
|
||||
key = _build_idempotency_key(account_id, resolved_info["resolved"])
|
||||
key = _build_idempotency_key(account_id, resolved_info["resolved"], preview_env)
|
||||
keys_for_lookup.append(key)
|
||||
if key not in key_to_index:
|
||||
key_to_index[key] = []
|
||||
@@ -930,6 +936,7 @@ class CommitIn(BaseModel):
|
||||
description="Indecsi de rand needs_review bifate explicit de utilizator",
|
||||
)
|
||||
confirmed_by: str | None = Field(None, description="Email/identifier utilizator (log atestare)")
|
||||
rar_env: str | None = Field(None, description="Mediu RAR tinta ('test'|'prod'). None = default cont.")
|
||||
|
||||
|
||||
@router.post("/{import_id}/commit")
|
||||
@@ -1024,6 +1031,18 @@ def commit_import(
|
||||
if n_total_ok == 0:
|
||||
raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.")
|
||||
|
||||
# Rezolva mediul RAR tinta al lotului (US-009): cerut > default cont > ancora globala.
|
||||
try:
|
||||
env = rezolva_rar_env(conn, account_id, req.rar_env)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail={"error": "mediu_invalid", "message": str(e)})
|
||||
except MediuIndisponibil as e:
|
||||
raise HTTPException(status_code=422, detail={
|
||||
"error": "mediu_indisponibil",
|
||||
"message": str(e),
|
||||
"disponibile": e.disponibile,
|
||||
})
|
||||
|
||||
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
|
||||
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
|
||||
from ...config import get_settings as _get_settings
|
||||
@@ -1172,8 +1191,8 @@ def commit_import(
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
|
||||
# Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine)
|
||||
key = build_key(account_id, canon)
|
||||
# Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env)
|
||||
key = build_key(account_id, canon, env)
|
||||
|
||||
# Hash row pentru atestare (valori rezolvate)
|
||||
rows_for_hash.append(json.dumps({
|
||||
@@ -1189,9 +1208,9 @@ def commit_import(
|
||||
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO submissions "
|
||||
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
|
||||
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ")",
|
||||
(key, acct, payload_json, import_id, row_index),
|
||||
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) "
|
||||
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?)",
|
||||
(key, acct, payload_json, import_id, row_index, env),
|
||||
)
|
||||
|
||||
if cur.rowcount == 0:
|
||||
|
||||
@@ -24,6 +24,7 @@ from ...crypto import encrypt_creds
|
||||
from ...db import get_connection
|
||||
from ...errors import eroare as err_eroare
|
||||
from ...idempotency import build_key, canonicalize_row
|
||||
from ...rar_env import MediuIndisponibil, rezolva_rar_env
|
||||
from ...mapping import (
|
||||
_emite_text_rule_hits,
|
||||
account_or_default,
|
||||
@@ -122,7 +123,7 @@ def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> Submissio
|
||||
)
|
||||
|
||||
|
||||
def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult:
|
||||
def _rezultat_respins(submission_id: int | None, cl: dict, rar_env: str = "test") -> SubmissionResult:
|
||||
"""Rezultat pentru on_unmapped_error=True: status='error', fara enqueue/reactivare.
|
||||
|
||||
`erori` pastreaza COD_NEMAPAT (compat clienti vechi); `nemapate` + `motiv` adaugate.
|
||||
@@ -131,6 +132,7 @@ def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult:
|
||||
return SubmissionResult(
|
||||
submission_id=submission_id, status="error",
|
||||
erori=nem, nemapate=nem, motiv=_motiv_clasificare(cl),
|
||||
rar_env=rar_env,
|
||||
)
|
||||
|
||||
|
||||
@@ -168,6 +170,29 @@ def create_prezentari(
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||
|
||||
# US-005: rezolva mediul RAR tinta (cerut > default cont > ancora globala).
|
||||
# MediuIndisponibil -> 422 inainte de orice enqueue (respinge tot lotul).
|
||||
try:
|
||||
env = rezolva_rar_env(conn, acct, req.rar_env)
|
||||
except MediuIndisponibil as e:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=err_eroare(
|
||||
"RAR_MEDIU_INDISPONIBIL",
|
||||
cauza=(
|
||||
f"mediu cerut: {e.env}; disponibile: "
|
||||
f"{', '.join(e.disponibile) or 'niciunul'}"
|
||||
),
|
||||
),
|
||||
)
|
||||
except ValueError:
|
||||
# Pydantic Literal prinde valorile invalide inainte sa ajunga aici;
|
||||
# ramura e defensiva pentru apeluri directe fara model Pydantic.
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"),
|
||||
)
|
||||
|
||||
# T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
|
||||
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
|
||||
from ...config import get_settings as _get_settings
|
||||
@@ -213,7 +238,7 @@ def create_prezentari(
|
||||
# build_key aplica account_or_default(account_id) inainte de hash:
|
||||
# None si 1 colapseaza la aceeasi cheie (canal API + canal import).
|
||||
canon = canonicalize_row(content)
|
||||
key = build_key(account_id, canon)
|
||||
key = build_key(account_id, canon, env)
|
||||
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
|
||||
content.update({
|
||||
"vin": canon["vin"],
|
||||
@@ -232,19 +257,20 @@ def create_prezentari(
|
||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||
if cl["blocked_error"]:
|
||||
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
|
||||
results.append(_rezultat_respins(existing["id"], cl))
|
||||
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
|
||||
continue
|
||||
cur = conn.execute(
|
||||
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
|
||||
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
|
||||
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, "
|
||||
"updated_at=datetime('now') WHERE id=? AND status='error'",
|
||||
"rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'",
|
||||
(cl["status"], json.dumps(cl["content"], ensure_ascii=False),
|
||||
cl["rar_error"], creds_enc, existing["id"]),
|
||||
cl["rar_error"], creds_enc, env, existing["id"]),
|
||||
)
|
||||
if cur.rowcount == 1:
|
||||
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc)
|
||||
# — ambele canale converg pe parola corectata.
|
||||
# US-013: muta pe slot env dupa login (write-back conservator).
|
||||
if req.rar_credentials is not None:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
||||
@@ -253,7 +279,7 @@ def create_prezentari(
|
||||
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
|
||||
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
|
||||
# needs_data/needs_mapping, expune motivul (nu doar status).
|
||||
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True))
|
||||
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True, rar_env=env))
|
||||
continue
|
||||
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
||||
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
|
||||
@@ -267,6 +293,7 @@ def create_prezentari(
|
||||
status=existing["status"],
|
||||
id_prezentare=existing["id_prezentare"],
|
||||
deduped=True,
|
||||
rar_env=env,
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -276,17 +303,17 @@ def create_prezentari(
|
||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||
if cl["blocked_error"]:
|
||||
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
||||
results.append(_rezultat_respins(None, cl))
|
||||
results.append(_rezultat_respins(None, cl, rar_env=env))
|
||||
continue
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env),
|
||||
)
|
||||
sub_id = int(cur.lastrowid)
|
||||
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
||||
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
||||
results.append(_rezultat_enqueue(sub_id, cl))
|
||||
results.append(_rezultat_enqueue(sub_id, cl, rar_env=env))
|
||||
|
||||
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
||||
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
||||
@@ -332,6 +359,27 @@ def valideaza_prezentari(
|
||||
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||
|
||||
# US-005 (DX F5): rezolva env identic ca trimiterea reala si ecou-ieste in raspuns.
|
||||
try:
|
||||
env = rezolva_rar_env(conn, acct, req.rar_env)
|
||||
except MediuIndisponibil as e:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=err_eroare(
|
||||
"RAR_MEDIU_INDISPONIBIL",
|
||||
cauza=(
|
||||
f"mediu cerut: {e.env}; disponibile: "
|
||||
f"{', '.join(e.disponibile) or 'niciunul'}"
|
||||
),
|
||||
),
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"),
|
||||
)
|
||||
|
||||
for i, prez in enumerate(req.prezentari):
|
||||
content = prez.model_dump()
|
||||
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||
@@ -346,6 +394,7 @@ def valideaza_prezentari(
|
||||
index=i,
|
||||
valid=(res["status"] == "queued"),
|
||||
status_estimat=res["status"],
|
||||
rar_env=env,
|
||||
erori=res["errors"],
|
||||
nemapate=nemapate,
|
||||
prestatii_rezolvate=res["resolved"],
|
||||
@@ -366,9 +415,10 @@ def list_prezentari(
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
|
||||
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
|
||||
# rar_env inclus (US-005): badge mediu in lista.
|
||||
cols = (
|
||||
"id, status, id_prezentare, rar_status_code, retry_count, "
|
||||
"created_at, updated_at, payload_json"
|
||||
"created_at, updated_at, payload_json, rar_env"
|
||||
)
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
@@ -403,6 +453,8 @@ _PREZENTARE_FIELDS = frozenset({
|
||||
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
|
||||
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
|
||||
"rar_error",
|
||||
# US-005: mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API.
|
||||
"rar_env",
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user