From 1d59f1a484ab836199adab8198f3984aa7598253 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 8 Apr 2026 20:30:34 +0000 Subject: [PATCH] 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) --- TODOS.md | 4 +- api/app/main.py | 4 - api/app/routers/sync.py | 119 +---------- api/app/services/gomag_client.py | 77 -------- api/app/services/price_sync_service.py | 264 ------------------------- api/app/services/sqlite_service.py | 17 -- api/app/services/validation_service.py | 190 ------------------ api/app/static/css/style.css | 1 - api/app/static/js/dashboard.js | 2 - api/app/static/js/settings.js | 57 +----- api/app/static/js/shared.js | 30 +-- api/app/templates/base.html | 6 +- api/app/templates/dashboard.html | 2 +- api/app/templates/settings.html | 36 +--- api/tests/e2e/test_order_detail.py | 2 +- api/tests/test_business_rules.py | 181 +---------------- 16 files changed, 20 insertions(+), 972 deletions(-) delete mode 100644 api/app/services/price_sync_service.py diff --git a/TODOS.md b/TODOS.md index 685aff7..12b89bb 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,9 +2,9 @@ ## P2: Refactor sync_service.py in module separate **What:** Split sync_service.py (870 linii) in: download_service, parse_service, sync_orchestrator. -**Why:** Faciliteza debugging si testare. Un bug in price sync nu ar trebui sa afecteze import flow. +**Why:** Faciliteza debugging si testare. **Effort:** M (human: ~1 sapt / CC: ~1-2h) -**Context:** Dupa implementarea planului Command Center (retry_service deja extras). sync_service face download + parse + validate + import + price sync + invoice check — prea multe responsabilitati. +**Context:** Dupa implementarea planului Command Center (retry_service deja extras). sync_service face download + parse + validate + import + invoice check — prea multe responsabilitati. **Depends on:** Finalizarea planului Command Center. ## P2: Email/webhook alert pe sync esuat diff --git a/api/app/main.py b/api/app/main.py index 326154f..683bcc1 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -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 diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index 0551c1a..3b702ce 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -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} diff --git a/api/app/services/gomag_client.py b/api/app/services/gomag_client.py index 81ef907..5fe7c4a 100644 --- a/api/app/services/gomag_client.py +++ b/api/app/services/gomag_client.py @@ -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 diff --git a/api/app/services/price_sync_service.py b/api/app/services/price_sync_service.py deleted file mode 100644 index 26fe89a..0000000 --- a/api/app/services/price_sync_service.py +++ /dev/null @@ -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 diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 80067d7..8ff330b 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -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: diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index b70733d..2eee3f9 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -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) diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css index 0f23445..624eee1 100644 --- a/api/app/static/css/style.css +++ b/api/app/static/css/style.css @@ -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 { diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 1ea1471..c1320e7 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -513,8 +513,6 @@ function diffDots(o, mobile) { d += ``; if (o.partner_mismatch===1) d += ``; - if (o.price_match===false) - d += ``; return d; } diff --git a/api/app/static/js/settings.js b/api/app/static/js/settings.js index a869fdb..fe89e8b 100644 --- a/api/app/static/js/settings.js +++ b/api/app/static/js/settings.js @@ -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 = ' Sincronizare...'; - try { - const res = await fetch('/api/price-sync/start', { method: 'POST' }); - const data = await res.json(); - if (data.error) { - status.innerHTML = `${escHtml(data.error)}`; - 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 = `${escHtml(err.message)}`; - btn.disabled = false; - btn.textContent = 'Sincronizează acum'; - } -} - function wireAutocomplete(inputId, dropdownId) { const input = document.getElementById(inputId); const dropdown = document.getElementById(dropdownId); diff --git a/api/app/static/js/shared.js b/api/app/static/js/shared.js index fa19e46..5db75a4 100644 --- a/api/app/static/js/shared.js +++ b/api/app/static/js/shared.js @@ -615,10 +615,6 @@ async function renderOrderDetailModal(orderNumber, opts) { : `${esc(item.codmat || '–')}`; 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 - ? `
ROA: ${fmtNum(priceInfo.pret_roa)} lei
` - : ''; return `
${esc(item.sku)} @@ -630,7 +626,6 @@ async function renderOrderDetailModal(orderNumber, opts) { ${fmtNum(valoare)} lei TVA ${item.vat != null ? Number(item.vat) : '?'}
- ${priceMismatchHtml}
`; }).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 = 'Kit'; - rowStyle = ''; - } else if (priceInfo.pret_roa == null && priceInfo.match == null) { - matchDot = ''; - rowStyle = ''; - } else if (priceInfo.match === false) { - matchDot = ''; - rowStyle = ' style="background:var(--error-light)"'; - } else { - matchDot = ''; - rowStyle = ''; - } - return ` + return ` ${esc(item.sku)} ${esc(item.product_name || '-')} ${renderCodmatCell(item)} ${item.quantity || 0} ${item.price != null ? fmtNum(item.price) : '-'} - ${pretRoaHtml} ${item.vat != null ? Number(item.vat) : '-'} ${fmtNum(valoare)} - ${matchDot} `; }).join(''); @@ -721,9 +698,7 @@ async function renderOrderDetailModal(orderNumber, opts) { Transport ${tCodmat ? '' + esc(tCodmat) + '' : ''} 1${fmtNum(order.delivery_cost)} - ${tVat}${fmtNum(order.delivery_cost)} - `; } @@ -739,9 +714,7 @@ async function renderOrderDetailModal(orderNumber, opts) { Discount ${dCodmat ? '' + esc(dCodmat) + '' : ''} \u20131${fmtNum(amt)} - ${Number(rate)}\u2013${fmtNum(amt)} - `; }); } 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 => { diff --git a/api/app/templates/base.html b/api/app/templates/base.html index fc7b2f0..e3a5f69 100644 --- a/api/app/templates/base.html +++ b/api/app/templates/base.html @@ -19,7 +19,7 @@ {% set rp = request.scope.get('root_path', '') %} - + @@ -145,10 +145,8 @@ CODMAT Cant. Pret GoMag - Pret ROA TVA% Valoare - ✓ @@ -170,7 +168,7 @@ - + + {% endblock %} diff --git a/api/app/templates/settings.html b/api/app/templates/settings.html index a312321..aeabfba 100644 --- a/api/app/templates/settings.html +++ b/api/app/templates/settings.html @@ -175,7 +175,7 @@ -
+
Pricing Kituri / Pachete
@@ -207,37 +207,11 @@
-
-
-
- - - -
-
-
-
Sincronizare Prețuri
-
-
- - -
-
- - -
-
@@ -253,5 +227,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/api/tests/e2e/test_order_detail.py b/api/tests/e2e/test_order_detail.py index 068b99d..69fa009 100644 --- a/api/tests/e2e/test_order_detail.py +++ b/api/tests/e2e/test_order_detail.py @@ -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}" diff --git a/api/tests/test_business_rules.py b/api/tests/test_business_rules.py index 3ef0570..21ec675 100644 --- a/api/tests/test_business_rules.py +++ b/api/tests/test_business_rules.py @@ -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) ──