feat(dashboard): add logs page, pagination, quick mapping modal, price pre-validation

- Add /logs page with per-order sync run details, filters (Toate/Importate/Fara Mapare/Erori)
- Add price pre-validation (validate_prices + ensure_prices) to prevent ORA-20000 on direct articles
- Add find_new_orders() to detect orders not yet in Oracle COMENZI
- Extend missing_skus table with order context (order_count, order_numbers, customers)
- Add server-side pagination on /api/validate/missing-skus and /missing-skus page
- Replace confusing "Skip"/"Err" with "Fara Mapare"/"Erori" terminology
- Add inline mapping modal on dashboard (replaces navigation to /mappings)
- Add 2-row stat cards: orders (Comenzi Noi/Ready/Importate/Fara Mapare/Erori) + articles
- Add ID_POL/ID_GESTIUNE/ID_SECTIE to config.py and .env
- Update .gitignore (venv, *.db, api/api/, logs/)
- 33/33 unit tests pass, E2E verified with Playwright

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 16:59:08 +02:00
parent 06daf24073
commit 97699fa0e5
17 changed files with 1050 additions and 93 deletions

13
.gitignore vendored
View File

@@ -26,3 +26,16 @@ vfp/output/
vfp/*.json vfp/*.json
*.~pck *.~pck
.claude/HANDOFF.md .claude/HANDOFF.md
# Virtual environments
venv/
.venv/
# SQLite databases
*.db
# Generated/duplicate directories
api/api/
# Logs directory
logs/

View File

@@ -30,6 +30,11 @@ class Settings(BaseSettings):
API_USERNAME: str = "" API_USERNAME: str = ""
API_PASSWORD: str = "" API_PASSWORD: str = ""
# ROA Import Settings
ID_POL: int = 0
ID_GESTIUNE: int = 0
ID_SECTIE: int = 0
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
settings = Settings() settings = Settings()

View File

@@ -91,7 +91,10 @@ CREATE TABLE IF NOT EXISTS missing_skus (
product_name TEXT, product_name TEXT,
first_seen TEXT DEFAULT (datetime('now')), first_seen TEXT DEFAULT (datetime('now')),
resolved INTEGER DEFAULT 0, resolved INTEGER DEFAULT 0,
resolved_at TEXT resolved_at TEXT,
order_count INTEGER DEFAULT 0,
order_numbers TEXT,
customers TEXT
); );
CREATE TABLE IF NOT EXISTS scheduler_config ( CREATE TABLE IF NOT EXISTS scheduler_config (
@@ -115,6 +118,21 @@ def init_sqlite():
# Create tables synchronously # Create tables synchronously
conn = sqlite3.connect(_sqlite_db_path) conn = sqlite3.connect(_sqlite_db_path)
conn.executescript(SQLITE_SCHEMA) conn.executescript(SQLITE_SCHEMA)
# Migrate: add columns if missing (for existing databases)
try:
cursor = conn.execute("PRAGMA table_info(missing_skus)")
cols = {row[1] for row in cursor.fetchall()}
for col, typedef in [("order_count", "INTEGER DEFAULT 0"),
("order_numbers", "TEXT"),
("customers", "TEXT")]:
if col not in cols:
conn.execute(f"ALTER TABLE missing_skus ADD COLUMN {col} {typedef}")
logger.info(f"Migrated missing_skus: added column {col}")
conn.commit()
except Exception as e:
logger.warning(f"Migration check failed: {e}")
conn.close() conn.close()
logger.info(f"SQLite initialized: {_sqlite_db_path}") logger.info(f"SQLite initialized: {_sqlite_db_path}")

View File

@@ -60,6 +60,11 @@ 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.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request):
return templates.TemplateResponse("logs.html", {"request": request})
@router.get("/api/sync/run/{run_id}") @router.get("/api/sync/run/{run_id}")
async def sync_run_detail(run_id: str): async def sync_run_detail(run_id: str):
"""Get details for a specific sync run.""" """Get details for a specific sync run."""
@@ -69,6 +74,30 @@ async def sync_run_detail(run_id: str):
return detail return detail
@router.get("/api/sync/run/{run_id}/log")
async def sync_run_log(run_id: str):
"""Get detailed log per order for a sync run."""
detail = await sqlite_service.get_sync_run_detail(run_id)
if not detail:
return {"error": "Run not found", "status_code": 404}
orders = detail.get("orders", [])
return {
"run_id": run_id,
"run": detail.get("run", {}),
"orders": [
{
"order_number": o.get("order_number"),
"customer_name": o.get("customer_name"),
"items_count": o.get("items_count"),
"status": o.get("status"),
"error_message": o.get("error_message"),
"missing_skus": o.get("missing_skus"),
}
for o in orders
]
}
@router.put("/api/sync/schedule") @router.put("/api/sync/schedule")
async def update_schedule(config: ScheduleConfig): async def update_schedule(config: ScheduleConfig):
"""Update scheduler configuration.""" """Update scheduler configuration."""

View File

@@ -1,9 +1,11 @@
import asyncio
import csv import csv
import io import io
from fastapi import APIRouter import json
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from ..services import order_reader, validation_service from ..services import order_reader, validation_service, sqlite_service
from ..database import get_sqlite from ..database import get_sqlite
router = APIRouter(prefix="/api/validate", tags=["validation"]) router = APIRouter(prefix="/api/validate", tags=["validation"])
@@ -20,27 +22,40 @@ async def scan_and_validate():
result = validation_service.validate_skus(all_skus) result = validation_service.validate_skus(all_skus)
importable, skipped = validation_service.classify_orders(orders, result) importable, skipped = validation_service.classify_orders(orders, result)
# Track missing SKUs in SQLite # Find new orders (not yet in Oracle)
db = await get_sqlite() all_order_numbers = [o.number for o in orders]
try: new_orders = await asyncio.to_thread(validation_service.find_new_orders, all_order_numbers)
for sku in result["missing"]:
# Find product name from orders
product_name = ""
for order in orders:
for item in order.items:
if item.sku == sku:
product_name = item.name
break
if product_name:
break
await db.execute(""" # Build SKU context from skipped orders and track missing SKUs
INSERT OR IGNORE INTO missing_skus (sku, product_name) sku_context = {} # sku -> {order_numbers: [], customers: []}
VALUES (?, ?) for order, missing_list in skipped:
""", (sku, product_name)) customer = order.billing.company_name or f"{order.billing.firstname} {order.billing.lastname}"
await db.commit() for sku in missing_list:
finally: if sku not in sku_context:
await db.close() sku_context[sku] = {"order_numbers": [], "customers": []}
sku_context[sku]["order_numbers"].append(order.number)
if customer not in sku_context[sku]["customers"]:
sku_context[sku]["customers"].append(customer)
for sku in result["missing"]:
# Find product name from orders
product_name = ""
for order in orders:
for item in order.items:
if item.sku == sku:
product_name = item.name
break
if product_name:
break
ctx = sku_context.get(sku, {})
await sqlite_service.track_missing_sku(
sku=sku,
product_name=product_name,
order_count=len(ctx.get("order_numbers", [])),
order_numbers=json.dumps(ctx.get("order_numbers", [])),
customers=json.dumps(ctx.get("customers", []))
)
return { return {
"json_files": json_count, "json_files": json_count,
@@ -48,11 +63,15 @@ async def scan_and_validate():
"total_skus": len(all_skus), "total_skus": len(all_skus),
"importable": len(importable), "importable": len(importable),
"skipped": len(skipped), "skipped": len(skipped),
"new_orders": len(new_orders),
"skus": { "skus": {
"mapped": len(result["mapped"]), "mapped": len(result["mapped"]),
"direct": len(result["direct"]), "direct": len(result["direct"]),
"missing": len(result["missing"]), "missing": len(result["missing"]),
"missing_list": sorted(result["missing"]) "missing_list": sorted(result["missing"]),
"total_skus": len(all_skus),
"mapped_skus": len(result["mapped"]),
"direct_skus": len(result["direct"])
}, },
"skipped_orders": [ "skipped_orders": [
{ {
@@ -66,23 +85,24 @@ async def scan_and_validate():
} }
@router.get("/missing-skus") @router.get("/missing-skus")
async def get_missing_skus(): async def get_missing_skus(
"""Get all tracked missing SKUs.""" page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
resolved: int = Query(0, ge=0, le=1)
):
"""Get paginated missing SKUs."""
result = await sqlite_service.get_missing_skus_paginated(page, per_page, resolved)
# Backward compat: also include 'unresolved' count
db = await get_sqlite() db = await get_sqlite()
try: try:
cursor = await db.execute(""" cursor = await db.execute(
SELECT sku, product_name, first_seen, resolved, resolved_at "SELECT COUNT(*) FROM missing_skus WHERE resolved = 0"
FROM missing_skus )
ORDER BY resolved ASC, first_seen DESC unresolved = (await cursor.fetchone())[0]
""")
rows = await cursor.fetchall()
return {
"missing_skus": [dict(row) for row in rows],
"total": len(rows),
"unresolved": sum(1 for r in rows if not r["resolved"])
}
finally: finally:
await db.close() await db.close()
result["unresolved"] = unresolved
return result
@router.get("/missing-skus-csv") @router.get("/missing-skus-csv")
async def export_missing_skus_csv(): async def export_missing_skus_csv():

View File

@@ -59,14 +59,25 @@ async def add_import_order(sync_run_id: str, order_number: str, order_date: str,
await db.close() await db.close()
async def track_missing_sku(sku: str, product_name: str = ""): async def track_missing_sku(sku: str, product_name: str = "",
"""Track a missing SKU.""" order_count: int = 0, order_numbers: str = None,
customers: str = None):
"""Track a missing SKU with order context."""
db = await get_sqlite() db = await get_sqlite()
try: try:
await db.execute(""" await db.execute("""
INSERT OR IGNORE INTO missing_skus (sku, product_name) INSERT OR IGNORE INTO missing_skus (sku, product_name)
VALUES (?, ?) VALUES (?, ?)
""", (sku, product_name)) """, (sku, product_name))
# Update context columns (always update with latest data)
if order_count or order_numbers or customers:
await db.execute("""
UPDATE missing_skus SET
order_count = ?,
order_numbers = ?,
customers = ?
WHERE sku = ?
""", (order_count, order_numbers, customers, sku))
await db.commit() await db.commit()
finally: finally:
await db.close() await db.close()
@@ -85,6 +96,38 @@ async def resolve_missing_sku(sku: str):
await db.close() await db.close()
async def get_missing_skus_paginated(page: int = 1, per_page: int = 20, resolved: int = 0):
"""Get paginated missing SKUs."""
db = await get_sqlite()
try:
offset = (page - 1) * per_page
cursor = await db.execute(
"SELECT COUNT(*) FROM missing_skus WHERE resolved = ?", (resolved,)
)
total = (await cursor.fetchone())[0]
cursor = await db.execute("""
SELECT sku, product_name, first_seen, resolved, resolved_at,
order_count, order_numbers, customers
FROM missing_skus
WHERE resolved = ?
ORDER BY order_count DESC, first_seen DESC
LIMIT ? OFFSET ?
""", (resolved, per_page, offset))
rows = await cursor.fetchall()
return {
"missing_skus": [dict(row) for row in rows],
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 0
}
finally:
await db.close()
async def get_sync_runs(page: int = 1, per_page: int = 20): async def get_sync_runs(page: int = 1, per_page: int = 20):
"""Get paginated sync run history.""" """Get paginated sync run history."""
db = await get_sqlite() db = await get_sqlite()
@@ -165,6 +208,17 @@ async def get_dashboard_stats():
) )
missing = (await cursor.fetchone())[0] missing = (await cursor.fetchone())[0]
# Article stats from last sync
cursor = await db.execute("""
SELECT COUNT(DISTINCT sku) FROM missing_skus
""")
total_missing_skus = (await cursor.fetchone())[0]
cursor = await db.execute("""
SELECT COUNT(DISTINCT sku) FROM missing_skus WHERE resolved = 0
""")
unresolved_skus = (await cursor.fetchone())[0]
# Last sync run # Last sync run
cursor = await db.execute(""" cursor = await db.execute("""
SELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 1 SELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 1
@@ -176,6 +230,8 @@ async def get_dashboard_stats():
"skipped": skipped, "skipped": skipped,
"errors": errors, "errors": errors,
"missing_skus": missing, "missing_skus": missing,
"total_tracked_skus": total_missing_skus,
"unresolved_skus": unresolved_skus,
"last_run": dict(last_run) if last_run else None "last_run": dict(last_run) if last_run else None
} }
finally: finally:

View File

@@ -1,9 +1,11 @@
import asyncio import asyncio
import json
import logging import logging
import uuid import uuid
from datetime import datetime from datetime import datetime
from . import order_reader, validation_service, import_service, sqlite_service from . import order_reader, validation_service, import_service, sqlite_service
from ..config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,12 +54,31 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
_current_sync["progress"] = f"Validating {len(orders)} orders..." _current_sync["progress"] = f"Validating {len(orders)} orders..."
# Step 2: Validate SKUs (blocking Oracle call -> run in thread) # Step 2a: Find new orders (not yet in Oracle)
all_order_numbers = [o.number for o in orders]
new_orders = await asyncio.to_thread(
validation_service.find_new_orders, all_order_numbers
)
# Step 2b: Validate SKUs (blocking Oracle call -> run in thread)
all_skus = order_reader.get_all_skus(orders) all_skus = order_reader.get_all_skus(orders)
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus) validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
importable, skipped = validation_service.classify_orders(orders, validation) importable, skipped = validation_service.classify_orders(orders, validation)
# Track missing SKUs # Step 2c: Build SKU context from skipped orders
sku_context = {} # {sku: {"orders": [], "customers": []}}
for order, missing_skus_list in skipped:
customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}"
for sku in missing_skus_list:
if sku not in sku_context:
sku_context[sku] = {"orders": [], "customers": []}
if order.number not in sku_context[sku]["orders"]:
sku_context[sku]["orders"].append(order.number)
if customer not in sku_context[sku]["customers"]:
sku_context[sku]["customers"].append(customer)
# Track missing SKUs with context
for sku in validation["missing"]: for sku in validation["missing"]:
product_name = "" product_name = ""
for order in orders: for order in orders:
@@ -67,7 +88,41 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
break break
if product_name: if product_name:
break break
await sqlite_service.track_missing_sku(sku, product_name) ctx = sku_context.get(sku, {})
await sqlite_service.track_missing_sku(
sku, product_name,
order_count=len(ctx.get("orders", [])),
order_numbers=json.dumps(ctx.get("orders", [])) if ctx.get("orders") else None,
customers=json.dumps(ctx.get("customers", [])) if ctx.get("customers") else None,
)
# Step 2d: Pre-validate prices for importable articles
id_pol = id_pol or settings.ID_POL
if id_pol and importable:
_current_sync["progress"] = "Validating prices..."
# Gather all CODMATs from importable orders
all_codmats = set()
for order in importable:
for item in order.items:
if item.sku in validation["mapped"]:
# Mapped SKUs resolve to codmat via ARTICOLE_TERTI (handled by import)
pass
elif item.sku in validation["direct"]:
all_codmats.add(item.sku)
# For mapped SKUs, we'd need the ARTICOLE_TERTI lookup - direct SKUs = codmat
if all_codmats:
price_result = await asyncio.to_thread(
validation_service.validate_prices, all_codmats, id_pol
)
if price_result["missing_price"]:
logger.info(
f"Auto-adding price 0 for {len(price_result['missing_price'])} "
f"direct articles in policy {id_pol}"
)
await asyncio.to_thread(
validation_service.ensure_prices,
price_result["missing_price"], id_pol
)
# Step 3: Record skipped orders # Step 3: Record skipped orders
for order, missing_skus in skipped: for order, missing_skus in skipped:
@@ -138,6 +193,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
"status": status, "status": status,
"json_files": json_count, "json_files": json_count,
"total_orders": len(orders), "total_orders": len(orders),
"new_orders": len(new_orders),
"imported": imported_count, "imported": imported_count,
"skipped": len(skipped), "skipped": len(skipped),
"errors": error_count, "errors": error_count,

View File

@@ -69,3 +69,123 @@ def classify_orders(orders, validation_result):
importable.append(order) importable.append(order)
return importable, skipped return importable, skipped
def find_new_orders(order_numbers: list[str]) -> set[str]:
"""Check which order numbers do NOT already exist in Oracle COMENZI.
Returns: set of order numbers that are truly new (not yet imported).
"""
if not order_numbers:
return set()
existing = set()
num_list = list(order_numbers)
with database.pool.acquire() as conn:
with conn.cursor() as cur:
for i in range(0, len(num_list), 500):
batch = num_list[i:i+500]
placeholders = ",".join([f":o{j}" for j in range(len(batch))])
params = {f"o{j}": num for j, num in enumerate(batch)}
cur.execute(f"""
SELECT DISTINCT comanda_externa FROM COMENZI
WHERE comanda_externa IN ({placeholders}) AND sters = 0
""", params)
for row in cur:
existing.add(row[0])
new_orders = set(order_numbers) - existing
logger.info(f"Order check: {len(new_orders)} new, {len(existing)} already exist out of {len(order_numbers)} total")
return new_orders
def validate_prices(codmats: set[str], id_pol: int) -> dict:
"""Check which CODMATs have a price entry in CRM_POLITICI_PRET_ART for the given policy.
Returns: {"has_price": set_of_codmats, "missing_price": set_of_codmats}
"""
if not codmats:
return {"has_price": set(), "missing_price": set()}
codmat_to_id = {}
ids_with_price = set()
codmat_list = list(codmats)
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Step 1: Get ID_ARTICOL for each CODMAT
for i in range(0, len(codmat_list), 500):
batch = codmat_list[i:i+500]
placeholders = ",".join([f":c{j}" for j in range(len(batch))])
params = {f"c{j}": cm for j, cm in enumerate(batch)}
cur.execute(f"""
SELECT id_articol, codmat FROM NOM_ARTICOLE
WHERE codmat IN ({placeholders})
""", params)
for row in cur:
codmat_to_id[row[1]] = row[0]
# Step 2: Check which ID_ARTICOLs have a price in the policy
id_list = list(codmat_to_id.values())
for i in range(0, len(id_list), 500):
batch = id_list[i:i+500]
placeholders = ",".join([f":a{j}" for j in range(len(batch))])
params = {f"a{j}": aid for j, aid in enumerate(batch)}
params["id_pol"] = id_pol
cur.execute(f"""
SELECT DISTINCT pa.ID_ARTICOL FROM CRM_POLITICI_PRET_ART pa
WHERE pa.ID_POL = :id_pol AND pa.ID_ARTICOL IN ({placeholders})
""", params)
for row in cur:
ids_with_price.add(row[0])
# Map back to CODMATs
has_price = {cm for cm, aid in codmat_to_id.items() if aid in ids_with_price}
missing_price = codmats - has_price
logger.info(f"Price validation (policy {id_pol}): {len(has_price)} have price, {len(missing_price)} missing price")
return {"has_price": has_price, "missing_price": missing_price}
def ensure_prices(codmats: set[str], id_pol: int):
"""Insert price 0 entries for CODMATs missing from the given price policy."""
if not codmats:
return
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Get ID_VALUTA for this policy
cur.execute("""
SELECT ID_VALUTA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :id_pol
""", {"id_pol": id_pol})
row = cur.fetchone()
if not row:
logger.error(f"Price policy {id_pol} not found in CRM_POLITICI_PRETURI")
return
id_valuta = row[0]
for codmat in codmats:
# Get ID_ARTICOL
cur.execute("""
SELECT id_articol FROM NOM_ARTICOLE WHERE codmat = :codmat
""", {"codmat": codmat})
row = cur.fetchone()
if not row:
logger.warning(f"CODMAT {codmat} not found in NOM_ARTICOLE, skipping price insert")
continue
id_articol = row[0]
cur.execute("""
INSERT INTO CRM_POLITICI_PRET_ART
(ID_POL_ART, ID_POL, ID_ARTICOL, PRET, ID_COMANDA, ID_VALUTA,
ID_UTIL, DATAORA, PROC_TVAV, ID_PARTR, ID_PARTZ,
PRETFTVA, PRETCTVA, CANTITATE, ID_UM, PRET_MIN, PRET_MIN_TVA)
VALUES
(SEQ_CRM_POLITICI_PRET_ART.NEXTVAL, :id_pol, :id_articol, 0, NULL, :id_valuta,
-3, SYSDATE, 1.19, NULL, NULL,
0, 0, 0, NULL, 0, 0)
""", {"id_pol": id_pol, "id_articol": id_articol, "id_valuta": id_valuta})
logger.info(f"Pret 0 adaugat pentru CODMAT {codmat} in politica {id_pol}")
conn.commit()
logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}")

View File

@@ -1,9 +1,22 @@
let refreshInterval = null; let refreshInterval = null;
let currentMapSku = '';
let acTimeout = null;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadDashboard(); loadDashboard();
// Auto-refresh every 10 seconds // Auto-refresh every 10 seconds
refreshInterval = setInterval(loadDashboard, 10000); refreshInterval = setInterval(loadDashboard, 10000);
const input = document.getElementById('mapCodmat');
if (input) {
input.addEventListener('input', () => {
clearTimeout(acTimeout);
acTimeout = setTimeout(() => autocompleteMap(input.value), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => document.getElementById('mapAutocomplete').classList.add('d-none'), 200);
});
}
}); });
async function loadDashboard() { async function loadDashboard() {
@@ -20,11 +33,36 @@ async function loadSyncStatus() {
const res = await fetch('/api/sync/status'); const res = await fetch('/api/sync/status');
const data = await res.json(); const data = await res.json();
// Update stats
const stats = data.stats || {}; const stats = data.stats || {};
document.getElementById('stat-imported').textContent = stats.imported || 0;
document.getElementById('stat-skipped').textContent = stats.skipped || 0; // Order-level stat cards from sync status
document.getElementById('stat-missing').textContent = stats.missing_skus || 0; document.getElementById('stat-imported').textContent = stats.imported != null ? stats.imported : 0;
document.getElementById('stat-skipped').textContent = stats.skipped != null ? stats.skipped : 0;
document.getElementById('stat-errors').textContent = stats.errors != null ? stats.errors : 0;
// Article-level stats from sync status
if (stats.total_tracked_skus != null) {
document.getElementById('stat-total-skus').textContent = stats.total_tracked_skus;
}
if (stats.unresolved_skus != null) {
document.getElementById('stat-missing-skus').textContent = stats.unresolved_skus;
const total = stats.total_tracked_skus || 0;
const unresolved = stats.unresolved_skus || 0;
document.getElementById('stat-mapped-skus').textContent = total - unresolved;
}
// Restore scan-derived stats from sessionStorage (preserved across auto-refresh)
const scanData = getScanData();
if (scanData) {
document.getElementById('stat-new').textContent = scanData.new_orders != null ? scanData.new_orders : (scanData.total_orders || '-');
document.getElementById('stat-ready').textContent = scanData.importable != null ? scanData.importable : '-';
if (scanData.skus) {
document.getElementById('stat-total-skus').textContent = scanData.skus.total_skus || stats.total_tracked_skus || '-';
document.getElementById('stat-missing-skus').textContent = scanData.skus.missing || stats.unresolved_skus || 0;
const mapped = (scanData.skus.total_skus || 0) - (scanData.skus.missing || 0);
document.getElementById('stat-mapped-skus').textContent = mapped >= 0 ? mapped : '-';
}
}
// Update sync status badge // Update sync status badge
const badge = document.getElementById('syncStatusBadge'); const badge = document.getElementById('syncStatusBadge');
@@ -46,7 +84,7 @@ async function loadSyncStatus() {
const lr = stats.last_run; const lr = stats.last_run;
const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : ''; const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : '';
document.getElementById('syncProgressText').textContent = document.getElementById('syncProgressText').textContent =
`Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} skip, ${lr.errors || 0} err`; `Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} fara mapare, ${lr.errors || 0} erori`;
} else { } else {
document.getElementById('syncProgressText').textContent = ''; document.getElementById('syncProgressText').textContent = '';
} }
@@ -93,32 +131,42 @@ async function loadSyncHistory() {
async function loadMissingSkus() { async function loadMissingSkus() {
try { try {
const res = await fetch('/api/validate/missing-skus'); const res = await fetch('/api/validate/missing-skus?page=1&per_page=10');
const data = await res.json(); const data = await res.json();
const tbody = document.getElementById('missingSkusBody'); const tbody = document.getElementById('missingSkusBody');
// Update stat card // Update article-level stat card (unresolved count)
document.getElementById('stat-missing').textContent = data.unresolved || 0; if (data.total != null) {
document.getElementById('stat-missing-skus').textContent = data.total;
}
const unresolved = (data.missing_skus || []).filter(s => !s.resolved); const unresolved = (data.missing_skus || []).filter(s => !s.resolved);
if (unresolved.length === 0) { if (unresolved.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">Toate SKU-urile sunt mapate</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Toate SKU-urile sunt mapate</td></tr>';
return; return;
} }
tbody.innerHTML = unresolved.slice(0, 10).map(s => ` tbody.innerHTML = unresolved.slice(0, 10).map(s => {
<tr> let firstCustomer = '-';
try {
const customers = JSON.parse(s.customers || '[]');
if (customers.length > 0) firstCustomer = customers[0];
} catch (e) { /* ignore */ }
return `<tr>
<td><code>${esc(s.sku)}</code></td> <td><code>${esc(s.sku)}</code></td>
<td>${esc(s.product_name || '-')}</td> <td>${esc(s.product_name || '-')}</td>
<td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td> <td>${s.order_count != null ? s.order_count : '-'}</td>
<td><small>${esc(firstCustomer)}</small></td>
<td> <td>
<a href="/mappings?sku=${encodeURIComponent(s.sku)}" class="btn btn-sm btn-outline-primary" title="Creeaza mapare"> <button class="btn btn-sm btn-outline-primary" title="Creeaza mapare"
<i class="bi bi-plus-lg"></i> onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')">
</a> <i class="bi bi-link-45deg"></i>
</button>
</td> </td>
</tr> </tr>`;
`).join(''); }).join('');
} catch (err) { } catch (err) {
console.error('loadMissingSkus error:', err); console.error('loadMissingSkus error:', err);
} }
@@ -169,11 +217,23 @@ async function scanOrders() {
const res = await fetch('/api/validate/scan', { method: 'POST' }); const res = await fetch('/api/validate/scan', { method: 'POST' });
const data = await res.json(); const data = await res.json();
// Update pending/ready stats // Persist scan results so auto-refresh doesn't overwrite them
document.getElementById('stat-pending').textContent = data.total_orders || 0; saveScanData(data);
document.getElementById('stat-ready').textContent = data.importable || 0;
let msg = `Scan complet: ${data.total_orders || 0} comenzi, ${data.importable || 0} ready, ${data.skipped || 0} skipped`; // Update stat cards immediately from scan response
document.getElementById('stat-new').textContent = data.new_orders != null ? data.new_orders : (data.total_orders || 0);
document.getElementById('stat-ready').textContent = data.importable != null ? data.importable : 0;
if (data.skus) {
document.getElementById('stat-total-skus').textContent = data.skus.total_skus || 0;
document.getElementById('stat-missing-skus').textContent = data.skus.missing || 0;
const mapped = (data.skus.total_skus || 0) - (data.skus.missing || 0);
document.getElementById('stat-mapped-skus').textContent = mapped >= 0 ? mapped : 0;
}
let msg = `Scan complet: ${data.total_orders || 0} comenzi`;
if (data.new_orders != null) msg += `, ${data.new_orders} noi`;
msg += `, ${data.importable || 0} ready`;
if (data.skus && data.skus.missing > 0) { if (data.skus && data.skus.missing > 0) {
msg += `, ${data.skus.missing} SKU-uri lipsa`; msg += `, ${data.skus.missing} SKU-uri lipsa`;
} }
@@ -209,6 +269,106 @@ async function updateSchedulerInterval() {
} }
} }
// --- Map Modal ---
function openMapModal(sku, productName) {
currentMapSku = sku;
document.getElementById('mapSku').textContent = sku;
document.getElementById('mapCodmat').value = productName || '';
document.getElementById('mapCantitate').value = '1';
document.getElementById('mapProcent').value = '100';
document.getElementById('mapSelectedArticle').textContent = '';
document.getElementById('mapAutocomplete').classList.add('d-none');
if (productName) {
autocompleteMap(productName);
}
new bootstrap.Modal(document.getElementById('mapModal')).show();
}
async function autocompleteMap(q) {
const dropdown = document.getElementById('mapAutocomplete');
if (!dropdown) return;
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
try {
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
if (!data.results || data.results.length === 0) {
dropdown.classList.add('d-none');
return;
}
dropdown.innerHTML = data.results.map(r => `
<div class="autocomplete-item" onmousedown="selectMapArticle('${esc(r.codmat)}', '${esc(r.denumire)}')">
<span class="codmat">${esc(r.codmat)}</span>
<br><span class="denumire">${esc(r.denumire)}</span>
</div>
`).join('');
dropdown.classList.remove('d-none');
} catch (err) {
dropdown.classList.add('d-none');
}
}
function selectMapArticle(codmat, denumire) {
document.getElementById('mapCodmat').value = codmat;
document.getElementById('mapSelectedArticle').textContent = denumire;
document.getElementById('mapAutocomplete').classList.add('d-none');
}
async function saveQuickMap() {
const codmat = document.getElementById('mapCodmat').value.trim();
const cantitate = parseFloat(document.getElementById('mapCantitate').value) || 1;
const procent = parseFloat(document.getElementById('mapProcent').value) || 100;
if (!codmat) { alert('Selecteaza un CODMAT'); return; }
try {
const res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sku: currentMapSku,
codmat: codmat,
cantitate_roa: cantitate,
procent_pret: procent
})
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissingSkus();
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
alert('Eroare: ' + err.message);
}
}
// --- sessionStorage helpers for scan data ---
function saveScanData(data) {
try {
sessionStorage.setItem('lastScanData', JSON.stringify(data));
sessionStorage.setItem('lastScanTime', Date.now().toString());
} catch (e) { /* ignore */ }
}
function getScanData() {
try {
const t = parseInt(sessionStorage.getItem('lastScanTime') || '0');
// Expire scan data after 5 minutes
if (Date.now() - t > 5 * 60 * 1000) return null;
const raw = sessionStorage.getItem('lastScanData');
return raw ? JSON.parse(raw) : null;
} catch (e) { return null; }
}
function esc(s) { function esc(s) {
if (s == null) return ''; if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');

216
api/app/static/js/logs.js Normal file
View File

@@ -0,0 +1,216 @@
// logs.js - Jurnale Import page logic
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function fmtDatetime(iso) {
if (!iso) return '-';
try {
return new Date(iso).toLocaleString('ro-RO', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch (e) {
return iso;
}
}
function fmtDuration(startedAt, finishedAt) {
if (!startedAt || !finishedAt) return '-';
const diffMs = new Date(finishedAt) - new Date(startedAt);
if (isNaN(diffMs) || diffMs < 0) return '-';
const secs = Math.round(diffMs / 1000);
if (secs < 60) return secs + 's';
return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
}
function statusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">IMPORTED</span>';
case 'SKIPPED': return '<span class="badge bg-warning text-dark">SKIPPED</span>';
case 'ERROR': return '<span class="badge bg-danger">ERROR</span>';
default: return `<span class="badge bg-secondary">${esc(status || '-')}</span>`;
}
}
function runStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'SUCCESS': return '<span class="badge bg-success ms-1">SUCCESS</span>';
case 'ERROR': return '<span class="badge bg-danger ms-1">ERROR</span>';
case 'RUNNING': return '<span class="badge bg-primary ms-1">RUNNING</span>';
case 'PARTIAL': return '<span class="badge bg-warning text-dark ms-1">PARTIAL</span>';
default: return `<span class="badge bg-secondary ms-1">${esc(status || '')}</span>`;
}
}
async function loadRuns() {
const sel = document.getElementById('runSelector');
sel.innerHTML = '<option value="">Se incarca...</option>';
try {
const res = await fetch('/api/sync/history?per_page=20');
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const runs = data.runs || [];
if (runs.length === 0) {
sel.innerHTML = '<option value="">Nu exista sync runs</option>';
return;
}
sel.innerHTML = '<option value="">-- Selecteaza un sync run --</option>' +
runs.map(r => {
const date = fmtDatetime(r.started_at);
const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`;
const statusText = (r.status || '').toUpperCase();
return `<option value="${esc(r.run_id)}">[${statusText}] ${date}${stats}</option>`;
}).join('');
} catch (err) {
sel.innerHTML = '<option value="">Eroare la incarcare: ' + esc(err.message) + '</option>';
}
}
async function loadRunLog(runId) {
const tbody = document.getElementById('logsBody');
const filterRow = document.getElementById('filterRow');
const runSummary = document.getElementById('runSummary');
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2"></div>Se incarca...</td></tr>';
filterRow.style.display = 'none';
runSummary.style.display = 'none';
try {
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const run = data.run || {};
const orders = data.orders || [];
// Populate summary bar
document.getElementById('sum-total').textContent = run.total_orders ?? '-';
document.getElementById('sum-imported').textContent = run.imported ?? '-';
document.getElementById('sum-skipped').textContent = run.skipped ?? '-';
document.getElementById('sum-errors').textContent = run.errors ?? '-';
document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at);
runSummary.style.display = '';
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
filterRow.style.display = 'none';
updateFilterCount();
return;
}
tbody.innerHTML = orders.map(order => {
const status = (order.status || '').toUpperCase();
// Parse missing_skus — API returns JSON string or null
let missingSkuTags = '';
if (order.missing_skus) {
try {
const skus = typeof order.missing_skus === 'string'
? JSON.parse(order.missing_skus)
: order.missing_skus;
if (Array.isArray(skus) && skus.length > 0) {
missingSkuTags = '<div class="mt-1">' +
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') +
'</div>';
}
} catch (e) {
// malformed JSON — skip
}
}
const details = order.error_message
? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}`
: missingSkuTags || '<span class="text-muted">-</span>';
return `<tr data-status="${esc(status)}">
<td><code>${esc(order.order_number || '-')}</code></td>
<td>${esc(order.customer_name || '-')}</td>
<td class="text-center">${order.items_count ?? '-'}</td>
<td>${statusBadge(status)}</td>
<td>${details}</td>
</tr>`;
}).join('');
filterRow.style.display = '';
// Reset filter to "Toate"
document.querySelectorAll('[data-filter]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === 'all');
});
applyFilter('all');
} catch (err) {
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3">
<i class="bi bi-exclamation-triangle me-1"></i>${esc(err.message)}
</td></tr>`;
filterRow.style.display = 'none';
runSummary.style.display = 'none';
}
}
function applyFilter(filter) {
const rows = document.querySelectorAll('#logsBody tr[data-status]');
let visible = 0;
rows.forEach(row => {
const show = filter === 'all' || row.dataset.status === filter;
row.style.display = show ? '' : 'none';
if (show) visible++;
});
updateFilterCount(visible, rows.length, filter);
}
function updateFilterCount(visible, total, filter) {
const el = document.getElementById('filterCount');
if (!el) return;
if (visible == null) {
el.textContent = '';
return;
}
if (filter === 'all') {
el.textContent = `${total} comenzi`;
} else {
el.textContent = `${visible} din ${total} comenzi`;
}
}
document.addEventListener('DOMContentLoaded', () => {
loadRuns();
// Dropdown change
document.getElementById('runSelector').addEventListener('change', function () {
const runId = this.value;
if (!runId) {
document.getElementById('logsBody').innerHTML = `<tr id="emptyState">
<td colspan="5" class="text-center text-muted py-5">
<i class="bi bi-journal-text fs-2 d-block mb-2 text-muted opacity-50"></i>
Selecteaza un sync run din lista de sus
</td>
</tr>`;
document.getElementById('filterRow').style.display = 'none';
document.getElementById('runSummary').style.display = 'none';
return;
}
loadRunLog(runId);
});
// Filter buttons
document.querySelectorAll('[data-filter]').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
applyFilter(this.dataset.filter);
});
});
});

