feat(sync): gate CUI invalid/ANAF-notFound → ERROR inainte de import Oracle

Incident 22.04.2026 (#485225171 NONA ROYAL SRL): clientul a inversat
cod_fiscal cu registru in GoMag → sistem a creat partener cu CUI=J1994000194225.

Adauga evaluate_cui_gate() care blocheaza comanda (ERROR) daca:
- CUI format invalid (ex: J.. in loc de cifre)
- CUI nu trece cifra de control
- ANAF returneaza explicit notFound (scpTVA=None + denumire_anaf="")

ANAF down (anaf_data=None) → fallback pass, comportament existent pastrat.
_record_order_error() DRY helper evita duplicarea upsert/add_items.
Contract ANAF down/notFound/found documentat in anaf_service._call_anaf_api.
9 teste unit (inclusiv T5 CRITIC: ANAF down nu blocheaza) + T7 COALESCE.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-22 09:40:07 +00:00
parent 7e30523242
commit 6620b28ed1
3 changed files with 351 additions and 37 deletions

View File

@@ -141,6 +141,12 @@ async def _call_anaf_api(body: list[dict], retry: int = 0, log_fn=None) -> dict[
checked_at = datetime.now().isoformat()
# CONTRACT (consumed by sync_service.evaluate_cui_gate):
# Return {} → transient error (down/429/5xx/timeout)
# Return {cui: {scpTVA: None, denumire_anaf: ""}} → ANAF notFound explicit
# Return {cui: {scpTVA: bool, denumire_anaf: str}} → ANAF found
# If you change this semantics, update the gate in sync_service too.
# Parse ANAF response
found_list = data.get("found", [])
for item in found_list:

View File

@@ -112,6 +112,58 @@ async def _record_phase_err(run_id: str, phase: str, err: Exception) -> None:
logger.warning(f"record_phase_failure failed for phase={phase}: {rec_err}")
def evaluate_cui_gate(
is_ro_company: bool,
company_code_raw: str | None,
bare_cui: str,
anaf_data: dict | None,
) -> str | None:
"""Return block reason or None if the order passes the CUI gate.
CONTRACT on anaf_data:
- None → ANAF down / transient error → tolerate (pass)
- {scpTVA: None, denumire_anaf: ""} → ANAF notFound explicit → block
- {scpTVA: bool, denumire_anaf: str} → ANAF found → pass
"""
if not is_ro_company or not company_code_raw:
return None
if not anaf_service.validate_cui(bare_cui):
return f"CUI invalid (format): {company_code_raw!r}"
if not anaf_service.validate_cui_checksum(bare_cui):
return f"CUI invalid (cifra de control): {bare_cui}"
if (
anaf_data is not None
and anaf_data.get("scpTVA") is None
and not (anaf_data.get("denumire_anaf") or "").strip()
):
return (
f"CUI {company_code_raw!r} (sanitizat: {bare_cui}) nu exista in registrul ANAF — "
f"verifica daca nu e inversat cu numarul de la registrul comertului"
)
return None
async def _record_order_error(
run_id: str, order, customer: str, shipping_name: str, billing_name: str,
payment_method: str, delivery_method: str, discount_split_json: str | None,
order_items_data: list, reason: str, id_partener: int | None = None,
) -> None:
"""Write an ERROR row to SQLite (orders + sync_run_orders + order_items)."""
await sqlite_service.upsert_order(
sync_run_id=run_id, order_number=order.number, order_date=order.date,
customer_name=customer, status=OrderStatus.ERROR.value,
id_partener=id_partener, error_message=reason,
items_count=len(order.items),
shipping_name=shipping_name, billing_name=billing_name,
payment_method=payment_method, delivery_method=delivery_method,
order_total=order.total or None, delivery_cost=order.delivery_cost or None,
discount_total=order.discount_total or None, web_status=order.status or None,
discount_split=discount_split_json,
)
await sqlite_service.add_sync_run_order(run_id, order.number, OrderStatus.ERROR.value)
await sqlite_service.add_order_items(order.number, order_items_data)
async def _check_escalation() -> tuple[str | None, dict[str, int]]:
"""Return (phase_to_halt_on, recent_counts).
@@ -919,6 +971,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
cod_fiscal_override = None
anaf_data_for_order = None
raw_cf = ""
bare_cui = ""
if order.billing.is_company and order.billing.company_code:
raw_cf = import_service.clean_web_text(order.billing.company_code) or ""
bare_cui, cui_warning = anaf_service.sanitize_cui(raw_cf)
@@ -938,6 +991,36 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
if is_ro_company and anaf_data_for_order and anaf_data_for_order.get("scpTVA") is not None:
anaf_strict = 1 # ANAF data available → strict search
# Build order items data and discount split (needed by gate error path)
order_items_data = []
for item in order.items:
ms = "mapped" if item.sku in validation["mapped"] else "direct"
order_items_data.append({
"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price,
"baseprice": item.baseprice, "vat": item.vat,
"mapping_status": ms, "codmat": None, "id_articol": None,
"cantitate_roa": None
})
ds = import_service.compute_discount_split(order, app_settings)
discount_split_json = json.dumps(ds) if ds else None
# Gate CUI (RO PJ): block if CUI invalid or ANAF explicit notFound
block_reason = evaluate_cui_gate(
is_ro_company, order.billing.company_code, bare_cui, anaf_data_for_order
)
if block_reason:
error_count += 1
_log_line(run_id, f"#{order.number} BLOCAT: {block_reason}")
await _record_order_error(
run_id, order, customer, shipping_name, billing_name,
payment_method, delivery_method, discount_split_json,
order_items_data, block_reason,
)
if error_count > 10:
break
continue
# ANAF official name override: used at partner creation (not lookup).
# Strip before truthy check → reject whitespace-only values.
denumire_override = None
@@ -955,22 +1038,6 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
denumire_override=denumire_override,
)
# Build order items data for storage (R9)
order_items_data = []
for item in order.items:
ms = "mapped" if item.sku in validation["mapped"] else "direct"
order_items_data.append({
"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price,
"baseprice": item.baseprice, "vat": item.vat,
"mapping_status": ms, "codmat": None, "id_articol": None,
"cantitate_roa": None
})
# Compute discount split for SQLite storage
ds = import_service.compute_discount_split(order, app_settings)
discount_split_json = json.dumps(ds) if ds else None
if result["success"]:
imported_count += 1
await sqlite_service.upsert_order(
@@ -1040,28 +1107,13 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
if not result["success"]:
error_count += 1
await sqlite_service.upsert_order(
sync_run_id=run_id,
order_number=order.number,
order_date=order.date,
customer_name=customer,
status=OrderStatus.ERROR.value,
id_partener=result.get("id_partener"),
error_message=result["error"],
items_count=len(order.items),
shipping_name=shipping_name,
billing_name=billing_name,
payment_method=payment_method,
delivery_method=delivery_method,
order_total=order.total or None,
delivery_cost=order.delivery_cost or None,
discount_total=order.discount_total or None,
web_status=order.status or None,
discount_split=discount_split_json,
)
await sqlite_service.add_sync_run_order(run_id, order.number, OrderStatus.ERROR.value)
await sqlite_service.add_order_items(order.number, order_items_data)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → EROARE: {result['error']}")
await _record_order_error(
run_id, order, customer, shipping_name, billing_name,
payment_method, delivery_method, discount_split_json,
order_items_data, result["error"],
id_partener=result.get("id_partener"),
)
# Safety: stop if too many errors
if error_count > 10:

View File

@@ -0,0 +1,256 @@
"""
CUI Gate Tests
==============
Unit tests for evaluate_cui_gate() and _record_order_error() in sync_service.
Tests 1-6: pure predicate, no IO.
Test 7: integration — _record_order_error with pre-seeded SQLite IMPORTED row
verifies COALESCE preserves existing id_partener.
Run:
cd api && python -m pytest tests/test_sync_cui_gate.py -v
"""
import os
import sys
import tempfile
import pytest
pytestmark = pytest.mark.unit
_tmpdir = tempfile.mkdtemp()
os.environ.setdefault("FORCE_THIN_MODE", "true")
os.environ.setdefault("SQLITE_DB_PATH", os.path.join(_tmpdir, "test_cui_gate.db"))
os.environ.setdefault("ORACLE_DSN", "dummy")
os.environ.setdefault("ORACLE_USER", "dummy")
os.environ.setdefault("ORACLE_PASSWORD", "dummy")
os.environ.setdefault("JSON_OUTPUT_DIR", _tmpdir)
_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _api_dir not in sys.path:
sys.path.insert(0, _api_dir)
from app import database
from app.services import sqlite_service
from app.services.sync_service import evaluate_cui_gate, _record_order_error
from app.services.order_reader import OrderBilling, OrderShipping, OrderData, OrderItem
from app.constants import OrderStatus
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_VALID_ANAF_FOUND = {"scpTVA": True, "denumire_anaf": "NONA ROYAL SRL", "checked_at": "2026-04-22T10:00:00"}
_ANAF_NOT_FOUND = {"scpTVA": None, "denumire_anaf": "", "checked_at": "2026-04-22T10:00:00"}
# A CUI with valid format and valid checksum (MATTEO&OANA CAFFE 2022 SRL)
_VALID_CUI = "49033051"
# Same body but last digit modified → fails checksum
_BAD_CHECKSUM_CUI = "49033052"
# J-format — the incident CUI (registru number in the CUI field)
_J_FORMAT = "J1994000194225"
def _make_pj_order(company_code=_VALID_CUI, number="O-001"):
billing = OrderBilling(
firstname="Ion", lastname="Pop", phone="0700", email="x@x.ro",
address="Str A 1", city="Cluj", region="Cluj", country="Romania",
company_name="TEST SRL", company_code=company_code,
company_reg="J12/123/2020", is_company=True,
)
shipping = OrderShipping(
firstname="Ion", lastname="Pop", phone="0700", email="x@x.ro",
address="Str A 1", city="Cluj", region="Cluj", country="Romania",
)
return OrderData(
id=number, number=number, date="2026-04-22",
billing=billing, shipping=shipping,
items=[OrderItem(sku="SKU1", name="Prod", price=10.0, quantity=1, vat=19)],
)
def _make_pf_order(number="O-PF-1"):
billing = OrderBilling(
firstname="Ana", lastname="Pop", phone="0700", email="a@x.ro",
address="Str B 2", city="Iasi", region="Iasi", country="Romania",
is_company=False,
)
shipping = OrderShipping(
firstname="Ana", lastname="Pop", phone="0700", email="a@x.ro",
address="Str B 2", city="Iasi", region="Iasi", country="Romania",
)
return OrderData(
id=number, number=number, date="2026-04-22",
billing=billing, shipping=shipping,
items=[OrderItem(sku="SKU1", name="Prod", price=10.0, quantity=1, vat=19)],
)
# ---------------------------------------------------------------------------
# Tests 1-6: pure predicate — no IO
# ---------------------------------------------------------------------------
class TestEvaluateCuiGate:
def test_format_invalid_incident_case(self):
"""Test 1: J-format in cod_fiscal field (the 22-Apr-2026 incident) → blocked."""
# bare_cui from sanitize_cui("J1994000194225") = "J1994000194225" (not digits)
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_J_FORMAT,
bare_cui=_J_FORMAT,
anaf_data=_VALID_ANAF_FOUND,
)
assert result is not None
assert "format" in result
def test_checksum_invalid(self):
"""Test 2: valid format, wrong check digit → blocked."""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_BAD_CHECKSUM_CUI,
bare_cui=_BAD_CHECKSUM_CUI,
anaf_data=_VALID_ANAF_FOUND,
)
assert result is not None
assert "cifra de control" in result
def test_anaf_not_found_explicit(self):
"""Test 3: ANAF explicit notFound → blocked with registry hint."""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_VALID_CUI,
bare_cui=_VALID_CUI,
anaf_data=_ANAF_NOT_FOUND,
)
assert result is not None
assert "nu exista in registrul ANAF" in result
assert "registrul comertului" in result
def test_anaf_found_vat_payer_passes(self):
"""Test 4: ANAF found + platitor TVA → pass."""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_VALID_CUI,
bare_cui=_VALID_CUI,
anaf_data=_VALID_ANAF_FOUND,
)
assert result is None
def test_anaf_down_fallback_passes(self):
"""Test 5 [CRITICAL REGRESSION]: ANAF down (anaf_data=None) + valid CUI → pass.
If this test fails, the gate is breaking the ANAF-down fallback and all
RO company orders would error when ANAF is unavailable.
"""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_VALID_CUI,
bare_cui=_VALID_CUI,
anaf_data=None, # ANAF down / transient error
)
assert result is None, (
"ANAF down must NOT block orders — gate must only block on explicit notFound"
)
def test_pf_always_passes(self):
"""Test 6: PF order (is_ro_company=False) → always pass, regardless of CUI."""
result = evaluate_cui_gate(
is_ro_company=False,
company_code_raw=_J_FORMAT,
bare_cui=_J_FORMAT,
anaf_data=_ANAF_NOT_FOUND,
)
assert result is None
def test_no_company_code_passes(self):
"""PJ without company_code → pass (nothing to validate)."""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=None,
bare_cui="",
anaf_data=None,
)
assert result is None
def test_anaf_found_non_vat_passes(self):
"""ANAF found non-platitor TVA (scpTVA=False) → pass."""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_VALID_CUI,
bare_cui=_VALID_CUI,
anaf_data={"scpTVA": False, "denumire_anaf": "FIRMA SRL", "checked_at": "2026-04-22T10:00:00"},
)
assert result is None
# ---------------------------------------------------------------------------
# Test 7: integration — COALESCE preserves id_partener on gate block
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _init_db():
database.init_sqlite()
@pytest.mark.asyncio
async def test_record_order_error_preserves_id_partener():
"""Test 7: _record_order_error called with id_partener=None preserves existing id_partener
via SQLite COALESCE in upsert_order.
Scenario: order was previously IMPORTED with id_partener=9001.
At resync the gate blocks it (bad CUI). _record_order_error passes id_partener=None.
After upsert, the row should have status=ERROR and id_partener=9001 (preserved).
"""
order = _make_pj_order(company_code=_J_FORMAT, number="O-COALESCE-1")
run_id = "test-run-coalesce"
# Seed an existing IMPORTED row with id_partener=9001
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"""INSERT OR REPLACE INTO orders
(order_number, order_date, customer_name, status, id_partener, items_count)
VALUES (?, ?, ?, ?, ?, ?)""",
(order.number, order.date, "TEST SRL", OrderStatus.IMPORTED.value, 9001, 1),
)
await db.commit()
finally:
await db.close()
# Gate fires → calls _record_order_error with id_partener=None (gate doesn't know it)
await _record_order_error(
run_id=run_id,
order=order,
customer="TEST SRL",
shipping_name="Ion Pop",
billing_name="TEST SRL",
payment_method="card",
delivery_method="curier",
discount_split_json=None,
order_items_data=[{
"sku": "SKU1", "product_name": "Prod", "quantity": 1,
"price": 10.0, "baseprice": None, "vat": 19,
"mapping_status": "direct", "codmat": None, "id_articol": None, "cantitate_roa": None,
}],
reason=f"CUI invalid (format): {_J_FORMAT!r}",
id_partener=None, # gate doesn't have it
)
# Verify: status=ERROR, id_partener=9001 (COALESCE preserved), error_message populated
db = await sqlite_service.get_sqlite()
try:
row = await db.execute(
"SELECT status, id_partener, error_message FROM orders WHERE order_number = ?",
(order.number,),
)
row = await row.fetchone()
finally:
await db.close()
assert row is not None, "Order row missing after _record_order_error"
assert row[0] == OrderStatus.ERROR.value, f"Expected ERROR, got {row[0]}"
assert row[1] == 9001, f"Expected id_partener=9001 (preserved by COALESCE), got {row[1]}"
assert row[2] and "format" in row[2], f"Expected error_message with 'format', got {row[2]!r}"