fix(retry): pre-populate price list before re-importing failed orders
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) <noreply@anthropic.com>
This commit is contained in:
412
api/tests/test_pre_validate_order_prices.py
Normal file
412
api/tests/test_pre_validate_order_prices.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user