View File

@@ -35,6 +35,11 @@
<i class="bi bi-exclamation-triangle"></i> SKU-uri Lipsa <i class="bi bi-exclamation-triangle"></i> SKU-uri Lipsa
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% block nav_logs %}{% endblock %}" href="/logs">
<i class="bi bi-journal-text"></i> Jurnale Import
</a>
</li>
</ul> </ul>
<div class="sidebar-footer"> <div class="sidebar-footer">
<small class="text-muted">v1.0</small> <small class="text-muted">v1.0</small>

View File

@@ -5,12 +5,12 @@
{% block content %} {% block content %}
<h4 class="mb-4">Dashboard</h4> <h4 class="mb-4">Dashboard</h4>
<!-- Stat cards row --> <!-- Stat cards - Row 1: Comenzi -->
<div class="row g-3 mb-4" id="statsRow"> <div class="row g-3 mb-2" id="statsRow">
<div class="col"> <div class="col">
<div class="card stat-card"> <div class="card stat-card">
<div class="stat-value text-secondary" id="stat-pending">-</div> <div class="stat-value text-info" id="stat-new">-</div>
<div class="stat-label">In Asteptare</div> <div class="stat-label">Comenzi Noi</div>
</div> </div>
</div> </div>
<div class="col"> <div class="col">
@@ -22,28 +22,57 @@
<div class="col"> <div class="col">
<div class="card stat-card"> <div class="card stat-card">
<div class="stat-value text-success" id="stat-imported">-</div> <div class="stat-value text-success" id="stat-imported">-</div>
<div class="stat-label">Imported</div> <div class="stat-label">Importate</div>
</div> </div>
</div> </div>
<div class="col"> <div class="col">
<div class="card stat-card"> <div class="card stat-card">
<div class="stat-value text-warning" id="stat-skipped">-</div> <div class="stat-value text-warning" id="stat-skipped">-</div>
<div class="stat-label">Skipped</div> <div class="stat-label">Fără Mapare</div>
</div> </div>
</div> </div>
<div class="col"> <div class="col">
<div class="card stat-card"> <div class="card stat-card">
<div class="stat-value text-danger" id="stat-missing">-</div> <div class="stat-value text-danger" id="stat-errors">-</div>
<div class="stat-label">SKU Lipsa</div> <div class="stat-label">Erori Import</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Stat cards - Row 2: Articole -->
<div class="row g-3 mb-4" id="statsRowArticles">
<div class="col">
<div class="card stat-card">
<div class="stat-value text-secondary" id="stat-total-skus">-</div>
<div class="stat-label">Total SKU Scanate</div>
</div>
</div>
<div class="col">
<div class="card stat-card">
<div class="stat-value text-success" id="stat-mapped-skus">-</div>
<div class="stat-label">Cu Mapare</div>
</div>
</div>
<div class="col">
<div class="card stat-card">
<div class="stat-value text-warning" id="stat-missing-skus">-</div>
<div class="stat-label">Fără Mapare</div>
</div>
</div>
<div class="col d-none d-md-block"></div>
<div class="col d-none d-md-block"></div>
</div>
<!-- Sync Control --> <!-- Sync Control -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<span>Sync Control</span> <span>Sync Control</span>
<span class="badge bg-secondary" id="syncStatusBadge">idle</span> <div class="d-flex align-items-center gap-2">
<a href="/logs" class="btn btn-sm btn-outline-info">
<i class="bi bi-journal-text"></i> Jurnale Import
</a>
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row align-items-center"> <div class="row align-items-center">
@@ -91,8 +120,8 @@
<th>Status</th> <th>Status</th>
<th>Total</th> <th>Total</th>
<th>OK</th> <th>OK</th>
<th>Skip</th> <th>Fără mapare</th>
<th>Err</th> <th>Erori</th>
<th>Durata</th> <th>Durata</th>
</tr> </tr>
</thead> </thead>
@@ -117,17 +146,52 @@
<tr> <tr>
<th>SKU</th> <th>SKU</th>
<th>Produs</th> <th>Produs</th>
<th>Data</th> <th>Nr. Comenzi</th>
<th>Actiune</th> <th>Primul Client</th>
<th colspan="2">Acțiune</th>
</tr> </tr>
</thead> </thead>
<tbody id="missingSkusBody"> <tbody id="missingSkusBody">
<tr><td colspan="4" class="text-center text-muted py-3">Se incarca...</td></tr> <tr><td colspan="5" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<!-- Map SKU Modal (copied from missing_skus.html) -->
<div class="modal fade" id="mapModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Mapeaza SKU: <code id="mapSku"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3 position-relative">
<label class="form-label">CODMAT (Articol ROA)</label>
<input type="text" class="form-control" id="mapCodmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
<div class="autocomplete-dropdown d-none" id="mapAutocomplete"></div>
<small class="text-muted" id="mapSelectedArticle"></small>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">Cantitate ROA</label>
<input type="number" class="form-control" id="mapCantitate" value="1" step="0.001" min="0.001">
</div>
<div class="col-6 mb-3">
<label class="form-label">Procent Pret (%)</label>
<input type="number" class="form-control" id="mapProcent" value="100" step="0.01" min="0" max="100">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
<button type="button" class="btn btn-primary" onclick="saveQuickMap()">Salveaza</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

