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

@@ -1,4 +1,3 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI
@@ -9,7 +8,6 @@ import os
from .config import settings
from .database import init_oracle, close_oracle, init_sqlite
from .routers.sync import backfill_price_match
# Configure logging with both stream and file handlers
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
@@ -58,8 +56,6 @@ async def lifespan(app: FastAPI):
except Exception:
pass
asyncio.create_task(backfill_price_match())
logger.info("GoMag Import Manager started")
yield

View File

@@ -12,7 +12,7 @@ from pydantic import BaseModel
from pathlib import Path
from typing import Optional
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service, validation_service
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service
from .. import database
router = APIRouter(tags=["sync"])
@@ -40,56 +40,6 @@ async def _enrich_items_with_codmat(items: list) -> None:
"denumire": nom_map[sku], "direct": True}]
async def backfill_price_match():
"""Background task: check prices for all imported orders without cached price_match."""
try:
from ..database import get_sqlite
db = await get_sqlite()
try:
# Reset all cached price_match to re-evaluate with current logic
await db.execute("UPDATE orders SET price_match = NULL WHERE price_match IS NOT NULL")
await db.commit()
cursor = await db.execute("""
SELECT order_number FROM orders
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
AND price_match IS NULL
ORDER BY order_date DESC
""")
rows = [r["order_number"] for r in await cursor.fetchall()]
finally:
await db.close()
if not rows:
logger.info("backfill_price_match: no unchecked orders")
return
logger.info(f"backfill_price_match: checking {len(rows)} orders...")
app_settings = await sqlite_service.get_app_settings()
checked = 0
for order_number in rows:
try:
detail = await sqlite_service.get_order_detail(order_number)
if not detail:
continue
items = detail.get("items", [])
await _enrich_items_with_codmat(items)
price_data = await asyncio.to_thread(
validation_service.get_prices_for_order, items, app_settings
)
summary = price_data.get("summary", {})
if summary.get("oracle_available") is not False:
pm = summary.get("mismatches", 0) == 0
await sqlite_service.update_order_price_match(order_number, pm)
checked += 1
except Exception as e:
logger.debug(f"backfill_price_match: order {order_number} failed: {e}")
logger.info(f"backfill_price_match: done, {checked}/{len(rows)} updated")
except Exception as e:
logger.error(f"backfill_price_match failed: {e}")
class ScheduleConfig(BaseModel):
enabled: bool
interval_minutes: int = 5
@@ -116,9 +66,6 @@ class AppSettingsUpdate(BaseModel):
kit_discount_codmat: str = ""
kit_discount_id_pol: str = ""
price_sync_enabled: str = "1"
catalog_sync_enabled: str = "0"
price_sync_schedule: str = ""
gomag_products_url: str = ""
# API endpoints
@@ -217,31 +164,6 @@ async def sync_history(page: int = 1, per_page: int = 20):
return await sqlite_service.get_sync_runs(page, per_page)
@router.post("/api/price-sync/start")
async def start_price_sync(background_tasks: BackgroundTasks):
"""Trigger manual catalog price sync."""
from ..services import price_sync_service
result = await price_sync_service.prepare_price_sync()
if result.get("error"):
return {"error": result["error"]}
run_id = result["run_id"]
background_tasks.add_task(price_sync_service.run_catalog_price_sync, run_id=run_id)
return {"message": "Price sync started", "run_id": run_id}
@router.get("/api/price-sync/status")
async def price_sync_status():
"""Get current price sync status."""
from ..services import price_sync_service
return await price_sync_service.get_price_sync_status()
@router.get("/api/price-sync/history")
async def price_sync_history(page: int = 1, per_page: int = 20):
"""Get price sync run history."""
return await sqlite_service.get_price_sync_runs(page, per_page)
@router.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request, run: str = None):
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
@@ -454,35 +376,8 @@ async def order_detail(order_number: str):
items = detail.get("items", [])
await _enrich_items_with_codmat(items)
# Price comparison against ROA Oracle
app_settings = await sqlite_service.get_app_settings()
try:
price_data = await asyncio.to_thread(
validation_service.get_prices_for_order, items, app_settings
)
price_items = price_data.get("items", {})
for idx, item in enumerate(items):
pi = price_items.get(idx)
if pi:
item["pret_roa"] = pi.get("pret_roa")
item["price_match"] = pi.get("match")
if pi.get("kit"):
item["kit"] = True
order_price_check = price_data.get("summary", {})
# Cache price_match in SQLite if changed
if order_price_check.get("oracle_available") is not False:
pm = order_price_check.get("mismatches", 0) == 0
cached = detail.get("order", {}).get("price_match")
cached_bool = True if cached == 1 else (False if cached == 0 else None)
if cached_bool != pm:
await sqlite_service.update_order_price_match(order_number, pm)
except Exception as e:
logger.warning(f"Price comparison failed for order {order_number}: {e}")
order_price_check = {"mismatches": 0, "checked": 0, "oracle_available": False}
# Enrich with invoice data
order = detail.get("order", {})
order["price_check"] = order_price_check
if order.get("factura_numar") and order.get("factura_data"):
order["invoice"] = {
"facturat": True,
@@ -562,7 +457,8 @@ async def order_detail(order_number: str):
"facturare_roa": order.get("adresa_facturare_roa"),
}
# Add settings for receipt display (app_settings already fetched above)
# Add settings for receipt display
app_settings = await sqlite_service.get_app_settings()
order["transport_vat"] = app_settings.get("transport_vat") or "21"
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
@@ -720,9 +616,6 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
all_orders = result["orders"]
for o in all_orders:
# price_match: 1=OK, 0=mismatch, NULL=not checked yet
pm = o.get("price_match")
o["price_match"] = True if pm == 1 else (False if pm == 0 else None)
if o.get("factura_numar") and o.get("factura_data"):
# Use cached invoice data from SQLite (only if complete)
o["invoice"] = {
@@ -1061,9 +954,6 @@ async def get_app_settings():
"kit_discount_codmat": s.get("kit_discount_codmat", ""),
"kit_discount_id_pol": s.get("kit_discount_id_pol", ""),
"price_sync_enabled": s.get("price_sync_enabled", "1"),
"catalog_sync_enabled": s.get("catalog_sync_enabled", "0"),
"price_sync_schedule": s.get("price_sync_schedule", ""),
"gomag_products_url": s.get("gomag_products_url", ""),
}
@@ -1090,9 +980,6 @@ async def update_app_settings(config: AppSettingsUpdate):
await sqlite_service.set_app_setting("kit_discount_codmat", config.kit_discount_codmat)
await sqlite_service.set_app_setting("kit_discount_id_pol", config.kit_discount_id_pol)
await sqlite_service.set_app_setting("price_sync_enabled", config.price_sync_enabled)
await sqlite_service.set_app_setting("catalog_sync_enabled", config.catalog_sync_enabled)
await sqlite_service.set_app_setting("price_sync_schedule", config.price_sync_schedule)
await sqlite_service.set_app_setting("gomag_products_url", config.gomag_products_url)
return {"success": True}

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)

