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:
Claude Agent
2026-03-27 14:47:03 +00:00
parent b2745a9a64
commit 6acb73b9ce
5 changed files with 103 additions and 31 deletions

View File

@@ -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}")

View File

@@ -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

View File

@@ -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")

View File

@@ -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()

View File

@@ -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 &gt;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) {