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"),
|
("discount_total", "REAL"),
|
||||||
("web_status", "TEXT"),
|
("web_status", "TEXT"),
|
||||||
("discount_split", "TEXT"),
|
("discount_split", "TEXT"),
|
||||||
|
("price_match", "INTEGER"),
|
||||||
]:
|
]:
|
||||||
if col not in order_cols:
|
if col not in order_cols:
|
||||||
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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
|
||||||
@@ -8,6 +9,7 @@ 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)
|
||||||
@@ -56,6 +58,8 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,74 @@ router = APIRouter(tags=["sync"])
|
|||||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
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):
|
class ScheduleConfig(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
interval_minutes: int = 5
|
interval_minutes: int = 5
|
||||||
@@ -380,30 +448,8 @@ async def order_detail(order_number: str):
|
|||||||
if not detail:
|
if not detail:
|
||||||
return {"error": "Order not found"}
|
return {"error": "Order not found"}
|
||||||
|
|
||||||
# Enrich items with ARTICOLE_TERTI mappings from Oracle
|
|
||||||
items = detail.get("items", [])
|
items = detail.get("items", [])
|
||||||
skus = {item["sku"] for item in items if item.get("sku")}
|
await _enrich_items_with_codmat(items)
|
||||||
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
|
|
||||||
}]
|
|
||||||
|
|
||||||
# Price comparison against ROA Oracle
|
# Price comparison against ROA Oracle
|
||||||
app_settings = await sqlite_service.get_app_settings()
|
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["pret_roa"] = pi.get("pret_roa")
|
||||||
item["price_match"] = pi.get("match")
|
item["price_match"] = pi.get("match")
|
||||||
order_price_check = price_data.get("summary", {})
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Price comparison failed for order {order_number}: {e}")
|
logger.warning(f"Price comparison failed for order {order_number}: {e}")
|
||||||
order_price_check = {"mismatches": 0, "checked": 0, "oracle_available": False}
|
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
|
# 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:
|
||||||
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"):
|
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"] = {
|
||||||
@@ -611,9 +666,8 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
|
|
||||||
# Use counts from sqlite_service (already period-scoped)
|
# Use counts from sqlite_service (already period-scoped)
|
||||||
counts = result.get("counts", {})
|
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"))
|
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(
|
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
|
||||||
1 for o in all_orders
|
1 for o in all_orders
|
||||||
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
|
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()
|
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:
|
async def get_invoiced_imported_orders() -> list:
|
||||||
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
|
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
|
||||||
db = await get_sqlite()
|
db = await get_sqlite()
|
||||||
|
|||||||
@@ -335,15 +335,15 @@ async function loadDashOrders() {
|
|||||||
if (attnEl) {
|
if (attnEl) {
|
||||||
const errors = c.error || 0;
|
const errors = c.error || 0;
|
||||||
const unmapped = c.unresolved_skus || 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>';
|
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
|
||||||
} else {
|
} else {
|
||||||
let items = [];
|
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 (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 (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>';
|
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,8 +494,7 @@ function statusLabelText(status) {
|
|||||||
function priceDot(order) {
|
function priceDot(order) {
|
||||||
if (order.price_match === true) return '<span class="dot dot-green" title="Preturi OK"></span>';
|
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 === 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 '<span class="dot dot-gray" title="Neverificat"></span>';
|
||||||
return '–';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function invoiceDot(order) {
|
function invoiceDot(order) {
|
||||||
|
|||||||
Reference in New Issue
Block a user