feat(T5/dashboard): import DBF idempotent + nomenclator browser + audit CSV + stare RAR
T5 (tools/import_dbf.py): citire prestatii_rar.DBF / mapare_prestatii.DBF cu dbfread, raport dry-run (randuri valide/duplicate/goale, mapari orfane = cod necunoscut in nomenclator), --commit cu upsert idempotent in tranzactie. Dashboard: browser nomenclator, indicator stare RAR (indisponibil? derivat din ultimul login < 30h, coada arata ultima stare locala), export audit CSV (/v1/audit/export?status=sent|all&date_from&date_to, b64Image exclus, coloana purge_after pentru retentia 90z). Verify: 11 teste noi (test_import_dbf 6, test_dashboard 5), suita 111 pass, dry-run real pe DBF-urile din repo + smoke live dashboard/CSV. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,12 @@ middleware (CORE) si exportul CSV vin ulterior — marcate TODO unde lipsesc.
|
||||
|
||||
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
|
||||
@@ -152,6 +155,104 @@ def get_nomenclator() -> dict:
|
||||
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):
|
||||
"""Randuri audit (sent implicit) filtrate pe data(updated_at) in [from, to].
|
||||
|
||||
payload_json e text in schelet (criptarea PII e P2); citim campurile-cheie
|
||||
pentru audit. b64_image NU intra in CSV (mare). Daca P2 cripteaza payload-ul,
|
||||
aici se decripteaza inainte de a construi randul.
|
||||
"""
|
||||
sql = "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, created_at, updated_at, purge_after FROM submissions"
|
||||
where = []
|
||||
params: list = []
|
||||
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)
|
||||
if where:
|
||||
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 "",
|
||||
"account_id": r["account_id"] or "",
|
||||
"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",
|
||||
) -> StreamingResponse:
|
||||
"""CSV cu ce s-a trimis (audit). 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. Leaga re_tinerea 90 zile prin coloana
|
||||
`purge_after` (plan.md sect. 4 + 8). 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):
|
||||
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(account_id: int | None = None) -> dict:
|
||||
conn = get_connection()
|
||||
|
||||
Reference in New Issue
Block a user