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) ──