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:
4
TODOS.md
4
TODOS.md
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## P2: Refactor sync_service.py in module separate
|
## P2: Refactor sync_service.py in module separate
|
||||||
**What:** Split sync_service.py (870 linii) in: download_service, parse_service, sync_orchestrator.
|
**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)
|
**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.
|
**Depends on:** Finalizarea planului Command Center.
|
||||||
|
|
||||||
## P2: Email/webhook alert pe sync esuat
|
## P2: Email/webhook alert pe sync esuat
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -9,7 +8,6 @@ import os
|
|||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import init_oracle, close_oracle, init_sqlite
|
from .database import init_oracle, close_oracle, init_sqlite
|
||||||
from .routers.sync import backfill_price_match
|
|
||||||
|
|
||||||
# Configure logging with both stream and file handlers
|
# Configure logging with both stream and file handlers
|
||||||
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
||||||
@@ -58,8 +56,6 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
asyncio.create_task(backfill_price_match())
|
|
||||||
|
|
||||||
logger.info("GoMag Import Manager started")
|
logger.info("GoMag Import Manager started")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pydantic import BaseModel
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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
|
from .. import database
|
||||||
|
|
||||||
router = APIRouter(tags=["sync"])
|
router = APIRouter(tags=["sync"])
|
||||||
@@ -40,56 +40,6 @@ async def _enrich_items_with_codmat(items: list) -> None:
|
|||||||
"denumire": nom_map[sku], "direct": True}]
|
"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):
|
class ScheduleConfig(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
interval_minutes: int = 5
|
interval_minutes: int = 5
|
||||||
@@ -116,9 +66,6 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
kit_discount_codmat: str = ""
|
kit_discount_codmat: str = ""
|
||||||
kit_discount_id_pol: str = ""
|
kit_discount_id_pol: str = ""
|
||||||
price_sync_enabled: str = "1"
|
price_sync_enabled: str = "1"
|
||||||
catalog_sync_enabled: str = "0"
|
|
||||||
price_sync_schedule: str = ""
|
|
||||||
gomag_products_url: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
# API endpoints
|
# 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)
|
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)
|
@router.get("/logs", response_class=HTMLResponse)
|
||||||
async def logs_page(request: Request, run: str = None):
|
async def logs_page(request: Request, run: str = None):
|
||||||
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
|
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", [])
|
items = detail.get("items", [])
|
||||||
await _enrich_items_with_codmat(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
|
# Enrich with invoice data
|
||||||
order = detail.get("order", {})
|
order = detail.get("order", {})
|
||||||
order["price_check"] = order_price_check
|
|
||||||
if order.get("factura_numar") and order.get("factura_data"):
|
if order.get("factura_numar") and order.get("factura_data"):
|
||||||
order["invoice"] = {
|
order["invoice"] = {
|
||||||
"facturat": True,
|
"facturat": True,
|
||||||
@@ -562,7 +457,8 @@ async def order_detail(order_number: str):
|
|||||||
"facturare_roa": order.get("adresa_facturare_roa"),
|
"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_vat"] = app_settings.get("transport_vat") or "21"
|
||||||
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
||||||
order["discount_codmat"] = app_settings.get("discount_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
|
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
|
||||||
all_orders = result["orders"]
|
all_orders = result["orders"]
|
||||||
for o in all_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"):
|
if o.get("factura_numar") and o.get("factura_data"):
|
||||||
# Use cached invoice data from SQLite (only if complete)
|
# Use cached invoice data from SQLite (only if complete)
|
||||||
o["invoice"] = {
|
o["invoice"] = {
|
||||||
@@ -1061,9 +954,6 @@ async def get_app_settings():
|
|||||||
"kit_discount_codmat": s.get("kit_discount_codmat", ""),
|
"kit_discount_codmat": s.get("kit_discount_codmat", ""),
|
||||||
"kit_discount_id_pol": s.get("kit_discount_id_pol", ""),
|
"kit_discount_id_pol": s.get("kit_discount_id_pol", ""),
|
||||||
"price_sync_enabled": s.get("price_sync_enabled", "1"),
|
"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_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("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("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}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -103,80 +103,3 @@ async def download_orders(
|
|||||||
return {"pages": total_pages, "total": total_orders, "files": saved_files}
|
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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1026,23 +1026,6 @@ async def get_skipped_orders_with_sku(sku: str) -> list[str]:
|
|||||||
|
|
||||||
# ── Price Sync Runs ───────────────────────────────
|
# ── 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 ───────────────────────────────────
|
# ── ANAF Cache ───────────────────────────────────
|
||||||
|
|
||||||
async def get_anaf_cache(bare_cui: str) -> dict | None:
|
async def get_anaf_cache(bare_cui: str) -> dict | None:
|
||||||
|
|||||||
@@ -588,193 +588,3 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
|
|||||||
return updated
|
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)
|
|
||||||
|
|||||||
@@ -1189,7 +1189,6 @@ tr.mapping-deleted td {
|
|||||||
.diff-badge-anaf { background:var(--error-light); color:var(--error-text); }
|
.diff-badge-anaf { background:var(--error-light); color:var(--error-text); }
|
||||||
.diff-badge-denumire { background:var(--compare-light); color:var(--compare-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-addr { background:var(--info-light); color:var(--info-text); }
|
||||||
.diff-badge-price { background:var(--success-light); color:var(--success-text); }
|
|
||||||
|
|
||||||
/* ── Compact order detail layout ──────────────── */
|
/* ── Compact order detail layout ──────────────── */
|
||||||
.detail-col-label {
|
.detail-col-label {
|
||||||
|
|||||||
@@ -513,8 +513,6 @@ function diffDots(o, mobile) {
|
|||||||
d += `<span style="${s};background:var(--info)" title="Adresa diferita"></span>`;
|
d += `<span style="${s};background:var(--info)" title="Adresa diferita"></span>`;
|
||||||
if (o.partner_mismatch===1)
|
if (o.partner_mismatch===1)
|
||||||
d += `<span style="${s};background:var(--warning)" title="Partener schimbat"></span>`;
|
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;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Kit pricing mode radio toggle
|
// Kit pricing mode radio toggle
|
||||||
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
|
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
|
||||||
r.addEventListener('change', () => {
|
r.addEventListener('change', () => {
|
||||||
|
const mode = document.querySelector('input[name="kitPricingMode"]:checked')?.value || '';
|
||||||
document.getElementById('kitModeBFields').style.display =
|
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 => {
|
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
|
||||||
r.checked = r.value === kitMode;
|
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('settKitDiscountCodmat')) el('settKitDiscountCodmat').value = data.kit_discount_codmat || '';
|
||||||
if (el('settKitDiscountIdPol')) el('settKitDiscountIdPol').value = data.kit_discount_id_pol || '';
|
if (el('settKitDiscountIdPol')) el('settKitDiscountIdPol').value = data.kit_discount_id_pol || '';
|
||||||
|
|
||||||
// Price sync
|
// Price sync
|
||||||
if (el('settPriceSyncEnabled')) el('settPriceSyncEnabled').checked = data.price_sync_enabled !== "0";
|
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) {
|
} catch (err) {
|
||||||
console.error('loadSettings error:', err);
|
console.error('loadSettings error:', err);
|
||||||
}
|
}
|
||||||
@@ -187,9 +173,6 @@ async function saveSettings() {
|
|||||||
kit_discount_codmat: el('settKitDiscountCodmat')?.value?.trim() || '',
|
kit_discount_codmat: el('settKitDiscountCodmat')?.value?.trim() || '',
|
||||||
kit_discount_id_pol: el('settKitDiscountIdPol')?.value?.trim() || '',
|
kit_discount_id_pol: el('settKitDiscountIdPol')?.value?.trim() || '',
|
||||||
price_sync_enabled: el('settPriceSyncEnabled')?.checked ? "1" : "0",
|
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 {
|
try {
|
||||||
const res = await fetch('/api/settings', {
|
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) {
|
function wireAutocomplete(inputId, dropdownId) {
|
||||||
const input = document.getElementById(inputId);
|
const input = document.getElementById(inputId);
|
||||||
const dropdown = document.getElementById(dropdownId);
|
const dropdown = document.getElementById(dropdownId);
|
||||||
|
|||||||
@@ -615,10 +615,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
: `<code>${esc(item.codmat || '–')}</code>`;
|
: `<code>${esc(item.codmat || '–')}</code>`;
|
||||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
|
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 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">
|
return `<div class="dif-item">
|
||||||
<div class="dif-row">
|
<div class="dif-row">
|
||||||
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
|
<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-val">${fmtNum(valoare)} lei</span>
|
||||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
||||||
</div>
|
</div>
|
||||||
${priceMismatchHtml}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -684,32 +679,14 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
|
|
||||||
let tableHtml = items.map((item, idx) => {
|
let tableHtml = items.map((item, idx) => {
|
||||||
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
||||||
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
|
return `<tr>
|
||||||
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}>
|
|
||||||
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
|
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
|
||||||
<td>${esc(item.product_name || '-')}</td>
|
<td>${esc(item.product_name || '-')}</td>
|
||||||
<td>${renderCodmatCell(item)}</td>
|
<td>${renderCodmatCell(item)}</td>
|
||||||
<td class="text-end">${item.quantity || 0}</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">${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">${item.vat != null ? Number(item.vat) : '-'}</td>
|
||||||
<td class="text-end font-data">${fmtNum(valoare)}</td>
|
<td class="text-end font-data">${fmtNum(valoare)}</td>
|
||||||
<td class="text-center">${matchDot}</td>
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -721,9 +698,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
<td></td><td class="text-muted">Transport</td>
|
<td></td><td class="text-muted">Transport</td>
|
||||||
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</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 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 class="text-end">${tVat}</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
|
||||||
<td></td>
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,9 +714,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
<td></td><td class="text-muted">Discount</td>
|
<td></td><td class="text-muted">Discount</td>
|
||||||
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
||||||
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(amt)}</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 class="text-end">${Number(rate)}</td><td class="text-end font-data">\u2013${fmtNum(amt)}</td>
|
||||||
<td></td>
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
} else {
|
} 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.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 (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'});
|
if (pi && pi.partner_mismatch) badges.push({label:'Partener', cls:'diff-badge-anaf', aria:'Partener schimbat in GoMag'});
|
||||||
let insertAfter = orderNumEl;
|
let insertAfter = orderNumEl;
|
||||||
badges.forEach(b => {
|
badges.forEach(b => {
|
||||||
|
|||||||
@@ -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@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">
|
<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', '') %}
|
{% 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Top Navbar (hidden on mobile via CSS) -->
|
<!-- Top Navbar (hidden on mobile via CSS) -->
|
||||||
@@ -145,10 +145,8 @@
|
|||||||
<th>CODMAT</th>
|
<th>CODMAT</th>
|
||||||
<th class="text-end">Cant.</th>
|
<th class="text-end">Cant.</th>
|
||||||
<th class="text-end">Pret GoMag</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">TVA%</th>
|
||||||
<th class="text-end">Valoare</th>
|
<th class="text-end">Valoare</th>
|
||||||
<th class="text-center">✓</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="detailItemsBody">
|
<tbody id="detailItemsBody">
|
||||||
@@ -170,7 +168,7 @@
|
|||||||
|
|
||||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
<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="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>
|
<script>
|
||||||
// Dark mode toggle
|
// Dark mode toggle
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
|
|||||||
@@ -114,5 +114,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-12">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header py-2 px-3 fw-semibold">Pricing Kituri / Pachete</div>
|
<div class="card-header py-2 px-3 fw-semibold">Pricing Kituri / Pachete</div>
|
||||||
<div class="card-body py-2 px-3">
|
<div class="card-body py-2 px-3">
|
||||||
@@ -207,37 +207,11 @@
|
|||||||
<option value="">— implicită —</option>
|
<option value="">— implicită —</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-check mt-2">
|
||||||
</div>
|
<input type="checkbox" class="form-check-input" id="settPriceSyncEnabled" checked>
|
||||||
</div>
|
<label class="form-check-label small" for="settPriceSyncEnabled">Sync automat prețuri din comenzi</label>
|
||||||
</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>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,5 +227,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def test_order_detail_items_table_columns(page: Page, app_url: str):
|
|||||||
texts = headers.all_text_contents()
|
texts = headers.all_text_contents()
|
||||||
|
|
||||||
# Current columns (may evolve — check dashboard.html for source of truth)
|
# 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:
|
for col in required_columns:
|
||||||
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
|
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
|
||||||
|
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ class TestSyncPricesKitSkip:
|
|||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
class TestKitComponentOwnMapping:
|
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):
|
def test_component_with_own_mapping_skipped(self):
|
||||||
"""If comp_codmat is itself a key in mapped_data, it's skipped."""
|
"""If comp_codmat is itself a key in mapped_data, it's skipped."""
|
||||||
@@ -306,7 +306,7 @@ class TestKitComponentOwnMapping:
|
|||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
class TestVatIncludedNormalization:
|
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):
|
def _compute_price_cu_tva(self, product):
|
||||||
price = float(product.get("price", "0"))
|
price = float(product.get("price", "0"))
|
||||||
@@ -494,183 +494,6 @@ class TestResolveCodmatIds:
|
|||||||
assert "COD2" in codmats
|
assert "COD2" in codmats
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# Group 6: get_prices_for_order() — cantitate_roa price normalization
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
from app.services.validation_service import get_prices_for_order
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_oracle_conn(pol_cu_tva=False, price_map=None):
|
|
||||||
"""Build a mock Oracle connection for get_prices_for_order.
|
|
||||||
|
|
||||||
price_map: {id_articol: (pret, proc_tvav)}
|
|
||||||
"""
|
|
||||||
if price_map is None:
|
|
||||||
price_map = {}
|
|
||||||
conn = MagicMock()
|
|
||||||
|
|
||||||
def cursor_ctx():
|
|
||||||
cur = MagicMock()
|
|
||||||
# CRM_POLITICI_PRETURI — PRETURI_CU_TVA flag
|
|
||||||
cu_tva_row = [1 if pol_cu_tva else 0]
|
|
||||||
# CRM_POLITICI_PRET_ART — prices
|
|
||||||
price_rows = [
|
|
||||||
(1, id_art, pret, proc_tvav)
|
|
||||||
for id_art, (pret, proc_tvav) in price_map.items()
|
|
||||||
]
|
|
||||||
# fetchone for PRETURI_CU_TVA, __iter__ for price rows
|
|
||||||
cur.fetchone = MagicMock(return_value=cu_tva_row)
|
|
||||||
cur.__iter__ = MagicMock(return_value=iter(price_rows))
|
|
||||||
return cur
|
|
||||||
|
|
||||||
cm = MagicMock()
|
|
||||||
cm.__enter__ = MagicMock(side_effect=cursor_ctx)
|
|
||||||
cm.__exit__ = MagicMock(return_value=False)
|
|
||||||
conn.cursor.return_value = cm
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetPricesForOrderCantitateRoa:
|
|
||||||
"""Regression: cantitate_roa < 1 must be treated as kit for price normalization.
|
|
||||||
|
|
||||||
Bug: SKU with cantitate_roa=0.5 (GoMag 50buc=7lei, ROA 100buc=14lei)
|
|
||||||
was reported as price mismatch because is_kit only checked > 1.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_cantitate_roa_half_matches(self):
|
|
||||||
"""cantitate_roa=0.5: kit item — price check skipped entirely."""
|
|
||||||
items = [{
|
|
||||||
"sku": "1057308134545",
|
|
||||||
"price": 7.00,
|
|
||||||
"quantity": 60,
|
|
||||||
"codmat_details": [{
|
|
||||||
"codmat": "8OZLRLP",
|
|
||||||
"cantitate_roa": 0.5,
|
|
||||||
"id_articol": 100,
|
|
||||||
"cont": "345",
|
|
||||||
}],
|
|
||||||
}]
|
|
||||||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={100: (14.00, 1.19)})
|
|
||||||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
|
||||||
|
|
||||||
assert result["items"][0]["match"] is None
|
|
||||||
assert result["items"][0]["kit"] is True
|
|
||||||
assert result["summary"]["mismatches"] == 0
|
|
||||||
|
|
||||||
def test_cantitate_roa_half_mismatch(self):
|
|
||||||
"""cantitate_roa=0.5: kit item — price check skipped even if prices differ."""
|
|
||||||
items = [{
|
|
||||||
"sku": "SKU-HALF",
|
|
||||||
"price": 7.00,
|
|
||||||
"quantity": 1,
|
|
||||||
"codmat_details": [{
|
|
||||||
"codmat": "COD1",
|
|
||||||
"cantitate_roa": 0.5,
|
|
||||||
"id_articol": 200,
|
|
||||||
"cont": "345",
|
|
||||||
}],
|
|
||||||
}]
|
|
||||||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={200: (10.00, 1.19)})
|
|
||||||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
|
||||||
|
|
||||||
assert result["items"][0]["match"] is None
|
|
||||||
assert result["items"][0]["kit"] is True
|
|
||||||
assert result["summary"]["mismatches"] == 0
|
|
||||||
|
|
||||||
def test_cantitate_roa_one_simple_item(self):
|
|
||||||
"""cantitate_roa=1 (default): simple item, direct price comparison."""
|
|
||||||
items = [{
|
|
||||||
"sku": "SKU-SIMPLE",
|
|
||||||
"price": 63.79,
|
|
||||||
"quantity": 8,
|
|
||||||
"codmat_details": [{
|
|
||||||
"codmat": "COD-DIRECT",
|
|
||||||
"cantitate_roa": 1,
|
|
||||||
"id_articol": 300,
|
|
||||||
"cont": "345",
|
|
||||||
}],
|
|
||||||
}]
|
|
||||||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={300: (63.79, 1.19)})
|
|
||||||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
|
||||||
|
|
||||||
assert result["items"][0]["match"] is True
|
|
||||||
assert result["summary"]["mismatches"] == 0
|
|
||||||
|
|
||||||
def test_cantitate_roa_gt1_kit(self):
|
|
||||||
"""cantitate_roa=2: kit item — price check skipped."""
|
|
||||||
items = [{
|
|
||||||
"sku": "SKU-KIT2",
|
|
||||||
"price": 20.00,
|
|
||||||
"quantity": 1,
|
|
||||||
"codmat_details": [{
|
|
||||||
"codmat": "COD-KIT",
|
|
||||||
"cantitate_roa": 2,
|
|
||||||
"id_articol": 400,
|
|
||||||
"cont": "345",
|
|
||||||
}],
|
|
||||||
}]
|
|
||||||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={400: (10.00, 1.19)})
|
|
||||||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
|
||||||
|
|
||||||
assert result["items"][0]["match"] is None
|
|
||||||
assert result["items"][0]["kit"] is True
|
|
||||||
assert result["summary"]["mismatches"] == 0
|
|
||||||
|
|
||||||
def test_multi_component_kit_skipped(self):
|
|
||||||
"""Multi-component kit (2 CODMATs): price check skipped, kit=True."""
|
|
||||||
items = [{
|
|
||||||
"sku": "SKU-MULTI",
|
|
||||||
"price": 15.00,
|
|
||||||
"quantity": 1,
|
|
||||||
"codmat_details": [
|
|
||||||
{"codmat": "COMP-A", "cantitate_roa": 1, "id_articol": 500, "cont": "345"},
|
|
||||||
{"codmat": "COMP-B", "cantitate_roa": 1, "id_articol": 501, "cont": "345"},
|
|
||||||
],
|
|
||||||
}]
|
|
||||||
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={500: (8.00, 1.19), 501: (9.00, 1.19)})
|
|
||||||
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
|
|
||||||
|
|
||||||
assert result["items"][0]["match"] is None
|
|
||||||
assert result["items"][0]["kit"] is True
|
|
||||||
assert result["summary"]["mismatches"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
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) ──
|
# ── normalize_company_name (II, PFA, INTREPRINDERE INDIVIDUALA) ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user