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>
This commit is contained in:
54
app/reconcile.py
Normal file
54
app/reconcile.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user