- Remove TIER 2 address lookup (county+city without street) from PL/SQL — creates new address when street differs instead of reusing wrong one - Replace generic "N diferente" badge with typed micro-badges (CUI, Denumire, TVA, Adr. livr., Adr. fact., Preturi) with red/amber semantic colors - Extend addrMatch() regex to strip full Romanian address words (STRADA, NUMAR, BLOC, COMUNA, SAT, MUNICIPIUL, etc.) — fixes "Strada X" vs "X" false positives - Extend normalize_company_name() for II, PFA, INTREPRINDERE INDIVIDUALA legal forms - Persist address_mismatch to SQLite so "Dif." filter includes address-only diffs - Add red/amber indicator dots to desktop table and mobile list rows - 12 unit tests for normalization and server-side address matching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
719 lines
29 KiB
Python
719 lines
29 KiB
Python
"""
|
||
Business Rule Regression Tests
|
||
==============================
|
||
Regression tests for historical bug fixes in kit pricing, discount calculation,
|
||
duplicate CODMAT resolution, price sync, and VAT normalization.
|
||
|
||
Run:
|
||
cd api && python -m pytest tests/test_business_rules.py -v
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
|
||
import pytest
|
||
|
||
pytestmark = pytest.mark.unit
|
||
|
||
# --- Set env vars BEFORE any app import ---
|
||
_tmpdir = tempfile.mkdtemp()
|
||
_sqlite_path = os.path.join(_tmpdir, "test_biz.db")
|
||
|
||
os.environ["FORCE_THIN_MODE"] = "true"
|
||
os.environ["SQLITE_DB_PATH"] = _sqlite_path
|
||
os.environ["ORACLE_DSN"] = "dummy"
|
||
os.environ["ORACLE_USER"] = "dummy"
|
||
os.environ["ORACLE_PASSWORD"] = "dummy"
|
||
os.environ["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 unittest.mock import MagicMock, patch
|
||
|
||
from app.services.import_service import build_articles_json, compute_discount_split
|
||
from app.services.order_reader import OrderData, OrderItem
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def make_item(sku="SKU1", price=100.0, quantity=1, vat=19):
|
||
return OrderItem(sku=sku, name=f"Product {sku}", price=price, quantity=quantity, vat=vat)
|
||
|
||
|
||
def make_order(items, discount_total=0.0, delivery_cost=0.0, discount_vat=None):
|
||
order = OrderData(
|
||
id="1", number="TEST-001", date="2026-01-01",
|
||
items=items, discount_total=discount_total,
|
||
delivery_cost=delivery_cost,
|
||
)
|
||
if discount_vat is not None:
|
||
order.discount_vat = discount_vat
|
||
return order
|
||
|
||
|
||
def is_kit(comps):
|
||
"""Kit detection pattern used in validation_service and price_sync_service."""
|
||
return len(comps) > 1 or (
|
||
len(comps) == 1 and (comps[0].get("cantitate_roa") or 1) != 1
|
||
)
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 1: compute_discount_split()
|
||
# ===========================================================================
|
||
|
||
class TestDiscountSplit:
|
||
"""Regression: discount split by VAT rate (import_service.py:63)."""
|
||
|
||
def test_single_vat_rate(self):
|
||
order = make_order([make_item(vat=19), make_item("SKU2", vat=19)], discount_total=10.0)
|
||
result = compute_discount_split(order, {"split_discount_vat": "1"})
|
||
assert result == {"19": 10.0}
|
||
|
||
def test_multiple_vat_proportional(self):
|
||
items = [make_item("A", price=100, quantity=1, vat=19),
|
||
make_item("B", price=50, quantity=1, vat=9)]
|
||
order = make_order(items, discount_total=15.0)
|
||
result = compute_discount_split(order, {"split_discount_vat": "1"})
|
||
assert result == {"9": 5.0, "19": 10.0}
|
||
|
||
def test_zero_returns_none(self):
|
||
order = make_order([make_item()], discount_total=0)
|
||
assert compute_discount_split(order, {"split_discount_vat": "1"}) is None
|
||
|
||
def test_zero_price_items_excluded(self):
|
||
items = [make_item("A", price=0, quantity=1, vat=19),
|
||
make_item("B", price=100, quantity=2, vat=9)]
|
||
order = make_order(items, discount_total=5.0)
|
||
result = compute_discount_split(order, {"split_discount_vat": "1"})
|
||
assert result == {"9": 5.0}
|
||
|
||
def test_disabled_multiple_rates(self):
|
||
items = [make_item("A", vat=19), make_item("B", vat=9)]
|
||
order = make_order(items, discount_total=10.0)
|
||
result = compute_discount_split(order, {"split_discount_vat": "0"})
|
||
assert result is None
|
||
|
||
def test_rounding_remainder(self):
|
||
items = [make_item("A", price=33.33, quantity=1, vat=19),
|
||
make_item("B", price=33.33, quantity=1, vat=9),
|
||
make_item("C", price=33.34, quantity=1, vat=5)]
|
||
order = make_order(items, discount_total=10.0)
|
||
result = compute_discount_split(order, {"split_discount_vat": "1"})
|
||
assert result is not None
|
||
assert abs(sum(result.values()) - 10.0) < 0.001
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 2: build_articles_json()
|
||
# ===========================================================================
|
||
|
||
class TestBuildArticlesJson:
|
||
"""Regression: discount lines, policy bridge, transport (import_service.py:117)."""
|
||
|
||
def test_discount_line_negative_quantity(self):
|
||
items = [make_item()]
|
||
order = make_order(items, discount_total=5.0)
|
||
settings = {"discount_codmat": "DISC01", "split_discount_vat": "0"}
|
||
result = json.loads(build_articles_json(items, order, settings))
|
||
disc_lines = [a for a in result if a["sku"] == "DISC01"]
|
||
assert len(disc_lines) == 1
|
||
assert disc_lines[0]["quantity"] == "-1"
|
||
assert disc_lines[0]["price"] == "5.0"
|
||
|
||
def test_discount_uses_actual_vat_not_21(self):
|
||
items = [make_item("A", vat=9), make_item("B", vat=9)]
|
||
order = make_order(items, discount_total=3.0)
|
||
settings = {"discount_codmat": "DISC01", "split_discount_vat": "1"}
|
||
result = json.loads(build_articles_json(items, order, settings))
|
||
disc_lines = [a for a in result if a["sku"] == "DISC01"]
|
||
assert len(disc_lines) == 1
|
||
assert disc_lines[0]["vat"] == "9"
|
||
|
||
def test_discount_multi_vat_creates_multiple_lines(self):
|
||
items = [make_item("A", price=100, vat=19), make_item("B", price=50, vat=9)]
|
||
order = make_order(items, discount_total=15.0)
|
||
settings = {"discount_codmat": "DISC01", "split_discount_vat": "1"}
|
||
result = json.loads(build_articles_json(items, order, settings))
|
||
disc_lines = [a for a in result if a["sku"] == "DISC01"]
|
||
assert len(disc_lines) == 2
|
||
vats = {d["vat"] for d in disc_lines}
|
||
assert "9" in vats
|
||
assert "19" in vats
|
||
|
||
def test_discount_fallback_uses_gomag_vat(self):
|
||
items = [make_item("A", vat=19), make_item("B", vat=9)]
|
||
order = make_order(items, discount_total=5.0, discount_vat="9")
|
||
settings = {"discount_codmat": "DISC01", "split_discount_vat": "0"}
|
||
result = json.loads(build_articles_json(items, order, settings))
|
||
disc_lines = [a for a in result if a["sku"] == "DISC01"]
|
||
assert len(disc_lines) == 1
|
||
assert disc_lines[0]["vat"] == "9"
|
||
|
||
def test_per_article_policy_bridge(self):
|
||
items = [make_item("SKU1")]
|
||
settings = {"_codmat_policy_map": {"SKU1": 42}, "id_pol": "1"}
|
||
result = json.loads(build_articles_json(items, settings=settings))
|
||
assert result[0]["id_pol"] == "42"
|
||
|
||
def test_policy_same_as_default_omitted(self):
|
||
items = [make_item("SKU1")]
|
||
settings = {"_codmat_policy_map": {"SKU1": 1}, "id_pol": "1"}
|
||
result = json.loads(build_articles_json(items, settings=settings))
|
||
assert "id_pol" not in result[0]
|
||
|
||
def test_transport_line_added(self):
|
||
items = [make_item()]
|
||
order = make_order(items, delivery_cost=15.0)
|
||
settings = {"transport_codmat": "TR", "transport_vat": "19"}
|
||
result = json.loads(build_articles_json(items, order, settings))
|
||
tr_lines = [a for a in result if a["sku"] == "TR"]
|
||
assert len(tr_lines) == 1
|
||
assert tr_lines[0]["quantity"] == "1"
|
||
assert tr_lines[0]["price"] == "15.0"
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 3: Kit Detection Pattern
|
||
# ===========================================================================
|
||
|
||
class TestKitDetection:
|
||
"""Regression: kit detection for single-component repackaging (multiple code locations)."""
|
||
|
||
def test_multi_component(self):
|
||
comps = [{"codmat": "A", "cantitate_roa": 1}, {"codmat": "B", "cantitate_roa": 1}]
|
||
assert is_kit(comps) is True
|
||
|
||
def test_single_component_repackaging(self):
|
||
comps = [{"codmat": "CAF01", "cantitate_roa": 10}]
|
||
assert is_kit(comps) is True
|
||
|
||
def test_true_1to1_not_kit(self):
|
||
comps = [{"codmat": "X", "cantitate_roa": 1}]
|
||
assert is_kit(comps) is False
|
||
|
||
def test_none_cantitate_treated_as_1(self):
|
||
comps = [{"codmat": "X", "cantitate_roa": None}]
|
||
assert is_kit(comps) is False
|
||
|
||
def test_empty_components(self):
|
||
assert is_kit([]) is False
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 4: sync_prices_from_order() — Kit Skip Logic
|
||
# ===========================================================================
|
||
|
||
class TestSyncPricesKitSkip:
|
||
"""Regression: kit SKUs must be skipped in order-based price sync."""
|
||
|
||
def _make_mock_order(self, sku, price=50.0):
|
||
mock_order = MagicMock()
|
||
mock_item = MagicMock()
|
||
mock_item.sku = sku
|
||
mock_item.price = price
|
||
mock_order.items = [mock_item]
|
||
return mock_order
|
||
|
||
@patch("app.services.validation_service.compare_and_update_price")
|
||
def test_skips_multi_component_kit(self, mock_compare):
|
||
from app.services.validation_service import sync_prices_from_order
|
||
orders = [self._make_mock_order("KIT01")]
|
||
mapped = {"KIT01": [
|
||
{"codmat": "A", "id_articol": 1, "cantitate_roa": 1},
|
||
{"codmat": "B", "id_articol": 2, "cantitate_roa": 1},
|
||
]}
|
||
mock_conn = MagicMock()
|
||
sync_prices_from_order(orders, mapped, {}, {}, 1, conn=mock_conn,
|
||
settings={"price_sync_enabled": "1"})
|
||
mock_compare.assert_not_called()
|
||
|
||
@patch("app.services.validation_service.compare_and_update_price")
|
||
def test_skips_repackaging_kit(self, mock_compare):
|
||
from app.services.validation_service import sync_prices_from_order
|
||
orders = [self._make_mock_order("CAFE100")]
|
||
mapped = {"CAFE100": [
|
||
{"codmat": "CAF01", "id_articol": 1, "cantitate_roa": 10},
|
||
]}
|
||
mock_conn = MagicMock()
|
||
sync_prices_from_order(orders, mapped, {}, {}, 1, conn=mock_conn,
|
||
settings={"price_sync_enabled": "1"})
|
||
mock_compare.assert_not_called()
|
||
|
||
@patch("app.services.validation_service.compare_and_update_price")
|
||
def test_processes_1to1_mapping(self, mock_compare):
|
||
from app.services.validation_service import sync_prices_from_order
|
||
mock_compare.return_value = {"updated": False, "old_price": 50.0, "new_price": 50.0, "codmat": "X"}
|
||
orders = [self._make_mock_order("SKU1", price=50.0)]
|
||
mapped = {"SKU1": [
|
||
{"codmat": "X", "id_articol": 100, "cantitate_roa": 1, "cont": "371"},
|
||
]}
|
||
mock_conn = MagicMock()
|
||
sync_prices_from_order(orders, mapped, {}, {"SKU1": 1}, 1, conn=mock_conn,
|
||
settings={"price_sync_enabled": "1"})
|
||
mock_compare.assert_called_once()
|
||
call_args = mock_compare.call_args
|
||
assert call_args[0][0] == 100 # id_articol
|
||
assert call_args[0][2] == 50.0 # price
|
||
|
||
@patch("app.services.validation_service.compare_and_update_price")
|
||
def test_skips_transport_discount_codmats(self, mock_compare):
|
||
from app.services.validation_service import sync_prices_from_order
|
||
orders = [self._make_mock_order("TRANSP", price=15.0)]
|
||
mock_conn = MagicMock()
|
||
sync_prices_from_order(orders, {}, {"TRANSP": {"id_articol": 99}}, {}, 1,
|
||
conn=mock_conn,
|
||
settings={"price_sync_enabled": "1",
|
||
"transport_codmat": "TRANSP",
|
||
"discount_codmat": "DISC"})
|
||
mock_compare.assert_not_called()
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 5: Kit Component with Own Mapping
|
||
# ===========================================================================
|
||
|
||
class TestKitComponentOwnMapping:
|
||
"""Regression: price_sync_service skips kit components that have their own ARTICOLE_TERTI mapping."""
|
||
|
||
def test_component_with_own_mapping_skipped(self):
|
||
"""If comp_codmat is itself a key in mapped_data, it's skipped."""
|
||
mapped_data = {
|
||
"PACK-A": [{"codmat": "COMP-X", "id_articol": 1, "cantitate_roa": 1, "cont": "371"}],
|
||
"COMP-X": [{"codmat": "COMP-X", "id_articol": 1, "cantitate_roa": 1, "cont": "371"}],
|
||
}
|
||
# The check is: if comp_codmat in mapped_data: continue
|
||
comp_codmat = "COMP-X"
|
||
assert comp_codmat in mapped_data # Should be skipped
|
||
|
||
def test_component_without_own_mapping_processed(self):
|
||
"""If comp_codmat is NOT in mapped_data, it should be processed."""
|
||
mapped_data = {
|
||
"PACK-A": [{"codmat": "COMP-Y", "id_articol": 2, "cantitate_roa": 1, "cont": "371"}],
|
||
}
|
||
comp_codmat = "COMP-Y"
|
||
assert comp_codmat not in mapped_data # Should be processed
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 6: VAT Included Type Normalization
|
||
# ===========================================================================
|
||
|
||
class TestVatIncludedNormalization:
|
||
"""Regression: GoMag returns vat_included as int 1 or string '1' (price_sync_service.py:144)."""
|
||
|
||
def _compute_price_cu_tva(self, product):
|
||
price = float(product.get("price", "0"))
|
||
vat = float(product.get("vat", "19"))
|
||
if str(product.get("vat_included", "1")) == "1":
|
||
return price
|
||
else:
|
||
return price * (1 + vat / 100)
|
||
|
||
def test_vat_included_int_1(self):
|
||
result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": 1})
|
||
assert result == 100.0
|
||
|
||
def test_vat_included_str_1(self):
|
||
result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": "1"})
|
||
assert result == 100.0
|
||
|
||
def test_vat_included_int_0(self):
|
||
result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": 0})
|
||
assert result == 119.0
|
||
|
||
def test_vat_included_str_0(self):
|
||
result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": "0"})
|
||
assert result == 119.0
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 7: validate_kit_component_prices — pret=0 allowed
|
||
# ===========================================================================
|
||
|
||
class TestKitComponentPriceValidation:
|
||
"""Regression: pret=0 in CRM is valid for kit components (validation_service.py:469)."""
|
||
|
||
def _call_validate(self, fetchone_returns):
|
||
from app.services.validation_service import validate_kit_component_prices
|
||
|
||
mock_conn = MagicMock()
|
||
mock_cursor = MagicMock()
|
||
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||
mock_cursor.fetchone.return_value = fetchone_returns
|
||
|
||
mapped = {"KIT-SKU": [
|
||
{"codmat": "COMP1", "id_articol": 100, "cont": "371", "cantitate_roa": 5},
|
||
]}
|
||
return validate_kit_component_prices(mapped, id_pol=1, conn=mock_conn)
|
||
|
||
def test_price_zero_not_rejected(self):
|
||
result = self._call_validate((0,))
|
||
assert result == {}
|
||
|
||
def test_missing_entry_rejected(self):
|
||
result = self._call_validate(None)
|
||
assert "KIT-SKU" in result
|
||
assert "COMP1" in result["KIT-SKU"]
|
||
|
||
def test_skips_true_1to1(self):
|
||
from app.services.validation_service import validate_kit_component_prices
|
||
mock_conn = MagicMock()
|
||
mapped = {"SKU1": [
|
||
{"codmat": "X", "id_articol": 1, "cont": "371", "cantitate_roa": 1},
|
||
]}
|
||
result = validate_kit_component_prices(mapped, id_pol=1, conn=mock_conn)
|
||
assert result == {}
|
||
|
||
def test_checks_repackaging(self):
|
||
"""Single component with cantitate_roa > 1 should be checked."""
|
||
from app.services.validation_service import validate_kit_component_prices
|
||
|
||
mock_conn = MagicMock()
|
||
mock_cursor = MagicMock()
|
||
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||
mock_cursor.fetchone.return_value = (51.50,)
|
||
|
||
mapped = {"CAFE100": [
|
||
{"codmat": "CAF01", "id_articol": 100, "cont": "371", "cantitate_roa": 10},
|
||
]}
|
||
result = validate_kit_component_prices(mapped, id_pol=1, conn=mock_conn)
|
||
assert result == {}
|
||
mock_cursor.execute.assert_called_once()
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 8: Dual Policy Assignment
|
||
# ===========================================================================
|
||
|
||
class TestDualPolicyAssignment:
|
||
"""Regression: cont 341/345 → production policy, others → sales (validation_service.py:282)."""
|
||
|
||
def _call_dual(self, codmats, direct_id_map, cursor_rows):
|
||
from app.services.validation_service import validate_and_ensure_prices_dual
|
||
|
||
mock_conn = MagicMock()
|
||
mock_cursor = MagicMock()
|
||
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||
# The code uses `for row in cur:` to iterate, not fetchall
|
||
mock_cursor.__iter__ = MagicMock(return_value=iter(cursor_rows))
|
||
# Mock ensure_prices to do nothing
|
||
with patch("app.services.validation_service.ensure_prices"):
|
||
return validate_and_ensure_prices_dual(
|
||
codmats, id_pol_vanzare=1, id_pol_productie=2,
|
||
conn=mock_conn, direct_id_map=direct_id_map
|
||
)
|
||
|
||
def test_cont_341_production(self):
|
||
result = self._call_dual(
|
||
{"COD1"},
|
||
{"COD1": {"id_articol": 100, "cont": "341"}},
|
||
[] # no existing prices
|
||
)
|
||
assert result["COD1"] == 2 # id_pol_productie
|
||
|
||
def test_cont_345_production(self):
|
||
result = self._call_dual(
|
||
{"COD1"},
|
||
{"COD1": {"id_articol": 100, "cont": "345"}},
|
||
[]
|
||
)
|
||
assert result["COD1"] == 2
|
||
|
||
def test_other_cont_sales(self):
|
||
result = self._call_dual(
|
||
{"COD1"},
|
||
{"COD1": {"id_articol": 100, "cont": "371"}},
|
||
[]
|
||
)
|
||
assert result["COD1"] == 1 # id_pol_vanzare
|
||
|
||
def test_existing_sales_preferred(self):
|
||
result = self._call_dual(
|
||
{"COD1"},
|
||
{"COD1": {"id_articol": 100, "cont": "345"}},
|
||
[(100, 1), (100, 2)] # price exists in BOTH policies
|
||
)
|
||
assert result["COD1"] == 1 # sales preferred when both exist
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 9: Duplicate CODMAT — resolve_codmat_ids
|
||
# ===========================================================================
|
||
|
||
class TestResolveCodmatIds:
|
||
"""Regression: ROW_NUMBER dedup returns exactly 1 id_articol per CODMAT."""
|
||
|
||
@patch("app.services.validation_service.database")
|
||
def test_returns_one_per_codmat(self, mock_db):
|
||
from app.services.validation_service import resolve_codmat_ids
|
||
|
||
mock_conn = MagicMock()
|
||
mock_cursor = MagicMock()
|
||
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||
# Simulate ROW_NUMBER already deduped: 1 row per codmat
|
||
mock_cursor.__iter__ = MagicMock(return_value=iter([
|
||
("COD1", 100, "345"),
|
||
("COD2", 200, "341"),
|
||
]))
|
||
|
||
result = resolve_codmat_ids({"COD1", "COD2"}, conn=mock_conn)
|
||
assert len(result) == 2
|
||
assert result["COD1"]["id_articol"] == 100
|
||
assert result["COD2"]["id_articol"] == 200
|
||
|
||
@patch("app.services.validation_service.database")
|
||
def test_resolve_mapped_one_per_sku_codmat(self, mock_db):
|
||
from app.services.validation_service import resolve_mapped_codmats
|
||
|
||
mock_conn = MagicMock()
|
||
mock_cursor = MagicMock()
|
||
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||
# 1 row per (sku, codmat) pair
|
||
mock_cursor.__iter__ = MagicMock(return_value=iter([
|
||
("SKU1", "COD1", 100, "345", 10),
|
||
("SKU1", "COD2", 200, "341", 1),
|
||
]))
|
||
|
||
result = resolve_mapped_codmats({"SKU1"}, mock_conn)
|
||
assert "SKU1" in result
|
||
assert len(result["SKU1"]) == 2
|
||
codmats = [c["codmat"] for c in result["SKU1"]]
|
||
assert "COD1" in codmats
|
||
assert "COD2" in codmats
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 6: get_prices_for_order() — cantitate_roa price normalization
|
||
# ===========================================================================
|
||
|
||
from app.services.validation_service import get_prices_for_order
|
||
|
||
|
||
def _mock_oracle_conn(pol_cu_tva=False, price_map=None):
|
||
"""Build a mock Oracle connection for get_prices_for_order.
|
||
|
||
price_map: {id_articol: (pret, proc_tvav)}
|
||
"""
|
||
if price_map is None:
|
||
price_map = {}
|
||
conn = MagicMock()
|
||
|
||
def cursor_ctx():
|
||
cur = MagicMock()
|
||
# CRM_POLITICI_PRETURI — PRETURI_CU_TVA flag
|
||
cu_tva_row = [1 if pol_cu_tva else 0]
|
||
# CRM_POLITICI_PRET_ART — prices
|
||
price_rows = [
|
||
(1, id_art, pret, proc_tvav)
|
||
for id_art, (pret, proc_tvav) in price_map.items()
|
||
]
|
||
# fetchone for PRETURI_CU_TVA, __iter__ for price rows
|
||
cur.fetchone = MagicMock(return_value=cu_tva_row)
|
||
cur.__iter__ = MagicMock(return_value=iter(price_rows))
|
||
return cur
|
||
|
||
cm = MagicMock()
|
||
cm.__enter__ = MagicMock(side_effect=cursor_ctx)
|
||
cm.__exit__ = MagicMock(return_value=False)
|
||
conn.cursor.return_value = cm
|
||
return conn
|
||
|
||
|
||
class TestGetPricesForOrderCantitateRoa:
|
||
"""Regression: cantitate_roa < 1 must be treated as kit for price normalization.
|
||
|
||
Bug: SKU with cantitate_roa=0.5 (GoMag 50buc=7lei, ROA 100buc=14lei)
|
||
was reported as price mismatch because is_kit only checked > 1.
|
||
"""
|
||
|
||
def test_cantitate_roa_half_matches(self):
|
||
"""cantitate_roa=0.5: kit item — price check skipped entirely."""
|
||
items = [{
|
||
"sku": "1057308134545",
|
||
"price": 7.00,
|
||
"quantity": 60,
|
||
"codmat_details": [{
|
||
"codmat": "8OZLRLP",
|
||
"cantitate_roa": 0.5,
|
||
"id_articol": 100,
|
||
"cont": "345",
|
||
}],
|
||
}]
|
||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={100: (14.00, 1.19)})
|
||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
||
|
||
assert result["items"][0]["match"] is None
|
||
assert result["items"][0]["kit"] is True
|
||
assert result["summary"]["mismatches"] == 0
|
||
|
||
def test_cantitate_roa_half_mismatch(self):
|
||
"""cantitate_roa=0.5: kit item — price check skipped even if prices differ."""
|
||
items = [{
|
||
"sku": "SKU-HALF",
|
||
"price": 7.00,
|
||
"quantity": 1,
|
||
"codmat_details": [{
|
||
"codmat": "COD1",
|
||
"cantitate_roa": 0.5,
|
||
"id_articol": 200,
|
||
"cont": "345",
|
||
}],
|
||
}]
|
||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={200: (10.00, 1.19)})
|
||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
||
|
||
assert result["items"][0]["match"] is None
|
||
assert result["items"][0]["kit"] is True
|
||
assert result["summary"]["mismatches"] == 0
|
||
|
||
def test_cantitate_roa_one_simple_item(self):
|
||
"""cantitate_roa=1 (default): simple item, direct price comparison."""
|
||
items = [{
|
||
"sku": "SKU-SIMPLE",
|
||
"price": 63.79,
|
||
"quantity": 8,
|
||
"codmat_details": [{
|
||
"codmat": "COD-DIRECT",
|
||
"cantitate_roa": 1,
|
||
"id_articol": 300,
|
||
"cont": "345",
|
||
}],
|
||
}]
|
||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={300: (63.79, 1.19)})
|
||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
||
|
||
assert result["items"][0]["match"] is True
|
||
assert result["summary"]["mismatches"] == 0
|
||
|
||
def test_cantitate_roa_gt1_kit(self):
|
||
"""cantitate_roa=2: kit item — price check skipped."""
|
||
items = [{
|
||
"sku": "SKU-KIT2",
|
||
"price": 20.00,
|
||
"quantity": 1,
|
||
"codmat_details": [{
|
||
"codmat": "COD-KIT",
|
||
"cantitate_roa": 2,
|
||
"id_articol": 400,
|
||
"cont": "345",
|
||
}],
|
||
}]
|
||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={400: (10.00, 1.19)})
|
||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
||
|
||
assert result["items"][0]["match"] is None
|
||
assert result["items"][0]["kit"] is True
|
||
assert result["summary"]["mismatches"] == 0
|
||
|
||
def test_multi_component_kit_skipped(self):
|
||
"""Multi-component kit (2 CODMATs): price check skipped, kit=True."""
|
||
items = [{
|
||
"sku": "SKU-MULTI",
|
||
"price": 15.00,
|
||
"quantity": 1,
|
||
"codmat_details": [
|
||
{"codmat": "COMP-A", "cantitate_roa": 1, "id_articol": 500, "cont": "345"},
|
||
{"codmat": "COMP-B", "cantitate_roa": 1, "id_articol": 501, "cont": "345"},
|
||
],
|
||
}]
|
||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={500: (8.00, 1.19), 501: (9.00, 1.19)})
|
||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
||
|
||
assert result["items"][0]["match"] is None
|
||
assert result["items"][0]["kit"] is True
|
||
assert result["summary"]["mismatches"] == 0
|
||
|
||
|
||
# ── normalize_company_name (II, PFA, INTREPRINDERE INDIVIDUALA) ──
|
||
|
||
|
||
class TestNormalizeCompanyNameExtended:
|
||
"""Tests for extended legal form stripping in normalize_company_name."""
|
||
|
||
def test_strip_ii_at_start(self):
|
||
from app.services.anaf_service import normalize_company_name
|
||
assert normalize_company_name("II CHIRITA N ION") == "CHIRITA N ION"
|
||
|
||
def test_no_strip_ii_mid_name(self):
|
||
from app.services.anaf_service import normalize_company_name
|
||
result = normalize_company_name("TEHNICA II SRL")
|
||
assert "II" in result # II should remain (Roman numeral)
|
||
|
||
def test_strip_pfa(self):
|
||
from app.services.anaf_service import normalize_company_name
|
||
assert normalize_company_name("PFA POPESCU ION") == "POPESCU ION"
|
||
|
||
def test_strip_intreprindere_individuala(self):
|
||
from app.services.anaf_service import normalize_company_name
|
||
result = normalize_company_name("CHIRIȚĂ N. ION ÎNTREPRINDERE INDIVIDUALĂ")
|
||
assert "CHIRITA N ION" == result
|
||
|
||
def test_strip_ii_with_dots(self):
|
||
from app.services.anaf_service import normalize_company_name
|
||
assert normalize_company_name("I.I. CHIRITA N ION") == "CHIRITA N ION"
|
||
|
||
|
||
# ── _addr_match (server-side address comparison) ─<><E29480><EFBFBD>
|
||
|
||
|
||
class TestAddrMatch:
|
||
"""Tests for _addr_match server-side address comparison."""
|
||
|
||
def test_matching_addresses(self):
|
||
from app.services.sync_service import _addr_match
|
||
import json
|
||
g = json.dumps({"address": "Str. Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"})
|
||
r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"})
|
||
assert _addr_match(g, r) is True
|
||
|
||
def test_street_mismatch(self):
|
||
from app.services.sync_service import _addr_match
|
||
import json
|
||
g = json.dumps({"address": "Str. Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"})
|
||
r = json.dumps({"strada": "Victoriei", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"})
|
||
assert _addr_match(g, r) is False
|
||
|
||
def test_city_mismatch(self):
|
||
from app.services.sync_service import _addr_match
|
||
import json
|
||
g = json.dumps({"address": "Elisabeta 10", "city": "Brasov", "region": "Brasov"})
|
||
r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Cluj", "judet": "Brasov"})
|
||
assert _addr_match(g, r) is False
|
||
|
||
def test_county_mismatch(self):
|
||
from app.services.sync_service import _addr_match
|
||
import json
|
||
g = json.dumps({"address": "Elisabeta 10", "city": "Brasov", "region": "Brasov"})
|
||
r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Brasov", "judet": "Cluj"})
|
||
assert _addr_match(g, r) is False
|
||
|
||
def test_none_input_returns_true(self):
|
||
from app.services.sync_service import _addr_match
|
||
assert _addr_match(None, None) is True
|
||
assert _addr_match(None, '{"strada":"x"}') is True
|
||
assert _addr_match('{"address":"x"}', None) is True
|
||
|
||
def test_strada_prefix_stripping(self):
|
||
"""'Strada Elisabeta' should match 'ELISABETA' after normalization."""
|
||
from app.services.sync_service import _addr_match
|
||
import json
|
||
g = json.dumps({"address": "Strada Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"})
|
||
r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"})
|
||
assert _addr_match(g, r) is True
|
||
|
||
def test_malformed_json_returns_true(self):
|
||
from app.services.sync_service import _addr_match
|
||
assert _addr_match("{bad json", '{"strada":"x"}') is True
|