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
*.~pck
.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_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"}
settings = Settings()

View File

@@ -91,7 +91,10 @@ CREATE TABLE IF NOT EXISTS missing_skus (
product_name TEXT,
first_seen TEXT DEFAULT (datetime('now')),
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 (
@@ -115,6 +118,21 @@ def init_sqlite():
# Create tables synchronously
conn = sqlite3.connect(_sqlite_db_path)
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()
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)
@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}")
async def sync_run_detail(run_id: str):
"""Get details for a specific sync run."""
@@ -69,6 +74,30 @@ async def sync_run_detail(run_id: str):
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")
async def update_schedule(config: ScheduleConfig):
"""Update scheduler configuration."""

View File

@@ -1,9 +1,11 @@
import asyncio
import csv
import io
from fastapi import APIRouter
import json
from fastapi import APIRouter, Query
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
router = APIRouter(prefix="/api/validate", tags=["validation"])
@@ -20,27 +22,40 @@ async def scan_and_validate():
result = validation_service.validate_skus(all_skus)
importable, skipped = validation_service.classify_orders(orders, result)
# Track missing SKUs in SQLite
db = await get_sqlite()
try:
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
# 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)
await db.execute("""
INSERT OR IGNORE INTO missing_skus (sku, product_name)
VALUES (?, ?)
""", (sku, product_name))
await db.commit()
finally:
await db.close()
# Build SKU context from skipped orders and track missing SKUs
sku_context = {} # sku -> {order_numbers: [], customers: []}
for order, missing_list in skipped:
customer = order.billing.company_name or f"{order.billing.firstname} {order.billing.lastname}"
for sku in missing_list:
if sku not in sku_context:
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 {
"json_files": json_count,
@@ -48,11 +63,15 @@ async def scan_and_validate():
"total_skus": len(all_skus),
"importable": len(importable),
"skipped": len(skipped),
"new_orders": len(new_orders),
"skus": {
"mapped": len(result["mapped"]),
"direct": len(result["direct"]),
"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": [
{
@@ -66,23 +85,24 @@ async def scan_and_validate():
}
@router.get("/missing-skus")
async def get_missing_skus():
"""Get all tracked missing SKUs."""
async def get_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()
try:
cursor = await db.execute("""
SELECT sku, product_name, first_seen, resolved, resolved_at
FROM missing_skus
ORDER BY resolved ASC, first_seen DESC
""")
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"])
}
cursor = await db.execute(
"SELECT COUNT(*) FROM missing_skus WHERE resolved = 0"
)
unresolved = (await cursor.fetchone())[0]
finally:
await db.close()
result["unresolved"] = unresolved
return result
@router.get("/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()
async def track_missing_sku(sku: str, product_name: str = ""):
"""Track a missing SKU."""
async def track_missing_sku(sku: str, product_name: str = "",
order_count: int = 0, order_numbers: str = None,
customers: str = None):
"""Track a missing SKU with order context."""
db = await get_sqlite()
try:
await db.execute("""
INSERT OR IGNORE INTO missing_skus (sku, product_name)
VALUES (?, ?)
""", (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()
finally:
await db.close()
@@ -85,6 +96,38 @@ async def resolve_missing_sku(sku: str):
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):
"""Get paginated sync run history."""
db = await get_sqlite()
@@ -165,6 +208,17 @@ async def get_dashboard_stats():
)
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
cursor = await db.execute("""
SELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 1
@@ -176,6 +230,8 @@ async def get_dashboard_stats():
"skipped": skipped,
"errors": errors,
"missing_skus": missing,
"total_tracked_skus": total_missing_skus,
"unresolved_skus": unresolved_skus,
"last_run": dict(last_run) if last_run else None
}
finally:

View File

@@ -1,9 +1,11 @@
import asyncio
import json
import logging
import uuid
from datetime import datetime
from . import order_reader, validation_service, import_service, sqlite_service
from ..config import settings
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..."
# 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)
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
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"]:
product_name = ""
for order in orders:
@@ -67,7 +88,41 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
break
if product_name:
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
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,
"json_files": json_count,
"total_orders": len(orders),
"new_orders": len(new_orders),
"imported": imported_count,
"skipped": len(skipped),
"errors": error_count,

View File

@@ -69,3 +69,123 @@ def classify_orders(orders, validation_result):
importable.append(order)
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 currentMapSku = '';
let acTimeout = null;
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
// Auto-refresh every 10 seconds
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() {
@@ -20,11 +33,36 @@ async function loadSyncStatus() {
const res = await fetch('/api/sync/status');
const data = await res.json();
// Update stats
const stats = data.stats || {};
document.getElementById('stat-imported').textContent = stats.imported || 0;
document.getElementById('stat-skipped').textContent = stats.skipped || 0;
document.getElementById('stat-missing').textContent = stats.missing_skus || 0;
// Order-level stat cards from sync status
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
const badge = document.getElementById('syncStatusBadge');
@@ -46,7 +84,7 @@ async function loadSyncStatus() {
const lr = stats.last_run;
const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : '';
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 {
document.getElementById('syncProgressText').textContent = '';
}
@@ -93,32 +131,42 @@ async function loadSyncHistory() {
async function loadMissingSkus() {
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 tbody = document.getElementById('missingSkusBody');
// Update stat card
document.getElementById('stat-missing').textContent = data.unresolved || 0;
// Update article-level stat card (unresolved count)
if (data.total != null) {
document.getElementById('stat-missing-skus').textContent = data.total;
}
const unresolved = (data.missing_skus || []).filter(s => !s.resolved);
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;
}
tbody.innerHTML = unresolved.slice(0, 10).map(s => `
<tr>
tbody.innerHTML = unresolved.slice(0, 10).map(s => {
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>${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>
<a href="/mappings?sku=${encodeURIComponent(s.sku)}" class="btn btn-sm btn-outline-primary" title="Creeaza mapare">
<i class="bi bi-plus-lg"></i>
</a>
<button class="btn btn-sm btn-outline-primary" title="Creeaza mapare"
onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')">
<i class="bi bi-link-45deg"></i>
</button>
</td>
</tr>
`).join('');
</tr>`;
}).join('');
} catch (err) {
console.error('loadMissingSkus error:', err);
}
@@ -169,11 +217,23 @@ async function scanOrders() {
const res = await fetch('/api/validate/scan', { method: 'POST' });
const data = await res.json();
// Update pending/ready stats
document.getElementById('stat-pending').textContent = data.total_orders || 0;
document.getElementById('stat-ready').textContent = data.importable || 0;
// Persist scan results so auto-refresh doesn't overwrite them
saveScanData(data);
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) {
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) {
if (s == null) return '';
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
</a>
</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>
<div class="sidebar-footer">
<small class="text-muted">v1.0</small>

View File

@@ -5,12 +5,12 @@
{% block content %}
<h4 class="mb-4">Dashboard</h4>
<!-- Stat cards row -->
<div class="row g-3 mb-4" id="statsRow">
<!-- Stat cards - Row 1: Comenzi -->
<div class="row g-3 mb-2" id="statsRow">
<div class="col">
<div class="card stat-card">
<div class="stat-value text-secondary" id="stat-pending">-</div>
<div class="stat-label">In Asteptare</div>
<div class="stat-value text-info" id="stat-new">-</div>
<div class="stat-label">Comenzi Noi</div>
</div>
</div>
<div class="col">
@@ -22,28 +22,57 @@
<div class="col">
<div class="card stat-card">
<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 class="col">
<div class="card stat-card">
<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 class="col">
<div class="card stat-card">
<div class="stat-value text-danger" id="stat-missing">-</div>
<div class="stat-label">SKU Lipsa</div>
<div class="stat-value text-danger" id="stat-errors">-</div>
<div class="stat-label">Erori Import</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 -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<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 class="card-body">
<div class="row align-items-center">
@@ -91,8 +120,8 @@
<th>Status</th>
<th>Total</th>
<th>OK</th>
<th>Skip</th>
<th>Err</th>
<th>Fără mapare</th>
<th>Erori</th>
<th>Durata</th>
</tr>
</thead>
@@ -117,17 +146,52 @@
<tr>
<th>SKU</th>
<th>Produs</th>
<th>Data</th>
<th>Actiune</th>
<th>Nr. Comenzi</th>
<th>Primul Client</th>
<th colspan="2">Acțiune</th>
</tr>
</thead>
<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>
</table>
</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 %}
{% 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>
<th>SKU</th>
<th>Produs</th>
<th>Nr. Comenzi</th>
<th>Client</th>
<th>First Seen</th>
<th>Status</th>
<th>Actiune</th>
</tr>
</thead>
<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>
</table>
</div>
@@ -39,6 +41,10 @@
</div>
</div>
<nav id="paginationNav" class="mt-3">
<ul class="pagination justify-content-center" id="paginationControls"></ul>
</nav>
<!-- Map SKU Modal -->
<div class="modal fade" id="mapModal" tabindex="-1">
<div class="modal-dialog">
@@ -78,9 +84,11 @@
<script>
let currentMapSku = '';
let acTimeout = null;
let currentPage = 1;
const perPage = 20;
document.addEventListener('DOMContentLoaded', () => {
loadMissing();
loadMissing(1);
const input = document.getElementById('mapCodmat');
input.addEventListener('input', () => {
@@ -92,18 +100,20 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
async function loadMissing() {
async function loadMissing(page) {
currentPage = page || 1;
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 tbody = document.getElementById('missingBody');
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 || [];
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;
}
@@ -112,31 +122,85 @@ async function loadMissing() {
? '<span class="badge bg-success">Rezolvat</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' : ''}">
<td><code>${esc(s.sku)}</code></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>${statusBadge}</td>
<td>
${!s.resolved ? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}')">
<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>`}
${!s.resolved
? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')">
<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>
</tr>`;
}).join('');
renderPagination(data);
} catch (err) {
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;
document.getElementById('mapSku').textContent = sku;
document.getElementById('mapCodmat').value = '';
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();
}
@@ -193,7 +257,7 @@ async function saveQuickMap() {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissing();
loadMissing(currentPage);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
@@ -205,7 +269,7 @@ async function saveQuickMap() {
async function scanForMissing() {
try {
await fetch('/api/validate/scan', { method: 'POST' });
loadMissing();
loadMissing(1);
} catch (err) {
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/schedule", "/api/sync/schedule", [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),
]

View File

@@ -1,9 +1,9 @@
ROA_CENTRAL =
(DESCRIPTION =
(ADDRESS_LIST =
(ADDRESS = (PROTOCOL = TCP)(HOST = 10.0.20.122)(PORT = 1521))
(ADDRESS = (PROTOCOL = tcp)(HOST = 10.0.20.121)(PORT = 1521))
)
(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