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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
256
api/tests/test_sync_cui_gate.py
Normal file
256
api/tests/test_sync_cui_gate.py
Normal 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}"
|
||||
Reference in New Issue
Block a user