refactor(price): remove price comparison UI and catalog sync

GoMag vs ROA price comparison generated too many false positives
(kits, volume discounts, special prices). Removes comparison columns,
dots, badges, catalog sync endpoints, and ~950 lines of dead code.
Keeps WRITE path (sync_prices_from_order) for kit pricing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-08 20:30:34 +00:00
parent 5584dd3c4f
commit 1d59f1a484
16 changed files with 20 additions and 972 deletions

View File

@@ -103,80 +103,3 @@ async def download_orders(
return {"pages": total_pages, "total": total_orders, "files": saved_files}
async def download_products(
api_key: str = None,
api_shop: str = None,
products_url: str = None,
log_fn: Callable[[str], None] = None,
) -> list[dict]:
"""Download all products from GoMag Products API.
Returns list of product dicts with: sku, price, vat, vat_included, bundleItems.
"""
def _log(msg: str):
logger.info(msg)
if log_fn:
log_fn(msg)
effective_key = api_key or settings.GOMAG_API_KEY
effective_shop = api_shop or settings.GOMAG_API_SHOP
default_url = "https://api.gomag.ro/api/v1/product/read/json"
effective_url = products_url or default_url
if not effective_key or not effective_shop:
_log("GoMag API keys neconfigurați, skip product download")
return []
headers = {
"Apikey": effective_key,
"ApiShop": effective_shop,
"User-Agent": "Mozilla/5.0",
"Content-Type": "application/json",
}
all_products = []
total_pages = 1
async with httpx.AsyncClient(timeout=30) as client:
page = 1
while page <= total_pages:
params = {"page": page, "limit": 100}
try:
response = await client.get(effective_url, headers=headers, params=params)
response.raise_for_status()
data = response.json()
except httpx.HTTPError as e:
_log(f"GoMag Products API eroare pagina {page}: {e}")
break
except Exception as e:
_log(f"GoMag Products eroare neașteptată pagina {page}: {e}")
break
if page == 1:
total_pages = int(data.get("pages", 1))
_log(f"GoMag Products: {data.get('total', '?')} produse în {total_pages} pagini")
products = data.get("products", [])
if isinstance(products, dict):
# GoMag returns products as {"1": {...}, "2": {...}} dict
first_val = next(iter(products.values()), None) if products else None
if isinstance(first_val, dict):
products = list(products.values())
else:
products = [products]
if isinstance(products, list):
for p in products:
if isinstance(p, dict) and p.get("sku"):
all_products.append({
"sku": p["sku"],
"price": p.get("price", "0"),
"vat": p.get("vat", "19"),
"vat_included": str(p.get("vat_included", "1")),
"bundleItems": p.get("bundleItems", []),
})
page += 1
if page <= total_pages:
await asyncio.sleep(1)
_log(f"GoMag Products: {len(all_products)} produse cu SKU descărcate")
return all_products

View File

@@ -1,264 +0,0 @@
"""Catalog price sync service — syncs product prices from GoMag catalog to ROA Oracle."""
import asyncio
import logging
import uuid
from datetime import datetime
from zoneinfo import ZoneInfo
from . import gomag_client, validation_service, sqlite_service
from .. import database
from ..config import settings
logger = logging.getLogger(__name__)
_tz = ZoneInfo("Europe/Bucharest")
_price_sync_lock = asyncio.Lock()
_current_price_sync = None
def _now():
return datetime.now(_tz).replace(tzinfo=None)
async def prepare_price_sync() -> dict:
global _current_price_sync
if _price_sync_lock.locked():
return {"error": "Price sync already running"}
run_id = _now().strftime("%Y%m%d_%H%M%S") + "_ps_" + uuid.uuid4().hex[:6]
_current_price_sync = {
"run_id": run_id, "status": "running",
"started_at": _now().isoformat(), "finished_at": None,
"phase_text": "Starting...",
}
# Create SQLite record
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"INSERT INTO price_sync_runs (run_id, started_at, status) VALUES (?, ?, 'running')",
(run_id, _now().strftime("%d.%m.%Y %H:%M:%S"))
)
await db.commit()
finally:
await db.close()
return {"run_id": run_id}
async def get_price_sync_status() -> dict:
if _current_price_sync and _current_price_sync.get("status") == "running":
return _current_price_sync
# Return last run from SQLite
db = await sqlite_service.get_sqlite()
try:
cursor = await db.execute(
"SELECT * FROM price_sync_runs ORDER BY started_at DESC LIMIT 1"
)
row = await cursor.fetchone()
if row:
return {"status": "idle", "last_run": dict(row)}
return {"status": "idle"}
except Exception:
return {"status": "idle"}
finally:
await db.close()
async def run_catalog_price_sync(run_id: str):
global _current_price_sync
async with _price_sync_lock:
log_lines = []
def _log(msg):
logger.info(msg)
log_lines.append(f"[{_now().strftime('%H:%M:%S')}] {msg}")
if _current_price_sync:
_current_price_sync["phase_text"] = msg
try:
app_settings = await sqlite_service.get_app_settings()
id_pol = int(app_settings.get("id_pol") or 0) or None
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
if not id_pol:
_log("Politica de preț nu e configurată — skip sync")
await _finish_run(run_id, "error", log_lines, error="No price policy")
return
# Fetch products from GoMag
_log("Descărcare produse din GoMag API...")
products = await gomag_client.download_products(
api_key=app_settings.get("gomag_api_key"),
api_shop=app_settings.get("gomag_api_shop"),
products_url=app_settings.get("gomag_products_url") or None,
log_fn=_log,
)
if not products:
_log("Niciun produs descărcat")
await _finish_run(run_id, "completed", log_lines, products_total=0)
return
# Index products by SKU for kit component lookup
products_by_sku = {p["sku"]: p for p in products}
# Connect to Oracle
conn = await asyncio.to_thread(database.get_oracle_connection)
try:
# Get all mappings from ARTICOLE_TERTI
_log("Citire mapări ARTICOLE_TERTI...")
mapped_data = await asyncio.to_thread(
validation_service.resolve_mapped_codmats,
{p["sku"] for p in products}, conn
)
# Get direct articles from NOM_ARTICOLE
_log("Identificare articole directe...")
direct_id_map = {}
with conn.cursor() as cur:
all_skus = list({p["sku"] for p in products})
for i in range(0, len(all_skus), 500):
batch = all_skus[i:i+500]
placeholders = ",".join([f":s{j}" for j in range(len(batch))])
params = {f"s{j}": sku for j, sku in enumerate(batch)}
cur.execute(f"""
SELECT codmat, id_articol, cont FROM nom_articole
WHERE codmat IN ({placeholders}) AND sters = 0 AND inactiv = 0
""", params)
for row in cur:
if row[0] not in mapped_data:
direct_id_map[row[0]] = {"id_articol": row[1], "cont": row[2]}
matched = 0
updated = 0
errors = 0
for product in products:
sku = product["sku"]
try:
price_str = product.get("price", "0")
price = float(price_str) if price_str else 0
if price <= 0:
continue
vat = float(product.get("vat", "19"))
# Calculate price with TVA (vat_included can be int 1 or str "1")
if str(product.get("vat_included", "1")) == "1":
price_cu_tva = price
else:
price_cu_tva = price * (1 + vat / 100)
# For kits, sync each component individually from standalone GoMag prices
mapped_comps = mapped_data.get(sku, [])
is_kit = len(mapped_comps) > 1 or (
len(mapped_comps) == 1 and (mapped_comps[0].get("cantitate_roa") or 1) > 1
)
if is_kit:
for comp in mapped_data[sku]:
comp_codmat = comp["codmat"]
# Skip components that have their own ARTICOLE_TERTI mapping
# (they'll be synced with correct cantitate_roa in individual path)
if comp_codmat in mapped_data:
continue
comp_product = products_by_sku.get(comp_codmat)
if not comp_product:
continue # Component not in GoMag as standalone product
comp_price_str = comp_product.get("price", "0")
comp_price = float(comp_price_str) if comp_price_str else 0
if comp_price <= 0:
continue
comp_vat = float(comp_product.get("vat", "19"))
# vat_included can be int 1 or str "1"
if str(comp_product.get("vat_included", "1")) == "1":
comp_price_cu_tva = comp_price
else:
comp_price_cu_tva = comp_price * (1 + comp_vat / 100)
comp_cont_str = str(comp.get("cont") or "").strip()
comp_pol = id_pol_productie if (comp_cont_str in ("341", "345") and id_pol_productie) else id_pol
matched += 1
result = await asyncio.to_thread(
validation_service.compare_and_update_price,
comp["id_articol"], comp_pol, comp_price_cu_tva, conn
)
if result and result["updated"]:
updated += 1
_log(f" {comp_codmat}: {result['old_price']:.2f}{result['new_price']:.2f} (kit {sku})")
elif result is None:
_log(f" {comp_codmat}: LIPSESTE din politica {comp_pol} — adauga manual in ROA (kit {sku})")
continue
# Determine id_articol and policy
id_articol = None
cantitate_roa = 1
if sku in mapped_data and len(mapped_data[sku]) == 1 and (mapped_data[sku][0].get("cantitate_roa") or 1) <= 1:
comp = mapped_data[sku][0]
id_articol = comp["id_articol"]
cantitate_roa = comp.get("cantitate_roa") or 1
elif sku in direct_id_map:
id_articol = direct_id_map[sku]["id_articol"]
else:
continue # SKU not in ROA
matched += 1
price_per_unit = price_cu_tva / cantitate_roa if cantitate_roa != 1 else price_cu_tva
# Determine policy
cont = None
if sku in mapped_data and len(mapped_data[sku]) == 1 and (mapped_data[sku][0].get("cantitate_roa") or 1) <= 1:
cont = mapped_data[sku][0].get("cont")
elif sku in direct_id_map:
cont = direct_id_map[sku].get("cont")
cont_str = str(cont or "").strip()
pol = id_pol_productie if (cont_str in ("341", "345") and id_pol_productie) else id_pol
result = await asyncio.to_thread(
validation_service.compare_and_update_price,
id_articol, pol, price_per_unit, conn
)
if result and result["updated"]:
updated += 1
_log(f" {result['codmat']}: {result['old_price']:.2f}{result['new_price']:.2f}")
except Exception as e:
errors += 1
_log(f"Eroare produs {sku}: {e}")
_log(f"Sync complet: {len(products)} produse, {matched} potrivite, {updated} actualizate, {errors} erori")
finally:
await asyncio.to_thread(database.pool.release, conn)
await _finish_run(run_id, "completed", log_lines,
products_total=len(products), matched=matched,
updated=updated, errors=errors)
except Exception as e:
_log(f"Eroare critică: {e}")
logger.error(f"Catalog price sync error: {e}", exc_info=True)
await _finish_run(run_id, "error", log_lines, error=str(e))
async def _finish_run(run_id, status, log_lines, products_total=0,
matched=0, updated=0, errors=0, error=None):
global _current_price_sync
db = await sqlite_service.get_sqlite()
try:
await db.execute("""
UPDATE price_sync_runs SET
finished_at = ?, status = ?, products_total = ?,
matched = ?, updated = ?, errors = ?,
log_text = ?
WHERE run_id = ?
""", (_now().strftime("%d.%m.%Y %H:%M:%S"), status, products_total, matched, updated, errors,
"\n".join(log_lines), run_id))
await db.commit()
finally:
await db.close()
_current_price_sync = None

