diff --git a/api/app/services/anaf_service.py b/api/app/services/anaf_service.py index d584b5d..99d21a5 100644 --- a/api/app/services/anaf_service.py +++ b/api/app/services/anaf_service.py @@ -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: diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index 2ac4942..e94c9ce 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -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: diff --git a/api/tests/test_sync_cui_gate.py b/api/tests/test_sync_cui_gate.py new file mode 100644 index 0000000..8558d33 --- /dev/null +++ b/api/tests/test_sync_cui_gate.py @@ -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}"