feat(sync): add SSE live feed, unified logs page, fix Oracle connection
- Add SSE event bus in sync_service (subscribe/unsubscribe/_emit) - Add GET /api/sync/stream SSE endpoint for real-time sync progress - Rewrite logs.html: unified runs table + live feed + summary + filters - Rewrite logs.js: SSE EventSource client, run selection, pagination - Dashboard: clickable runs navigate to /logs?run=, sync started banner - Remove "Import Comenzi" nav item, delete sync_detail.html - Add error_message column to sync_runs table with migration - Fix: export TNS_ADMIN as OS env var so oracledb finds tnsnames.ora - Fix: use get_oracle_connection() instead of direct pool.acquire() - Fix: CRM_POLITICI_PRET_ART INSERT to match actual table schema Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,10 @@ def init_oracle():
|
|||||||
instantclient_path = settings.INSTANTCLIENTPATH
|
instantclient_path = settings.INSTANTCLIENTPATH
|
||||||
dsn = settings.ORACLE_DSN
|
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:
|
if force_thin:
|
||||||
logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}")
|
logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}")
|
||||||
elif instantclient_path:
|
elif instantclient_path:
|
||||||
@@ -68,7 +72,8 @@ CREATE TABLE IF NOT EXISTS sync_runs (
|
|||||||
imported INTEGER DEFAULT 0,
|
imported INTEGER DEFAULT 0,
|
||||||
skipped INTEGER DEFAULT 0,
|
skipped INTEGER DEFAULT 0,
|
||||||
errors 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 (
|
CREATE TABLE IF NOT EXISTS import_orders (
|
||||||
@@ -129,6 +134,12 @@ def init_sqlite():
|
|||||||
if col not in cols:
|
if col not in cols:
|
||||||
conn.execute(f"ALTER TABLE missing_skus ADD COLUMN {col} {typedef}")
|
conn.execute(f"ALTER TABLE missing_skus ADD COLUMN {col} {typedef}")
|
||||||
logger.info(f"Migrated missing_skus: added column {col}")
|
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()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Migration check failed: {e}")
|
logger.warning(f"Migration check failed: {e}")
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, BackgroundTasks
|
from fastapi import APIRouter, Request, BackgroundTasks
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
from starlette.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -16,27 +20,46 @@ class ScheduleConfig(BaseModel):
|
|||||||
interval_minutes: int = 5
|
interval_minutes: int = 5
|
||||||
|
|
||||||
|
|
||||||
# HTML pages
|
# SSE streaming endpoint
|
||||||
@router.get("/sync", response_class=HTMLResponse)
|
@router.get("/api/sync/stream")
|
||||||
async def sync_page(request: Request):
|
async def sync_stream(request: Request):
|
||||||
return templates.TemplateResponse("dashboard.html", {"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)
|
return StreamingResponse(
|
||||||
async def sync_detail_page(request: Request, run_id: str):
|
event_generator(),
|
||||||
return templates.TemplateResponse("sync_detail.html", {"request": request, "run_id": run_id})
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
@router.post("/api/sync/start")
|
@router.post("/api/sync/start")
|
||||||
async def start_sync(background_tasks: BackgroundTasks):
|
async def start_sync(background_tasks: BackgroundTasks):
|
||||||
"""Trigger a sync run in the background."""
|
"""Trigger a sync run in the background."""
|
||||||
status = await sync_service.get_sync_status()
|
result = await sync_service.prepare_sync()
|
||||||
if status.get("status") == "running":
|
if result.get("error"):
|
||||||
return {"error": "Sync already running", "run_id": status.get("run_id")}
|
return {"error": result["error"], "run_id": result.get("run_id")}
|
||||||
|
|
||||||
background_tasks.add_task(sync_service.run_sync)
|
run_id = result["run_id"]
|
||||||
return {"message": "Sync started"}
|
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")
|
@router.post("/api/sync/stop")
|
||||||
@@ -61,8 +84,8 @@ async def sync_history(page: int = 1, per_page: int = 20):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/logs", response_class=HTMLResponse)
|
@router.get("/logs", response_class=HTMLResponse)
|
||||||
async def logs_page(request: Request):
|
async def logs_page(request: Request, run: str = None):
|
||||||
return templates.TemplateResponse("logs.html", {"request": request})
|
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/sync/run/{run_id}")
|
@router.get("/api/sync/run/{run_id}")
|
||||||
|
|||||||
@@ -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_number = clean_web_text(order.number)
|
||||||
order_date = convert_web_date(order.date)
|
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 database.pool.acquire() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
# Step 1: Process partner
|
# 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,
|
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."""
|
"""Update sync run with results."""
|
||||||
db = await get_sqlite()
|
db = await get_sqlite()
|
||||||
try:
|
try:
|
||||||
@@ -31,9 +32,10 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
|
|||||||
total_orders = ?,
|
total_orders = ?,
|
||||||
imported = ?,
|
imported = ?,
|
||||||
skipped = ?,
|
skipped = ?,
|
||||||
errors = ?
|
errors = ?,
|
||||||
|
error_message = ?
|
||||||
WHERE run_id = ?
|
WHERE run_id = ?
|
||||||
""", (status, total_orders, imported, skipped, errors, run_id))
|
""", (status, total_orders, imported, skipped, errors, error_message, run_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|||||||
@@ -13,6 +13,33 @@ logger = logging.getLogger(__name__)
|
|||||||
_sync_lock = asyncio.Lock()
|
_sync_lock = asyncio.Lock()
|
||||||
_current_sync = None # dict with run_id, status, progress info
|
_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():
|
async def get_sync_status():
|
||||||
"""Get current sync status."""
|
"""Get current sync status."""
|
||||||
@@ -21,7 +48,25 @@ async def get_sync_status():
|
|||||||
return {"status": "idle"}
|
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."""
|
"""Run a full sync cycle. Returns summary dict."""
|
||||||
global _current_sync
|
global _current_sync
|
||||||
|
|
||||||
@@ -29,6 +74,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
|||||||
return {"error": "Sync already running"}
|
return {"error": "Sync already running"}
|
||||||
|
|
||||||
async with _sync_lock:
|
async with _sync_lock:
|
||||||
|
# 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]
|
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||||
_current_sync = {
|
_current_sync = {
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
@@ -37,22 +84,23 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
|||||||
"progress": "Reading JSON files..."
|
"progress": "Reading JSON files..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_current_sync["progress"] = "Reading JSON files..."
|
||||||
|
await _emit({"type": "phase", "run_id": run_id, "message": "Reading JSON files..."})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Read orders
|
# Step 1: Read orders
|
||||||
orders, json_count = order_reader.read_json_orders()
|
orders, json_count = order_reader.read_json_orders()
|
||||||
await sqlite_service.create_sync_run(run_id, json_count)
|
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:
|
if not orders:
|
||||||
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
|
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
|
||||||
_current_sync = None
|
summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
|
||||||
return {
|
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
|
||||||
"run_id": run_id,
|
return summary
|
||||||
"status": "completed",
|
|
||||||
"message": "No orders found",
|
|
||||||
"json_files": json_count
|
|
||||||
}
|
|
||||||
|
|
||||||
_current_sync["progress"] = f"Validating {len(orders)} orders..."
|
_current_sync["progress"] = f"Validating {len(orders)} orders..."
|
||||||
|
await _emit({"type": "phase", "run_id": run_id, "message": f"Validating {len(orders)} orders..."})
|
||||||
|
|
||||||
# Step 2a: Find new orders (not yet in Oracle)
|
# Step 2a: Find new orders (not yet in Oracle)
|
||||||
all_order_numbers = [o.number for o in orders]
|
all_order_numbers = [o.number for o in orders]
|
||||||
@@ -65,6 +113,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
|||||||
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
|
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
|
||||||
importable, skipped = validation_service.classify_orders(orders, validation)
|
importable, skipped = validation_service.classify_orders(orders, validation)
|
||||||
|
|
||||||
|
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
|
# Step 2c: Build SKU context from skipped orders
|
||||||
sku_context = {} # {sku: {"orders": [], "customers": []}}
|
sku_context = {} # {sku: {"orders": [], "customers": []}}
|
||||||
for order, missing_skus_list in skipped:
|
for order, missing_skus_list in skipped:
|
||||||
@@ -100,6 +150,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
|||||||
id_pol = id_pol or settings.ID_POL
|
id_pol = id_pol or settings.ID_POL
|
||||||
if id_pol and importable:
|
if id_pol and importable:
|
||||||
_current_sync["progress"] = "Validating prices..."
|
_current_sync["progress"] = "Validating prices..."
|
||||||
|
await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."})
|
||||||
# Gather all CODMATs from importable orders
|
# Gather all CODMATs from importable orders
|
||||||
all_codmats = set()
|
all_codmats = set()
|
||||||
for order in importable:
|
for order in importable:
|
||||||
@@ -124,7 +175,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
|||||||
price_result["missing_price"], id_pol
|
price_result["missing_price"], id_pol
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: Record skipped orders
|
# Step 3: Record skipped orders + emit events
|
||||||
for order, missing_skus in skipped:
|
for order, missing_skus in skipped:
|
||||||
customer = order.billing.company_name or \
|
customer = order.billing.company_name or \
|
||||||
f"{order.billing.firstname} {order.billing.lastname}"
|
f"{order.billing.firstname} {order.billing.lastname}"
|
||||||
@@ -137,13 +188,20 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
|||||||
missing_skus=missing_skus,
|
missing_skus=missing_skus,
|
||||||
items_count=len(order.items)
|
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
|
# Step 4: Import valid orders
|
||||||
imported_count = 0
|
imported_count = 0
|
||||||
error_count = 0
|
error_count = 0
|
||||||
|
|
||||||
for i, order in enumerate(importable):
|
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(
|
result = await asyncio.to_thread(
|
||||||
import_service.import_single_order,
|
import_service.import_single_order,
|
||||||
@@ -164,6 +222,12 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
|||||||
id_partener=result["id_partener"],
|
id_partener=result["id_partener"],
|
||||||
items_count=len(order.items)
|
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:
|
else:
|
||||||
error_count += 1
|
error_count += 1
|
||||||
await sqlite_service.add_import_order(
|
await sqlite_service.add_import_order(
|
||||||
@@ -176,6 +240,12 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
|||||||
error_message=result["error"],
|
error_message=result["error"],
|
||||||
items_count=len(order.items)
|
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
|
# Safety: stop if too many errors
|
||||||
if error_count > 10:
|
if error_count > 10:
|
||||||
@@ -204,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"Sync {run_id} completed: {imported_count} imported, "
|
||||||
f"{len(skipped)} skipped, {error_count} errors"
|
f"{len(skipped)} skipped, {error_count} errors"
|
||||||
)
|
)
|
||||||
|
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Sync {run_id} failed: {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)}
|
return {"run_id": run_id, "status": "failed", "error": str(e)}
|
||||||
finally:
|
finally:
|
||||||
|
# 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
|
_current_sync = None
|
||||||
|
asyncio.ensure_future(_clear_current_sync())
|
||||||
|
|
||||||
|
|
||||||
def stop_sync():
|
def stop_sync():
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ def validate_skus(skus: set[str]) -> dict:
|
|||||||
direct = set()
|
direct = set()
|
||||||
sku_list = list(skus)
|
sku_list = list(skus)
|
||||||
|
|
||||||
with database.pool.acquire() as conn:
|
conn = database.get_oracle_connection()
|
||||||
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
# Check in batches of 500
|
# Check in batches of 500
|
||||||
for i in range(0, len(sku_list), 500):
|
for i in range(0, len(sku_list), 500):
|
||||||
@@ -44,6 +45,8 @@ def validate_skus(skus: set[str]) -> dict:
|
|||||||
""", params2)
|
""", params2)
|
||||||
for row in cur:
|
for row in cur:
|
||||||
direct.add(row[0])
|
direct.add(row[0])
|
||||||
|
finally:
|
||||||
|
database.pool.release(conn)
|
||||||
|
|
||||||
missing = skus - mapped - direct
|
missing = skus - mapped - direct
|
||||||
|
|
||||||
@@ -80,7 +83,8 @@ def find_new_orders(order_numbers: list[str]) -> set[str]:
|
|||||||
existing = set()
|
existing = set()
|
||||||
num_list = list(order_numbers)
|
num_list = list(order_numbers)
|
||||||
|
|
||||||
with database.pool.acquire() as conn:
|
conn = database.get_oracle_connection()
|
||||||
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
for i in range(0, len(num_list), 500):
|
for i in range(0, len(num_list), 500):
|
||||||
batch = num_list[i:i+500]
|
batch = num_list[i:i+500]
|
||||||
@@ -93,6 +97,8 @@ def find_new_orders(order_numbers: list[str]) -> set[str]:
|
|||||||
""", params)
|
""", params)
|
||||||
for row in cur:
|
for row in cur:
|
||||||
existing.add(row[0])
|
existing.add(row[0])
|
||||||
|
finally:
|
||||||
|
database.pool.release(conn)
|
||||||
|
|
||||||
new_orders = set(order_numbers) - existing
|
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")
|
logger.info(f"Order check: {len(new_orders)} new, {len(existing)} already exist out of {len(order_numbers)} total")
|
||||||
@@ -109,7 +115,8 @@ def validate_prices(codmats: set[str], id_pol: int) -> dict:
|
|||||||
ids_with_price = set()
|
ids_with_price = set()
|
||||||
codmat_list = list(codmats)
|
codmat_list = list(codmats)
|
||||||
|
|
||||||
with database.pool.acquire() as conn:
|
conn = database.get_oracle_connection()
|
||||||
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
# Step 1: Get ID_ARTICOL for each CODMAT
|
# Step 1: Get ID_ARTICOL for each CODMAT
|
||||||
for i in range(0, len(codmat_list), 500):
|
for i in range(0, len(codmat_list), 500):
|
||||||
@@ -138,6 +145,8 @@ def validate_prices(codmats: set[str], id_pol: int) -> dict:
|
|||||||
""", params)
|
""", params)
|
||||||
for row in cur:
|
for row in cur:
|
||||||
ids_with_price.add(row[0])
|
ids_with_price.add(row[0])
|
||||||
|
finally:
|
||||||
|
database.pool.release(conn)
|
||||||
|
|
||||||
# Map back to CODMATs
|
# Map back to CODMATs
|
||||||
has_price = {cm for cm, aid in codmat_to_id.items() if aid in ids_with_price}
|
has_price = {cm for cm, aid in codmat_to_id.items() if aid in ids_with_price}
|
||||||
@@ -151,7 +160,8 @@ def ensure_prices(codmats: set[str], id_pol: int):
|
|||||||
if not codmats:
|
if not codmats:
|
||||||
return
|
return
|
||||||
|
|
||||||
with database.pool.acquire() as conn:
|
conn = database.get_oracle_connection()
|
||||||
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
# Get ID_VALUTA for this policy
|
# Get ID_VALUTA for this policy
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -176,16 +186,18 @@ def ensure_prices(codmats: set[str], id_pol: int):
|
|||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO CRM_POLITICI_PRET_ART
|
INSERT INTO CRM_POLITICI_PRET_ART
|
||||||
(ID_POL_ART, ID_POL, ID_ARTICOL, PRET, ID_COMANDA, ID_VALUTA,
|
(ID_POL_ART, ID_POL, ID_ARTICOL, PRET, ID_VALUTA,
|
||||||
ID_UTIL, DATAORA, PROC_TVAV, ID_PARTR, ID_PARTZ,
|
ID_UTIL, DATAORA, PROC_TVAV,
|
||||||
PRETFTVA, PRETCTVA, CANTITATE, ID_UM, PRET_MIN, PRET_MIN_TVA)
|
PRETFTVA, PRETCTVA)
|
||||||
VALUES
|
VALUES
|
||||||
(SEQ_CRM_POLITICI_PRET_ART.NEXTVAL, :id_pol, :id_articol, 0, NULL, :id_valuta,
|
(SEQ_CRM_POLITICI_PRET_ART.NEXTVAL, :id_pol, :id_articol, 0, :id_valuta,
|
||||||
-3, SYSDATE, 1.19, NULL, NULL,
|
-3, SYSDATE, 1.19,
|
||||||
0, 0, 0, NULL, 0, 0)
|
0, 0)
|
||||||
""", {"id_pol": id_pol, "id_articol": id_articol, "id_valuta": id_valuta})
|
""", {"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}")
|
logger.info(f"Pret 0 adaugat pentru CODMAT {codmat} in politica {id_pol}")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
database.pool.release(conn)
|
||||||
|
|
||||||
logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}")
|
logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}")
|
||||||
|
|||||||
@@ -212,3 +212,73 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ async function loadSyncHistory() {
|
|||||||
}
|
}
|
||||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
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>${started}</td>
|
||||||
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
|
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
|
||||||
<td>${r.total_orders || 0}</td>
|
<td>${r.total_orders || 0}</td>
|
||||||
@@ -192,6 +192,16 @@ async function startSync() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
alert(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();
|
loadDashboard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
// logs.js - Jurnale Import page logic
|
// 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) {
|
function esc(s) {
|
||||||
if (s == null) return '';
|
if (s == null) return '';
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
.replace(/</g, '<')
|
.replace(/>/g, '>').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) {
|
function fmtDatetime(iso) {
|
||||||
if (!iso) return '-';
|
if (!iso) return '-';
|
||||||
try {
|
try {
|
||||||
@@ -17,9 +27,7 @@ function fmtDatetime(iso) {
|
|||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit'
|
hour: '2-digit', minute: '2-digit'
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) { return iso; }
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDuration(startedAt, finishedAt) {
|
function fmtDuration(startedAt, finishedAt) {
|
||||||
@@ -41,50 +49,252 @@ function statusBadge(status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runStatusBadge(status) {
|
function runStatusBadge(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toLowerCase()) {
|
||||||
case 'SUCCESS': return '<span class="badge bg-success ms-1">SUCCESS</span>';
|
case 'completed': return '<span class="badge bg-success">completed</span>';
|
||||||
case 'ERROR': return '<span class="badge bg-danger ms-1">ERROR</span>';
|
case 'running': return '<span class="badge bg-primary">running</span>';
|
||||||
case 'RUNNING': return '<span class="badge bg-primary ms-1">RUNNING</span>';
|
case 'failed': return '<span class="badge bg-danger">failed</span>';
|
||||||
case 'PARTIAL': return '<span class="badge bg-warning text-dark ms-1">PARTIAL</span>';
|
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||||
default: return `<span class="badge bg-secondary ms-1">${esc(status || '')}</span>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRuns() {
|
// ── Runs Table ──────────────────────────────────
|
||||||
const sel = document.getElementById('runSelector');
|
|
||||||
sel.innerHTML = '<option value="">Se incarca...</option>';
|
async function loadRuns(page) {
|
||||||
|
if (page != null) runsPage = page;
|
||||||
|
const perPage = 20;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sync/history?per_page=20');
|
const res = await fetch(`/api/sync/history?page=${runsPage}&per_page=${perPage}`);
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const runs = data.runs || [];
|
const runs = data.runs || [];
|
||||||
if (runs.length === 0) {
|
const total = data.total || runs.length;
|
||||||
sel.innerHTML = '<option value="">Nu exista sync runs</option>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Populate dropdown
|
||||||
|
const sel = document.getElementById('runSelector');
|
||||||
sel.innerHTML = '<option value="">-- Selecteaza un sync run --</option>' +
|
sel.innerHTML = '<option value="">-- Selecteaza un sync run --</option>' +
|
||||||
runs.map(r => {
|
runs.map(r => {
|
||||||
const date = fmtDatetime(r.started_at);
|
const date = fmtDatetime(r.started_at);
|
||||||
const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`;
|
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)}"${r.run_id === currentRunId ? ' selected' : ''}>[${(r.status||'').toUpperCase()}] ${date} — ${stats}</option>`;
|
||||||
return `<option value="${esc(r.run_id)}">[${statusText}] ${date} — ${stats}</option>`;
|
|
||||||
}).join('');
|
}).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) {
|
} catch (err) {
|
||||||
sel.innerHTML = '<option value="">Eroare la incarcare: ' + esc(err.message) + '</option>';
|
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) {
|
async function loadRunLog(runId) {
|
||||||
const tbody = document.getElementById('logsBody');
|
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>';
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`);
|
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`);
|
||||||
@@ -100,35 +310,28 @@ async function loadRunLog(runId) {
|
|||||||
document.getElementById('sum-skipped').textContent = run.skipped ?? '-';
|
document.getElementById('sum-skipped').textContent = run.skipped ?? '-';
|
||||||
document.getElementById('sum-errors').textContent = run.errors ?? '-';
|
document.getElementById('sum-errors').textContent = run.errors ?? '-';
|
||||||
document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at);
|
document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at);
|
||||||
runSummary.style.display = '';
|
|
||||||
|
|
||||||
if (orders.length === 0) {
|
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>';
|
const runError = run.error_message
|
||||||
filterRow.style.display = 'none';
|
? `<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();
|
updateFilterCount();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = orders.map(order => {
|
tbody.innerHTML = orders.map(order => {
|
||||||
const status = (order.status || '').toUpperCase();
|
const status = (order.status || '').toUpperCase();
|
||||||
|
|
||||||
// Parse missing_skus — API returns JSON string or null
|
|
||||||
let missingSkuTags = '';
|
let missingSkuTags = '';
|
||||||
if (order.missing_skus) {
|
if (order.missing_skus) {
|
||||||
try {
|
try {
|
||||||
const skus = typeof order.missing_skus === 'string'
|
const skus = typeof order.missing_skus === 'string' ? JSON.parse(order.missing_skus) : order.missing_skus;
|
||||||
? JSON.parse(order.missing_skus)
|
|
||||||
: order.missing_skus;
|
|
||||||
if (Array.isArray(skus) && skus.length > 0) {
|
if (Array.isArray(skus) && skus.length > 0) {
|
||||||
missingSkuTags = '<div class="mt-1">' +
|
missingSkuTags = '<div class="mt-1">' +
|
||||||
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') +
|
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') + '</div>';
|
||||||
'</div>';
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) { /* skip */ }
|
||||||
// malformed JSON — skip
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const details = order.error_message
|
const details = order.error_message
|
||||||
? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}`
|
? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}`
|
||||||
: missingSkuTags || '<span class="text-muted">-</span>';
|
: missingSkuTags || '<span class="text-muted">-</span>';
|
||||||
@@ -142,67 +345,45 @@ async function loadRunLog(runId) {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
filterRow.style.display = '';
|
// Reset filter
|
||||||
// Reset filter to "Toate"
|
|
||||||
document.querySelectorAll('[data-filter]').forEach(btn => {
|
document.querySelectorAll('[data-filter]').forEach(btn => {
|
||||||
btn.classList.toggle('active', btn.dataset.filter === 'all');
|
btn.classList.toggle('active', btn.dataset.filter === 'all');
|
||||||
});
|
});
|
||||||
applyFilter('all');
|
applyFilter('all');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3">
|
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>`;
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>${esc(err.message)}
|
|
||||||
</td></tr>`;
|
|
||||||
filterRow.style.display = 'none';
|
|
||||||
runSummary.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Filters ─────────────────────────────────────
|
||||||
|
|
||||||
function applyFilter(filter) {
|
function applyFilter(filter) {
|
||||||
const rows = document.querySelectorAll('#logsBody tr[data-status]');
|
const rows = document.querySelectorAll('#logsBody tr[data-status]');
|
||||||
let visible = 0;
|
let visible = 0;
|
||||||
|
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
const show = filter === 'all' || row.dataset.status === filter;
|
const show = filter === 'all' || row.dataset.status === filter;
|
||||||
row.style.display = show ? '' : 'none';
|
row.style.display = show ? '' : 'none';
|
||||||
if (show) visible++;
|
if (show) visible++;
|
||||||
});
|
});
|
||||||
|
|
||||||
updateFilterCount(visible, rows.length, filter);
|
updateFilterCount(visible, rows.length, filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFilterCount(visible, total, filter) {
|
function updateFilterCount(visible, total, filter) {
|
||||||
const el = document.getElementById('filterCount');
|
const el = document.getElementById('filterCount');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (visible == null) {
|
if (visible == null) { el.textContent = ''; return; }
|
||||||
el.textContent = '';
|
el.textContent = filter === 'all' ? `${total} comenzi` : `${visible} din ${total} comenzi`;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (filter === 'all') {
|
|
||||||
el.textContent = `${total} comenzi`;
|
|
||||||
} else {
|
|
||||||
el.textContent = `${visible} din ${total} comenzi`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Init ────────────────────────────────────────
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadRuns();
|
loadRuns();
|
||||||
|
|
||||||
// Dropdown change
|
// Dropdown change
|
||||||
document.getElementById('runSelector').addEventListener('change', function() {
|
document.getElementById('runSelector').addEventListener('change', function() {
|
||||||
const runId = this.value;
|
selectRun(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
|
// Filter buttons
|
||||||
@@ -213,4 +394,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
applyFilter(this.dataset.filter);
|
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
|
<i class="bi bi-speedometer2"></i> Dashboard
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings">
|
<a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings">
|
||||||
<i class="bi bi-link-45deg"></i> Mapari SKU
|
<i class="bi bi-link-45deg"></i> Mapari SKU
|
||||||
|
|||||||
@@ -105,6 +105,11 @@
|
|||||||
<small class="text-muted" id="syncProgressText"></small>
|
<small class="text-muted" id="syncProgressText"></small>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,27 +15,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter buttons -->
|
<!-- Sync Runs Table (always visible) -->
|
||||||
<div class="mb-3" id="filterRow" style="display:none;">
|
<div class="card mb-4">
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<button type="button" class="btn btn-outline-secondary active" data-filter="all">
|
<span>Sync Runs</span>
|
||||||
<i class="bi bi-list-ul"></i> Toate
|
<div id="runsTablePagination" class="d-flex align-items-center gap-2"></div>
|
||||||
</button>
|
</div>
|
||||||
<button type="button" class="btn btn-outline-success" data-filter="IMPORTED">
|
<div class="card-body p-0">
|
||||||
<i class="bi bi-check-circle"></i> Importate
|
<div class="table-responsive">
|
||||||
</button>
|
<table class="table table-hover mb-0">
|
||||||
<button type="button" class="btn btn-outline-warning" data-filter="SKIPPED">
|
<thead>
|
||||||
<i class="bi bi-skip-forward"></i> Fara Mapare
|
<tr>
|
||||||
</button>
|
<th>Data</th>
|
||||||
<button type="button" class="btn btn-outline-danger" data-filter="ERROR">
|
<th>Status</th>
|
||||||
<i class="bi bi-x-circle"></i> Erori
|
<th>Total</th>
|
||||||
</button>
|
<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>
|
||||||
<small class="text-muted ms-3" id="filterCount"></small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Run summary bar -->
|
<!-- Run Detail Section (shown when run selected or live sync) -->
|
||||||
<div class="row g-3 mb-3" id="runSummary" style="display:none;">
|
<div id="runDetailSection" style="display:none;">
|
||||||
|
|
||||||
|
<!-- Run Summary Bar -->
|
||||||
|
<div class="row g-3 mb-3" id="runSummary">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="card stat-card px-3 py-2">
|
<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-value text-primary" id="sum-total" style="font-size:1.25rem;">-</div>
|
||||||
@@ -68,6 +80,36 @@
|
|||||||
</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 -->
|
<!-- Orders table -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@@ -83,17 +125,15 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="logsBody">
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden field for pre-selected run from URL/server -->
|
||||||
|
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
Reference in New Issue
Block a user