fix(anaf): handle notFound integers, skip 4xx retry, propagate errors to run log
ANAF notFound items are plain integers, not dicts — caused 'int has no attribute get'. 4xx errors (like 404) no longer retry uselessly. ANAF errors now appear in the UI sync log via log_fn callback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,7 @@ def sanitize_cui(raw_cf: str) -> tuple[str, str | None]:
|
|||||||
return bare, f"CUI {raw_cf!r} contine caractere invalide dupa sanitizare: {bare!r}"
|
return bare, f"CUI {raw_cf!r} contine caractere invalide dupa sanitizare: {bare!r}"
|
||||||
|
|
||||||
|
|
||||||
async def check_vat_status_batch(cui_list: list[str], date: str = None) -> dict[str, dict]:
|
async def check_vat_status_batch(cui_list: list[str], date: str = None, log_fn=None) -> dict[str, dict]:
|
||||||
"""POST to ANAF API to check VAT status for a batch of CUIs.
|
"""POST to ANAF API to check VAT status for a batch of CUIs.
|
||||||
|
|
||||||
Chunks in batches of 500 (ANAF API limit).
|
Chunks in batches of 500 (ANAF API limit).
|
||||||
@@ -91,35 +91,49 @@ async def check_vat_status_batch(cui_list: list[str], date: str = None) -> dict[
|
|||||||
if not body:
|
if not body:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
chunk_results = await _call_anaf_api(body)
|
chunk_results = await _call_anaf_api(body, log_fn=log_fn)
|
||||||
results.update(chunk_results)
|
results.update(chunk_results)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]:
|
async def _call_anaf_api(body: list[dict], retry: int = 0, log_fn=None) -> dict[str, dict]:
|
||||||
"""Internal: single ANAF API call with retry logic."""
|
"""Internal: single ANAF API call with retry logic."""
|
||||||
url = "https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva"
|
url = "https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva"
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
|
def _log_error(msg: str):
|
||||||
|
logger.error(msg)
|
||||||
|
if log_fn:
|
||||||
|
log_fn(f"ANAF eroare: {msg}")
|
||||||
|
|
||||||
|
def _log_warning(msg: str):
|
||||||
|
logger.warning(msg)
|
||||||
|
if log_fn:
|
||||||
|
log_fn(f"ANAF warn: {msg}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.post(url, json=body)
|
response = await client.post(url, json=body)
|
||||||
|
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
if retry < 1:
|
if retry < 1:
|
||||||
logger.warning("ANAF API rate limited (429), retrying in 10s...")
|
_log_warning("ANAF API rate limited (429), retrying in 10s...")
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
return await _call_anaf_api(body, retry + 1)
|
return await _call_anaf_api(body, retry + 1, log_fn)
|
||||||
logger.error("ANAF API rate limited after retry")
|
_log_error("ANAF API rate limited after retry")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if response.status_code >= 500:
|
if response.status_code >= 500:
|
||||||
if retry < 1:
|
if retry < 1:
|
||||||
logger.warning(f"ANAF API server error ({response.status_code}), retrying in 3s...")
|
_log_warning(f"ANAF API server error ({response.status_code}), retrying in 3s...")
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
return await _call_anaf_api(body, retry + 1)
|
return await _call_anaf_api(body, retry + 1, log_fn)
|
||||||
logger.error(f"ANAF API server error after retry: {response.status_code}")
|
_log_error(f"ANAF API server error after retry: {response.status_code}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
_log_error(f"ANAF API client error {response.status_code} (nu se reincearca)")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -138,11 +152,14 @@ async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]:
|
|||||||
"checked_at": checked_at,
|
"checked_at": checked_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Not found CUIs
|
# Not found CUIs — ANAF returns plain integers (CUI values), not dicts
|
||||||
notfound_list = data.get("notFound", [])
|
notfound_list = data.get("notFound", [])
|
||||||
for item in notfound_list:
|
for item in notfound_list:
|
||||||
date_gen = item.get("date_generale", {})
|
if isinstance(item, int):
|
||||||
cui_str = str(date_gen.get("cui", item.get("cui", "")))
|
cui_str = str(item)
|
||||||
|
else:
|
||||||
|
date_gen = item.get("date_generale", {})
|
||||||
|
cui_str = str(date_gen.get("cui", item.get("cui", "")))
|
||||||
results[cui_str] = {
|
results[cui_str] = {
|
||||||
"scpTVA": None,
|
"scpTVA": None,
|
||||||
"denumire_anaf": "",
|
"denumire_anaf": "",
|
||||||
@@ -153,16 +170,16 @@ async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]:
|
|||||||
|
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
if retry < 1:
|
if retry < 1:
|
||||||
logger.warning("ANAF API timeout, retrying in 3s...")
|
_log_warning("ANAF API timeout, retrying in 3s...")
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
return await _call_anaf_api(body, retry + 1)
|
return await _call_anaf_api(body, retry + 1, log_fn)
|
||||||
logger.error("ANAF API timeout after retry")
|
_log_error("ANAF API timeout after retry")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if retry < 1:
|
if retry < 1:
|
||||||
logger.warning(f"ANAF API error: {e}, retrying in 3s...")
|
_log_warning(f"ANAF API error: {e}, retrying in 3s...")
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
return await _call_anaf_api(body, retry + 1)
|
return await _call_anaf_api(body, retry + 1, log_fn)
|
||||||
logger.error(f"ANAF API error after retry: {e}")
|
_log_error(f"ANAF API error after retry: {e}")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
@@ -683,7 +683,9 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
prepop_cuis = await sqlite_service.get_expired_cuis_for_prepopulate()
|
prepop_cuis = await sqlite_service.get_expired_cuis_for_prepopulate()
|
||||||
if prepop_cuis:
|
if prepop_cuis:
|
||||||
_log_line(run_id, f"ANAF pre-populare: {len(prepop_cuis)} CUI-uri cu cache expirat")
|
_log_line(run_id, f"ANAF pre-populare: {len(prepop_cuis)} CUI-uri cu cache expirat")
|
||||||
prepop_results = await anaf_service.check_vat_status_batch(prepop_cuis)
|
prepop_results = await anaf_service.check_vat_status_batch(
|
||||||
|
prepop_cuis, log_fn=lambda msg: _log_line(run_id, msg)
|
||||||
|
)
|
||||||
if prepop_results:
|
if prepop_results:
|
||||||
await sqlite_service.bulk_populate_anaf_cache(prepop_results)
|
await sqlite_service.bulk_populate_anaf_cache(prepop_results)
|
||||||
_log_line(run_id, f"ANAF pre-populare: {len(prepop_results)} rezultate stocate")
|
_log_line(run_id, f"ANAF pre-populare: {len(prepop_results)} rezultate stocate")
|
||||||
@@ -716,7 +718,9 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
# Batch ANAF call for uncached CUIs only
|
# Batch ANAF call for uncached CUIs only
|
||||||
if uncached_cuis:
|
if uncached_cuis:
|
||||||
_log_line(run_id, f"ANAF: verificare {len(uncached_cuis)} CUI-uri noi...")
|
_log_line(run_id, f"ANAF: verificare {len(uncached_cuis)} CUI-uri noi...")
|
||||||
anaf_results = await anaf_service.check_vat_status_batch(uncached_cuis)
|
anaf_results = await anaf_service.check_vat_status_batch(
|
||||||
|
uncached_cuis, log_fn=lambda msg: _log_line(run_id, msg)
|
||||||
|
)
|
||||||
if anaf_results:
|
if anaf_results:
|
||||||
await sqlite_service.bulk_populate_anaf_cache(anaf_results)
|
await sqlite_service.bulk_populate_anaf_cache(anaf_results)
|
||||||
cached_results.update(anaf_results)
|
cached_results.update(anaf_results)
|
||||||
|
|||||||
@@ -28,11 +28,15 @@ _api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||||||
if _api_dir not in sys.path:
|
if _api_dir not in sys.path:
|
||||||
sys.path.insert(0, _api_dir)
|
sys.path.insert(0, _api_dir)
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
from app.services.anaf_service import (
|
from app.services.anaf_service import (
|
||||||
strip_ro_prefix,
|
strip_ro_prefix,
|
||||||
validate_cui,
|
validate_cui,
|
||||||
validate_cui_checksum,
|
validate_cui_checksum,
|
||||||
sanitize_cui,
|
sanitize_cui,
|
||||||
|
_call_anaf_api,
|
||||||
|
check_vat_status_batch,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -210,3 +214,178 @@ class TestSanitizeCui:
|
|||||||
bare, warning = sanitize_cui("Ro 50519951")
|
bare, warning = sanitize_cui("Ro 50519951")
|
||||||
assert bare == "50519951"
|
assert bare == "50519951"
|
||||||
assert warning is None
|
assert warning is None
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# _call_anaf_api — notFound parsing + error handling
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestCallAnafApi:
|
||||||
|
"""Tests for ANAF API response parsing and error handling."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notfound_as_integers(self):
|
||||||
|
"""ANAF notFound items are plain integers (CUI values), not dicts."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"found": [],
|
||||||
|
"notFound": [12345678, 87654321],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await _call_anaf_api([{"cui": 12345678, "data": "2026-04-07"}])
|
||||||
|
|
||||||
|
assert "12345678" in results
|
||||||
|
assert "87654321" in results
|
||||||
|
assert results["12345678"]["scpTVA"] is None
|
||||||
|
assert results["87654321"]["scpTVA"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notfound_as_dicts_still_works(self):
|
||||||
|
"""Backward compat: if ANAF ever returns notFound as dicts, still parse them."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"found": [],
|
||||||
|
"notFound": [{"date_generale": {"cui": 99999999}}],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await _call_anaf_api([{"cui": 99999999, "data": "2026-04-07"}])
|
||||||
|
|
||||||
|
assert "99999999" in results
|
||||||
|
assert results["99999999"]["scpTVA"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_found_items_parsed(self):
|
||||||
|
"""Normal found items are parsed correctly."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"found": [{
|
||||||
|
"date_generale": {"cui": 15134434, "denumire": "AUTOKLASS CENTER SRL"},
|
||||||
|
"inregistrare_scop_Tva": {"scpTVA": True},
|
||||||
|
}],
|
||||||
|
"notFound": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await _call_anaf_api([{"cui": 15134434, "data": "2026-04-07"}])
|
||||||
|
|
||||||
|
assert results["15134434"]["scpTVA"] is True
|
||||||
|
assert results["15134434"]["denumire_anaf"] == "AUTOKLASS CENTER SRL"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_4xx_error_no_retry(self):
|
||||||
|
"""4xx client errors (like 404) should not retry."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 404
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
log_messages = []
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await _call_anaf_api(
|
||||||
|
[{"cui": 12345678, "data": "2026-04-07"}],
|
||||||
|
log_fn=lambda msg: log_messages.append(msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results == {}
|
||||||
|
# Should only call once (no retry for 4xx)
|
||||||
|
assert mock_client.post.call_count == 1
|
||||||
|
assert any("404" in msg for msg in log_messages)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_fn_receives_errors(self):
|
||||||
|
"""log_fn callback receives error messages for UI display."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
log_messages = []
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
results = await _call_anaf_api(
|
||||||
|
[{"cui": 12345678, "data": "2026-04-07"}],
|
||||||
|
log_fn=lambda msg: log_messages.append(msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results == {}
|
||||||
|
assert len(log_messages) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckVatStatusBatch:
|
||||||
|
"""Tests for check_vat_status_batch with log_fn propagation."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_fn_passed_through(self):
|
||||||
|
"""log_fn is forwarded from check_vat_status_batch to _call_anaf_api."""
|
||||||
|
log_messages = []
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {"found": [], "notFound": [12345678]}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await check_vat_status_batch(
|
||||||
|
["12345678"], log_fn=lambda msg: log_messages.append(msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "12345678" in results
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_list_returns_empty(self):
|
||||||
|
assert await check_vat_status_batch([]) == {}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_digit_cuis_filtered(self):
|
||||||
|
"""CUIs that aren't pure digits are filtered out before API call."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {"found": [], "notFound": []}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await check_vat_status_batch(["ABC", "12345678"])
|
||||||
|
|
||||||
|
# Only the digit CUI should be in the body
|
||||||
|
call_body = mock_client.post.call_args[1]["json"]
|
||||||
|
assert len(call_body) == 1
|
||||||
|
assert call_body[0]["cui"] == 12345678
|
||||||
|
|||||||
Reference in New Issue
Block a user