View File

@@ -1189,7 +1189,6 @@ tr.mapping-deleted td {
.diff-badge-anaf { background:var(--error-light); color:var(--error-text); }
.diff-badge-denumire { background:var(--compare-light); color:var(--compare-text); }
.diff-badge-addr { background:var(--info-light); color:var(--info-text); }
.diff-badge-price { background:var(--success-light); color:var(--success-text); }
/* ── Compact order detail layout ──────────────── */
.detail-col-label {

View File

@@ -513,8 +513,6 @@ function diffDots(o, mobile) {
d += `<span style="${s};background:var(--info)" title="Adresa diferita"></span>`;
if (o.partner_mismatch===1)
d += `<span style="${s};background:var(--warning)" title="Partener schimbat"></span>`;
if (o.price_match===false)
d += `<span style="${s};background:var(--error)" title="Pret GoMag > ROA"></span>`;
return d;
}

View File

@@ -10,8 +10,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// Kit pricing mode radio toggle
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
r.addEventListener('change', () => {
const mode = document.querySelector('input[name="kitPricingMode"]:checked')?.value || '';
document.getElementById('kitModeBFields').style.display =
document.getElementById('kitModeSeparate').checked ? '' : 'none';
(mode === 'separate_line' || mode === 'distributed') ? '' : 'none';
});
});
@@ -138,27 +139,12 @@ async function loadSettings() {
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
r.checked = r.value === kitMode;
});
document.getElementById('kitModeBFields').style.display = kitMode === 'separate_line' ? '' : 'none';
document.getElementById('kitModeBFields').style.display = (kitMode === 'separate_line' || kitMode === 'distributed') ? '' : 'none';
if (el('settKitDiscountCodmat')) el('settKitDiscountCodmat').value = data.kit_discount_codmat || '';
if (el('settKitDiscountIdPol')) el('settKitDiscountIdPol').value = data.kit_discount_id_pol || '';
// Price sync
if (el('settPriceSyncEnabled')) el('settPriceSyncEnabled').checked = data.price_sync_enabled !== "0";
if (el('settCatalogSyncEnabled')) {
el('settCatalogSyncEnabled').checked = data.catalog_sync_enabled === "1";
document.getElementById('catalogSyncOptions').style.display = data.catalog_sync_enabled === "1" ? '' : 'none';
}
if (el('settPriceSyncSchedule')) el('settPriceSyncSchedule').value = data.price_sync_schedule || '';
// Load price sync status
try {
const psRes = await fetch('/api/price-sync/status');
const psData = await psRes.json();
const psEl = document.getElementById('settPriceSyncStatus');
if (psEl && psData.last_run) {
psEl.textContent = `Ultima: ${psData.last_run.finished_at || ''}${psData.last_run.updated || 0} actualizate din ${psData.last_run.matched || 0}`;
}
} catch {}
} catch (err) {
console.error('loadSettings error:', err);
}
@@ -187,9 +173,6 @@ async function saveSettings() {
kit_discount_codmat: el('settKitDiscountCodmat')?.value?.trim() || '',
kit_discount_id_pol: el('settKitDiscountIdPol')?.value?.trim() || '',
price_sync_enabled: el('settPriceSyncEnabled')?.checked ? "1" : "0",
catalog_sync_enabled: el('settCatalogSyncEnabled')?.checked ? "1" : "0",
price_sync_schedule: el('settPriceSyncSchedule')?.value || '',
gomag_products_url: '',
};
try {
const res = await fetch('/api/settings', {
@@ -211,40 +194,6 @@ async function saveSettings() {
}
}
async function startCatalogSync() {
const btn = document.getElementById('btnCatalogSync');
const status = document.getElementById('settPriceSyncStatus');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Sincronizare...';
try {
const res = await fetch('/api/price-sync/start', { method: 'POST' });
const data = await res.json();
if (data.error) {
status.innerHTML = `<span class="text-danger">${escHtml(data.error)}</span>`;
btn.disabled = false;
btn.textContent = 'Sincronizează acum';
return;
}
// Poll status
const pollInterval = setInterval(async () => {
const sr = await fetch('/api/price-sync/status');
const sd = await sr.json();
if (sd.status === 'running') {
status.textContent = sd.phase_text || 'Sincronizare în curs...';
} else {
clearInterval(pollInterval);
btn.disabled = false;
btn.textContent = 'Sincronizează acum';
if (sd.last_run) status.textContent = `Ultima: ${sd.last_run.finished_at || ''}${sd.last_run.updated || 0} actualizate din ${sd.last_run.matched || 0}`;
}
}, 2000);
} catch (err) {
status.innerHTML = `<span class="text-danger">${escHtml(err.message)}</span>`;
btn.disabled = false;
btn.textContent = 'Sincronizează acum';
}
}
function wireAutocomplete(inputId, dropdownId) {
const input = document.getElementById(inputId);
const dropdown = document.getElementById(dropdownId);

View File

@@ -615,10 +615,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
: `<code>${esc(item.codmat || '')}</code>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
const clickAttr = opts.onQuickMap ? `onclick="_sharedModalQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}',${idx})"` : '';
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
const priceMismatchHtml = priceInfo.match === false
? `<div class="text-danger" style="font-size:0.7rem">ROA: ${fmtNum(priceInfo.pret_roa)} lei</div>`
: '';
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
@@ -630,7 +626,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
<span class="dif-val">${fmtNum(valoare)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
</div>
${priceMismatchHtml}
</div>`;
}).join('');
@@ -684,32 +679,14 @@ async function renderOrderDetailModal(orderNumber, opts) {
let tableHtml = items.map((item, idx) => {
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
const pretRoaHtml = priceInfo.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '';
let matchDot, rowStyle;
if (item.kit) {
matchDot = '<span class="badge" style="background:var(--info-light);color:var(--info-text);font-size:10px;padding:2px 6px">Kit</span>';
rowStyle = '';
} else if (priceInfo.pret_roa == null && priceInfo.match == null) {
matchDot = '<span class="dot dot-gray"></span>';
rowStyle = '';
} else if (priceInfo.match === false) {
matchDot = '<span class="dot dot-red"></span>';
rowStyle = ' style="background:var(--error-light)"';
} else {
matchDot = '<span class="dot dot-green"></span>';
rowStyle = '';
}
return `<tr${rowStyle}>
return `<tr>
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${renderCodmatCell(item)}</td>
<td class="text-end">${item.quantity || 0}</td>
<td class="text-end font-data">${item.price != null ? fmtNum(item.price) : '-'}</td>
<td class="text-end font-data">${pretRoaHtml}</td>
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
<td class="text-end font-data">${fmtNum(valoare)}</td>
<td class="text-center">${matchDot}</td>
</tr>`;
}).join('');
@@ -721,9 +698,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
<td></td><td class="text-muted">Transport</td>
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
<td class="text-end">1</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
<td></td>
<td class="text-end">${tVat}</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
<td></td>
</tr>`;
}
@@ -739,9 +714,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(amt)}</td>
<td></td>
<td class="text-end">${Number(rate)}</td><td class="text-end font-data">\u2013${fmtNum(amt)}</td>
<td></td>
</tr>`;
});
} else {
@@ -1053,7 +1026,6 @@ function _renderHeaderInfo(order) {
}
if (addr && addr.livrare_roa && !addrMatch(addr.livrare_gomag, addr.livrare_roa)) badges.push({label:'Adr. livr.', cls:'diff-badge-addr', aria:'Adresa livrare diferita'});
if (addr && addr.facturare_roa && !addrMatch(addr.facturare_gomag, addr.facturare_roa)) badges.push({label:'Adr. fact.', cls:'diff-badge-addr', aria:'Adresa facturare diferita'});
if (order.price_check && order.price_check.mismatches > 0) badges.push({label:'Preturi (' + order.price_check.mismatches + ')', cls:'diff-badge-price', aria:'Preturi diferite: ' + order.price_check.mismatches});
if (pi && pi.partner_mismatch) badges.push({label:'Partener', cls:'diff-badge-anaf', aria:'Partener schimbat in GoMag'});
let insertAfter = orderNumEl;
badges.forEach(b => {

View File

@@ -19,7 +19,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
{% set rp = request.scope.get('root_path', '') %}
<link href="{{ rp }}/static/css/style.css?v=38" rel="stylesheet">
<link href="{{ rp }}/static/css/style.css?v=39" rel="stylesheet">
</head>
<body>
<!-- Top Navbar (hidden on mobile via CSS) -->
@@ -145,10 +145,8 @@
<th>CODMAT</th>
<th class="text-end">Cant.</th>
<th class="text-end">Pret GoMag</th>
<th class="text-end">Pret ROA</th>
<th class="text-end">TVA%</th>
<th class="text-end">Valoare</th>
<th class="text-center"></th>
</tr>
</thead>
<tbody id="detailItemsBody">
@@ -170,7 +168,7 @@
<script>window.ROOT_PATH = "{{ rp }}";</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ rp }}/static/js/shared.js?v=36"></script>
<script src="{{ rp }}/static/js/shared.js?v=38"></script>
<script>
// Dark mode toggle
function toggleDarkMode() {

View File

@@ -114,5 +114,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=45"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=46"></script>
{% endblock %}

View File

@@ -175,7 +175,7 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="col-md-12">
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Pricing Kituri / Pachete</div>
<div class="card-body py-2 px-3">
@@ -207,37 +207,11 @@
<option value="">— implicită —</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Sincronizare Prețuri</div>
<div class="card-body py-2 px-3">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="settPriceSyncEnabled" checked>
<label class="form-check-label small" for="settPriceSyncEnabled">Sync automat prețuri din comenzi</label>
</div>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="settCatalogSyncEnabled">
<label class="form-check-label small" for="settCatalogSyncEnabled">Sync prețuri din catalog GoMag</label>
</div>
<div id="catalogSyncOptions" style="display:none">
<div class="mb-2">
<label class="form-label mb-0 small">Program</label>
<select class="form-select form-select-sm" id="settPriceSyncSchedule">
<option value="">Doar manual</option>
<option value="daily_03:00">Zilnic la 03:00</option>
<option value="daily_06:00">Zilnic la 06:00</option>
</select>
<div class="form-check mt-2">
<input type="checkbox" class="form-check-input" id="settPriceSyncEnabled" checked>
<label class="form-check-label small" for="settPriceSyncEnabled">Sync automat prețuri din comenzi</label>
</div>
</div>
<div id="settPriceSyncStatus" class="text-muted small mt-2"></div>
<button class="btn btn-sm btn-outline-primary mt-2" id="btnCatalogSync" onclick="startCatalogSync()">Sincronizează acum</button>
</div>
</div>
</div>
@@ -253,5 +227,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=9"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=10"></script>
{% endblock %}

View File

@@ -29,7 +29,7 @@ def test_order_detail_items_table_columns(page: Page, app_url: str):
texts = headers.all_text_contents()
# Current columns (may evolve — check dashboard.html for source of truth)
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret GoMag", "Pret ROA", "Valoare"]
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret GoMag", "TVA%", "Valoare"]
for col in required_columns:
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"

View File

@@ -280,7 +280,7 @@ class TestSyncPricesKitSkip:
# ===========================================================================
class TestKitComponentOwnMapping:
"""Regression: price_sync_service skips kit components that have their own ARTICOLE_TERTI mapping."""
"""Regression: kit components that have their own ARTICOLE_TERTI mapping should be skipped."""
def test_component_with_own_mapping_skipped(self):
"""If comp_codmat is itself a key in mapped_data, it's skipped."""
@@ -306,7 +306,7 @@ class TestKitComponentOwnMapping:
# ===========================================================================
class TestVatIncludedNormalization:
"""Regression: GoMag returns vat_included as int 1 or string '1' (price_sync_service.py:144)."""
"""Regression: GoMag returns vat_included as int 1 or string '1'."""
def _compute_price_cu_tva(self, product):
price = float(product.get("price", "0"))
@@ -494,183 +494,6 @@ class TestResolveCodmatIds:
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
class TestGetPricesDirectionalMatch:
"""Price match is directional: gomag <= roa is OK, gomag > roa is mismatch."""
def test_gomag_below_roa_is_match(self):
"""GoMag price lower than ROA (promo/volume discount) → match=True."""
items = [{"sku": "SKU-DISC", "price": 28.59, "baseprice": 33.0, "quantity": 48,
"codmat_details": [{"codmat": "COD1", "cantitate_roa": 1,
"id_articol": 100, "cont": "345"}]}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={100: (28.99, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0]["match"] is True
assert result["items"][0]["pret_roa"] == 28.99
assert result["summary"]["mismatches"] == 0
def test_gomag_above_roa_is_mismatch(self):
"""GoMag price higher than ROA → match=False, mismatch counted."""
items = [{"sku": "SKU-HIGH", "price": 30.00, "quantity": 1,
"codmat_details": [{"codmat": "COD2", "cantitate_roa": 1,
"id_articol": 200, "cont": "345"}]}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={200: (28.99, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0]["match"] is False
assert result["summary"]["mismatches"] == 1
def test_gomag_equals_roa_is_match(self):
"""GoMag price equals ROA → match=True."""
items = [{"sku": "SKU-FULL", "price": 28.99, "quantity": 1,
"codmat_details": [{"codmat": "COD3", "cantitate_roa": 1,
"id_articol": 300, "cont": "345"}]}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={300: (28.99, 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
# ── normalize_company_name (II, PFA, INTREPRINDERE INDIVIDUALA) ──