Files
rar-autopass/app/reconcile.py
Claude Agent 77088daf29 feat(T2): reconciliere anti-duplicat + retry/backoff + recuperare orfane
Inchide bucla de trimitere (plan.md sect. 4 worker, failure registry).

- app/reconcile.py: match_finalizata pe vin+dataPrestatie+odometruFinal (int),
  alege id maxim la duplicate (RAR accepta duplicate, confirmat live)
- app/rar_client.get_finalizate: parseaza data.content (descoperit live ca
  ruta = GET /prezentari/getAllPrezentariFinalizate; filtrele nu merg pe test)
- app/worker rescris:
  - recuperare orfane (rand 'sending' peste lease = worker mort mid-POST)
  - pe eroare tranzitorie/timeout: reconciliere INAINTE de re-send (anti-duplicat);
    daca recordul exista la RAR -> sent fara re-POST
  - retry/backoff exponential; peste worker_max_retries -> error + banner
  - re-login la token expirat (JWT 30h)
- schema: coloana next_attempt_at (backoff) + migrare aditiva in init_db
- config: worker_sending_lease_s, worker_retry_base_s/max_s, worker_max_retries
- contract: documentata ruta+forma getAllPrezentariFinalizate (verificat live)

Verify: pytest 54 passed (15 noi T2) + validare live (reconciliere record 68514).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:20:32 +00:00

55 lines
1.9 KiB
Python

"""Reconciliere anti-duplicat pe raspuns pierdut (T2 — P1).
Daca un POST postPrezentare ajunge la RAR si RAR insereaza, dar raspunsul se
pierde (timeout/retea), randul ramane `sending`. Un re-send orb ar crea duplicat
(RAR ACCEPTA duplicate — confirmat live, vezi contract). Inainte de re-send,
interogam lista finalizate si cautam o potrivire pe `vin + dataPrestatie +
odometruFinal`. UNIQUE pe `submissions` NU acopera acest caz (e despre starea la RAR).
Functie pura, unit-testabila. odometruFinal e NUMAR in listarea RAR, string in
payload-ul nostru -> comparam ca int.
"""
from __future__ import annotations
from typing import Any
def _as_int(value: object) -> int | None:
s = str(value if value is not None else "").strip()
try:
return int(s)
except ValueError:
return None
def match_finalizata(
finalizate: list[dict[str, Any]],
*,
vin: str,
data_prestatie: str,
odometru_final: object,
) -> int | None:
"""Intoarce id-ul (data.id) unei prezentari finalizate care se potriveste, altfel None.
Match: vin (case-insensitive) + dataPrestatie (egal) + odometruFinal (egal ca int).
Daca exista mai multe potriviri (RAR accepta duplicate), intoarce id-ul MAXIM
(cel mai recent) — orice match dovedeste ca RAR are deja inregistrarea.
"""
want_vin = (vin or "").strip().upper()
want_odo = _as_int(odometru_final)
want_data = (data_prestatie or "").strip()
matches: list[int] = []
for item in finalizate:
if (item.get("vin") or "").strip().upper() != want_vin:
continue
if (str(item.get("dataPrestatie") or "").strip()) != want_data:
continue
if _as_int(item.get("odometruFinal")) != want_odo:
continue
item_id = _as_int(item.get("id"))
if item_id is not None:
matches.append(item_id)
return max(matches) if matches else None