fix(dashboard): cache ROA price status and align nefacturate counts
- Add price_match column to SQLite, cached on order detail view - Background backfill on startup checks all unchecked imported orders - Extract _enrich_items_with_codmat() helper to deduplicate SKU enrichment - Attention card now shows same nefacturate count as filter pill Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
|
||||
} else {
|
||||
let items = [];
|
||||
if (errors > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=ERROR]')?.click()"><i class="bi bi-exclamation-triangle"></i> ${errors} erori import</span>`);
|
||||
if (unmapped > 0) items.push(`<span class="attention-item attention-warning" onclick="window.location='${window.ROOT_PATH||''}/missing-skus'"><i class="bi bi-puzzle"></i> ${unmapped} SKU-uri nemapate</span>`);
|
||||
if (uninvOld > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${uninvOld} nefacturate >3 zile</span>`);
|
||||
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
|
||||
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
|
||||
}
|
||||
}
|
||||
@@ -494,8 +494,7 @@ function statusLabelText(status) {
|
||||
function priceDot(order) {
|
||||
if (order.price_match === true) return '<span class="dot dot-green" title="Preturi OK"></span>';
|
||||
if (order.price_match === false) return '<span class="dot dot-red" title="Diferenta de pret"></span>';
|
||||
if (order.price_match === null) return '<span class="dot dot-gray" title="Preturi ROA indisponibile"></span>';
|
||||
return '–';
|
||||
return '<span class="dot dot-gray" title="Neverificat"></span>';
|
||||
}
|
||||
|
||||
function invoiceDot(order) {
|
||||
|
||||
Reference in New Issue
Block a user