View File

@@ -1026,23 +1026,6 @@ async def get_skipped_orders_with_sku(sku: str) -> list[str]:
# ── Price Sync Runs ───────────────────────────────
async def get_price_sync_runs(page: int = 1, per_page: int = 20):
"""Get paginated price sync run history."""
db = await get_sqlite()
try:
offset = (page - 1) * per_page
cursor = await db.execute("SELECT COUNT(*) FROM price_sync_runs")
total = (await cursor.fetchone())[0]
cursor = await db.execute(
"SELECT * FROM price_sync_runs ORDER BY started_at DESC LIMIT ? OFFSET ?",
(per_page, offset)
)
runs = [dict(r) for r in await cursor.fetchall()]
return {"runs": runs, "total": total, "page": page, "pages": (total + per_page - 1) // per_page}
finally:
await db.close()
# ── ANAF Cache ───────────────────────────────────
async def get_anaf_cache(bare_cui: str) -> dict | None:

View File

@@ -588,193 +588,3 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
return updated
def get_prices_for_order(items: list[dict], app_settings: dict, conn=None) -> dict:
"""Compare GoMag prices with ROA prices for order items.
Args:
items: list of order items, each with 'sku', 'price', 'quantity', 'codmat_details'
(codmat_details = [{"codmat", "cantitate_roa", "id_articol"?, "cont"?, "direct"?}])
app_settings: dict with 'id_pol', 'id_pol_productie'
conn: Oracle connection (optional, will acquire if None)
Returns: {
"items": {idx: {"pret_roa": float|None, "match": bool|None, "pret_gomag": float}},
"summary": {"mismatches": int, "checked": int, "oracle_available": bool}
}
"""
try:
id_pol = int(app_settings.get("id_pol", 0) or 0)
id_pol_productie = int(app_settings.get("id_pol_productie", 0) or 0)
except (ValueError, TypeError):
id_pol = 0
id_pol_productie = 0
def _empty_result(oracle_available: bool) -> dict:
return {
"items": {
idx: {"pret_roa": None, "match": None, "pret_gomag": float(item.get("price") or 0)}
for idx, item in enumerate(items)
},
"summary": {"mismatches": 0, "checked": 0, "oracle_available": oracle_available}
}
if not items or not id_pol:
return _empty_result(oracle_available=False)
own_conn = conn is None
try:
if own_conn:
conn = database.get_oracle_connection()
# Step 1: Collect codmats; use id_articol/cont from codmat_details when already known
pre_resolved = {} # {codmat: {"id_articol": int, "cont": str}}
all_codmats = set()
for item in items:
for cd in (item.get("codmat_details") or []):
codmat = cd.get("codmat")
if not codmat:
continue
all_codmats.add(codmat)
if cd.get("id_articol") and codmat not in pre_resolved:
pre_resolved[codmat] = {
"id_articol": cd["id_articol"],
"cont": cd.get("cont") or "",
}
# Step 2: Resolve missing id_articols via nom_articole
need_resolve = all_codmats - set(pre_resolved.keys())
if need_resolve:
db_resolved = resolve_codmat_ids(need_resolve, conn=conn)
pre_resolved.update(db_resolved)
codmat_info = pre_resolved # {codmat: {"id_articol": int, "cont": str}}
# Step 3: Get PRETURI_CU_TVA flag once per policy
policies = {id_pol}
if id_pol_productie and id_pol_productie != id_pol:
policies.add(id_pol_productie)
pol_cu_tva = {} # {id_pol: bool}
with conn.cursor() as cur:
for pol in policies:
cur.execute(
"SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol",
{"pol": pol},
)
row = cur.fetchone()
pol_cu_tva[pol] = (int(row[0] or 0) == 1) if row else False
# Step 4: Batch query PRET + PROC_TVAV for all id_articols across both policies
all_id_articols = list({
info["id_articol"]
for info in codmat_info.values()
if info.get("id_articol")
})
price_map = {} # {(id_pol, id_articol): (pret, proc_tvav)}
if all_id_articols:
pol_list = list(policies)
pol_placeholders = ",".join([f":p{k}" for k in range(len(pol_list))])
with conn.cursor() as cur:
for i in range(0, len(all_id_articols), 500):
batch = all_id_articols[i:i + 500]
art_placeholders = ",".join([f":a{j}" for j in range(len(batch))])
params = {f"a{j}": aid for j, aid in enumerate(batch)}
for k, pol in enumerate(pol_list):
params[f"p{k}"] = pol
cur.execute(f"""
SELECT ID_POL, ID_ARTICOL, PRET, PROC_TVAV
FROM CRM_POLITICI_PRET_ART
WHERE ID_POL IN ({pol_placeholders}) AND ID_ARTICOL IN ({art_placeholders})
""", params)
for row in cur:
price_map[(row[0], row[1])] = (row[2], row[3])
# Step 5: Compute pret_roa per item and compare with GoMag price
result_items = {}
mismatches = 0
checked = 0
for idx, item in enumerate(items):
pret_gomag = float(item.get("price") or 0)
result_items[idx] = {"pret_gomag": pret_gomag, "pret_roa": None, "match": None}
codmat_details = item.get("codmat_details") or []
if not codmat_details:
continue
is_kit = len(codmat_details) > 1 or (
len(codmat_details) == 1
and float(codmat_details[0].get("cantitate_roa") or 1) != 1
)
if is_kit:
# Kit/pachet: prețul GoMag e comercial, ROA e suma componente din lista
# de prețuri — diferența e gestionată de discount line
result_items[idx]["kit"] = True
continue
pret_roa_total = 0.0
all_resolved = True
for cd in codmat_details:
codmat = cd.get("codmat")
if not codmat:
all_resolved = False
break
info = codmat_info.get(codmat, {})
id_articol = info.get("id_articol")
if not id_articol:
all_resolved = False
break
# Dual-policy routing: cont 341/345 → production, else → sales
cont = str(info.get("cont") or cd.get("cont") or "").strip()
if cont in ("341", "345") and id_pol_productie:
pol = id_pol_productie
else:
pol = id_pol
price_entry = price_map.get((pol, id_articol))
if price_entry is None:
all_resolved = False
break
pret, proc_tvav = price_entry
proc_tvav = float(proc_tvav or 1.19)
if pol_cu_tva.get(pol):
pret_cu_tva = float(pret or 0)
else:
pret_cu_tva = float(pret or 0) * proc_tvav
cantitate_roa = float(cd.get("cantitate_roa") or 1)
if is_kit:
pret_roa_total += pret_cu_tva * cantitate_roa
else:
pret_roa_total = pret_cu_tva # cantitate_roa==1 for simple items
if not all_resolved:
continue
pret_roa = round(pret_roa_total, 4)
match = pret_gomag <= pret_roa + 0.01
result_items[idx]["pret_roa"] = pret_roa
result_items[idx]["match"] = match
checked += 1
if not match:
mismatches += 1
logger.info(f"get_prices_for_order: {checked}/{len(items)} checked, {mismatches} mismatches")
return {
"items": result_items,
"summary": {"mismatches": mismatches, "checked": checked, "oracle_available": True},
}
except Exception as e:
logger.error(f"get_prices_for_order failed: {e}")
return _empty_result(oracle_available=False)
finally:
if own_conn and conn:
database.pool.release(conn)