8 stories TDD (echipa Sonnet, lead orchestreaza). US-001 scoate hold-ul auto_send din mapare (has_no_auto_send->False, simbol pastrat; cod rezolvat->queued). US-002 scoate bifa auto_send din UI. US-003 preview pas 3 in format .tabel-trimiteri (STARI_PREVIEW + nota_umana_preview, fara repr Python; view-model prez). US-004 filtre layout/stil ca referinta + buton Custom. US-005 navigatie Trimiteri/Mapari sub contoare pe toate paginile. US-006 import <details> nativ colapsabil. US-007 post-commit reveal (OOB _coada/_status + HX-Trigger). US-008 auto-refresh dupa actiuni (nudge eliminat). VERIFY context curat PASS (8/8). /code-review high: 3 buguri reparate (tab nav la self-refresh, pill Custom valori stale, nota_umana_preview precedenta needs_mapping). 934 passed, 1 skipped. Backend trimitere + schema NEATINSE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
673 lines
26 KiB
Python
673 lines
26 KiB
Python
"""API v1 — suprafata gateway.
|
|
|
|
Endpointuri:
|
|
- POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE).
|
|
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
|
|
- GET /v1/nomenclator: cache local.
|
|
- GET /v1/mapari: listare mapari cont.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ...auth import resolve_account_id
|
|
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 ...mapping import (
|
|
_emite_text_rule_hits,
|
|
account_or_default,
|
|
account_scope_clause,
|
|
classify_prezentare,
|
|
load_mapping_meta,
|
|
load_nomenclator_codes,
|
|
load_text_rules,
|
|
pending_unmapped,
|
|
reresolve_account,
|
|
save_mapping,
|
|
)
|
|
from ...models import (
|
|
PrezentareRequest,
|
|
PrezentariResponse,
|
|
SubmissionResult,
|
|
ValidarePrezentariRequest,
|
|
ValidareResponse,
|
|
ValidareResult,
|
|
)
|
|
from ...observ import log_event
|
|
from ...payload_view import prezentare_din_payload
|
|
from ...submissions_admin import (
|
|
SubmissionNotFound,
|
|
SubmissionStateConflict,
|
|
delete_submission,
|
|
requeue_submission,
|
|
)
|
|
|
|
router = APIRouter(prefix="/v1", tags=["v1"])
|
|
|
|
|
|
def _effective_on_unmapped_error(conn, acct: int, req_value: bool | None) -> bool:
|
|
"""Modul efectiv la cod necunoscut/nemapat (True => respinge cererea, False => needs_mapping).
|
|
|
|
Precedenta: override per-cerere > default cont (on_unmapped_error_default) > False.
|
|
"""
|
|
if req_value is not None:
|
|
return req_value
|
|
row = conn.execute("SELECT on_unmapped_error_default FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
return bool(row["on_unmapped_error_default"]) if row else False
|
|
|
|
|
|
def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules=None) -> dict:
|
|
"""classify_prezentare + aplicarea modului on_unmapped_error.
|
|
|
|
Cand exista coduri nemapate si error_mode=True, marcheaza outcome-ul ca respingere
|
|
(blocked_error=True): rutele NU mai fac enqueue, ci intorc o eroare per-element.
|
|
"""
|
|
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, text_rules)
|
|
cl["blocked_error"] = bool(cl["unmapped"]) and error_mode
|
|
return cl
|
|
|
|
|
|
def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
|
|
"""Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT)."""
|
|
return [
|
|
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")}
|
|
for u in unmapped
|
|
]
|
|
|
|
|
|
def _motiv_clasificare(cl: dict) -> str | None:
|
|
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
|
|
|
|
None cand status='queued'. Acopera ramurile de blocaj: erori de continut
|
|
(needs_data) si coduri fara mapare RAR (needs_mapping).
|
|
Dupa US-001: needs_mapping apare EXCLUSIV cand unmapped e non-gol
|
|
(ramura auto_send_oprit era inaccesibila si a fost eliminata).
|
|
"""
|
|
if cl["status"] == "queued":
|
|
return None
|
|
if cl["errors"]:
|
|
return "; ".join(
|
|
(e.get("problema") or e.get("message") or "") for e in cl["errors"]
|
|
).strip("; ") or "Date incomplete (respinse de RAR)."
|
|
if cl["unmapped"]:
|
|
coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"])
|
|
return f"Coduri fara mapare RAR: {coduri}"
|
|
return None
|
|
|
|
|
|
def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult:
|
|
"""SubmissionResult onest dintr-un rezultat de clasificare.
|
|
|
|
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
|
|
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None.
|
|
"""
|
|
return SubmissionResult(
|
|
submission_id=submission_id,
|
|
status=cl["status"],
|
|
erori=list(cl["errors"]),
|
|
nemapate=_erori_nemapate(cl["unmapped"]),
|
|
motiv=_motiv_clasificare(cl),
|
|
**extra,
|
|
)
|
|
|
|
|
|
def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult:
|
|
"""Rezultat pentru on_unmapped_error=True: status='error', fara enqueue/reactivare.
|
|
|
|
`erori` pastreaza COD_NEMAPAT (compat clienti vechi); `nemapate` + `motiv` adaugate.
|
|
"""
|
|
nem = _erori_nemapate(cl["unmapped"])
|
|
return SubmissionResult(
|
|
submission_id=submission_id, status="error",
|
|
erori=nem, nemapate=nem, motiv=_motiv_clasificare(cl),
|
|
)
|
|
|
|
|
|
@router.post("/prezentari", response_model=PrezentariResponse)
|
|
def create_prezentari(
|
|
req: PrezentareRequest,
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> PrezentariResponse:
|
|
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
|
|
|
|
Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU
|
|
resping cererea, ci enqueue-aza cu status `needs_data` + motiv. JSON malformat ->
|
|
422 din Pydantic (validare de shape).
|
|
account_id vine din cheia API (resolve_account_id): cont real cu cheie,
|
|
implicit id=1 in dev fara cheie, 401 fara cheie valida in prod.
|
|
Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
|
|
cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`).
|
|
"""
|
|
acct = account_or_default(account_id)
|
|
# Creds RAR efemere: criptate si lipite de fiecare submission nou pana la
|
|
# primul login reusit pentru cont (worker le sterge atunci). Zero-storage at
|
|
# rest — niciodata in clar in DB/loguri. Optional: cand lipsesc,
|
|
# creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului.
|
|
creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None
|
|
conn = get_connection()
|
|
results: list[SubmissionResult] = []
|
|
try:
|
|
# load_mapping_meta incarca maparea op->cod RAR; dupa US-001, auto_send nu mai tine randuri.
|
|
mapping_meta = load_mapping_meta(conn, acct)
|
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
|
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
|
|
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
|
|
valid_codes = load_nomenclator_codes(conn) or None
|
|
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
|
|
text_rules = load_text_rules(conn, acct)
|
|
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
|
for prez in req.prezentari:
|
|
content = prez.model_dump()
|
|
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
|
# 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)
|
|
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
|
|
content.update({
|
|
"vin": canon["vin"],
|
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
|
"odometru_final": canon["odometru_final"],
|
|
})
|
|
existing = conn.execute(
|
|
"SELECT id, status, id_prezentare FROM submissions WHERE idempotency_key=?",
|
|
(key,),
|
|
).fetchone()
|
|
if existing:
|
|
# Un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
|
|
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
|
|
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
|
|
if existing["status"] == "error":
|
|
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))
|
|
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'",
|
|
(cl["status"], json.dumps(cl["content"], ensure_ascii=False),
|
|
cl["rar_error"], creds_enc, existing["id"]),
|
|
)
|
|
if cur.rowcount == 1:
|
|
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc)
|
|
# — ambele canale converg pe parola corectata.
|
|
if req.rar_credentials is not None:
|
|
conn.execute(
|
|
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
|
(encrypt_creds(req.rar_credentials.model_dump()), acct),
|
|
)
|
|
_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))
|
|
continue
|
|
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
|
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
|
|
existing = conn.execute(
|
|
"SELECT id, status, id_prezentare FROM submissions WHERE id=?",
|
|
(existing["id"],),
|
|
).fetchone()
|
|
results.append(
|
|
SubmissionResult(
|
|
submission_id=existing["id"],
|
|
status=existing["status"],
|
|
id_prezentare=existing["id_prezentare"],
|
|
deduped=True,
|
|
)
|
|
)
|
|
continue
|
|
|
|
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
|
# (canonicalize + mapare op->cod + validare; auto_send gate eliminat dupa US-001).
|
|
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))
|
|
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),
|
|
)
|
|
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))
|
|
|
|
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
|
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
|
dist: dict[str, int] = {}
|
|
for r in results:
|
|
if r.reactivated:
|
|
cheie = "reactivated"
|
|
elif r.deduped:
|
|
cheie = "deduped"
|
|
else:
|
|
cheie = r.status
|
|
dist[cheie] = dist.get(cheie, 0) + 1
|
|
log_event(
|
|
"api_prezentari",
|
|
account_id=acct,
|
|
mesaj=f"{len(results)} prezentari procesate",
|
|
context={"count": len(results), "distributie": dist},
|
|
conn=conn,
|
|
)
|
|
finally:
|
|
conn.close()
|
|
return PrezentariResponse(results=results)
|
|
|
|
|
|
@router.post("/prezentari/valideaza", response_model=ValidareResponse)
|
|
def valideaza_prezentari(
|
|
req: ValidarePrezentariRequest,
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> ValidareResponse:
|
|
"""Dry-run: valideaza payload exact ca POST /prezentari, fara enqueue si fara efecte secundare.
|
|
|
|
Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de
|
|
continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi
|
|
payload + aceeasi mapare de cont. rar_credentials ignorat complet.
|
|
"""
|
|
acct = account_or_default(account_id)
|
|
conn = get_connection()
|
|
results: list[ValidareResult] = []
|
|
try:
|
|
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
|
|
# 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)
|
|
for i, prez in enumerate(req.prezentari):
|
|
content = prez.model_dump()
|
|
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
|
if res["blocked_error"]:
|
|
res = {**res, "status": "error"}
|
|
# Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
|
nemapate = [
|
|
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")}
|
|
for u in res["unmapped"]
|
|
]
|
|
results.append(ValidareResult(
|
|
index=i,
|
|
valid=(res["status"] == "queued"),
|
|
status_estimat=res["status"],
|
|
erori=res["errors"],
|
|
nemapate=nemapate,
|
|
prestatii_rezolvate=res["resolved"],
|
|
))
|
|
finally:
|
|
conn.close()
|
|
return ValidareResponse(results=results)
|
|
|
|
|
|
@router.get("/prezentari")
|
|
def list_prezentari(
|
|
status: str | None = None,
|
|
limit: int = 100,
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> dict:
|
|
conn = get_connection()
|
|
try:
|
|
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.
|
|
cols = (
|
|
"id, status, id_prezentare, rar_status_code, retry_count, "
|
|
"created_at, updated_at, payload_json"
|
|
)
|
|
if status:
|
|
rows = conn.execute(
|
|
f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? "
|
|
f"ORDER BY id DESC LIMIT ?",
|
|
scope_params + [status, limit],
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
|
|
scope_params + [limit],
|
|
).fetchall()
|
|
out = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
# Campuri afisabile derivate din payload (acelasi helper ca dashboardul web);
|
|
# payload_json brut nu se intoarce in raspuns.
|
|
d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None))
|
|
out.append(d)
|
|
return {"submissions": out}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita.
|
|
# Exclude: rar_creds_enc, payload_json, idempotency_key, sending_since.
|
|
_PREZENTARE_FIELDS = frozenset({
|
|
"id", "status", "id_prezentare", "rar_status_code", "retry_count",
|
|
"next_attempt_at", "created_at", "updated_at", "account_id",
|
|
"batch_id", "row_index", "purge_after",
|
|
# rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
|
|
# 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",
|
|
})
|
|
|
|
|
|
@router.get("/prezentari/{submission_id}")
|
|
def get_prezentare(
|
|
submission_id: int,
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> dict:
|
|
conn = get_connection()
|
|
try:
|
|
scope_sql, scope_params = account_scope_clause(account_id)
|
|
row = conn.execute(
|
|
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
|
|
[submission_id] + scope_params,
|
|
).fetchone()
|
|
if not row:
|
|
# Acelasi mesaj indiferent daca randul exista dar apartine altui cont
|
|
# sau nu exista deloc — nu confirmam existenta.
|
|
raise HTTPException(status_code=404, detail="submission inexistent")
|
|
row_dict = dict(row)
|
|
return {k: v for k, v in row_dict.items() if k in _PREZENTARE_FIELDS}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.delete("/prezentari/{submission_id}")
|
|
def delete_prezentare(
|
|
submission_id: int,
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> dict:
|
|
"""Sterge o trimitere blocata a contului cheii API.
|
|
|
|
Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat
|
|
INAINTEA starii: cross-account / inexistent -> 404 (acelasi mesaj);
|
|
own-account `sent`/`sending` -> 409 (conflict de stare).
|
|
"""
|
|
conn = get_connection()
|
|
try:
|
|
try:
|
|
res = delete_submission(conn, account_id, submission_id)
|
|
except SubmissionNotFound:
|
|
raise HTTPException(status_code=404, detail="submission inexistent")
|
|
except SubmissionStateConflict as exc:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"trimiterea nu se poate sterge in starea '{exc.status}'",
|
|
)
|
|
return {"ok": True, **res}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/prezentari/{submission_id}/repune")
|
|
def repune_prezentare(
|
|
submission_id: int,
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> dict:
|
|
"""Re-pune in coada o trimitere blocata a contului cheii API.
|
|
|
|
`error -> queued`, re-ruleaza classify. Acelasi oracol de scope/stare ca DELETE
|
|
(404 cross-account/inexistent, 409 sent/sending).
|
|
"""
|
|
conn = get_connection()
|
|
try:
|
|
try:
|
|
res = requeue_submission(conn, account_id, submission_id)
|
|
except SubmissionNotFound:
|
|
raise HTTPException(status_code=404, detail="submission inexistent")
|
|
except SubmissionStateConflict as exc:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"trimiterea nu se poate re-pune in starea '{exc.status}'",
|
|
)
|
|
return {"ok": True, **res}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/nomenclator")
|
|
def get_nomenclator() -> dict:
|
|
conn = get_connection()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
|
|
).fetchall()
|
|
return {"nomenclator": [dict(r) for r in rows]}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
AUDIT_COLUMNS = [
|
|
"submission_id",
|
|
"status",
|
|
"id_prezentare",
|
|
"account_id",
|
|
"vin",
|
|
"nr_inmatriculare",
|
|
"data_prestatie",
|
|
"odometru_final",
|
|
"prestatii",
|
|
"rar_status_code",
|
|
"created_at",
|
|
"updated_at",
|
|
"purge_after",
|
|
]
|
|
|
|
|
|
def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, account_id: int):
|
|
"""Randuri audit filtrate pe cont + data(updated_at) in [from, to].
|
|
|
|
account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu
|
|
account_id IS NULL apartin contului 1. b64_image NU intra in CSV.
|
|
"""
|
|
scope_sql, scope_params = account_scope_clause(account_id)
|
|
sql = (
|
|
"SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, "
|
|
"created_at, updated_at, purge_after FROM submissions"
|
|
)
|
|
where = [scope_sql]
|
|
params: list = list(scope_params)
|
|
if status != "all":
|
|
where.append("status=?")
|
|
params.append(status)
|
|
if date_from:
|
|
where.append("date(updated_at) >= date(?)")
|
|
params.append(date_from)
|
|
if date_to:
|
|
where.append("date(updated_at) <= date(?)")
|
|
params.append(date_to)
|
|
sql += " WHERE " + " AND ".join(where)
|
|
sql += " ORDER BY id"
|
|
|
|
for r in conn.execute(sql, params).fetchall():
|
|
try:
|
|
p = json.loads(r["payload_json"]) if r["payload_json"] else {}
|
|
except (ValueError, TypeError):
|
|
p = {}
|
|
codes = ",".join(
|
|
(it.get("cod_prestatie") or it.get("cod_op_service") or "")
|
|
for it in (p.get("prestatii") or [])
|
|
if isinstance(it, dict)
|
|
)
|
|
yield {
|
|
"submission_id": r["id"],
|
|
"status": r["status"],
|
|
"id_prezentare": r["id_prezentare"] or "",
|
|
# NULL→cont 1: coloana reflecta invariantul de scope, nu "" ambiguu.
|
|
"account_id": account_or_default(r["account_id"]),
|
|
"vin": p.get("vin") or "",
|
|
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
|
|
"data_prestatie": p.get("data_prestatie") or "",
|
|
"odometru_final": p.get("odometru_final") or "",
|
|
"prestatii": codes,
|
|
"rar_status_code": r["rar_status_code"] or "",
|
|
"created_at": r["created_at"],
|
|
"updated_at": r["updated_at"],
|
|
"purge_after": r["purge_after"] or "",
|
|
}
|
|
|
|
|
|
@router.get("/audit/export")
|
|
def audit_export(
|
|
date_from: str | None = None,
|
|
date_to: str | None = None,
|
|
status: str = "sent",
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> StreamingResponse:
|
|
"""CSV audit scoped pe contul cheii API. Filtre optionale `date_from`/`date_to` (YYYY-MM-DD)
|
|
|
|
pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR);
|
|
`status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana
|
|
`purge_after`. b64_image nu se exporta.
|
|
"""
|
|
conn = get_connection()
|
|
try:
|
|
buf = io.StringIO()
|
|
writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS)
|
|
writer.writeheader()
|
|
for row in _audit_rows(conn, date_from, date_to, status, account_id):
|
|
writer.writerow(row)
|
|
data = buf.getvalue()
|
|
finally:
|
|
conn.close()
|
|
|
|
fname = f"audit_{status}_{date_from or 'inceput'}_{date_to or 'azi'}.csv"
|
|
return StreamingResponse(
|
|
iter([data]),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
|
)
|
|
|
|
|
|
@router.get("/mapari")
|
|
def get_mapari(
|
|
key_account: int = Depends(resolve_account_id),
|
|
account_id: int | None = None,
|
|
) -> dict:
|
|
"""Maparile operatie->cod ale contului curent.
|
|
|
|
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul
|
|
efectiv vine MEREU din cheia API. Daca e prezent si difera -> 400.
|
|
"""
|
|
if account_id is not None and account_id != key_account:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="account_id din query nu corespunde contului cheii API",
|
|
)
|
|
conn = get_connection()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service",
|
|
(key_account,),
|
|
).fetchall()
|
|
return {"mapari": [dict(r) for r in rows]}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/mapari/pending")
|
|
def get_mapari_pending(
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> dict:
|
|
"""Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy.
|
|
|
|
Filtrate pe contul cheii API. Fiecare intrare: {account_id, cod_op_service,
|
|
denumire, blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}.
|
|
"""
|
|
conn = get_connection()
|
|
try:
|
|
return {"pending": pending_unmapped(conn, account_id=account_id)}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
class MapareIn(BaseModel):
|
|
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,
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> dict:
|
|
"""Salveaza/actualizeaza o mapare op->cod si re-rezolva submission-urile blocate.
|
|
|
|
Contul vine din cheia API (NU din body) — un cont nu poate edita maparile
|
|
altuia. Verifica intai ca `cod_prestatie` exista in nomenclator (nu lasam
|
|
mapari catre coduri inexistente). Apoi upsert + re-rezolvare `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, account_id, req.cod_op_service, cod, req.auto_send)
|
|
stats = reresolve_account(conn, account_id)
|
|
return {"saved": {"cod_op_service": req.cod_op_service.strip(), "cod_prestatie": cod}, "reresolve": stats}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
class RarCredsIn(BaseModel):
|
|
"""Creds RAR durabile per-cont. Stocate criptate (Fernet) in accounts.rar_creds_enc."""
|
|
|
|
email: str = Field(..., min_length=1)
|
|
password: str = Field(..., min_length=1, repr=False)
|
|
|
|
|
|
@router.post("/conturi/rar-creds")
|
|
def set_rar_creds(
|
|
req: RarCredsIn,
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> dict:
|
|
"""Seteaza creds RAR durabile per-cont.
|
|
|
|
Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback
|
|
cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker).
|
|
Contul vine din cheia API.
|
|
"""
|
|
acct = account_or_default(account_id)
|
|
enc = encrypt_creds({"email": req.email, "password": req.password})
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute(
|
|
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
|
(enc, acct),
|
|
)
|
|
return {"ok": True, "account_id": acct}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.delete("/conturi/rar-creds")
|
|
def delete_rar_creds(
|
|
account_id: int = Depends(resolve_account_id),
|
|
) -> dict:
|
|
"""Sterge creds RAR durabile per-cont (revenire la modelul efemer Treapta 1)."""
|
|
acct = account_or_default(account_id)
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute("UPDATE accounts SET rar_creds_enc=NULL WHERE id=?", (acct,))
|
|
return {"ok": True, "account_id": acct}
|
|
finally:
|
|
conn.close()
|