101
api/app/templates/logs.html Normal file
View File

@@ -0,0 +1,101 @@
{% extends "base.html" %}
{% block title %}Jurnale Import - GoMag Import{% endblock %}
{% block nav_logs %}active{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Jurnale Import</h4>
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" id="runSelector" style="min-width: 320px;">
<option value="">-- Selecteaza un sync run --</option>
</select>
<button class="btn btn-sm btn-outline-secondary" onclick="loadRuns()" title="Reincarca lista">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<!-- Filter buttons -->
<div class="mb-3" id="filterRow" style="display:none;">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary active" data-filter="all">
<i class="bi bi-list-ul"></i> Toate
</button>
<button type="button" class="btn btn-outline-success" data-filter="IMPORTED">
<i class="bi bi-check-circle"></i> Importate
</button>
<button type="button" class="btn btn-outline-warning" data-filter="SKIPPED">
<i class="bi bi-skip-forward"></i> Fara Mapare
</button>
<button type="button" class="btn btn-outline-danger" data-filter="ERROR">
<i class="bi bi-x-circle"></i> Erori
</button>
</div>
<small class="text-muted ms-3" id="filterCount"></small>
</div>
<!-- Run summary bar -->
<div class="row g-3 mb-3" id="runSummary" style="display:none;">
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-primary" id="sum-total" style="font-size:1.25rem;">-</div>
<div class="stat-label">Total</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-success" id="sum-imported" style="font-size:1.25rem;">-</div>
<div class="stat-label">Importate</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-warning" id="sum-skipped" style="font-size:1.25rem;">-</div>
<div class="stat-label">Omise</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-danger" id="sum-errors" style="font-size:1.25rem;">-</div>
<div class="stat-label">Erori</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-secondary" id="sum-duration" style="font-size:1.25rem;">-</div>
<div class="stat-label">Durata</div>
</div>
</div>
</div>
<!-- Orders table -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="logsTable">
<thead>
<tr>
<th style="width:140px;">Nr. Comanda</th>
<th>Client</th>
<th style="width:100px;" class="text-center">Nr. Articole</th>
<th style="width:120px;">Status</th>
<th>Eroare / Detalii</th>
</tr>
</thead>
<tbody id="logsBody">
<tr id="emptyState">
<td colspan="5" class="text-center text-muted py-5">
<i class="bi bi-journal-text fs-2 d-block mb-2 text-muted opacity-50"></i>
Selecteaza un sync run din lista de sus
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/logs.js"></script>
{% endblock %}

View File

@@ -23,13 +23,15 @@
<tr> <tr>
<th>SKU</th> <th>SKU</th>
<th>Produs</th> <th>Produs</th>
<th>Nr. Comenzi</th>
<th>Client</th>
<th>First Seen</th> <th>First Seen</th>
<th>Status</th> <th>Status</th>
<th>Actiune</th> <th>Actiune</th>
</tr> </tr>
</thead> </thead>
<tbody id="missingBody"> <tbody id="missingBody">
<tr><td colspan="5" class="text-center text-muted py-4">Se incarca...</td></tr> <tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -39,6 +41,10 @@
</div> </div>
</div> </div>
<nav id="paginationNav" class="mt-3">
<ul class="pagination justify-content-center" id="paginationControls"></ul>
</nav>
<!-- Map SKU Modal --> <!-- Map SKU Modal -->
<div class="modal fade" id="mapModal" tabindex="-1"> <div class="modal fade" id="mapModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
@@ -78,9 +84,11 @@
<script> <script>
let currentMapSku = ''; let currentMapSku = '';
let acTimeout = null; let acTimeout = null;
let currentPage = 1;
const perPage = 20;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadMissing(); loadMissing(1);
const input = document.getElementById('mapCodmat'); const input = document.getElementById('mapCodmat');
input.addEventListener('input', () => { input.addEventListener('input', () => {
@@ -92,18 +100,20 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
async function loadMissing() { async function loadMissing(page) {
currentPage = page || 1;
try { try {
const res = await fetch('/api/validate/missing-skus'); const res = await fetch(`/api/validate/missing-skus?page=${currentPage}&per_page=${perPage}`);
const data = await res.json(); const data = await res.json();
const tbody = document.getElementById('missingBody'); const tbody = document.getElementById('missingBody');
document.getElementById('missingInfo').textContent = document.getElementById('missingInfo').textContent =
`Total: ${data.total || 0} | Nerezolvate: ${data.unresolved || 0}`; `Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
const skus = data.missing_skus || []; const skus = data.missing_skus || [];
if (skus.length === 0) { if (skus.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Toate SKU-urile sunt mapate!</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Toate SKU-urile sunt mapate!</td></tr>';
renderPagination(data);
return; return;
} }
@@ -112,31 +122,85 @@ async function loadMissing() {
? '<span class="badge bg-success">Rezolvat</span>' ? '<span class="badge bg-success">Rezolvat</span>'
: '<span class="badge bg-warning text-dark">Nerezolvat</span>'; : '<span class="badge bg-warning text-dark">Nerezolvat</span>';
let firstCustomer = '-';
try {
const customers = JSON.parse(s.customers || '[]');
if (customers.length > 0) firstCustomer = customers[0];
} catch (e) { /* ignore parse errors */ }
const orderCount = s.order_count != null ? s.order_count : '-';
return `<tr class="${s.resolved ? 'table-light' : ''}"> return `<tr class="${s.resolved ? 'table-light' : ''}">
<td><code>${esc(s.sku)}</code></td> <td><code>${esc(s.sku)}</code></td>
<td>${esc(s.product_name || '-')}</td> <td>${esc(s.product_name || '-')}</td>
<td>${esc(orderCount)}</td>
<td><small>${esc(firstCustomer)}</small></td>
<td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td> <td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td> <td>
${!s.resolved ? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}')"> ${!s.resolved
<i class="bi bi-link-45deg"></i> Mapeaza ? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')">
</button>` : `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`} <i class="bi bi-link-45deg"></i> Mapeaza
</button>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
renderPagination(data);
} catch (err) { } catch (err) {
document.getElementById('missingBody').innerHTML = document.getElementById('missingBody').innerHTML =
`<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`; `<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`;
} }
} }
function openMapModal(sku) { function renderPagination(data) {
const ul = document.getElementById('paginationControls');
const total = data.pages || 1;
const page = data.page || 1;
if (total <= 1) {
ul.innerHTML = '';
return;
}
let html = '';
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a>
</li>`;
const range = 2;
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
html += `<li class="page-item ${i === page ? 'active' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${i}); return false;">${i}</a>
</li>`;
} else if (i === page - range - 1 || i === page + range + 1) {
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
}
}
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${page + 1}); return false;">Urmator</a>
</li>`;
ul.innerHTML = html;
}
function openMapModal(sku, productName) {
currentMapSku = sku; currentMapSku = sku;
document.getElementById('mapSku').textContent = sku; document.getElementById('mapSku').textContent = sku;
document.getElementById('mapCodmat').value = ''; document.getElementById('mapCodmat').value = productName || '';
document.getElementById('mapCantitate').value = '1'; document.getElementById('mapCantitate').value = '1';
document.getElementById('mapProcent').value = '100'; document.getElementById('mapProcent').value = '100';
document.getElementById('mapSelectedArticle').textContent = ''; document.getElementById('mapSelectedArticle').textContent = '';
document.getElementById('mapAutocomplete').classList.add('d-none');
if (productName) {
autocompleteMap(productName);
}
new bootstrap.Modal(document.getElementById('mapModal')).show(); new bootstrap.Modal(document.getElementById('mapModal')).show();
} }
@@ -193,7 +257,7 @@ async function saveQuickMap() {
if (data.success) { if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissing(); loadMissing(currentPage);
} else { } else {
alert('Eroare: ' + (data.error || 'Unknown')); alert('Eroare: ' + (data.error || 'Unknown'));
} }
@@ -205,7 +269,7 @@ async function saveQuickMap() {
async function scanForMissing() { async function scanForMissing() {
try { try {
await fetch('/api/validate/scan', { method: 'POST' }); await fetch('/api/validate/scan', { method: 'POST' });
loadMissing(); loadMissing(1);
} catch (err) { } catch (err) {
alert('Eroare scan: ' + err.message); alert('Eroare scan: ' + err.message);
} }

View File

@@ -96,6 +96,9 @@ GET_ROUTES = [
("GET /api/sync/history", "/api/sync/history", [200], False), ("GET /api/sync/history", "/api/sync/history", [200], False),
("GET /api/sync/schedule", "/api/sync/schedule", [200], False), ("GET /api/sync/schedule", "/api/sync/schedule", [200], False),
("GET /api/validate/missing-skus", "/api/validate/missing-skus", [200], False), ("GET /api/validate/missing-skus", "/api/validate/missing-skus", [200], False),
("GET /api/validate/missing-skus?page=1", "/api/validate/missing-skus?page=1&per_page=10", [200], False),
("GET /logs (HTML)", "/logs", [200, 500], False),
("GET /api/sync/run/nonexistent/log", "/api/sync/run/nonexistent/log", [200, 404], False),
("GET /api/articles/search?q=ab", "/api/articles/search?q=ab", [200, 503], True), ("GET /api/articles/search?q=ab", "/api/articles/search?q=ab", [200, 503], True),
] ]

View File

@@ -1,9 +1,9 @@
ROA_CENTRAL = ROA_CENTRAL =
(DESCRIPTION = (DESCRIPTION =
(ADDRESS_LIST = (ADDRESS_LIST =
(ADDRESS = (PROTOCOL = TCP)(HOST = 10.0.20.122)(PORT = 1521)) (ADDRESS = (PROTOCOL = tcp)(HOST = 10.0.20.121)(PORT = 1521))
) )
(CONNECT_DATA = (CONNECT_DATA =
(SID = ROA) (SERVICE_NAME = ROA)
) )
) )

27
start.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Start GoMag Import Manager - WSL/Linux
cd "$(dirname "$0")"
# Create venv if it doesn't exist
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python3 -m venv venv
fi
# Activate venv
source venv/bin/activate
# Install/update dependencies if needed
if [ api/requirements.txt -nt venv/.deps_installed ] || [ ! -f venv/.deps_installed ]; then
echo "Installing dependencies..."
pip install -r api/requirements.txt
touch venv/.deps_installed
fi
# Oracle config
export TNS_ADMIN="$(pwd)/api"
export LD_LIBRARY_PATH=/opt/oracle/instantclient_21_15:$LD_LIBRARY_PATH
cd api
echo "Starting GoMag Import Manager on http://0.0.0.0:5003"
python -m uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload