From bcd65d9fd6790e396e642430c893e7a45431c755 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 29 Apr 2026 10:19:38 +0000 Subject: [PATCH] fix(retry): pre-populate price list before re-importing failed orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production VENDING orders #485841978 and #485841895 (2026-04-28) crashed on Retry with PL/SQL COM-001 because the retry path skipped the CRM_POLITICI_PRET_ART pre-population step that bulk sync runs. The price-list auto-insert (PRET=0) for missing CODMATs was only invoked in sync_service.run_sync (lines 592-718). retry_service called import_single_order directly, hitting pack_comenzi.adauga_articol_comanda NO_DATA_FOUND on every CODMAT without a price entry. Extracted the validation block into validation_service.pre_validate_order_prices and call it from both bulk sync and retry. Single source of truth for SKU validation, dual-policy routing (cont 341/345 → productie), ARTICOLE_TERTI mapping resolution, and kit component price gating. Tests: 3 unit + 3 oracle integration covering the regression scenario, empty input, dual-policy routing, idempotency, and pre-validation exception propagation. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/app/services/retry_service.py | 45 ++- api/app/services/sync_service.py | 140 +------ api/app/services/validation_service.py | 141 +++++++ api/tests/test_pre_validate_order_prices.py | 412 ++++++++++++++++++++ 4 files changed, 608 insertions(+), 130 deletions(-) create mode 100644 api/tests/test_pre_validate_order_prices.py diff --git a/api/app/services/retry_service.py b/api/app/services/retry_service.py index d4dea38..4d8629a 100644 --- a/api/app/services/retry_service.py +++ b/api/app/services/retry_service.py @@ -59,6 +59,40 @@ async def _download_and_reimport(order_number: str, order_date_str: str, custome id_gestiune = app_settings.get("id_gestiune", "") id_gestiuni = [int(g.strip()) for g in id_gestiune.split(",") if g.strip()] if id_gestiune else None + # Pre-validate prices: auto-insert PRET=0 in CRM_POLITICI_PRET_ART for missing + # CODMATs so PL/SQL doesn't crash with COM-001. Mirrors sync_service flow. + from .. import database + validation = {"mapped": set(), "direct": set(), "missing": set(), "direct_id_map": {}} + if database.pool is not None: + conn = await asyncio.to_thread(database.get_oracle_connection) + try: + skus = {item.sku for item in target_order.items if item.sku} + if skus: + validation = await asyncio.to_thread( + validation_service.validate_skus, skus, conn, id_gestiuni, + ) + if id_pol and skus: + id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None + cota_tva = float(app_settings.get("discount_vat") or 21) + await asyncio.to_thread( + validation_service.pre_validate_order_prices, + [target_order], app_settings, conn, id_pol, id_pol_productie, + id_gestiuni, validation, None, cota_tva, + ) + except Exception as e: + logger.error(f"Retry pre-validation failed for {order_number}: {e}") + await sqlite_service.upsert_order( + sync_run_id="retry", + order_number=order_number, + order_date=order_date_str, + customer_name=customer_name, + status=OrderStatus.ERROR.value, + error_message=f"Retry pre-validation failed: {e}", + ) + return {"success": False, "message": f"Eroare pre-validare preturi: {e}"} + finally: + await asyncio.to_thread(database.pool.release, conn) + try: result = await asyncio.to_thread( import_service.import_single_order, @@ -77,17 +111,6 @@ async def _download_and_reimport(order_number: str, order_date_str: str, custome ) return {"success": False, "message": f"Eroare import: {e}"} - # Build order_items data from fresh GoMag download (mirrors sync_service:882-891). - # Resolves ARTICOLE_TERTI mapping so UI shows mapped/direct badge. - try: - skus = {item.sku for item in target_order.items if item.sku} - validation = await asyncio.to_thread( - validation_service.validate_skus, skus, None, id_gestiuni - ) if skus else {"mapped": set(), "direct": set()} - except Exception as e: - logger.warning(f"Retry: validate_skus failed for {order_number}, defaulting mapping_status=direct: {e}") - validation = {"mapped": set(), "direct": set()} - order_items_data = [ { "sku": item.sku, "product_name": item.name, diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index e94c9ce..a5498ab 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -592,132 +592,34 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None # Step 2d: Pre-validate prices for importable articles if id_pol and (truly_importable or already_in_roa): _update_progress("validation", "Validating prices...", 0, len(truly_importable)) - _log_line(run_id, "Validare preturi...") - all_codmats = set() - for order in (truly_importable + already_in_roa): - for item in order.items: - if item.sku in validation["mapped"]: - pass - elif item.sku in validation["direct"]: - all_codmats.add(item.sku) - # Get standard VAT rate from settings for PROC_TVAV metadata cota_tva = float(app_settings.get("discount_vat") or 21) - - # Dual pricing policy support id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None - codmat_policy_map = {} - if all_codmats: - if id_pol_productie: - # Dual-policy: classify articles by cont (sales vs production) - codmat_policy_map = await asyncio.to_thread( - validation_service.validate_and_ensure_prices_dual, - all_codmats, id_pol, id_pol_productie, - conn, validation.get("direct_id_map"), - cota_tva=cota_tva - ) - _log_line(run_id, - f"Politici duale: {sum(1 for v in codmat_policy_map.values() if v == id_pol)} vanzare, " - f"{sum(1 for v in codmat_policy_map.values() if v == id_pol_productie)} productie") - else: - # Single-policy (backward compatible) - price_result = await asyncio.to_thread( - validation_service.validate_prices, all_codmats, id_pol, - conn, validation.get("direct_id_map") - ) - if price_result["missing_price"]: - logger.info( - f"Auto-adding price 0 for {len(price_result['missing_price'])} " - f"direct articles in policy {id_pol}" - ) - await asyncio.to_thread( - validation_service.ensure_prices, - price_result["missing_price"], id_pol, - conn, validation.get("direct_id_map"), - cota_tva=cota_tva - ) + pv_result = await asyncio.to_thread( + validation_service.pre_validate_order_prices, + truly_importable + already_in_roa, + app_settings, conn, id_pol, id_pol_productie, + id_gestiuni, validation, + lambda msg: _log_line(run_id, msg), + cota_tva, + ) - # Also validate mapped SKU prices (cherry-pick 1) - mapped_skus_in_orders = set() - for order in (truly_importable + already_in_roa): - for item in order.items: - if item.sku in validation["mapped"]: - mapped_skus_in_orders.add(item.sku) - - mapped_codmat_data = {} - if mapped_skus_in_orders: - mapped_codmat_data = await asyncio.to_thread( - validation_service.resolve_mapped_codmats, mapped_skus_in_orders, conn, - id_gestiuni=id_gestiuni - ) - # Build id_map for mapped codmats and validate/ensure their prices - mapped_id_map = {} - for sku, entries in mapped_codmat_data.items(): - for entry in entries: - mapped_id_map[entry["codmat"]] = { - "id_articol": entry["id_articol"], - "cont": entry.get("cont") - } - mapped_codmats = set(mapped_id_map.keys()) - if mapped_codmats: - if id_pol_productie: - mapped_policy_map = await asyncio.to_thread( - validation_service.validate_and_ensure_prices_dual, - mapped_codmats, id_pol, id_pol_productie, - conn, mapped_id_map, cota_tva=cota_tva - ) - codmat_policy_map.update(mapped_policy_map) + # Filter truly_importable for kits with missing component prices + kit_missing = pv_result.get("kit_missing") or {} + if kit_missing: + kit_skus_missing = set(kit_missing.keys()) + new_truly = [] + for order in truly_importable: + order_skus = {item.sku for item in order.items} + if order_skus & kit_skus_missing: + missing_list = list(order_skus & kit_skus_missing) + skipped.append((order, missing_list)) else: - mp_result = await asyncio.to_thread( - validation_service.validate_prices, - mapped_codmats, id_pol, conn, mapped_id_map - ) - if mp_result["missing_price"]: - await asyncio.to_thread( - validation_service.ensure_prices, - mp_result["missing_price"], id_pol, - conn, mapped_id_map, cota_tva=cota_tva - ) - - # Add SKU → policy entries for mapped articles (1:1 and kits) - # codmat_policy_map has CODMAT keys, but build_articles_json - # looks up by GoMag SKU — bridge the gap here - if codmat_policy_map and mapped_codmat_data: - for sku, entries in mapped_codmat_data.items(): - if len(entries) == 1: - # 1:1 mapping: SKU inherits the CODMAT's policy - codmat = entries[0]["codmat"] - if codmat in codmat_policy_map: - codmat_policy_map[sku] = codmat_policy_map[codmat] - - # Pass codmat_policy_map to import via app_settings - if codmat_policy_map: - app_settings["_codmat_policy_map"] = codmat_policy_map - - # ── Kit component price validation ── - kit_pricing_mode = app_settings.get("kit_pricing_mode") - if kit_pricing_mode and mapped_codmat_data: - id_pol_prod = int(app_settings.get("id_pol_productie") or 0) or None - kit_missing = await asyncio.to_thread( - validation_service.validate_kit_component_prices, - mapped_codmat_data, id_pol, id_pol_prod, conn - ) - if kit_missing: - kit_skus_missing = set(kit_missing.keys()) - for sku, missing_codmats in kit_missing.items(): - _log_line(run_id, f"Kit {sku}: prețuri lipsă pentru {', '.join(missing_codmats)}") - new_truly = [] - for order in truly_importable: - order_skus = {item.sku for item in order.items} - if order_skus & kit_skus_missing: - missing_list = list(order_skus & kit_skus_missing) - skipped.append((order, missing_list)) - else: - new_truly.append(order) - truly_importable = new_truly + new_truly.append(order) + truly_importable = new_truly # Mode B config validation - if kit_pricing_mode == "separate_line": + if app_settings.get("kit_pricing_mode") == "separate_line": if not app_settings.get("kit_discount_codmat"): _log_line(run_id, "EROARE: Kit mode 'separate_line' dar kit_discount_codmat nu e configurat!") finally: diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index 0f97dde..dd94ee9 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -529,6 +529,147 @@ def resolve_mapped_codmats(mapped_skus: set[str], conn, return result +def pre_validate_order_prices(orders, app_settings: dict, conn, id_pol: int, + id_pol_productie: int = None, + id_gestiuni: list[int] = None, + validation: dict = None, + log_callback=None, + cota_tva: float = 21) -> dict: + """Pre-validate prices for orders before importing them via PACK_IMPORT_COMENZI. + + Auto-inserts PRET=0 rows in CRM_POLITICI_PRET_ART for missing CODMATs so + PL/SQL adauga_articol_comanda doesn't raise COM-001. Mutates + app_settings["_codmat_policy_map"] for build_articles_json routing. + + Used by both bulk sync (sync_service.run_sync) and retry (retry_service). + + Args: + orders: list of orders to scan for SKUs/CODMATs + app_settings: mutated with _codmat_policy_map (SKU/CODMAT → id_pol) + conn: Oracle connection (caller manages lifecycle) + id_pol: default sales price policy + id_pol_productie: production policy for cont 341/345 (None = single-policy) + id_gestiuni: gestiune filter for resolve_mapped_codmats + validation: output of validate_skus; computed internally if None + log_callback: optional Callable[[str], None] for progress messages + cota_tva: VAT rate for PROC_TVAV metadata (default 21) + + Returns: {"codmat_policy_map": dict, "kit_missing": dict, "validation": dict} + - codmat_policy_map: {codmat_or_sku: id_pol} + - kit_missing: {sku: [missing_codmats]} for kits with unprice components + - validation: validate_skus result (for caller convenience) + """ + log = log_callback or (lambda _msg: None) + + if not orders: + return {"codmat_policy_map": {}, "kit_missing": {}, "validation": validation or {}} + + if validation is None: + all_skus = {item.sku for o in orders for item in o.items if item.sku} + validation = validate_skus(all_skus, conn, id_gestiuni) + + log("Validare preturi...") + + # Direct CODMATs (SKU exists in NOM_ARTICOLE without ARTICOLE_TERTI mapping) + all_codmats = set() + for order in orders: + for item in order.items: + if item.sku in validation["mapped"]: + continue + if item.sku in validation["direct"]: + all_codmats.add(item.sku) + + codmat_policy_map = {} + + if all_codmats: + if id_pol_productie: + codmat_policy_map = validate_and_ensure_prices_dual( + all_codmats, id_pol, id_pol_productie, + conn, validation.get("direct_id_map"), cota_tva=cota_tva, + ) + log(f"Politici duale: " + f"{sum(1 for v in codmat_policy_map.values() if v == id_pol)} vanzare, " + f"{sum(1 for v in codmat_policy_map.values() if v == id_pol_productie)} productie") + else: + price_result = validate_prices( + all_codmats, id_pol, conn, validation.get("direct_id_map"), + ) + if price_result["missing_price"]: + logger.info( + f"Auto-adding price 0 for {len(price_result['missing_price'])} " + f"direct articles in policy {id_pol}" + ) + ensure_prices( + price_result["missing_price"], id_pol, + conn, validation.get("direct_id_map"), cota_tva=cota_tva, + ) + + # Mapped SKUs (via ARTICOLE_TERTI) + mapped_skus_in_orders = { + item.sku for o in orders for item in o.items + if item.sku in validation["mapped"] + } + + mapped_codmat_data = {} + if mapped_skus_in_orders: + mapped_codmat_data = resolve_mapped_codmats( + mapped_skus_in_orders, conn, id_gestiuni=id_gestiuni, + ) + mapped_id_map = {} + for sku, entries in mapped_codmat_data.items(): + for entry in entries: + mapped_id_map[entry["codmat"]] = { + "id_articol": entry["id_articol"], + "cont": entry.get("cont"), + } + mapped_codmats = set(mapped_id_map.keys()) + if mapped_codmats: + if id_pol_productie: + mapped_policy_map = validate_and_ensure_prices_dual( + mapped_codmats, id_pol, id_pol_productie, + conn, mapped_id_map, cota_tva=cota_tva, + ) + codmat_policy_map.update(mapped_policy_map) + else: + mp_result = validate_prices( + mapped_codmats, id_pol, conn, mapped_id_map, + ) + if mp_result["missing_price"]: + ensure_prices( + mp_result["missing_price"], id_pol, + conn, mapped_id_map, cota_tva=cota_tva, + ) + + # Bridge SKU → policy via 1:1 mappings (build_articles_json reads by SKU) + if codmat_policy_map and mapped_codmat_data: + for sku, entries in mapped_codmat_data.items(): + if len(entries) == 1: + codmat = entries[0]["codmat"] + if codmat in codmat_policy_map: + codmat_policy_map[sku] = codmat_policy_map[codmat] + + if codmat_policy_map: + app_settings["_codmat_policy_map"] = codmat_policy_map + + # Kit component price gating + kit_missing = {} + kit_pricing_mode = app_settings.get("kit_pricing_mode") + if kit_pricing_mode and mapped_codmat_data: + kit_missing = validate_kit_component_prices( + mapped_codmat_data, id_pol, id_pol_productie, conn, + ) + if kit_missing: + for sku, missing_codmats in kit_missing.items(): + log(f"Kit {sku}: prețuri lipsă pentru {', '.join(missing_codmats)}") + + return { + "codmat_policy_map": codmat_policy_map, + "kit_missing": kit_missing, + "validation": validation, + "mapped_codmat_data": mapped_codmat_data, + } + + def validate_kit_component_prices(mapped_codmat_data: dict, id_pol: int, id_pol_productie: int = None, conn=None) -> dict: """Pre-validate that kit components have non-zero prices in crm_politici_pret_art. diff --git a/api/tests/test_pre_validate_order_prices.py b/api/tests/test_pre_validate_order_prices.py new file mode 100644 index 0000000..7a40a7b --- /dev/null +++ b/api/tests/test_pre_validate_order_prices.py @@ -0,0 +1,412 @@ +"""Tests for validation_service.pre_validate_order_prices and retry pre-validation. + +Regression source: production VENDING orders #485841978 and #485841895 (2026-04-28) +crashed with PL/SQL COM-001 'Pretul pentru acest articol nu a fost gasit in lista +de preturi' because the Retry button skipped the price-list pre-population step +that bulk sync runs. + +These tests verify: +- pre_validate_order_prices auto-inserts PRET=0 in CRM_POLITICI_PRET_ART for + CODMATs missing entries (so PL/SQL doesn't crash). +- Dual-policy routing: cont 341/345 → id_pol_productie; else → id_pol. +- Empty input returns empty result without DB calls. +- Idempotent: running twice when prices already exist does no inserts. +- retry_service propagates pre-validation failures as ERROR with clear message. +""" +import os +import sys +from unittest.mock import patch, MagicMock, AsyncMock + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.services.order_reader import OrderData, OrderItem, OrderShipping, OrderBilling + + +def _make_order(number: str, items: list[tuple[str, float, float]]) -> OrderData: + """items = [(sku, quantity, price), ...]""" + return OrderData( + id=number, + number=number, + date="2026-04-28 10:00:00", + items=[ + OrderItem(sku=sku, name=f"Product {sku}", price=price, + quantity=qty, vat=21.0, baseprice=price) + for sku, qty, price in items + ], + billing=OrderBilling(firstname="Ion", lastname="Test"), + shipping=OrderShipping(firstname="Ion", lastname="Test"), + ) + + +# ============================================================ +# UNIT TESTS — no Oracle +# ============================================================ + +@pytest.mark.unit +def test_pre_validate_empty_orders_returns_empty(): + """Empty orders list short-circuits without any DB calls.""" + from app.services import validation_service + + app_settings = {} + mock_conn = MagicMock() + + result = validation_service.pre_validate_order_prices( + orders=[], + app_settings=app_settings, + conn=mock_conn, + id_pol=1, + ) + + assert result["codmat_policy_map"] == {} + assert result["kit_missing"] == {} + # No DB cursor was opened + mock_conn.cursor.assert_not_called() + # app_settings unchanged + assert "_codmat_policy_map" not in app_settings + + +@pytest.mark.unit +def test_pre_validate_no_skus_in_orders(): + """Orders with no items skip price validation entirely.""" + from app.services import validation_service + + order = OrderData(id="1", number="1", date="2026-04-28", items=[], + billing=OrderBilling()) + app_settings = {} + mock_conn = MagicMock() + + # validation passed-in is empty + validation = {"mapped": set(), "direct": set(), "missing": set(), + "direct_id_map": {}} + + result = validation_service.pre_validate_order_prices( + orders=[order], + app_settings=app_settings, + conn=mock_conn, + id_pol=1, + validation=validation, + ) + + assert result["codmat_policy_map"] == {} + assert result["kit_missing"] == {} + + +@pytest.mark.unit +async def test_retry_propagates_pre_validation_error(): + """Pre-validation failure in retry path returns ERROR with clear message.""" + from app.services import retry_service + + target_order = _make_order("RETRY-FAIL-1", [("SKU-X", 1, 100)]) + + fake_pool = MagicMock() + fake_conn = MagicMock() + fake_pool.release = MagicMock() + + with patch("app.services.gomag_client.download_orders", + new=AsyncMock(return_value=None)), \ + patch("app.services.order_reader.read_json_orders", + return_value=([target_order], 1)), \ + patch("app.services.sqlite_service.upsert_order", + new=AsyncMock()) as mock_upsert, \ + patch("app.services.validation_service.validate_skus", + return_value={"mapped": set(), "direct": {"SKU-X"}, + "missing": set(), "direct_id_map": {"SKU-X": 1}}), \ + patch("app.services.validation_service.pre_validate_order_prices", + side_effect=RuntimeError("ORA-12541: TNS no listener")), \ + patch("app.database.pool", fake_pool), \ + patch("app.database.get_oracle_connection", return_value=fake_conn): + + app_settings = {"id_pol": "1", "id_gestiune": "1", "discount_vat": "21"} + + result = await retry_service._download_and_reimport( + order_number="RETRY-FAIL-1", + order_date_str="2026-04-28T10:00:00", + customer_name="Ion Test", + app_settings=app_settings, + ) + + assert result["success"] is False + assert "pre-validare" in result["message"].lower() + assert "TNS" in result["message"] + # Verify ERROR persisted to SQLite + mock_upsert.assert_called_once() + call_kwargs = mock_upsert.call_args.kwargs + assert call_kwargs["status"] == "ERROR" + + +# ============================================================ +# ORACLE INTEGRATION TESTS — require live Oracle +# ============================================================ + +# Oracle connection setup (lazy import to keep unit tests isolated) +def _get_oracle_conn(): + import oracledb + from dotenv import load_dotenv + load_dotenv('.env') + user = os.environ['ORACLE_USER'] + password = os.environ['ORACLE_PASSWORD'] + dsn = os.environ['ORACLE_DSN'] + try: + instantclient_path = os.environ.get( + 'INSTANTCLIENTPATH', '/opt/oracle/instantclient_23_9' + ) + oracledb.init_oracle_client(lib_dir=instantclient_path) + except Exception: + pass + return oracledb.connect(user=user, password=password, dsn=dsn) + + +def _has_price_entry(cur, id_pol: int, id_articol: int) -> tuple[bool, float | None]: + """Returns (exists, pret). pret is None if row doesn't exist.""" + cur.execute(""" + SELECT PRET FROM crm_politici_pret_art + WHERE id_pol = :p AND id_articol = :a + """, {"p": id_pol, "a": id_articol}) + row = cur.fetchone() + return (row is not None, row[0] if row else None) + + +def _pick_unpriced_article(cur, id_pol: int, count: int = 1) -> list[tuple[int, str]]: + """Find existing NOM_ARTICOLE rows without CRM_POLITICI_PRET_ART entry for id_pol. + Returns: [(id_articol, codmat), ...]. Skips test if not enough found. + """ + cur.execute(""" + SELECT id_articol, codmat FROM nom_articole na + WHERE sters = 0 AND inactiv = 0 + AND NOT EXISTS ( + SELECT 1 FROM crm_politici_pret_art pa + WHERE pa.id_articol = na.id_articol AND pa.id_pol = :pol + ) + AND ROWNUM <= :n + """, {"pol": id_pol, "n": count}) + return cur.fetchall() + + +def _pick_priced_article(cur, id_pol: int) -> tuple[int, str, float] | None: + """Find any (id_articol, codmat, pret) with existing CRM_POLITICI_PRET_ART entry.""" + cur.execute(""" + SELECT na.id_articol, na.codmat, pa.pret + FROM nom_articole na + JOIN crm_politici_pret_art pa ON pa.id_articol = na.id_articol + WHERE pa.id_pol = :pol AND na.sters = 0 AND na.inactiv = 0 + AND ROWNUM <= 1 + """, {"pol": id_pol}) + return cur.fetchone() + + +def _pick_default_id_pol(cur) -> int | None: + """Pick first usable id_pol from CRM_POLITICI_PRETURI.""" + cur.execute(""" + SELECT id_pol FROM crm_politici_preturi + WHERE sters = 0 AND ROWNUM <= 1 + ORDER BY id_pol + """) + row = cur.fetchone() + return row[0] if row else None + + +@pytest.mark.oracle +def test_pre_validate_inserts_missing_prices_for_direct_sku(): + """REGRESSION (prod orders #485841978, #485841895): + A SKU that resolves directly to a CODMAT in NOM_ARTICOLE with NO entry + in CRM_POLITICI_PRET_ART must auto-insert PRET=0 so the import doesn't + crash with COM-001. + + Uses a real unpriced article from the test schema. Cleans up after. + """ + from app.services import validation_service + + with _get_oracle_conn() as conn: + with conn.cursor() as cur: + id_pol = _pick_default_id_pol(cur) + assert id_pol is not None, "No usable id_pol found in CRM_POLITICI_PRETURI" + + unpriced = _pick_unpriced_article(cur, id_pol, count=1) + if not unpriced: + pytest.skip(f"All articles in policy {id_pol} already have prices") + + id_art, codmat = unpriced[0] + inserted = False + try: + # Pre-condition + exists, _ = _has_price_entry(cur, id_pol, id_art) + assert not exists, f"Pre-condition: {codmat} should be unpriced" + + # Use codmat as direct SKU. validate_skus → direct (matches NOM_ARTICOLE) + order = _make_order("VEN-PV-DIRECT", [(codmat, 1, 100)]) + app_settings = {} + validation = { + "mapped": set(), + "direct": {codmat}, + "missing": set(), + "direct_id_map": {codmat: {"id_articol": id_art, "cont": None}}, + } + + validation_service.pre_validate_order_prices( + orders=[order], app_settings=app_settings, conn=conn, + id_pol=id_pol, validation=validation, cota_tva=21, + ) + conn.commit() + inserted = True + + # Post-condition: PRET=0 row created + exists, pret = _has_price_entry(cur, id_pol, id_art) + assert exists, ( + f"REGRESSION: price entry for {codmat} (id={id_art}) " + f"in policy {id_pol} should be auto-created" + ) + assert pret == 0, f"Auto-inserted price should be 0, got {pret}" + finally: + if inserted: + cur.execute( + "DELETE FROM crm_politici_pret_art " + "WHERE id_pol = :p AND id_articol = :a AND pret = 0", + {"p": id_pol, "a": id_art}, + ) + conn.commit() + + +@pytest.mark.oracle +def test_pre_validate_idempotent_when_prices_exist(): + """When all CODMATs already have CRM_POLITICI_PRET_ART entries, no INSERTs run. + Verifies idempotency on a second pre-validation pass — existing prices untouched.""" + from app.services import validation_service + + with _get_oracle_conn() as conn: + with conn.cursor() as cur: + id_pol = _pick_default_id_pol(cur) + assert id_pol is not None, "No usable id_pol found" + + priced = _pick_priced_article(cur, id_pol) + if not priced: + pytest.skip(f"No priced articles in policy {id_pol}") + + id_art, codmat, pret_orig = priced + + cur.execute("""SELECT COUNT(*) FROM crm_politici_pret_art + WHERE id_articol = :a AND id_pol = :p""", + {"a": id_art, "p": id_pol}) + count_before = cur.fetchone()[0] + + order = _make_order("VEN-IDEM", [(codmat, 1, 200)]) + app_settings = {} + validation = { + "mapped": set(), "direct": {codmat}, "missing": set(), + "direct_id_map": {codmat: {"id_articol": id_art, "cont": None}}, + } + + for _ in range(2): # Run twice + validation_service.pre_validate_order_prices( + orders=[order], app_settings=app_settings, conn=conn, + id_pol=id_pol, validation=validation, cota_tva=21, + ) + conn.commit() + + cur.execute("""SELECT COUNT(*), MAX(pret) FROM crm_politici_pret_art + WHERE id_articol = :a AND id_pol = :p""", + {"a": id_art, "p": id_pol}) + count_after, pret_after = cur.fetchone() + assert count_after == count_before, ( + f"Idempotency violated: {count_before} → {count_after} rows" + ) + assert pret_after == pret_orig, ( + f"Existing price changed: {pret_orig} → {pret_after}" + ) + + +@pytest.mark.oracle +def test_pre_validate_dual_policy_routing(): + """Articles with cont 341/345 route to id_pol_productie; others to id_pol_vanzare. + + Picks two existing unpriced articles, marks one with cont=341, runs + pre_validate, asserts each landed in the expected policy. + """ + from app.services import validation_service + + with _get_oracle_conn() as conn: + with conn.cursor() as cur: + id_pol = _pick_default_id_pol(cur) + assert id_pol is not None, "No usable id_pol" + + # Find a second policy to use as productie (any other usable id_pol) + cur.execute("""SELECT id_pol FROM crm_politici_preturi + WHERE sters = 0 AND id_pol != :p AND ROWNUM <= 1 + ORDER BY id_pol""", {"p": id_pol}) + row = cur.fetchone() + if not row: + pytest.skip("Need 2 distinct id_pol values for dual-policy test") + id_pol_productie = row[0] + + unpriced = _pick_unpriced_article(cur, id_pol, count=2) + if len(unpriced) < 2: + pytest.skip("Need 2 unpriced articles for dual-policy test") + (id_prod, codmat_prod), (id_sales, codmat_sales) = unpriced[0], unpriced[1] + + # Save original cont values for cleanup + cur.execute("SELECT cont FROM nom_articole WHERE id_articol = :a", + {"a": id_prod}) + cont_prod_orig = cur.fetchone()[0] + + try: + cur.execute("UPDATE nom_articole SET cont = '341' " + "WHERE id_articol = :a", {"a": id_prod}) + conn.commit() + + order = _make_order( + "VEN-DUAL", + [(codmat_prod, 1, 50), (codmat_sales, 1, 80)], + ) + app_settings = {} + validation = { + "mapped": set(), + "direct": {codmat_prod, codmat_sales}, + "missing": set(), + "direct_id_map": { + codmat_prod: {"id_articol": id_prod, "cont": "341"}, + codmat_sales: {"id_articol": id_sales, "cont": cont_prod_orig or "302"}, + }, + } + + result = validation_service.pre_validate_order_prices( + orders=[order], app_settings=app_settings, conn=conn, + id_pol=id_pol, id_pol_productie=id_pol_productie, + validation=validation, cota_tva=21, + ) + conn.commit() + + policy_map = result["codmat_policy_map"] + assert policy_map.get(codmat_prod) == id_pol_productie, ( + f"cont=341 article ({codmat_prod}) should route to " + f"productie={id_pol_productie}, got {policy_map.get(codmat_prod)}" + ) + assert policy_map.get(codmat_sales) == id_pol, ( + f"non-341 article ({codmat_sales}) should route to " + f"vanzare={id_pol}, got {policy_map.get(codmat_sales)}" + ) + + # Verify rows landed in the right policy + exists_prod_in_prod, _ = _has_price_entry(cur, id_pol_productie, id_prod) + exists_prod_in_sales, _ = _has_price_entry(cur, id_pol, id_prod) + exists_sales_in_sales, _ = _has_price_entry(cur, id_pol, id_sales) + exists_sales_in_prod, _ = _has_price_entry(cur, id_pol_productie, id_sales) + assert exists_prod_in_prod and not exists_prod_in_sales, ( + "cont=341 row should be in productie policy only" + ) + assert exists_sales_in_sales and not exists_sales_in_prod, ( + "Non-341 row should be in sales policy only" + ) + finally: + # Cleanup: restore cont, delete inserted PRET=0 rows + cur.execute("UPDATE nom_articole SET cont = :c " + "WHERE id_articol = :a", + {"c": cont_prod_orig, "a": id_prod}) + cur.execute( + "DELETE FROM crm_politici_pret_art " + "WHERE id_pol IN (:p1, :p2) " + "AND id_articol IN (:a1, :a2) AND pret = 0", + {"p1": id_pol, "p2": id_pol_productie, + "a1": id_prod, "a2": id_sales}, + ) + conn.commit()