diff --git a/api/app/database.py b/api/app/database.py index 984f0cb..e8c127c 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -332,6 +332,7 @@ def init_sqlite(): ("discount_total", "REAL"), ("web_status", "TEXT"), ("discount_split", "TEXT"), + ("price_match", "INTEGER"), ]: if col not in order_cols: conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") diff --git a/api/app/main.py b/api/app/main.py index 611cad6..89c15a5 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1,3 +1,4 @@ +import asyncio from contextlib import asynccontextmanager from datetime import datetime from fastapi import FastAPI @@ -8,6 +9,7 @@ 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) @@ -56,6 +58,8 @@ 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 1cdb067..c0bea7c 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -19,6 +19,74 @@ router = APIRouter(tags=["sync"]) templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) +async def _enrich_items_with_codmat(items: list) -> None: + """Enrich order items with codmat_details from ARTICOLE_TERTI + NOM_ARTICOLE fallback.""" + skus = {item["sku"] for item in items if item.get("sku")} + if not skus: + return + codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus) + for item in items: + sku = item.get("sku") + if sku and sku in codmat_map: + item["codmat_details"] = codmat_map[sku] + remaining_skus = {item["sku"] for item in items + if item.get("sku") and not item.get("codmat_details")} + if remaining_skus: + nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus) + for item in items: + sku = item.get("sku") + if sku and sku in nom_map and not item.get("codmat_details"): + item["codmat_details"] = [{"codmat": sku, "cantitate_roa": 1, + "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: + 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 @@ -380,30 +448,8 @@ async def order_detail(order_number: str): if not detail: return {"error": "Order not found"} - # Enrich items with ARTICOLE_TERTI mappings from Oracle items = detail.get("items", []) - skus = {item["sku"] for item in items if item.get("sku")} - if skus: - codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus) - for item in items: - sku = item.get("sku") - if sku and sku in codmat_map: - item["codmat_details"] = codmat_map[sku] - - # Enrich remaining SKUs via NOM_ARTICOLE (fallback for stale mapping_status) - remaining_skus = {item["sku"] for item in items - if item.get("sku") and not item.get("codmat_details")} - if remaining_skus: - nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus) - for item in items: - sku = item.get("sku") - if sku and sku in nom_map and not item.get("codmat_details"): - item["codmat_details"] = [{ - "codmat": sku, - "cantitate_roa": 1, - "denumire": nom_map[sku], - "direct": True - }] + await _enrich_items_with_codmat(items) # Price comparison against ROA Oracle app_settings = await sqlite_service.get_app_settings() @@ -418,6 +464,13 @@ async def order_detail(order_number: str): item["pret_roa"] = pi.get("pret_roa") item["price_match"] = pi.get("match") 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} @@ -560,7 +613,9 @@ 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: - o["price_match"] = None # Populated when order detail is opened + # 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"] = { @@ -611,9 +666,8 @@ async def dashboard_orders(page: int = 1, per_page: int = 50, # Use counts from sqlite_service (already period-scoped) counts = result.get("counts", {}) - # Count newly-cached invoices found during this request + # Adjust uninvoiced count for invoices discovered via Oracle during this request newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat")) - # Adjust uninvoiced count: start from SQLite count, subtract newly-found invoices uninvoiced_base = counts.get("uninvoiced_sqlite", sum( 1 for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice") diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 9dfc476..df46c29 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -831,6 +831,20 @@ async def update_order_invoice(order_number: str, serie: str = None, await db.close() +async def update_order_price_match(order_number: str, match: bool | None): + """Cache price_match result (True=OK, False=mismatch, None=unavailable).""" + db = await get_sqlite() + try: + val = None if match is None else (1 if match else 0) + await db.execute( + "UPDATE orders SET price_match = ?, updated_at = datetime('now') WHERE order_number = ?", + (val, order_number), + ) + await db.commit() + finally: + await db.close() + + async def get_invoiced_imported_orders() -> list: """Get imported orders that HAVE cached invoice data (for re-verification).""" db = await get_sqlite() diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index a803c11..1ebf08e 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -335,15 +335,15 @@ async function loadDashOrders() { if (attnEl) { const errors = c.error || 0; const unmapped = c.unresolved_skus || 0; - const uninvOld = c.uninvoiced_old || 0; + const nefact = c.nefacturate || 0; - if (errors === 0 && unmapped === 0 && uninvOld === 0) { + if (errors === 0 && unmapped === 0 && nefact === 0) { attnEl.innerHTML = '
Totul in ordine
'; } else { let items = []; if (errors > 0) items.push(` ${errors} erori import`); if (unmapped > 0) items.push(` ${unmapped} SKU-uri nemapate`); - if (uninvOld > 0) items.push(` ${uninvOld} nefacturate >3 zile`); + if (nefact > 0) items.push(` ${nefact} nefacturate`); attnEl.innerHTML = '
' + items.join('') + '
'; } } @@ -494,8 +494,7 @@ function statusLabelText(status) { function priceDot(order) { if (order.price_match === true) return ''; if (order.price_match === false) return ''; - if (order.price_match === null) return ''; - return '–'; + return ''; } function invoiceDot(order) {