Compare commits
2 Commits
06daf24073
...
650e98539e
| Author | SHA1 | Date | |
|---|---|---|---|
| 650e98539e | |||
| 97699fa0e5 |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -19,6 +19,10 @@ def init_oracle():
|
||||
instantclient_path = settings.INSTANTCLIENTPATH
|
||||
dsn = settings.ORACLE_DSN
|
||||
|
||||
# Ensure TNS_ADMIN is set as OS env var so oracledb can find tnsnames.ora
|
||||
if settings.TNS_ADMIN:
|
||||
os.environ['TNS_ADMIN'] = settings.TNS_ADMIN
|
||||
|
||||
if force_thin:
|
||||
logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}")
|
||||
elif instantclient_path:
|
||||
@@ -68,7 +72,8 @@ CREATE TABLE IF NOT EXISTS sync_runs (
|
||||
imported INTEGER DEFAULT 0,
|
||||
skipped INTEGER DEFAULT 0,
|
||||
errors INTEGER DEFAULT 0,
|
||||
json_files INTEGER DEFAULT 0
|
||||
json_files INTEGER DEFAULT 0,
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_orders (
|
||||
@@ -91,7 +96,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 +123,27 @@ 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}")
|
||||
# Migrate sync_runs: add error_message column
|
||||
cursor = conn.execute("PRAGMA table_info(sync_runs)")
|
||||
sync_cols = {row[1] for row in cursor.fetchall()}
|
||||
if "error_message" not in sync_cols:
|
||||
conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT")
|
||||
logger.info("Migrated sync_runs: added column error_message")
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration check failed: {e}")
|
||||
|
||||
conn.close()
|
||||
logger.info(f"SQLite initialized: {_sqlite_db_path}")
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Request, BackgroundTasks
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -16,27 +20,46 @@ class ScheduleConfig(BaseModel):
|
||||
interval_minutes: int = 5
|
||||
|
||||
|
||||
# HTML pages
|
||||
@router.get("/sync", response_class=HTMLResponse)
|
||||
async def sync_page(request: Request):
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||
# SSE streaming endpoint
|
||||
@router.get("/api/sync/stream")
|
||||
async def sync_stream(request: Request):
|
||||
"""SSE stream for real-time sync progress."""
|
||||
q = sync_service.subscribe()
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while True:
|
||||
# Check if client disconnected
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
event = await asyncio.wait_for(q.get(), timeout=15.0)
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
if event.get("type") in ("completed", "failed"):
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
finally:
|
||||
sync_service.unsubscribe(q)
|
||||
|
||||
@router.get("/sync/run/{run_id}", response_class=HTMLResponse)
|
||||
async def sync_detail_page(request: Request, run_id: str):
|
||||
return templates.TemplateResponse("sync_detail.html", {"request": request, "run_id": run_id})
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
)
|
||||
|
||||
|
||||
# API endpoints
|
||||
@router.post("/api/sync/start")
|
||||
async def start_sync(background_tasks: BackgroundTasks):
|
||||
"""Trigger a sync run in the background."""
|
||||
status = await sync_service.get_sync_status()
|
||||
if status.get("status") == "running":
|
||||
return {"error": "Sync already running", "run_id": status.get("run_id")}
|
||||
result = await sync_service.prepare_sync()
|
||||
if result.get("error"):
|
||||
return {"error": result["error"], "run_id": result.get("run_id")}
|
||||
|
||||
background_tasks.add_task(sync_service.run_sync)
|
||||
return {"message": "Sync started"}
|
||||
run_id = result["run_id"]
|
||||
background_tasks.add_task(sync_service.run_sync, run_id=run_id)
|
||||
return {"message": "Sync started", "run_id": run_id}
|
||||
|
||||
|
||||
@router.post("/api/sync/stop")
|
||||
@@ -60,6 +83,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, run: str = None):
|
||||
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
|
||||
|
||||
|
||||
@router.get("/api/sync/run/{run_id}")
|
||||
async def sync_run_detail(run_id: str):
|
||||
"""Get details for a specific sync run."""
|
||||
@@ -69,6 +97,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."""
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -91,6 +91,8 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
||||
order_number = clean_web_text(order.number)
|
||||
order_date = convert_web_date(order.date)
|
||||
|
||||
if database.pool is None:
|
||||
raise RuntimeError("Oracle pool not initialized")
|
||||
with database.pool.acquire() as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Step 1: Process partner
|
||||
|
||||
@@ -20,7 +20,8 @@ async def create_sync_run(run_id: str, json_files: int = 0):
|
||||
|
||||
|
||||
async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
|
||||
imported: int = 0, skipped: int = 0, errors: int = 0):
|
||||
imported: int = 0, skipped: int = 0, errors: int = 0,
|
||||
error_message: str = None):
|
||||
"""Update sync run with results."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
@@ -31,9 +32,10 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
|
||||
total_orders = ?,
|
||||
imported = ?,
|
||||
skipped = ?,
|
||||
errors = ?
|
||||
errors = ?,
|
||||
error_message = ?
|
||||
WHERE run_id = ?
|
||||
""", (status, total_orders, imported, skipped, errors, run_id))
|
||||
""", (status, total_orders, imported, skipped, errors, error_message, run_id))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
@@ -59,14 +61,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 +98,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 +210,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 +232,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:
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -11,6 +13,33 @@ logger = logging.getLogger(__name__)
|
||||
_sync_lock = asyncio.Lock()
|
||||
_current_sync = None # dict with run_id, status, progress info
|
||||
|
||||
# SSE subscriber system
|
||||
_subscribers: list[asyncio.Queue] = []
|
||||
|
||||
|
||||
def subscribe() -> asyncio.Queue:
|
||||
"""Subscribe to sync events. Returns a queue that will receive event dicts."""
|
||||
q = asyncio.Queue()
|
||||
_subscribers.append(q)
|
||||
return q
|
||||
|
||||
|
||||
def unsubscribe(q: asyncio.Queue):
|
||||
"""Unsubscribe from sync events."""
|
||||
try:
|
||||
_subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
async def _emit(event: dict):
|
||||
"""Push an event to all subscriber queues."""
|
||||
for q in _subscribers:
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
async def get_sync_status():
|
||||
"""Get current sync status."""
|
||||
@@ -19,7 +48,25 @@ async def get_sync_status():
|
||||
return {"status": "idle"}
|
||||
|
||||
|
||||
async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
||||
async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
||||
"""Prepare a sync run - creates run_id and sets initial state.
|
||||
Returns {"run_id": ..., "status": "starting"} or {"error": ...} if already running.
|
||||
"""
|
||||
global _current_sync
|
||||
if _sync_lock.locked():
|
||||
return {"error": "Sync already running", "run_id": _current_sync.get("run_id") if _current_sync else None}
|
||||
|
||||
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
_current_sync = {
|
||||
"run_id": run_id,
|
||||
"status": "running",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"progress": "Starting..."
|
||||
}
|
||||
return {"run_id": run_id, "status": "starting"}
|
||||
|
||||
|
||||
async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None) -> dict:
|
||||
"""Run a full sync cycle. Returns summary dict."""
|
||||
global _current_sync
|
||||
|
||||
@@ -27,37 +74,61 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
||||
return {"error": "Sync already running"}
|
||||
|
||||
async with _sync_lock:
|
||||
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
_current_sync = {
|
||||
"run_id": run_id,
|
||||
"status": "running",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"progress": "Reading JSON files..."
|
||||
}
|
||||
# Use provided run_id or generate one
|
||||
if not run_id:
|
||||
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
_current_sync = {
|
||||
"run_id": run_id,
|
||||
"status": "running",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"progress": "Reading JSON files..."
|
||||
}
|
||||
|
||||
_current_sync["progress"] = "Reading JSON files..."
|
||||
await _emit({"type": "phase", "run_id": run_id, "message": "Reading JSON files..."})
|
||||
|
||||
try:
|
||||
# Step 1: Read orders
|
||||
orders, json_count = order_reader.read_json_orders()
|
||||
await sqlite_service.create_sync_run(run_id, json_count)
|
||||
await _emit({"type": "phase", "run_id": run_id, "message": f"Found {len(orders)} orders in {json_count} files"})
|
||||
|
||||
if not orders:
|
||||
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
|
||||
_current_sync = None
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"status": "completed",
|
||||
"message": "No orders found",
|
||||
"json_files": json_count
|
||||
}
|
||||
summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
|
||||
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
|
||||
return summary
|
||||
|
||||
_current_sync["progress"] = f"Validating {len(orders)} orders..."
|
||||
await _emit({"type": "phase", "run_id": run_id, "message": 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
|
||||
await _emit({"type": "phase", "run_id": run_id, "message": f"{len(importable)} importable, {len(skipped)} skipped (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,9 +138,44 @@ 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 3: Record skipped orders
|
||||
# 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..."
|
||||
await _emit({"type": "phase", "run_id": run_id, "message": "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 + emit events
|
||||
for order, missing_skus in skipped:
|
||||
customer = order.billing.company_name or \
|
||||
f"{order.billing.firstname} {order.billing.lastname}"
|
||||
@@ -82,13 +188,20 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
||||
missing_skus=missing_skus,
|
||||
items_count=len(order.items)
|
||||
)
|
||||
await _emit({
|
||||
"type": "order_result", "run_id": run_id,
|
||||
"order_number": order.number, "customer_name": customer,
|
||||
"status": "SKIPPED", "missing_skus": missing_skus,
|
||||
"items_count": len(order.items), "progress": f"0/{len(importable)}"
|
||||
})
|
||||
|
||||
# Step 4: Import valid orders
|
||||
imported_count = 0
|
||||
error_count = 0
|
||||
|
||||
for i, order in enumerate(importable):
|
||||
_current_sync["progress"] = f"Importing {i+1}/{len(importable)}: #{order.number}"
|
||||
progress_str = f"{i+1}/{len(importable)}"
|
||||
_current_sync["progress"] = f"Importing {progress_str}: #{order.number}"
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
import_service.import_single_order,
|
||||
@@ -109,6 +222,12 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
||||
id_partener=result["id_partener"],
|
||||
items_count=len(order.items)
|
||||
)
|
||||
await _emit({
|
||||
"type": "order_result", "run_id": run_id,
|
||||
"order_number": order.number, "customer_name": customer,
|
||||
"status": "IMPORTED", "items_count": len(order.items),
|
||||
"id_comanda": result["id_comanda"], "progress": progress_str
|
||||
})
|
||||
else:
|
||||
error_count += 1
|
||||
await sqlite_service.add_import_order(
|
||||
@@ -121,6 +240,12 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
||||
error_message=result["error"],
|
||||
items_count=len(order.items)
|
||||
)
|
||||
await _emit({
|
||||
"type": "order_result", "run_id": run_id,
|
||||
"order_number": order.number, "customer_name": customer,
|
||||
"status": "ERROR", "error_message": result["error"],
|
||||
"items_count": len(order.items), "progress": progress_str
|
||||
})
|
||||
|
||||
# Safety: stop if too many errors
|
||||
if error_count > 10:
|
||||
@@ -138,6 +263,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,
|
||||
@@ -148,14 +274,22 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
||||
f"Sync {run_id} completed: {imported_count} imported, "
|
||||
f"{len(skipped)} skipped, {error_count} errors"
|
||||
)
|
||||
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sync {run_id} failed: {e}")
|
||||
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1)
|
||||
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e))
|
||||
_current_sync["error"] = str(e)
|
||||
await _emit({"type": "failed", "run_id": run_id, "error": str(e)})
|
||||
return {"run_id": run_id, "status": "failed", "error": str(e)}
|
||||
finally:
|
||||
_current_sync = None
|
||||
# Keep _current_sync for 10 seconds so status endpoint can show final result
|
||||
async def _clear_current_sync():
|
||||
await asyncio.sleep(10)
|
||||
global _current_sync
|
||||
_current_sync = None
|
||||
asyncio.ensure_future(_clear_current_sync())
|
||||
|
||||
|
||||
def stop_sync():
|
||||
|
||||
@@ -17,7 +17,8 @@ def validate_skus(skus: set[str]) -> dict:
|
||||
direct = set()
|
||||
sku_list = list(skus)
|
||||
|
||||
with database.pool.acquire() as conn:
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Check in batches of 500
|
||||
for i in range(0, len(sku_list), 500):
|
||||
@@ -44,6 +45,8 @@ def validate_skus(skus: set[str]) -> dict:
|
||||
""", params2)
|
||||
for row in cur:
|
||||
direct.add(row[0])
|
||||
finally:
|
||||
database.pool.release(conn)
|
||||
|
||||
missing = skus - mapped - direct
|
||||
|
||||
@@ -69,3 +72,132 @@ 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)
|
||||
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
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])
|
||||
finally:
|
||||
database.pool.release(conn)
|
||||
|
||||
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)
|
||||
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
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])
|
||||
finally:
|
||||
database.pool.release(conn)
|
||||
|
||||
# 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
|
||||
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
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_VALUTA,
|
||||
ID_UTIL, DATAORA, PROC_TVAV,
|
||||
PRETFTVA, PRETCTVA)
|
||||
VALUES
|
||||
(SEQ_CRM_POLITICI_PRET_ART.NEXTVAL, :id_pol, :id_articol, 0, :id_valuta,
|
||||
-3, SYSDATE, 1.19,
|
||||
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()
|
||||
finally:
|
||||
database.pool.release(conn)
|
||||
|
||||
logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}")
|
||||
|
||||
@@ -212,3 +212,73 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Live Feed */
|
||||
.live-feed {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 0.8125rem;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.feed-entry {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.feed-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.feed-entry.phase {
|
||||
background-color: #eff6ff;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.feed-entry.error {
|
||||
background-color: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.feed-entry.success {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.feed-entry .feed-time {
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.feed-entry .feed-icon {
|
||||
min-width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feed-entry .feed-msg {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Live pulse animation */
|
||||
.live-pulse {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Clickable table rows */
|
||||
.table-hover tbody tr[data-href] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-hover tbody tr[data-href]:hover {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
@@ -76,7 +114,7 @@ async function loadSyncHistory() {
|
||||
}
|
||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
||||
|
||||
return `<tr style="cursor:pointer" onclick="window.location='/sync/run/${esc(r.run_id)}'">
|
||||
return `<tr style="cursor:pointer" onclick="window.location='/logs?run=${esc(r.run_id)}'">
|
||||
<td>${started}</td>
|
||||
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
|
||||
<td>${r.total_orders || 0}</td>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -144,6 +192,16 @@ async function startSync() {
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
// Show banner with link to live logs
|
||||
if (data.run_id) {
|
||||
const banner = document.getElementById('syncStartedBanner');
|
||||
const link = document.getElementById('syncRunLink');
|
||||
if (banner && link) {
|
||||
link.href = '/logs?run=' + encodeURIComponent(data.run_id);
|
||||
banner.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
@@ -169,11 +227,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 +279,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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
405
api/app/static/js/logs.js
Normal file
405
api/app/static/js/logs.js
Normal file
@@ -0,0 +1,405 @@
|
||||
// logs.js - Unified Logs page with SSE live feed
|
||||
|
||||
let currentRunId = null;
|
||||
let eventSource = null;
|
||||
let runsPage = 1;
|
||||
let liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
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 || '').toLowerCase()) {
|
||||
case 'completed': return '<span class="badge bg-success">completed</span>';
|
||||
case 'running': return '<span class="badge bg-primary">running</span>';
|
||||
case 'failed': return '<span class="badge bg-danger">failed</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Runs Table ──────────────────────────────────
|
||||
|
||||
async function loadRuns(page) {
|
||||
if (page != null) runsPage = page;
|
||||
const perPage = 20;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sync/history?page=${runsPage}&per_page=${perPage}`);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
const runs = data.runs || [];
|
||||
const total = data.total || runs.length;
|
||||
|
||||
// Populate dropdown
|
||||
const sel = document.getElementById('runSelector');
|
||||
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`;
|
||||
return `<option value="${esc(r.run_id)}"${r.run_id === currentRunId ? ' selected' : ''}>[${(r.status||'').toUpperCase()}] ${date} — ${stats}</option>`;
|
||||
}).join('');
|
||||
|
||||
// Populate table
|
||||
const tbody = document.getElementById('runsTableBody');
|
||||
if (runs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = runs.map(r => {
|
||||
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
|
||||
const duration = fmtDuration(r.started_at, r.finished_at);
|
||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
||||
const activeClass = r.run_id === currentRunId ? 'table-active' : '';
|
||||
return `<tr class="${activeClass}" data-href="/logs?run=${esc(r.run_id)}" onclick="selectRun('${esc(r.run_id)}')">
|
||||
<td>${started}</td>
|
||||
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
|
||||
<td>${r.total_orders || 0}</td>
|
||||
<td class="text-success">${r.imported || 0}</td>
|
||||
<td class="text-warning">${r.skipped || 0}</td>
|
||||
<td class="text-danger">${r.errors || 0}</td>
|
||||
<td>${duration}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const pagDiv = document.getElementById('runsTablePagination');
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
if (totalPages > 1) {
|
||||
pagDiv.innerHTML = `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${runsPage <= 1 ? 'disabled' : ''} onclick="loadRuns(${runsPage - 1})"><i class="bi bi-chevron-left"></i></button>
|
||||
<small class="text-muted">${runsPage} / ${totalPages}</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" ${runsPage >= totalPages ? 'disabled' : ''} onclick="loadRuns(${runsPage + 1})"><i class="bi bi-chevron-right"></i></button>
|
||||
`;
|
||||
} else {
|
||||
pagDiv.innerHTML = '';
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('runsTableBody').innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Run Selection ───────────────────────────────
|
||||
|
||||
async function selectRun(runId) {
|
||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||
currentRunId = runId;
|
||||
|
||||
// Update URL without reload
|
||||
const url = new URL(window.location);
|
||||
if (runId) { url.searchParams.set('run', runId); } else { url.searchParams.delete('run'); }
|
||||
history.replaceState(null, '', url);
|
||||
|
||||
// Highlight active row in table
|
||||
document.querySelectorAll('#runsTableBody tr').forEach(tr => {
|
||||
tr.classList.toggle('table-active', tr.getAttribute('data-href') === `/logs?run=${runId}`);
|
||||
});
|
||||
|
||||
// Update dropdown
|
||||
document.getElementById('runSelector').value = runId || '';
|
||||
|
||||
if (!runId) {
|
||||
document.getElementById('runDetailSection').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('runDetailSection').style.display = '';
|
||||
|
||||
// Check if this run is currently active
|
||||
try {
|
||||
const statusRes = await fetch('/api/sync/status');
|
||||
const statusData = await statusRes.json();
|
||||
if (statusData.status === 'running' && statusData.run_id === runId) {
|
||||
startLiveFeed(runId);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* fall through to historical load */ }
|
||||
|
||||
// Load historical data
|
||||
document.getElementById('liveFeedCard').style.display = 'none';
|
||||
await loadRunLog(runId);
|
||||
}
|
||||
|
||||
// ── Live SSE Feed ───────────────────────────────
|
||||
|
||||
function startLiveFeed(runId) {
|
||||
liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
|
||||
|
||||
// Show live feed card, clear it
|
||||
const feedCard = document.getElementById('liveFeedCard');
|
||||
feedCard.style.display = '';
|
||||
document.getElementById('liveFeed').innerHTML = '';
|
||||
document.getElementById('logsBody').innerHTML = '';
|
||||
|
||||
// Reset summary
|
||||
document.getElementById('sum-total').textContent = '-';
|
||||
document.getElementById('sum-imported').textContent = '0';
|
||||
document.getElementById('sum-skipped').textContent = '0';
|
||||
document.getElementById('sum-errors').textContent = '0';
|
||||
document.getElementById('sum-duration').textContent = 'live...';
|
||||
|
||||
connectSSE();
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) eventSource.close();
|
||||
eventSource = new EventSource('/api/sync/stream');
|
||||
|
||||
eventSource.onmessage = function(e) {
|
||||
let event;
|
||||
try { event = JSON.parse(e.data); } catch (err) { return; }
|
||||
|
||||
if (event.type === 'keepalive') return;
|
||||
|
||||
if (event.type === 'phase') {
|
||||
appendFeedEntry('phase', event.message);
|
||||
}
|
||||
else if (event.type === 'order_result') {
|
||||
const icon = event.status === 'IMPORTED' ? '✅' : event.status === 'SKIPPED' ? '⏭️' : '❌';
|
||||
const progressText = event.progress ? `[${event.progress}]` : '';
|
||||
appendFeedEntry(
|
||||
event.status === 'ERROR' ? 'error' : event.status === 'IMPORTED' ? 'success' : '',
|
||||
`${progressText} #${event.order_number} ${event.customer_name || ''} → ${icon} ${event.status}${event.error_message ? ' — ' + event.error_message : ''}`
|
||||
);
|
||||
addOrderRow(event);
|
||||
updateLiveSummary(event);
|
||||
}
|
||||
else if (event.type === 'completed') {
|
||||
appendFeedEntry('phase', '🏁 Sync completed');
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
document.querySelector('.live-pulse')?.remove();
|
||||
// Reload full data from REST after short delay
|
||||
setTimeout(() => {
|
||||
loadRunLog(currentRunId);
|
||||
loadRuns();
|
||||
}, 500);
|
||||
}
|
||||
else if (event.type === 'failed') {
|
||||
appendFeedEntry('error', '💥 Sync failed: ' + (event.error || 'Unknown error'));
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
document.querySelector('.live-pulse')?.remove();
|
||||
setTimeout(() => {
|
||||
loadRunLog(currentRunId);
|
||||
loadRuns();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
// SSE disconnected — try to load historical data
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
setTimeout(() => loadRunLog(currentRunId), 1000);
|
||||
};
|
||||
}
|
||||
|
||||
function appendFeedEntry(type, message) {
|
||||
const feed = document.getElementById('liveFeed');
|
||||
const now = new Date().toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const typeClass = type ? ` ${type}` : '';
|
||||
const iconMap = { phase: 'ℹ️', error: '❌', success: '✅' };
|
||||
const icon = iconMap[type] || '▶';
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `feed-entry${typeClass}`;
|
||||
entry.innerHTML = `<span class="feed-time">${now}</span><span class="feed-icon">${icon}</span><span class="feed-msg">${esc(message)}</span>`;
|
||||
feed.appendChild(entry);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
function addOrderRow(event) {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
const status = (event.status || '').toUpperCase();
|
||||
|
||||
let details = '';
|
||||
if (event.error_message) {
|
||||
details = `<span class="text-danger">${esc(event.error_message)}</span>`;
|
||||
}
|
||||
if (event.missing_skus && Array.isArray(event.missing_skus) && event.missing_skus.length > 0) {
|
||||
details += `<div class="mt-1">${event.missing_skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('')}</div>`;
|
||||
}
|
||||
if (event.id_comanda) {
|
||||
details += `<small class="text-success">ID: ${event.id_comanda}</small>`;
|
||||
}
|
||||
if (!details) details = '<span class="text-muted">-</span>';
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-status', status);
|
||||
tr.innerHTML = `
|
||||
<td><code>${esc(event.order_number || '-')}</code></td>
|
||||
<td>${esc(event.customer_name || '-')}</td>
|
||||
<td class="text-center">${event.items_count ?? '-'}</td>
|
||||
<td>${statusBadge(status)}</td>
|
||||
<td>${details}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function updateLiveSummary(event) {
|
||||
liveCounts.total++;
|
||||
if (event.status === 'IMPORTED') liveCounts.imported++;
|
||||
else if (event.status === 'SKIPPED') liveCounts.skipped++;
|
||||
else if (event.status === 'ERROR') liveCounts.errors++;
|
||||
|
||||
document.getElementById('sum-total').textContent = liveCounts.total;
|
||||
document.getElementById('sum-imported').textContent = liveCounts.imported;
|
||||
document.getElementById('sum-skipped').textContent = liveCounts.skipped;
|
||||
document.getElementById('sum-errors').textContent = liveCounts.errors;
|
||||
}
|
||||
|
||||
// ── Historical Run Log ──────────────────────────
|
||||
|
||||
async function loadRunLog(runId) {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
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>';
|
||||
|
||||
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);
|
||||
|
||||
if (orders.length === 0) {
|
||||
const runError = run.error_message
|
||||
? `<tr><td colspan="5" class="text-center py-4"><span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${esc(run.error_message)}</span></td></tr>`
|
||||
: '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
|
||||
tbody.innerHTML = runError;
|
||||
updateFilterCount();
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = orders.map(order => {
|
||||
const status = (order.status || '').toUpperCase();
|
||||
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) { /* 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('');
|
||||
|
||||
// Reset filter
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filters ─────────────────────────────────────
|
||||
|
||||
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; }
|
||||
el.textContent = filter === 'all' ? `${total} comenzi` : `${visible} din ${total} comenzi`;
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadRuns();
|
||||
|
||||
// Dropdown change
|
||||
document.getElementById('runSelector').addEventListener('change', function() {
|
||||
selectRun(this.value);
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-select run from URL or server
|
||||
const preselected = document.getElementById('preselectedRun');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
|
||||
if (runFromUrl) {
|
||||
selectRun(runFromUrl);
|
||||
}
|
||||
});
|
||||
@@ -20,11 +20,6 @@
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_sync %}{% endblock %}" href="/sync">
|
||||
<i class="bi bi-arrow-repeat"></i> Import Comenzi
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings">
|
||||
<i class="bi bi-link-45deg"></i> Mapari SKU
|
||||
@@ -35,6 +30,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>
|
||||
|
||||
@@ -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">
|
||||
@@ -76,6 +105,11 @@
|
||||
<small class="text-muted" id="syncProgressText"></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 d-none" id="syncStartedBanner">
|
||||
<div class="alert alert-info alert-sm py-1 px-2 mb-0 d-inline-block">
|
||||
<small><i class="bi bi-broadcast"></i> Sync pornit — <a href="#" id="syncRunLink">vezi progresul live</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,8 +125,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 +151,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 %}
|
||||
|
||||
141
api/app/templates/logs.html
Normal file
141
api/app/templates/logs.html
Normal file
@@ -0,0 +1,141 @@
|
||||
{% 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>
|
||||
|
||||
<!-- Sync Runs Table (always visible) -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Sync Runs</span>
|
||||
<div id="runsTablePagination" class="d-flex align-items-center gap-2"></div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<th>OK</th>
|
||||
<th>Fara mapare</th>
|
||||
<th>Erori</th>
|
||||
<th>Durata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="runsTableBody">
|
||||
<tr><td colspan="7" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run Detail Section (shown when run selected or live sync) -->
|
||||
<div id="runDetailSection" style="display:none;">
|
||||
|
||||
<!-- Run Summary Bar -->
|
||||
<div class="row g-3 mb-3" id="runSummary">
|
||||
<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>
|
||||
|
||||
<!-- Live Feed (visible only during active sync) -->
|
||||
<div class="card mb-3" id="liveFeedCard" style="display:none;">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-broadcast"></i> Live Feed
|
||||
<span class="badge bg-danger ms-2 live-pulse">LIVE</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="live-feed" id="liveFeed"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter buttons -->
|
||||
<div class="mb-3" id="filterRow">
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden field for pre-selected run from URL/server -->
|
||||
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/logs.js"></script>
|
||||
{% endblock %}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sync Run - GoMag Import{% endblock %}
|
||||
{% block nav_sync %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="/" class="text-decoration-none text-muted"><i class="bi bi-arrow-left"></i> Dashboard</a>
|
||||
<h4 class="mb-0 mt-1">Sync Run <small class="text-muted" id="runId">{{ run_id }}</small></h4>
|
||||
</div>
|
||||
<span class="badge bg-secondary fs-6" id="runStatusBadge">-</span>
|
||||
</div>
|
||||
|
||||
<!-- Run summary -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value" id="runTotal">-</div>
|
||||
<div class="stat-label">Total Comenzi</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-success" id="runImported">-</div>
|
||||
<div class="stat-label">Imported</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-warning" id="runSkipped">-</div>
|
||||
<div class="stat-label">Skipped</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-danger" id="runErrors">-</div>
|
||||
<div class="stat-label">Errors</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted" id="runTiming"></small>
|
||||
</div>
|
||||
|
||||
<!-- Orders table -->
|
||||
<div class="card">
|
||||
<div class="card-header">Comenzi</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Nr Comanda</th>
|
||||
<th>Data</th>
|
||||
<th>Client</th>
|
||||
<th>Articole</th>
|
||||
<th>Status</th>
|
||||
<th>Detalii</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ordersBody">
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const RUN_ID = '{{ run_id }}';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadRunDetail);
|
||||
|
||||
async function loadRunDetail() {
|
||||
try {
|
||||
const res = await fetch(`/api/sync/run/${RUN_ID}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('ordersBody').innerHTML =
|
||||
`<tr><td colspan="7" class="text-center text-danger">${data.error}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const run = data.run;
|
||||
|
||||
// Update summary
|
||||
document.getElementById('runTotal').textContent = run.total_orders || 0;
|
||||
document.getElementById('runImported').textContent = run.imported || 0;
|
||||
document.getElementById('runSkipped').textContent = run.skipped || 0;
|
||||
document.getElementById('runErrors').textContent = run.errors || 0;
|
||||
|
||||
const badge = document.getElementById('runStatusBadge');
|
||||
badge.textContent = run.status;
|
||||
badge.className = 'badge fs-6 ' + (run.status === 'completed' ? 'bg-success' : run.status === 'running' ? 'bg-primary' : 'bg-danger');
|
||||
|
||||
// Timing
|
||||
if (run.started_at) {
|
||||
let timing = 'Start: ' + new Date(run.started_at).toLocaleString('ro-RO');
|
||||
if (run.finished_at) {
|
||||
const sec = Math.round((new Date(run.finished_at) - new Date(run.started_at)) / 1000);
|
||||
timing += ` | Durata: ${sec < 60 ? sec + 's' : Math.floor(sec/60) + 'm ' + (sec%60) + 's'}`;
|
||||
}
|
||||
document.getElementById('runTiming').textContent = timing;
|
||||
}
|
||||
|
||||
// Orders table
|
||||
const orders = data.orders || [];
|
||||
if (orders.length === 0) {
|
||||
document.getElementById('ordersBody').innerHTML =
|
||||
'<tr><td colspan="7" class="text-center text-muted py-4">Nicio comanda</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('ordersBody').innerHTML = orders.map((o, i) => {
|
||||
const statusClass = o.status === 'IMPORTED' ? 'badge-imported' : o.status === 'SKIPPED' ? 'badge-skipped' : 'badge-error';
|
||||
|
||||
let details = '';
|
||||
if (o.status === 'IMPORTED' && o.id_comanda) {
|
||||
details = `<small class="text-success">ID: ${o.id_comanda}</small>`;
|
||||
} else if (o.status === 'SKIPPED' && o.missing_skus) {
|
||||
try {
|
||||
const skus = JSON.parse(o.missing_skus);
|
||||
details = `<small class="text-warning">SKU lipsa: ${skus.map(s => '<code>' + esc(s) + '</code>').join(', ')}</small>`;
|
||||
} catch(e) {
|
||||
details = `<small class="text-warning">${esc(o.missing_skus)}</small>`;
|
||||
}
|
||||
} else if (o.status === 'ERROR' && o.error_message) {
|
||||
details = `<small class="text-danger">${esc(o.error_message).substring(0, 100)}</small>`;
|
||||
}
|
||||
|
||||
return `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td><strong>${esc(o.order_number)}</strong></td>
|
||||
<td><small>${o.order_date ? o.order_date.substring(0, 10) : '-'}</small></td>
|
||||
<td>${esc(o.customer_name)}</td>
|
||||
<td>${o.items_count || '-'}</td>
|
||||
<td><span class="badge ${statusClass}">${o.status}</span></td>
|
||||
<td>${details}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('ordersBody').innerHTML =
|
||||
`<tr><td colspan="7" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
27
start.sh
Normal 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
|
||||
Reference in New Issue
Block a user