Compare commits

..

2 Commits

Author SHA1 Message Date
650e98539e 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>
2026-03-11 18:08:09 +02:00
97699fa0e5 feat(dashboard): add logs page, pagination, quick mapping modal, price pre-validation
- Add /logs page with per-order sync run details, filters (Toate/Importate/Fara Mapare/Erori)
- Add price pre-validation (validate_prices + ensure_prices) to prevent ORA-20000 on direct articles
- Add find_new_orders() to detect orders not yet in Oracle COMENZI
- Extend missing_skus table with order context (order_count, order_numbers, customers)
- Add server-side pagination on /api/validate/missing-skus and /missing-skus page
- Replace confusing "Skip"/"Err" with "Fara Mapare"/"Erori" terminology
- Add inline mapping modal on dashboard (replaces navigation to /mappings)
- Add 2-row stat cards: orders (Comenzi Noi/Ready/Importate/Fara Mapare/Erori) + articles
- Add ID_POL/ID_GESTIUNE/ID_SECTIE to config.py and .env
- Update .gitignore (venv, *.db, api/api/, logs/)
- 33/33 unit tests pass, E2E verified with Playwright

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:59:08 +02:00
20 changed files with 1529 additions and 293 deletions

13
.gitignore vendored
View File

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

View File

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

View File

@@ -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}")

View File

@@ -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."""

View File

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

View File

@@ -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

View File

@@ -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:

View File

@@ -1,9 +1,11 @@
import asyncio
import json
import logging
import uuid
from datetime import datetime
from . import order_reader, validation_service, import_service, sqlite_service
from ..config import settings
logger = logging.getLogger(__name__)
@@ -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():

View File

@@ -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}")

View File

@@ -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;
}

View File

@@ -1,9 +1,22 @@
let refreshInterval = null;
let currentMapSku = '';
let acTimeout = null;
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
// Auto-refresh every 10 seconds
refreshInterval = setInterval(loadDashboard, 10000);
const input = document.getElementById('mapCodmat');
if (input) {
input.addEventListener('input', () => {
clearTimeout(acTimeout);
acTimeout = setTimeout(() => autocompleteMap(input.value), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => document.getElementById('mapAutocomplete').classList.add('d-none'), 200);
});
}
});
async function loadDashboard() {
@@ -20,11 +33,36 @@ async function loadSyncStatus() {
const res = await fetch('/api/sync/status');
const data = await res.json();
// Update stats
const stats = data.stats || {};
document.getElementById('stat-imported').textContent = stats.imported || 0;
document.getElementById('stat-skipped').textContent = stats.skipped || 0;
document.getElementById('stat-missing').textContent = stats.missing_skus || 0;
// Order-level stat cards from sync status
document.getElementById('stat-imported').textContent = stats.imported != null ? stats.imported : 0;
document.getElementById('stat-skipped').textContent = stats.skipped != null ? stats.skipped : 0;
document.getElementById('stat-errors').textContent = stats.errors != null ? stats.errors : 0;
// Article-level stats from sync status
if (stats.total_tracked_skus != null) {
document.getElementById('stat-total-skus').textContent = stats.total_tracked_skus;
}
if (stats.unresolved_skus != null) {
document.getElementById('stat-missing-skus').textContent = stats.unresolved_skus;
const total = stats.total_tracked_skus || 0;
const unresolved = stats.unresolved_skus || 0;
document.getElementById('stat-mapped-skus').textContent = total - unresolved;
}
// Restore scan-derived stats from sessionStorage (preserved across auto-refresh)
const scanData = getScanData();
if (scanData) {
document.getElementById('stat-new').textContent = scanData.new_orders != null ? scanData.new_orders : (scanData.total_orders || '-');
document.getElementById('stat-ready').textContent = scanData.importable != null ? scanData.importable : '-';
if (scanData.skus) {
document.getElementById('stat-total-skus').textContent = scanData.skus.total_skus || stats.total_tracked_skus || '-';
document.getElementById('stat-missing-skus').textContent = scanData.skus.missing || stats.unresolved_skus || 0;
const mapped = (scanData.skus.total_skus || 0) - (scanData.skus.missing || 0);
document.getElementById('stat-mapped-skus').textContent = mapped >= 0 ? mapped : '-';
}
}
// Update sync status badge
const badge = document.getElementById('syncStatusBadge');
@@ -46,7 +84,7 @@ async function loadSyncStatus() {
const lr = stats.last_run;
const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : '';
document.getElementById('syncProgressText').textContent =
`Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} skip, ${lr.errors || 0} err`;
`Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} fara mapare, ${lr.errors || 0} erori`;
} else {
document.getElementById('syncProgressText').textContent = '';
}
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');

405
api/app/static/js/logs.js Normal file
View 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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);
}
});

View File

@@ -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>

View File

@@ -5,12 +5,12 @@
{% block content %}
<h4 class="mb-4">Dashboard</h4>
<!-- Stat cards row -->
<div class="row g-3 mb-4" id="statsRow">
<!-- Stat cards - Row 1: Comenzi -->
<div class="row g-3 mb-2" id="statsRow">
<div class="col">
<div class="card stat-card">
<div class="stat-value text-secondary" id="stat-pending">-</div>
<div class="stat-label">In Asteptare</div>
<div class="stat-value text-info" id="stat-new">-</div>
<div class="stat-label">Comenzi Noi</div>
</div>
</div>
<div class="col">
@@ -22,28 +22,57 @@
<div class="col">
<div class="card stat-card">
<div class="stat-value text-success" id="stat-imported">-</div>
<div class="stat-label">Imported</div>
<div class="stat-label">Importate</div>
</div>
</div>
<div class="col">
<div class="card stat-card">
<div class="stat-value text-warning" id="stat-skipped">-</div>
<div class="stat-label">Skipped</div>
<div class="stat-label">Fără Mapare</div>
</div>
</div>
<div class="col">
<div class="card stat-card">
<div class="stat-value text-danger" id="stat-missing">-</div>
<div class="stat-label">SKU Lipsa</div>
<div class="stat-value text-danger" id="stat-errors">-</div>
<div class="stat-label">Erori Import</div>
</div>
</div>
</div>
<!-- Stat cards - Row 2: Articole -->
<div class="row g-3 mb-4" id="statsRowArticles">
<div class="col">
<div class="card stat-card">
<div class="stat-value text-secondary" id="stat-total-skus">-</div>
<div class="stat-label">Total SKU Scanate</div>
</div>
</div>
<div class="col">
<div class="card stat-card">
<div class="stat-value text-success" id="stat-mapped-skus">-</div>
<div class="stat-label">Cu Mapare</div>
</div>
</div>
<div class="col">
<div class="card stat-card">
<div class="stat-value text-warning" id="stat-missing-skus">-</div>
<div class="stat-label">Fără Mapare</div>
</div>
</div>
<div class="col d-none d-md-block"></div>
<div class="col d-none d-md-block"></div>
</div>
<!-- Sync Control -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Sync Control</span>
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
<div class="d-flex align-items-center gap-2">
<a href="/logs" class="btn btn-sm btn-outline-info">
<i class="bi bi-journal-text"></i> Jurnale Import
</a>
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
</div>
</div>
<div class="card-body">
<div class="row align-items-center">
@@ -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
View 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 %}

View File

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

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
</script>
{% endblock %}

View File

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

View File

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

27
start.sh Normal file
View File

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