diff --git a/api/app/config.py b/api/app/config.py
index 0323fb6..4ace5c4 100644
--- a/api/app/config.py
+++ b/api/app/config.py
@@ -2,6 +2,9 @@ from pydantic_settings import BaseSettings
from pathlib import Path
import os
+# Resolve .env relative to this file (api/app/config.py → api/.env)
+_env_path = Path(__file__).resolve().parent.parent / ".env"
+
class Settings(BaseSettings):
# Oracle
ORACLE_USER: str = "MARIUSM_AUTO"
@@ -35,6 +38,6 @@ class Settings(BaseSettings):
ID_GESTIUNE: int = 0
ID_SECTIE: int = 0
- model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
+ model_config = {"env_file": str(_env_path), "env_file_encoding": "utf-8", "extra": "ignore"}
settings = Settings()
diff --git a/api/app/database.py b/api/app/database.py
index c5bca76..20655e3 100644
--- a/api/app/database.py
+++ b/api/app/database.py
@@ -76,19 +76,31 @@ CREATE TABLE IF NOT EXISTS sync_runs (
error_message TEXT
);
-CREATE TABLE IF NOT EXISTS import_orders (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- sync_run_id TEXT REFERENCES sync_runs(run_id),
- order_number TEXT,
- order_date TEXT,
- customer_name TEXT,
- status TEXT,
- id_comanda INTEGER,
- id_partener INTEGER,
- error_message TEXT,
- missing_skus TEXT,
- items_count INTEGER,
- created_at TEXT DEFAULT (datetime('now'))
+CREATE TABLE IF NOT EXISTS orders (
+ order_number TEXT PRIMARY KEY,
+ order_date TEXT,
+ customer_name TEXT,
+ status TEXT,
+ id_comanda INTEGER,
+ id_partener INTEGER,
+ id_adresa_facturare INTEGER,
+ id_adresa_livrare INTEGER,
+ error_message TEXT,
+ missing_skus TEXT,
+ items_count INTEGER,
+ times_skipped INTEGER DEFAULT 0,
+ first_seen_at TEXT DEFAULT (datetime('now')),
+ last_sync_run_id TEXT REFERENCES sync_runs(run_id),
+ updated_at TEXT DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
+CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
+
+CREATE TABLE IF NOT EXISTS sync_run_orders (
+ sync_run_id TEXT REFERENCES sync_runs(run_id),
+ order_number TEXT REFERENCES orders(order_number),
+ status_at_run TEXT,
+ PRIMARY KEY (sync_run_id, order_number)
);
CREATE TABLE IF NOT EXISTS missing_skus (
@@ -106,6 +118,30 @@ CREATE TABLE IF NOT EXISTS scheduler_config (
key TEXT PRIMARY KEY,
value TEXT
);
+
+CREATE TABLE IF NOT EXISTS web_products (
+ sku TEXT PRIMARY KEY,
+ product_name TEXT,
+ first_seen TEXT DEFAULT (datetime('now')),
+ last_seen TEXT DEFAULT (datetime('now')),
+ order_count INTEGER DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS order_items (
+ order_number TEXT,
+ sku TEXT,
+ product_name TEXT,
+ quantity REAL,
+ price REAL,
+ vat REAL,
+ mapping_status TEXT,
+ codmat TEXT,
+ id_articol INTEGER,
+ cantitate_roa REAL,
+ created_at TEXT DEFAULT (datetime('now')),
+ PRIMARY KEY (order_number, sku)
+);
+CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_number);
"""
_sqlite_db_path = None
@@ -122,6 +158,101 @@ def init_sqlite():
# Create tables synchronously
conn = sqlite3.connect(_sqlite_db_path)
+
+ # Check existing tables before running schema
+ cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ existing_tables = {row[0] for row in cursor.fetchall()}
+
+ # Migration: import_orders → orders (one row per order)
+ if 'import_orders' in existing_tables and 'orders' not in existing_tables:
+ logger.info("Migrating import_orders → orders schema...")
+ conn.executescript("""
+ CREATE TABLE orders (
+ order_number TEXT PRIMARY KEY,
+ order_date TEXT,
+ customer_name TEXT,
+ status TEXT,
+ id_comanda INTEGER,
+ id_partener INTEGER,
+ id_adresa_facturare INTEGER,
+ id_adresa_livrare INTEGER,
+ error_message TEXT,
+ missing_skus TEXT,
+ items_count INTEGER,
+ times_skipped INTEGER DEFAULT 0,
+ first_seen_at TEXT DEFAULT (datetime('now')),
+ last_sync_run_id TEXT,
+ updated_at TEXT DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
+ CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
+
+ CREATE TABLE sync_run_orders (
+ sync_run_id TEXT,
+ order_number TEXT,
+ status_at_run TEXT,
+ PRIMARY KEY (sync_run_id, order_number)
+ );
+ """)
+ # Copy latest record per order_number into orders
+ conn.execute("""
+ INSERT INTO orders
+ (order_number, order_date, customer_name, status,
+ id_comanda, id_partener, id_adresa_facturare, id_adresa_livrare,
+ error_message, missing_skus, items_count, last_sync_run_id)
+ SELECT io.order_number, io.order_date, io.customer_name, io.status,
+ io.id_comanda, io.id_partener,
+ CASE WHEN io.order_number IN (SELECT order_number FROM import_orders WHERE id_adresa_facturare IS NOT NULL) THEN
+ (SELECT id_adresa_facturare FROM import_orders WHERE order_number = io.order_number AND id_adresa_facturare IS NOT NULL LIMIT 1) ELSE NULL END,
+ CASE WHEN io.order_number IN (SELECT order_number FROM import_orders WHERE id_adresa_livrare IS NOT NULL) THEN
+ (SELECT id_adresa_livrare FROM import_orders WHERE order_number = io.order_number AND id_adresa_livrare IS NOT NULL LIMIT 1) ELSE NULL END,
+ io.error_message, io.missing_skus, io.items_count, io.sync_run_id
+ FROM import_orders io
+ INNER JOIN (
+ SELECT order_number, MAX(id) as max_id
+ FROM import_orders
+ GROUP BY order_number
+ ) latest ON io.id = latest.max_id
+ """)
+ # Populate sync_run_orders from all import_orders rows
+ conn.execute("""
+ INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run)
+ SELECT sync_run_id, order_number, status
+ FROM import_orders
+ WHERE sync_run_id IS NOT NULL
+ """)
+ # Migrate order_items: drop sync_run_id, change PK to (order_number, sku)
+ if 'order_items' in existing_tables:
+ conn.executescript("""
+ CREATE TABLE order_items_new (
+ order_number TEXT,
+ sku TEXT,
+ product_name TEXT,
+ quantity REAL,
+ price REAL,
+ vat REAL,
+ mapping_status TEXT,
+ codmat TEXT,
+ id_articol INTEGER,
+ cantitate_roa REAL,
+ created_at TEXT DEFAULT (datetime('now')),
+ PRIMARY KEY (order_number, sku)
+ );
+ INSERT OR IGNORE INTO order_items_new
+ (order_number, sku, product_name, quantity, price, vat,
+ mapping_status, codmat, id_articol, cantitate_roa, created_at)
+ SELECT order_number, sku, product_name, quantity, price, vat,
+ mapping_status, codmat, id_articol, cantitate_roa, created_at
+ FROM order_items;
+ DROP TABLE order_items;
+ ALTER TABLE order_items_new RENAME TO order_items;
+ CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_number);
+ """)
+ # Rename old table instead of dropping (safety backup)
+ conn.execute("ALTER TABLE import_orders RENAME TO import_orders_bak")
+ conn.commit()
+ logger.info("Migration complete: import_orders → orders")
+
conn.executescript(SQLITE_SCHEMA)
# Migrate: add columns if missing (for existing databases)
@@ -140,6 +271,7 @@ def init_sqlite():
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}")
diff --git a/api/app/routers/mappings.py b/api/app/routers/mappings.py
index 13be8aa..0f5981a 100644
--- a/api/app/routers/mappings.py
+++ b/api/app/routers/mappings.py
@@ -8,6 +8,9 @@ import io
from ..services import mapping_service, sqlite_service
+import logging
+logger = logging.getLogger(__name__)
+
router = APIRouter(tags=["mappings"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@@ -22,6 +25,21 @@ class MappingUpdate(BaseModel):
procent_pret: Optional[float] = None
activ: Optional[int] = None
+class MappingEdit(BaseModel):
+ new_sku: str
+ new_codmat: str
+ cantitate_roa: float = 1
+ procent_pret: float = 100
+
+class MappingLine(BaseModel):
+ codmat: str
+ cantitate_roa: float = 1
+ procent_pret: float = 100
+
+class MappingBatchCreate(BaseModel):
+ sku: str
+ mappings: list[MappingLine]
+
# HTML page
@router.get("/mappings", response_class=HTMLResponse)
async def mappings_page(request: Request):
@@ -29,8 +47,18 @@ async def mappings_page(request: Request):
# API endpoints
@router.get("/api/mappings")
-def list_mappings(search: str = "", page: int = 1, per_page: int = 50):
- return mapping_service.get_mappings(search=search, page=page, per_page=per_page)
+async def list_mappings(search: str = "", page: int = 1, per_page: int = 50,
+ sort_by: str = "sku", sort_dir: str = "asc",
+ show_deleted: bool = False):
+ result = mapping_service.get_mappings(search=search, page=page, per_page=per_page,
+ sort_by=sort_by, sort_dir=sort_dir,
+ show_deleted=show_deleted)
+ # Merge product names from web_products (R4)
+ skus = list({m["sku"] for m in result.get("mappings", [])})
+ product_names = await sqlite_service.get_web_products_batch(skus)
+ for m in result.get("mappings", []):
+ m["product_name"] = product_names.get(m["sku"], "")
+ return result
@router.post("/api/mappings")
async def create_mapping(data: MappingCreate):
@@ -50,6 +78,15 @@ def update_mapping(sku: str, codmat: str, data: MappingUpdate):
except Exception as e:
return {"success": False, "error": str(e)}
+@router.put("/api/mappings/{sku}/{codmat}/edit")
+def edit_mapping(sku: str, codmat: str, data: MappingEdit):
+ try:
+ result = mapping_service.edit_mapping(sku, codmat, data.new_sku, data.new_codmat,
+ data.cantitate_roa, data.procent_pret)
+ return {"success": result}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
@router.delete("/api/mappings/{sku}/{codmat}")
def delete_mapping(sku: str, codmat: str):
try:
@@ -58,6 +95,38 @@ def delete_mapping(sku: str, codmat: str):
except Exception as e:
return {"success": False, "error": str(e)}
+@router.post("/api/mappings/{sku}/{codmat}/restore")
+def restore_mapping(sku: str, codmat: str):
+ try:
+ restored = mapping_service.restore_mapping(sku, codmat)
+ return {"success": restored}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+@router.post("/api/mappings/batch")
+async def create_batch_mapping(data: MappingBatchCreate):
+ """Create multiple (sku, codmat) rows for complex sets (R11)."""
+ if not data.mappings:
+ return {"success": False, "error": "No mappings provided"}
+
+ # Validate procent_pret sums to 100 for multi-line sets
+ if len(data.mappings) > 1:
+ total_pct = sum(m.procent_pret for m in data.mappings)
+ if abs(total_pct - 100) > 0.01:
+ return {"success": False, "error": f"Procent pret trebuie sa fie 100% (actual: {total_pct}%)"}
+
+ try:
+ results = []
+ for m in data.mappings:
+ r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, m.procent_pret)
+ results.append(r)
+ # Mark SKU as resolved in missing_skus tracking
+ await sqlite_service.resolve_missing_sku(data.sku)
+ return {"success": True, "created": len(results)}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+
@router.post("/api/mappings/import-csv")
async def import_csv(file: UploadFile = File(...)):
content = await file.read()
diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py
index c8d7418..600da85 100644
--- a/api/app/routers/sync.py
+++ b/api/app/routers/sync.py
@@ -1,5 +1,6 @@
import asyncio
import json
+from datetime import datetime
from fastapi import APIRouter, Request, BackgroundTasks
from fastapi.templating import Jinja2Templates
@@ -9,7 +10,7 @@ from pydantic import BaseModel
from pathlib import Path
from typing import Optional
-from ..services import sync_service, scheduler_service, sqlite_service
+from ..services import sync_service, scheduler_service, sqlite_service, invoice_service
router = APIRouter(tags=["sync"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@@ -110,9 +111,12 @@ async def sync_run_log(run_id: str):
"orders": [
{
"order_number": o.get("order_number"),
+ "order_date": o.get("order_date"),
"customer_name": o.get("customer_name"),
"items_count": o.get("items_count"),
"status": o.get("status"),
+ "id_comanda": o.get("id_comanda"),
+ "id_partener": o.get("id_partener"),
"error_message": o.get("error_message"),
"missing_skus": o.get("missing_skus"),
}
@@ -121,6 +125,211 @@ async def sync_run_log(run_id: str):
}
+def _format_text_log_from_detail(detail: dict) -> str:
+ """Build a text log from SQLite stored data for completed runs."""
+ run = detail.get("run", {})
+ orders = detail.get("orders", [])
+
+ run_id = run.get("run_id", "?")
+ started = run.get("started_at", "")
+
+ lines = [f"=== Sync Run {run_id} ==="]
+ if started:
+ try:
+ dt = datetime.fromisoformat(started)
+ lines.append(f"Inceput: {dt.strftime('%d.%m.%Y %H:%M:%S')}")
+ except (ValueError, TypeError):
+ lines.append(f"Inceput: {started}")
+ lines.append("")
+
+ for o in orders:
+ status = (o.get("status") or "").upper()
+ number = o.get("order_number", "?")
+ customer = o.get("customer_name", "?")
+ order_date = o.get("order_date") or "?"
+
+ if status == "IMPORTED":
+ id_cmd = o.get("id_comanda", "?")
+ lines.append(f"#{number} [{order_date}] {customer} → IMPORTAT (ID: {id_cmd})")
+ elif status == "SKIPPED":
+ missing = o.get("missing_skus", "")
+ if isinstance(missing, str):
+ try:
+ missing = json.loads(missing)
+ except (json.JSONDecodeError, TypeError):
+ missing = [missing] if missing else []
+ skus_str = ", ".join(missing) if isinstance(missing, list) else str(missing)
+ lines.append(f"#{number} [{order_date}] {customer} → OMIS (lipsa: {skus_str})")
+ elif status == "ERROR":
+ err = o.get("error_message", "necunoscuta")
+ lines.append(f"#{number} [{order_date}] {customer} → EROARE: {err}")
+
+ # Summary line
+ lines.append("")
+ total = run.get("total_orders", 0)
+ imported = run.get("imported", 0)
+ skipped = run.get("skipped", 0)
+ errors = run.get("errors", 0)
+
+ duration_str = ""
+ finished = run.get("finished_at", "")
+ if started and finished:
+ try:
+ dt_start = datetime.fromisoformat(started)
+ dt_end = datetime.fromisoformat(finished)
+ secs = int((dt_end - dt_start).total_seconds())
+ duration_str = f" | Durata: {secs}s"
+ except (ValueError, TypeError):
+ pass
+
+ lines.append(f"Finalizat: {imported} importate, {skipped} nemapate, {errors} erori din {total} comenzi{duration_str}")
+
+ return "\n".join(lines)
+
+
+@router.get("/api/sync/run/{run_id}/text-log")
+async def sync_run_text_log(run_id: str):
+ """Get text log for a sync run - live from memory or reconstructed from SQLite."""
+ # Check in-memory first (active/recent runs)
+ live_log = sync_service.get_run_text_log(run_id)
+ if live_log is not None:
+ status = "running"
+ current = await sync_service.get_sync_status()
+ if current.get("run_id") != run_id or current.get("status") != "running":
+ status = "completed"
+ return {"text": live_log, "status": status, "finished": status != "running"}
+
+ # Fall back to SQLite for historical runs
+ detail = await sqlite_service.get_sync_run_detail(run_id)
+ if not detail:
+ return {"error": "Run not found", "text": "", "status": "unknown", "finished": True}
+
+ run = detail.get("run", {})
+ text = _format_text_log_from_detail(detail)
+ status = run.get("status", "completed")
+ return {"text": text, "status": status, "finished": True}
+
+
+@router.get("/api/sync/run/{run_id}/orders")
+async def sync_run_orders(run_id: str, status: str = "all", page: int = 1, per_page: int = 50,
+ sort_by: str = "created_at", sort_dir: str = "asc"):
+ """Get filtered, paginated orders for a sync run (R1)."""
+ return await sqlite_service.get_run_orders_filtered(run_id, status, page, per_page,
+ sort_by=sort_by, sort_dir=sort_dir)
+
+
+def _get_articole_terti_for_skus(skus: set) -> dict:
+ """Query ARTICOLE_TERTI for all active codmat/cantitate/procent per SKU."""
+ from .. import database
+ result = {}
+ sku_list = list(skus)
+ conn = database.get_oracle_connection()
+ try:
+ with conn.cursor() as cur:
+ for i in range(0, len(sku_list), 500):
+ batch = sku_list[i:i+500]
+ placeholders = ",".join([f":s{j}" for j in range(len(batch))])
+ params = {f"s{j}": sku for j, sku in enumerate(batch)}
+ cur.execute(f"""
+ SELECT at.sku, at.codmat, at.cantitate_roa, at.procent_pret,
+ na.denumire
+ FROM ARTICOLE_TERTI at
+ LEFT JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0
+ WHERE at.sku IN ({placeholders}) AND at.activ = 1 AND at.sters = 0
+ ORDER BY at.sku, at.codmat
+ """, params)
+ for row in cur:
+ sku = row[0]
+ if sku not in result:
+ result[sku] = []
+ result[sku].append({
+ "codmat": row[1],
+ "cantitate_roa": float(row[2]) if row[2] else 1,
+ "procent_pret": float(row[3]) if row[3] else 100,
+ "denumire": row[4] or ""
+ })
+ finally:
+ database.pool.release(conn)
+ return result
+
+
+@router.get("/api/sync/order/{order_number}")
+async def order_detail(order_number: str):
+ """Get order detail with line items (R9), enriched with ARTICOLE_TERTI data."""
+ detail = await sqlite_service.get_order_detail(order_number)
+ if not detail:
+ return {"error": "Order not found"}
+
+ # Enrich items with ARTICOLE_TERTI mappings from Oracle
+ items = detail.get("items", [])
+ skus = {item["sku"] for item in items if item.get("sku")}
+ if skus:
+ codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus)
+ for item in items:
+ sku = item.get("sku")
+ if sku and sku in codmat_map:
+ item["codmat_details"] = codmat_map[sku]
+
+ return detail
+
+
+@router.get("/api/dashboard/orders")
+async def dashboard_orders(page: int = 1, per_page: int = 50,
+ search: str = "", status: str = "all",
+ sort_by: str = "order_date", sort_dir: str = "desc",
+ period_days: int = 7):
+ """Get orders for dashboard, enriched with invoice data. period_days=0 means all time."""
+ is_uninvoiced_filter = (status == "UNINVOICED")
+
+ # For UNINVOICED: fetch all IMPORTED orders, then filter post-invoice-check
+ fetch_status = "IMPORTED" if is_uninvoiced_filter else status
+ fetch_per_page = 10000 if is_uninvoiced_filter else per_page
+ fetch_page = 1 if is_uninvoiced_filter else page
+
+ result = await sqlite_service.get_orders(
+ page=fetch_page, per_page=fetch_per_page, search=search,
+ status_filter=fetch_status, sort_by=sort_by, sort_dir=sort_dir,
+ period_days=period_days
+ )
+
+ # Enrich imported orders with invoice data from Oracle
+ all_orders = result["orders"]
+ imported_orders = [o for o in all_orders if o.get("id_comanda")]
+ invoice_data = {}
+ if imported_orders:
+ id_comanda_list = [o["id_comanda"] for o in imported_orders]
+ invoice_data = await asyncio.to_thread(
+ invoice_service.check_invoices_for_orders, id_comanda_list
+ )
+
+ for o in all_orders:
+ idc = o.get("id_comanda")
+ if idc and idc in invoice_data:
+ o["invoice"] = invoice_data[idc]
+ else:
+ o["invoice"] = None
+
+ # Count uninvoiced (IMPORTED without invoice)
+ uninvoiced_count = sum(
+ 1 for o in all_orders
+ if o.get("status") == "IMPORTED" and not o.get("invoice")
+ )
+ result["counts"]["uninvoiced"] = uninvoiced_count
+
+ # For UNINVOICED filter: apply server-side filtering + pagination
+ if is_uninvoiced_filter:
+ filtered = [o for o in all_orders if o.get("status") == "IMPORTED" and not o.get("invoice")]
+ total = len(filtered)
+ offset = (page - 1) * per_page
+ result["orders"] = filtered[offset:offset + per_page]
+ result["total"] = total
+ result["page"] = page
+ result["per_page"] = per_page
+ result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
+
+ return result
+
+
@router.put("/api/sync/schedule")
async def update_schedule(config: ScheduleConfig):
"""Update scheduler configuration."""
diff --git a/api/app/routers/validation.py b/api/app/routers/validation.py
index 5d491f8..f1f7c94 100644
--- a/api/app/routers/validation.py
+++ b/api/app/routers/validation.py
@@ -88,9 +88,9 @@ async def scan_and_validate():
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)
+ resolved: int = Query(0, ge=-1, le=1)
):
- """Get paginated missing SKUs."""
+ """Get paginated missing SKUs. resolved=-1 means show all (R10)."""
result = await sqlite_service.get_missing_skus_paginated(page, per_page, resolved)
# Backward compat: also include 'unresolved' count
db = await get_sqlite()
@@ -106,7 +106,7 @@ async def get_missing_skus(
@router.get("/missing-skus-csv")
async def export_missing_skus_csv():
- """Export missing SKUs as CSV."""
+ """Export missing SKUs as CSV compatible with mapping import (R8)."""
db = await get_sqlite()
try:
cursor = await db.execute("""
@@ -120,9 +120,9 @@ async def export_missing_skus_csv():
output = io.StringIO()
writer = csv.writer(output)
- writer.writerow(["sku", "product_name", "first_seen"])
+ writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret", "product_name"])
for row in rows:
- writer.writerow([row["sku"], row["product_name"], row["first_seen"]])
+ writer.writerow([row["sku"], "", "", "", row["product_name"] or ""])
return StreamingResponse(
io.BytesIO(output.getvalue().encode("utf-8-sig")),
diff --git a/api/app/services/article_service.py b/api/app/services/article_service.py
index f6dc8d9..2a4f1e0 100644
--- a/api/app/services/article_service.py
+++ b/api/app/services/article_service.py
@@ -15,10 +15,11 @@ def search_articles(query: str, limit: int = 20):
with database.pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute("""
- SELECT id_articol, codmat, denumire
+ SELECT id_articol, codmat, denumire, um
FROM nom_articole
WHERE (UPPER(codmat) LIKE UPPER(:q) || '%'
OR UPPER(denumire) LIKE '%' || UPPER(:q) || '%')
+ AND sters = 0 AND inactiv = 0
AND ROWNUM <= :lim
ORDER BY CASE WHEN UPPER(codmat) LIKE UPPER(:q) || '%' THEN 0 ELSE 1 END, codmat
""", {"q": query, "lim": limit})
diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py
index 5278032..c77e5ab 100644
--- a/api/app/services/import_service.py
+++ b/api/app/services/import_service.py
@@ -44,9 +44,12 @@ def convert_web_date(date_str: str) -> datetime:
if not date_str:
return datetime.now()
try:
- return datetime.strptime(date_str[:10], '%Y-%m-%d')
+ return datetime.strptime(date_str.strip(), '%Y-%m-%d %H:%M:%S')
except ValueError:
- return datetime.now()
+ try:
+ return datetime.strptime(date_str.strip()[:10], '%Y-%m-%d')
+ except ValueError:
+ return datetime.now()
def format_address_for_oracle(address: str, city: str, region: str) -> str:
@@ -78,18 +81,26 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
success: bool
id_comanda: int or None
id_partener: int or None
+ id_adresa_facturare: int or None
+ id_adresa_livrare: int or None
error: str or None
"""
result = {
"success": False,
"id_comanda": None,
"id_partener": None,
+ "id_adresa_facturare": None,
+ "id_adresa_livrare": None,
"error": None
}
try:
order_number = clean_web_text(order.number)
order_date = convert_web_date(order.date)
+ logger.info(
+ f"Order {order.number}: raw date={order.date!r} → "
+ f"parsed={order_date.strftime('%Y-%m-%d %H:%M:%S')}"
+ )
if database.pool is None:
raise RuntimeError("Oracle pool not initialized")
@@ -99,14 +110,14 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
if order.billing.is_company:
- denumire = clean_web_text(order.billing.company_name)
+ denumire = clean_web_text(order.billing.company_name).upper()
cod_fiscal = clean_web_text(order.billing.company_code) or None
registru = clean_web_text(order.billing.company_reg) or None
is_pj = 1
else:
denumire = clean_web_text(
- f"{order.billing.firstname} {order.billing.lastname}"
- )
+ f"{order.billing.lastname} {order.billing.firstname}"
+ ).upper()
cod_fiscal = None
registru = None
is_pj = 0
@@ -151,6 +162,11 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
])
addr_livr_id = id_adresa_livr.getvalue()
+ if addr_fact_id is not None:
+ result["id_adresa_facturare"] = int(addr_fact_id)
+ if addr_livr_id is not None:
+ result["id_adresa_livrare"] = int(addr_livr_id)
+
# Step 4: Build articles JSON and import order
articles_json = build_articles_json(order.items)
diff --git a/api/app/services/invoice_service.py b/api/app/services/invoice_service.py
new file mode 100644
index 0000000..149f88d
--- /dev/null
+++ b/api/app/services/invoice_service.py
@@ -0,0 +1,43 @@
+import logging
+from .. import database
+
+logger = logging.getLogger(__name__)
+
+
+def check_invoices_for_orders(id_comanda_list: list) -> dict:
+ """Check which orders have been invoiced in Oracle (vanzari table).
+ Returns {id_comanda: {facturat, numar_act, serie_act, total_fara_tva, total_tva, total_cu_tva}}
+ """
+ if not id_comanda_list or database.pool is None:
+ return {}
+
+ result = {}
+ conn = database.get_oracle_connection()
+ try:
+ with conn.cursor() as cur:
+ for i in range(0, len(id_comanda_list), 500):
+ batch = id_comanda_list[i:i+500]
+ placeholders = ",".join([f":c{j}" for j in range(len(batch))])
+ params = {f"c{j}": cid for j, cid in enumerate(batch)}
+
+ cur.execute(f"""
+ SELECT id_comanda, numar_act, serie_act,
+ total_fara_tva, total_tva, total_cu_tva
+ FROM vanzari
+ WHERE id_comanda IN ({placeholders}) AND sters = 0
+ """, params)
+ for row in cur:
+ result[row[0]] = {
+ "facturat": True,
+ "numar_act": row[1],
+ "serie_act": row[2],
+ "total_fara_tva": float(row[3]) if row[3] else 0,
+ "total_tva": float(row[4]) if row[4] else 0,
+ "total_cu_tva": float(row[5]) if row[5] else 0,
+ }
+ except Exception as e:
+ logger.warning(f"Invoice check failed (table may not exist): {e}")
+ finally:
+ database.pool.release(conn)
+
+ return result
diff --git a/api/app/services/mapping_service.py b/api/app/services/mapping_service.py
index ebae0d7..382e01a 100644
--- a/api/app/services/mapping_service.py
+++ b/api/app/services/mapping_service.py
@@ -7,23 +7,47 @@ from .. import database
logger = logging.getLogger(__name__)
-def get_mappings(search: str = "", page: int = 1, per_page: int = 50):
- """Get paginated mappings with optional search."""
+def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
+ sort_by: str = "sku", sort_dir: str = "asc",
+ show_deleted: bool = False):
+ """Get paginated mappings with optional search and sorting."""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
offset = (page - 1) * per_page
+ # Validate and resolve sort parameters
+ allowed_sort = {
+ "sku": "at.sku",
+ "codmat": "at.codmat",
+ "denumire": "na.denumire",
+ "um": "na.um",
+ "cantitate_roa": "at.cantitate_roa",
+ "procent_pret": "at.procent_pret",
+ "activ": "at.activ",
+ }
+ sort_col = allowed_sort.get(sort_by, "at.sku")
+ if sort_dir.lower() not in ("asc", "desc"):
+ sort_dir = "asc"
+ order_clause = f"{sort_col} {sort_dir}"
+ # Always add secondary sort to keep groups together
+ if sort_col not in ("at.sku",):
+ order_clause += ", at.sku"
+ order_clause += ", at.codmat"
+
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Build WHERE clause
- where = ""
+ where_clauses = []
params = {}
+ if not show_deleted:
+ where_clauses.append("at.sters = 0")
if search:
- where = """WHERE (UPPER(at.sku) LIKE '%' || UPPER(:search) || '%'
+ where_clauses.append("""(UPPER(at.sku) LIKE '%' || UPPER(:search) || '%'
OR UPPER(at.codmat) LIKE '%' || UPPER(:search) || '%'
- OR UPPER(na.denumire) LIKE '%' || UPPER(:search) || '%')"""
+ OR UPPER(na.denumire) LIKE '%' || UPPER(:search) || '%')""")
params["search"] = search
+ where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# Count total
count_sql = f"""
@@ -36,13 +60,13 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50):
# Get page
data_sql = f"""
- SELECT at.sku, at.codmat, na.denumire, at.cantitate_roa,
- at.procent_pret, at.activ,
+ SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa,
+ at.procent_pret, at.activ, at.sters,
TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare
FROM ARTICOLE_TERTI at
LEFT JOIN nom_articole na ON na.codmat = at.codmat
{where}
- ORDER BY at.sku, at.codmat
+ ORDER BY {order_clause}
OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY
"""
params["offset"] = offset
@@ -68,8 +92,8 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret
with database.pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute("""
- INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, data_creare, id_util_creare)
- VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, SYSDATE, -3)
+ INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
+ VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
conn.commit()
return {"sku": sku, "codmat": codmat}
@@ -108,8 +132,68 @@ def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, procent_p
return cur.rowcount > 0
def delete_mapping(sku: str, codmat: str):
- """Soft delete (set activ=0)."""
- return update_mapping(sku, codmat, activ=0)
+ """Soft delete (set sters=1)."""
+ if database.pool is None:
+ raise HTTPException(status_code=503, detail="Oracle unavailable")
+
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ cur.execute("""
+ UPDATE ARTICOLE_TERTI SET sters = 1, data_modif = SYSDATE
+ WHERE sku = :sku AND codmat = :codmat
+ """, {"sku": sku, "codmat": codmat})
+ conn.commit()
+ return cur.rowcount > 0
+
+def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
+ cantitate_roa: float = 1, procent_pret: float = 100):
+ """Edit a mapping. If PK changed, soft-delete old and insert new."""
+ if database.pool is None:
+ raise HTTPException(status_code=503, detail="Oracle unavailable")
+
+ if old_sku == new_sku and old_codmat == new_codmat:
+ # Simple update - only cantitate/procent changed
+ return update_mapping(new_sku, new_codmat, cantitate_roa, procent_pret)
+ else:
+ # PK changed: soft-delete old, upsert new (MERGE handles existing soft-deleted target)
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ # Mark old record as deleted
+ cur.execute("""
+ UPDATE ARTICOLE_TERTI SET sters = 1, data_modif = SYSDATE
+ WHERE sku = :sku AND codmat = :codmat
+ """, {"sku": old_sku, "codmat": old_codmat})
+ # Upsert new record (MERGE in case target PK exists as soft-deleted)
+ cur.execute("""
+ MERGE INTO ARTICOLE_TERTI t
+ USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s
+ ON (t.sku = s.sku AND t.codmat = s.codmat)
+ WHEN MATCHED THEN UPDATE SET
+ cantitate_roa = :cantitate_roa,
+ procent_pret = :procent_pret,
+ activ = 1, sters = 0,
+ data_modif = SYSDATE
+ WHEN NOT MATCHED THEN INSERT
+ (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
+ VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
+ """, {"sku": new_sku, "codmat": new_codmat,
+ "cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
+ conn.commit()
+ return True
+
+def restore_mapping(sku: str, codmat: str):
+ """Restore a soft-deleted mapping (set sters=0)."""
+ if database.pool is None:
+ raise HTTPException(status_code=503, detail="Oracle unavailable")
+
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ cur.execute("""
+ UPDATE ARTICOLE_TERTI SET sters = 0, data_modif = SYSDATE
+ WHERE sku = :sku AND codmat = :codmat
+ """, {"sku": sku, "codmat": codmat})
+ conn.commit()
+ return cur.rowcount > 0
def import_csv(file_content: str):
"""Import mappings from CSV content. Returns summary."""
@@ -143,10 +227,11 @@ def import_csv(file_content: str):
cantitate_roa = :cantitate_roa,
procent_pret = :procent_pret,
activ = 1,
+ sters = 0,
data_modif = SYSDATE
WHEN NOT MATCHED THEN INSERT
- (sku, codmat, cantitate_roa, procent_pret, activ, data_creare, id_util_creare)
- VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, SYSDATE, -3)
+ (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
+ VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent})
# Check if it was insert or update by rowcount
@@ -172,7 +257,7 @@ def export_csv():
with conn.cursor() as cur:
cur.execute("""
SELECT sku, codmat, cantitate_roa, procent_pret, activ
- FROM ARTICOLE_TERTI ORDER BY sku, codmat
+ FROM ARTICOLE_TERTI WHERE sters = 0 ORDER BY sku, codmat
""")
for row in cur:
writer.writerow(row)
diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py
index a8bf136..fe70ef3 100644
--- a/api/app/services/sqlite_service.py
+++ b/api/app/services/sqlite_service.py
@@ -41,21 +41,48 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
await db.close()
-async def add_import_order(sync_run_id: str, order_number: str, order_date: str,
- customer_name: str, status: str, id_comanda: int = None,
- id_partener: int = None, error_message: str = None,
- missing_skus: list = None, items_count: int = 0):
- """Record an individual order import result."""
+async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
+ customer_name: str, status: str, id_comanda: int = None,
+ id_partener: int = None, error_message: str = None,
+ missing_skus: list = None, items_count: int = 0):
+ """Upsert a single order — one row per order_number, status updated in place."""
db = await get_sqlite()
try:
await db.execute("""
- INSERT INTO import_orders
- (sync_run_id, order_number, order_date, customer_name, status,
- id_comanda, id_partener, error_message, missing_skus, items_count)
+ INSERT INTO orders
+ (order_number, order_date, customer_name, status,
+ id_comanda, id_partener, error_message, missing_skus, items_count,
+ last_sync_run_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """, (sync_run_id, order_number, order_date, customer_name, status,
+ ON CONFLICT(order_number) DO UPDATE SET
+ status = excluded.status,
+ error_message = excluded.error_message,
+ missing_skus = excluded.missing_skus,
+ items_count = excluded.items_count,
+ id_comanda = COALESCE(excluded.id_comanda, orders.id_comanda),
+ id_partener = COALESCE(excluded.id_partener, orders.id_partener),
+ times_skipped = CASE WHEN excluded.status = 'SKIPPED'
+ THEN orders.times_skipped + 1
+ ELSE orders.times_skipped END,
+ last_sync_run_id = excluded.last_sync_run_id,
+ updated_at = datetime('now')
+ """, (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message,
- json.dumps(missing_skus) if missing_skus else None, items_count))
+ json.dumps(missing_skus) if missing_skus else None,
+ items_count, sync_run_id))
+ await db.commit()
+ finally:
+ await db.close()
+
+
+async def add_sync_run_order(sync_run_id: str, order_number: str, status_at_run: str):
+ """Record that this run processed this order (junction table)."""
+ db = await get_sqlite()
+ try:
+ await db.execute("""
+ INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run)
+ VALUES (?, ?, ?)
+ """, (sync_run_id, order_number, status_at_run))
await db.commit()
finally:
await db.close()
@@ -71,7 +98,6 @@ async def track_missing_sku(sku: str, product_name: str = "",
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
@@ -99,24 +125,35 @@ async def resolve_missing_sku(sku: str):
async def get_missing_skus_paginated(page: int = 1, per_page: int = 20, resolved: int = 0):
- """Get paginated missing SKUs."""
+ """Get paginated missing SKUs. resolved=-1 means show all."""
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]
+ if resolved == -1:
+ cursor = await db.execute("SELECT COUNT(*) FROM missing_skus")
+ 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
+ ORDER BY resolved ASC, order_count DESC, first_seen DESC
+ LIMIT ? OFFSET ?
+ """, (per_page, offset))
+ else:
+ 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))
- 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 {
@@ -157,7 +194,7 @@ async def get_sync_runs(page: int = 1, per_page: int = 20):
async def get_sync_run_detail(run_id: str):
- """Get details for a specific sync run including its orders."""
+ """Get details for a specific sync run including its orders via sync_run_orders."""
db = await get_sqlite()
try:
cursor = await db.execute(
@@ -168,9 +205,10 @@ async def get_sync_run_detail(run_id: str):
return None
cursor = await db.execute("""
- SELECT * FROM import_orders
- WHERE sync_run_id = ?
- ORDER BY created_at
+ SELECT o.* FROM orders o
+ INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number
+ WHERE sro.sync_run_id = ?
+ ORDER BY o.order_date
""", (run_id,))
orders = await cursor.fetchall()
@@ -186,42 +224,34 @@ async def get_dashboard_stats():
"""Get stats for the dashboard."""
db = await get_sqlite()
try:
- # Total imported
cursor = await db.execute(
- "SELECT COUNT(*) FROM import_orders WHERE status = 'IMPORTED'"
+ "SELECT COUNT(*) FROM orders WHERE status = 'IMPORTED'"
)
imported = (await cursor.fetchone())[0]
- # Total skipped
cursor = await db.execute(
- "SELECT COUNT(*) FROM import_orders WHERE status = 'SKIPPED'"
+ "SELECT COUNT(*) FROM orders WHERE status = 'SKIPPED'"
)
skipped = (await cursor.fetchone())[0]
- # Total errors
cursor = await db.execute(
- "SELECT COUNT(*) FROM import_orders WHERE status = 'ERROR'"
+ "SELECT COUNT(*) FROM orders WHERE status = 'ERROR'"
)
errors = (await cursor.fetchone())[0]
- # Missing SKUs (unresolved)
cursor = await db.execute(
"SELECT COUNT(*) FROM missing_skus WHERE resolved = 0"
)
missing = (await cursor.fetchone())[0]
- # Article stats from last sync
- cursor = await db.execute("""
- SELECT COUNT(DISTINCT sku) FROM missing_skus
- """)
+ 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
- """)
+ 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
""")
@@ -262,3 +292,266 @@ async def set_scheduler_config(key: str, value: str):
await db.commit()
finally:
await db.close()
+
+
+# ── web_products ─────────────────────────────────
+
+async def upsert_web_product(sku: str, product_name: str):
+ """Insert or update a web product, incrementing order_count."""
+ db = await get_sqlite()
+ try:
+ await db.execute("""
+ INSERT INTO web_products (sku, product_name, order_count)
+ VALUES (?, ?, 1)
+ ON CONFLICT(sku) DO UPDATE SET
+ product_name = COALESCE(NULLIF(excluded.product_name, ''), web_products.product_name),
+ last_seen = datetime('now'),
+ order_count = web_products.order_count + 1
+ """, (sku, product_name))
+ await db.commit()
+ finally:
+ await db.close()
+
+
+async def get_web_product_name(sku: str) -> str:
+ """Lookup product name by SKU."""
+ db = await get_sqlite()
+ try:
+ cursor = await db.execute(
+ "SELECT product_name FROM web_products WHERE sku = ?", (sku,)
+ )
+ row = await cursor.fetchone()
+ return row["product_name"] if row else ""
+ finally:
+ await db.close()
+
+
+async def get_web_products_batch(skus: list) -> dict:
+ """Batch lookup product names by SKU list. Returns {sku: product_name}."""
+ if not skus:
+ return {}
+ db = await get_sqlite()
+ try:
+ placeholders = ",".join("?" for _ in skus)
+ cursor = await db.execute(
+ f"SELECT sku, product_name FROM web_products WHERE sku IN ({placeholders})",
+ list(skus)
+ )
+ rows = await cursor.fetchall()
+ return {row["sku"]: row["product_name"] for row in rows}
+ finally:
+ await db.close()
+
+
+# ── order_items ──────────────────────────────────
+
+async def add_order_items(order_number: str, items: list):
+ """Bulk insert order items. Uses INSERT OR IGNORE — PK is (order_number, sku)."""
+ if not items:
+ return
+ db = await get_sqlite()
+ try:
+ await db.executemany("""
+ INSERT OR IGNORE INTO order_items
+ (order_number, sku, product_name, quantity, price, vat,
+ mapping_status, codmat, id_articol, cantitate_roa)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, [
+ (order_number,
+ item.get("sku"), item.get("product_name"),
+ item.get("quantity"), item.get("price"), item.get("vat"),
+ item.get("mapping_status"), item.get("codmat"),
+ item.get("id_articol"), item.get("cantitate_roa"))
+ for item in items
+ ])
+ await db.commit()
+ finally:
+ await db.close()
+
+
+async def get_order_items(order_number: str) -> list:
+ """Fetch items for one order."""
+ db = await get_sqlite()
+ try:
+ cursor = await db.execute("""
+ SELECT * FROM order_items
+ WHERE order_number = ?
+ ORDER BY sku
+ """, (order_number,))
+ rows = await cursor.fetchall()
+ return [dict(row) for row in rows]
+ finally:
+ await db.close()
+
+
+async def get_order_detail(order_number: str) -> dict:
+ """Get full order detail: order metadata + items."""
+ db = await get_sqlite()
+ try:
+ cursor = await db.execute("""
+ SELECT * FROM orders WHERE order_number = ?
+ """, (order_number,))
+ order = await cursor.fetchone()
+ if not order:
+ return None
+
+ cursor = await db.execute("""
+ SELECT * FROM order_items WHERE order_number = ?
+ ORDER BY sku
+ """, (order_number,))
+ items = await cursor.fetchall()
+
+ return {
+ "order": dict(order),
+ "items": [dict(i) for i in items]
+ }
+ finally:
+ await db.close()
+
+
+async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
+ page: int = 1, per_page: int = 50,
+ sort_by: str = "order_date", sort_dir: str = "asc"):
+ """Get paginated orders for a run via sync_run_orders junction table."""
+ db = await get_sqlite()
+ try:
+ where = "WHERE sro.sync_run_id = ?"
+ params = [run_id]
+
+ if status_filter and status_filter != "all":
+ where += " AND UPPER(o.status) = ?"
+ params.append(status_filter.upper())
+
+ allowed_sort = {"order_date", "order_number", "customer_name", "items_count",
+ "status", "first_seen_at", "updated_at"}
+ if sort_by not in allowed_sort:
+ sort_by = "order_date"
+ if sort_dir.lower() not in ("asc", "desc"):
+ sort_dir = "asc"
+
+ cursor = await db.execute(
+ f"SELECT COUNT(*) FROM orders o INNER JOIN sync_run_orders sro "
+ f"ON sro.order_number = o.order_number {where}", params
+ )
+ total = (await cursor.fetchone())[0]
+
+ offset = (page - 1) * per_page
+ cursor = await db.execute(f"""
+ SELECT o.* FROM orders o
+ INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number
+ {where}
+ ORDER BY o.{sort_by} {sort_dir}
+ LIMIT ? OFFSET ?
+ """, params + [per_page, offset])
+ rows = await cursor.fetchall()
+
+ cursor = await db.execute("""
+ SELECT o.status, COUNT(*) as cnt
+ FROM orders o
+ INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number
+ WHERE sro.sync_run_id = ?
+ GROUP BY o.status
+ """, (run_id,))
+ status_counts = {row["status"]: row["cnt"] for row in await cursor.fetchall()}
+
+ return {
+ "orders": [dict(r) for r in rows],
+ "total": total,
+ "page": page,
+ "per_page": per_page,
+ "pages": (total + per_page - 1) // per_page if total > 0 else 0,
+ "counts": {
+ "imported": status_counts.get("IMPORTED", 0),
+ "skipped": status_counts.get("SKIPPED", 0),
+ "error": status_counts.get("ERROR", 0),
+ "total": sum(status_counts.values())
+ }
+ }
+ finally:
+ await db.close()
+
+
+async def get_orders(page: int = 1, per_page: int = 50,
+ search: str = "", status_filter: str = "all",
+ sort_by: str = "order_date", sort_dir: str = "desc",
+ period_days: int = 7):
+ """Get orders with filters, sorting, and period. period_days=0 means all time."""
+ db = await get_sqlite()
+ try:
+ where_clauses = []
+ params = []
+
+ if period_days and period_days > 0:
+ where_clauses.append("order_date >= date('now', ?)")
+ params.append(f"-{period_days} days")
+
+ if search:
+ where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")
+ params.extend([f"%{search}%", f"%{search}%"])
+
+ if status_filter and status_filter not in ("all", "UNINVOICED"):
+ where_clauses.append("UPPER(status) = ?")
+ params.append(status_filter.upper())
+
+ where = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
+
+ allowed_sort = {"order_date", "order_number", "customer_name", "items_count",
+ "status", "first_seen_at", "updated_at"}
+ if sort_by not in allowed_sort:
+ sort_by = "order_date"
+ if sort_dir.lower() not in ("asc", "desc"):
+ sort_dir = "desc"
+
+ cursor = await db.execute(f"SELECT COUNT(*) FROM orders {where}", params)
+ total = (await cursor.fetchone())[0]
+
+ offset = (page - 1) * per_page
+ cursor = await db.execute(f"""
+ SELECT * FROM orders
+ {where}
+ ORDER BY {sort_by} {sort_dir}
+ LIMIT ? OFFSET ?
+ """, params + [per_page, offset])
+ rows = await cursor.fetchall()
+
+ # Counts by status (on full period, not just this page)
+ cursor = await db.execute(f"""
+ SELECT status, COUNT(*) as cnt FROM orders
+ {where}
+ GROUP BY status
+ """, params)
+ status_counts = {row["status"]: row["cnt"] for row in await cursor.fetchall()}
+
+ return {
+ "orders": [dict(r) for r in rows],
+ "total": total,
+ "page": page,
+ "per_page": per_page,
+ "pages": (total + per_page - 1) // per_page if total > 0 else 0,
+ "counts": {
+ "imported": status_counts.get("IMPORTED", 0),
+ "skipped": status_counts.get("SKIPPED", 0),
+ "error": status_counts.get("ERROR", 0),
+ "total": sum(status_counts.values())
+ }
+ }
+ finally:
+ await db.close()
+
+
+async def update_import_order_addresses(order_number: str,
+ id_adresa_facturare: int = None,
+ id_adresa_livrare: int = None):
+ """Update ROA address IDs on an order record."""
+ db = await get_sqlite()
+ try:
+ await db.execute("""
+ UPDATE orders SET
+ id_adresa_facturare = ?,
+ id_adresa_livrare = ?,
+ updated_at = datetime('now')
+ WHERE order_number = ?
+ """, (id_adresa_facturare, id_adresa_livrare, order_number))
+ await db.commit()
+ finally:
+ await db.close()
diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py
index 58061d0..e5e1c76 100644
--- a/api/app/services/sync_service.py
+++ b/api/app/services/sync_service.py
@@ -16,6 +16,9 @@ _current_sync = None # dict with run_id, status, progress info
# SSE subscriber system
_subscribers: list[asyncio.Queue] = []
+# In-memory text log buffer per run
+_run_logs: dict[str, list[str]] = {}
+
def subscribe() -> asyncio.Queue:
"""Subscribe to sync events. Returns a queue that will receive event dicts."""
@@ -32,6 +35,22 @@ def unsubscribe(q: asyncio.Queue):
pass
+def _log_line(run_id: str, message: str):
+ """Append a timestamped line to the in-memory log buffer."""
+ if run_id not in _run_logs:
+ _run_logs[run_id] = []
+ ts = datetime.now().strftime("%H:%M:%S")
+ _run_logs[run_id].append(f"[{ts}] {message}")
+
+
+def get_run_text_log(run_id: str) -> str | None:
+ """Return the accumulated text log for a run, or None if not found."""
+ lines = _run_logs.get(run_id)
+ if lines is None:
+ return None
+ return "\n".join(lines)
+
+
async def _emit(event: dict):
"""Push an event to all subscriber queues."""
for q in _subscribers:
@@ -87,13 +106,30 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
_current_sync["progress"] = "Reading JSON files..."
await _emit({"type": "phase", "run_id": run_id, "message": "Reading JSON files..."})
+ started_dt = datetime.now()
+ _run_logs[run_id] = [
+ f"=== Sync Run {run_id} ===",
+ f"Inceput: {started_dt.strftime('%d.%m.%Y %H:%M:%S')}",
+ ""
+ ]
+ _log_line(run_id, "Citire fisiere JSON...")
+
try:
- # Step 1: Read orders
+ # Step 1: Read orders and sort chronologically (oldest first - R3)
orders, json_count = order_reader.read_json_orders()
+ orders.sort(key=lambda o: o.date or '')
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"})
+ _log_line(run_id, f"Gasite {len(orders)} comenzi in {json_count} fisiere")
+
+ # Populate web_products catalog from all orders (R4)
+ for order in orders:
+ for item in order.items:
+ if item.sku and item.name:
+ await sqlite_service.upsert_web_product(item.sku, item.name)
if not orders:
+ _log_line(run_id, "Nicio comanda gasita.")
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
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})
@@ -114,6 +150,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
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)"})
+ _log_line(run_id, f"Validare SKU-uri: {len(importable)} importabile, {len(skipped)} nemapate")
# Step 2c: Build SKU context from skipped orders
sku_context = {} # {sku: {"orders": [], "customers": []}}
@@ -148,9 +185,13 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
# Step 2d: Pre-validate prices for importable articles
id_pol = id_pol or settings.ID_POL
+ id_sectie = id_sectie or settings.ID_SECTIE
+ logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
+ _log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
if id_pol and importable:
_current_sync["progress"] = "Validating prices..."
await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."})
+ _log_line(run_id, "Validare preturi...")
# Gather all CODMATs from importable orders
all_codmats = set()
for order in importable:
@@ -175,11 +216,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
price_result["missing_price"], id_pol
)
- # Step 3: Record skipped orders + emit events
+ # Step 3: Record skipped orders + emit events + store items
for order, missing_skus in skipped:
customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}"
- await sqlite_service.add_import_order(
+ await sqlite_service.upsert_order(
sync_run_id=run_id,
order_number=order.number,
order_date=order.date,
@@ -188,9 +229,24 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
missing_skus=missing_skus,
items_count=len(order.items)
)
+ await sqlite_service.add_sync_run_order(run_id, order.number, "SKIPPED")
+ # Store order items with mapping status (R9)
+ order_items_data = []
+ for item in order.items:
+ ms = "missing" if item.sku in validation["missing"] else \
+ "mapped" if item.sku in validation["mapped"] else "direct"
+ order_items_data.append({
+ "sku": item.sku, "product_name": item.name,
+ "quantity": item.quantity, "price": item.price, "vat": item.vat,
+ "mapping_status": ms, "codmat": None, "id_articol": None,
+ "cantitate_roa": None
+ })
+ await sqlite_service.add_order_items(order.number, order_items_data)
+ _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
+ "order_date": order.date,
"status": "SKIPPED", "missing_skus": missing_skus,
"items_count": len(order.items), "progress": f"0/{len(importable)}"
})
@@ -210,9 +266,20 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}"
+ # Build order items data for storage (R9)
+ order_items_data = []
+ for item in order.items:
+ ms = "mapped" if item.sku in validation["mapped"] else "direct"
+ order_items_data.append({
+ "sku": item.sku, "product_name": item.name,
+ "quantity": item.quantity, "price": item.price, "vat": item.vat,
+ "mapping_status": ms, "codmat": None, "id_articol": None,
+ "cantitate_roa": None
+ })
+
if result["success"]:
imported_count += 1
- await sqlite_service.add_import_order(
+ await sqlite_service.upsert_order(
sync_run_id=run_id,
order_number=order.number,
order_date=order.date,
@@ -222,15 +289,25 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
id_partener=result["id_partener"],
items_count=len(order.items)
)
+ await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
+ # Store ROA address IDs (R9)
+ await sqlite_service.update_import_order_addresses(
+ order.number,
+ id_adresa_facturare=result.get("id_adresa_facturare"),
+ id_adresa_livrare=result.get("id_adresa_livrare")
+ )
+ await sqlite_service.add_order_items(order.number, order_items_data)
+ _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → IMPORTAT (ID: {result['id_comanda']})")
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
+ "order_date": order.date,
"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(
+ await sqlite_service.upsert_order(
sync_run_id=run_id,
order_number=order.number,
order_date=order.date,
@@ -240,9 +317,13 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
error_message=result["error"],
items_count=len(order.items)
)
+ await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR")
+ await sqlite_service.add_order_items(order.number, order_items_data)
+ _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → EROARE: {result['error']}")
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
+ "order_date": order.date,
"status": "ERROR", "error_message": result["error"],
"items_count": len(order.items), "progress": progress_str
})
@@ -275,10 +356,16 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
f"{len(skipped)} skipped, {error_count} errors"
)
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
+
+ duration = (datetime.now() - started_dt).total_seconds()
+ _log_line(run_id, "")
+ _run_logs[run_id].append(f"Finalizat: {imported_count} importate, {len(skipped)} nemapate, {error_count} erori din {len(orders)} comenzi | Durata: {int(duration)}s")
+
return summary
except Exception as e:
logger.error(f"Sync {run_id} failed: {e}")
+ _log_line(run_id, f"EROARE FATALA: {e}")
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)})
@@ -291,6 +378,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
_current_sync = None
asyncio.ensure_future(_clear_current_sync())
+ async def _clear_run_logs():
+ await asyncio.sleep(300) # 5 minutes
+ _run_logs.pop(run_id, None)
+ asyncio.ensure_future(_clear_run_logs())
+
def stop_sync():
"""Signal sync to stop. Currently sync runs to completion."""
diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py
index 7798bac..e014e0b 100644
--- a/api/app/services/validation_service.py
+++ b/api/app/services/validation_service.py
@@ -29,7 +29,7 @@ def validate_skus(skus: set[str]) -> dict:
# Check ARTICOLE_TERTI
cur.execute(f"""
SELECT DISTINCT sku FROM ARTICOLE_TERTI
- WHERE sku IN ({placeholders}) AND activ = 1
+ WHERE sku IN ({placeholders}) AND activ = 1 AND sters = 0
""", params)
for row in cur:
mapped.add(row[0])
@@ -41,7 +41,7 @@ def validate_skus(skus: set[str]) -> dict:
params2 = {f"n{j}": sku for j, sku in enumerate(remaining)}
cur.execute(f"""
SELECT DISTINCT codmat FROM NOM_ARTICOLE
- WHERE codmat IN ({placeholders2})
+ WHERE codmat IN ({placeholders2}) AND sters = 0 AND inactiv = 0
""", params2)
for row in cur:
direct.add(row[0])
diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css
index 9f8c226..31789c2 100644
--- a/api/app/static/css/style.css
+++ b/api/app/static/css/style.css
@@ -111,25 +111,6 @@ body {
.badge-pending { background-color: #94a3b8; }
.badge-ready { background-color: #3b82f6; }
-/* Stat cards */
-.stat-card {
- text-align: center;
- padding: 1rem;
-}
-
-.stat-card .stat-value {
- font-size: 1.75rem;
- font-weight: 700;
- line-height: 1.2;
-}
-
-.stat-card .stat-label {
- font-size: 0.8rem;
- color: #64748b;
- text-transform: uppercase;
- letter-spacing: 0.05em;
-}
-
/* Tables */
.table {
font-size: 0.875rem;
@@ -213,65 +194,20 @@ body {
justify-content: center;
}
-/* Live Feed */
-.live-feed {
- max-height: 300px;
- overflow-y: auto;
+/* Log viewer */
+.log-viewer {
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; }
+ line-height: 1.5;
+ max-height: 600px;
+ overflow-y: auto;
+ padding: 1rem;
+ margin: 0;
+ background-color: #1e293b;
+ color: #e2e8f0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ border-radius: 0 0 0.5rem 0.5rem;
}
/* Clickable table rows */
@@ -282,3 +218,87 @@ body {
.table-hover tbody tr[data-href]:hover {
background-color: #e2e8f0;
}
+
+/* Sortable table headers (R7) */
+.sortable {
+ cursor: pointer;
+ user-select: none;
+}
+.sortable:hover {
+ background-color: #f1f5f9;
+}
+.sort-icon {
+ font-size: 0.75rem;
+ margin-left: 0.25rem;
+ color: #3b82f6;
+}
+
+/* SKU group visual grouping (R6) */
+.sku-group-even {
+ /* default background */
+}
+.sku-group-odd {
+ background-color: #f8fafc;
+}
+
+/* Editable cells */
+.editable {
+ cursor: pointer;
+}
+.editable:hover {
+ background-color: #e2e8f0;
+}
+
+/* Order detail modal items */
+.modal-lg .table-sm td,
+.modal-lg .table-sm th {
+ font-size: 0.8125rem;
+ padding: 0.35rem 0.5rem;
+}
+
+/* Filter button badges */
+#orderFilterBtns .badge {
+ font-size: 0.7rem;
+}
+
+/* Modal stacking for quickMap over orderDetail */
+#quickMapModal {
+ z-index: 1060;
+}
+#quickMapModal + .modal-backdrop,
+.modal-backdrop ~ .modal-backdrop {
+ z-index: 1055;
+}
+
+/* Deleted mapping rows */
+tr.mapping-deleted td {
+ text-decoration: line-through;
+ opacity: 0.5;
+}
+
+/* Map icon button (minimal, no border) */
+.btn-map-icon {
+ color: #3b82f6;
+ padding: 0.1rem 0.25rem;
+ cursor: pointer;
+ font-size: 1rem;
+ text-decoration: none;
+}
+.btn-map-icon:hover {
+ color: #1d4ed8;
+}
+
+/* Last sync summary card columns */
+.last-sync-col {
+ border-right: 1px solid #e2e8f0;
+}
+
+/* Dashboard filter badges */
+#dashFilterBtns .badge {
+ font-size: 0.7rem;
+}
+
+/* Cursor pointer utility */
+.cursor-pointer {
+ cursor: pointer;
+}
diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js
index 1a4a8ee..2ab12f1 100644
--- a/api/app/static/js/dashboard.js
+++ b/api/app/static/js/dashboard.js
@@ -1,76 +1,38 @@
let refreshInterval = null;
-let currentMapSku = '';
-let acTimeout = null;
+let dashPage = 1;
+let dashFilter = 'all';
+let dashSearch = '';
+let dashSortCol = 'order_date';
+let dashSortDir = 'desc';
+let dashSearchTimeout = null;
+let dashPeriodDays = 7;
+let currentQmSku = '';
+let currentQmOrderNumber = '';
+let qmAcTimeout = null;
+let syncEventSource = 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);
- });
- }
+ loadSchedulerStatus();
+ loadSyncStatus();
+ loadLastSync();
+ loadDashOrders();
+ refreshInterval = setInterval(() => {
+ loadSyncStatus();
+ }, 10000);
});
-async function loadDashboard() {
- await Promise.all([
- loadSyncStatus(),
- loadSyncHistory(),
- loadMissingSkus(),
- loadSchedulerStatus()
- ]);
-}
+// ── Sync Status ──────────────────────────────────
async function loadSyncStatus() {
try {
const res = await fetch('/api/sync/status');
const data = await res.json();
- const stats = data.stats || {};
-
- // 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');
const status = data.status || 'idle';
badge.textContent = status;
badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary');
- // Show/hide start/stop buttons
if (status === 'running') {
document.getElementById('btnStartSync').classList.add('d-none');
document.getElementById('btnStopSync').classList.remove('d-none');
@@ -79,12 +41,12 @@ async function loadSyncStatus() {
document.getElementById('btnStartSync').classList.remove('d-none');
document.getElementById('btnStopSync').classList.add('d-none');
- // Show last run info
+ const stats = data.stats || {};
if (stats.last_run) {
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} fara mapare, ${lr.errors || 0} erori`;
+ `Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} nemapate, ${lr.errors || 0} erori`;
} else {
document.getElementById('syncProgressText').textContent = '';
}
@@ -94,98 +56,453 @@ async function loadSyncStatus() {
}
}
-async function loadSyncHistory() {
- try {
- const res = await fetch('/api/sync/history?per_page=10');
- const data = await res.json();
- const tbody = document.getElementById('syncRunsBody');
+// ── Last Sync Summary Card ───────────────────────
- if (!data.runs || data.runs.length === 0) {
- tbody.innerHTML = '
| Niciun sync run |
';
+async function loadLastSync() {
+ try {
+ const res = await fetch('/api/sync/history?per_page=1');
+ const data = await res.json();
+ const runs = data.runs || [];
+
+ if (runs.length === 0) {
+ document.getElementById('lastSyncDate').textContent = '-';
return;
}
- tbody.innerHTML = data.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'}) : '-';
- let duration = '-';
- if (r.started_at && r.finished_at) {
- const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000);
- duration = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
- }
- const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
+ const r = runs[0];
+ document.getElementById('lastSyncDate').textContent = r.started_at
+ ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})
+ : '-';
- return `
- | ${started} |
- ${esc(r.status)} |
- ${r.total_orders || 0} |
- ${r.imported || 0} |
- ${r.skipped || 0} |
- ${r.errors || 0} |
- ${duration} |
-
`;
- }).join('');
+ const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
+ document.getElementById('lastSyncStatus').innerHTML = `${esc(r.status)}`;
+ document.getElementById('lastSyncImported').textContent = r.imported || 0;
+ document.getElementById('lastSyncSkipped').textContent = r.skipped || 0;
+ document.getElementById('lastSyncErrors').textContent = r.errors || 0;
+
+ if (r.started_at && r.finished_at) {
+ const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000);
+ document.getElementById('lastSyncDuration').textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
+ } else {
+ document.getElementById('lastSyncDuration').textContent = '-';
+ }
} catch (err) {
- console.error('loadSyncHistory error:', err);
+ console.error('loadLastSync error:', err);
}
}
-async function loadMissingSkus() {
- try {
- const res = await fetch('/api/validate/missing-skus?page=1&per_page=10');
- const data = await res.json();
- const tbody = document.getElementById('missingSkusBody');
+// ── Dashboard Orders Table ───────────────────────
- // Update article-level stat card (unresolved count)
- if (data.total != null) {
- document.getElementById('stat-missing-skus').textContent = data.total;
+function debounceDashSearch() {
+ clearTimeout(dashSearchTimeout);
+ dashSearchTimeout = setTimeout(() => {
+ dashSearch = document.getElementById('dashSearchInput').value;
+ dashPage = 1;
+ loadDashOrders();
+ }, 300);
+}
+
+function dashFilterOrders(filter) {
+ dashFilter = filter;
+ dashPage = 1;
+
+ // Update button styles
+ const colorMap = {
+ 'all': 'primary',
+ 'IMPORTED': 'success',
+ 'SKIPPED': 'warning',
+ 'ERROR': 'danger',
+ 'UNINVOICED': 'info'
+ };
+ document.querySelectorAll('#dashFilterBtns button').forEach(btn => {
+ const text = btn.textContent.trim().split(' ')[0];
+ let btnFilter = 'all';
+ if (text === 'Importate') btnFilter = 'IMPORTED';
+ else if (text === 'Omise') btnFilter = 'SKIPPED';
+ else if (text === 'Erori') btnFilter = 'ERROR';
+ else if (text === 'Nefacturate') btnFilter = 'UNINVOICED';
+
+ const color = colorMap[btnFilter] || 'primary';
+ if (btnFilter === filter) {
+ btn.className = `btn btn-sm btn-${color}`;
+ } else {
+ btn.className = `btn btn-sm btn-outline-${color}`;
+ }
+ });
+
+ loadDashOrders();
+}
+
+function dashSortBy(col) {
+ if (dashSortCol === col) {
+ dashSortDir = dashSortDir === 'asc' ? 'desc' : 'asc';
+ } else {
+ dashSortCol = col;
+ dashSortDir = 'asc';
+ }
+ // Update sort icons
+ document.querySelectorAll('#dashOrdersBody').forEach(() => {}); // noop
+ document.querySelectorAll('.sort-icon').forEach(span => {
+ const c = span.dataset.col;
+ span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
+ });
+ dashPage = 1;
+ loadDashOrders();
+}
+
+function dashSetPeriod(days) {
+ dashPeriodDays = days;
+ dashPage = 1;
+ document.querySelectorAll('#dashPeriodBtns button').forEach(btn => {
+ const val = parseInt(btn.dataset.days);
+ btn.className = val === days
+ ? 'btn btn-sm btn-secondary'
+ : 'btn btn-sm btn-outline-secondary';
+ });
+ loadDashOrders();
+}
+
+async function loadDashOrders() {
+ const params = new URLSearchParams({
+ page: dashPage,
+ per_page: 50,
+ search: dashSearch,
+ status: dashFilter,
+ sort_by: dashSortCol,
+ sort_dir: dashSortDir,
+ period_days: dashPeriodDays
+ });
+
+ try {
+ const res = await fetch(`/api/dashboard/orders?${params}`);
+ const data = await res.json();
+
+ const counts = data.counts || {};
+ document.getElementById('dashCountAll').textContent = counts.total || 0;
+ document.getElementById('dashCountImported').textContent = counts.imported || 0;
+ document.getElementById('dashCountSkipped').textContent = counts.skipped || 0;
+ document.getElementById('dashCountError').textContent = counts.error || 0;
+ document.getElementById('dashCountUninvoiced').textContent = counts.uninvoiced || 0;
+
+ const tbody = document.getElementById('dashOrdersBody');
+ const orders = data.orders || [];
+
+ if (orders.length === 0) {
+ tbody.innerHTML = '| Nicio comanda |
';
+ } else {
+ tbody.innerHTML = orders.map(o => {
+ const dateStr = fmtDate(o.order_date);
+ const statusBadge = orderStatusBadge(o.status);
+
+ // Invoice info
+ let invoiceBadge = '';
+ let invoiceTotal = '';
+ if (o.status !== 'IMPORTED') {
+ invoiceBadge = '-';
+ } else if (o.invoice && o.invoice.facturat) {
+ invoiceBadge = `Facturat`;
+ if (o.invoice.serie_act || o.invoice.numar_act) {
+ invoiceBadge += `
${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}`;
+ }
+ invoiceTotal = o.invoice.total_cu_tva ? Number(o.invoice.total_cu_tva).toFixed(2) : '-';
+ } else {
+ invoiceBadge = 'Nefacturat';
+ }
+
+ return `
+ ${esc(o.order_number)} |
+ ${dateStr} |
+ ${esc(o.customer_name)} |
+ ${o.items_count || 0} |
+ ${statusBadge} |
+ ${o.id_comanda || '-'} |
+ ${invoiceBadge} |
+ ${invoiceTotal} |
+
`;
+ }).join('');
}
- const unresolved = (data.missing_skus || []).filter(s => !s.resolved);
+ // Pagination
+ const totalPages = data.pages || 1;
+ document.getElementById('dashPageInfo').textContent = `${data.total || 0} comenzi | Pagina ${dashPage} din ${totalPages}`;
- if (unresolved.length === 0) {
- tbody.innerHTML = '| Toate SKU-urile sunt mapate |
';
+ const pagDiv = document.getElementById('dashPagination');
+ if (totalPages > 1) {
+ pagDiv.innerHTML = `
+
+ ${dashPage} / ${totalPages}
+
+ `;
+ } else {
+ pagDiv.innerHTML = '';
+ }
+
+ // Update sort icons
+ document.querySelectorAll('.sort-icon').forEach(span => {
+ const c = span.dataset.col;
+ span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
+ });
+ } catch (err) {
+ document.getElementById('dashOrdersBody').innerHTML =
+ `| ${esc(err.message)} |
`;
+ }
+}
+
+function dashGoPage(p) {
+ dashPage = p;
+ loadDashOrders();
+}
+
+// ── Helper functions ─────────────────────────────
+
+function fmtDate(dateStr) {
+ if (!dateStr) return '-';
+ try {
+ const d = new Date(dateStr);
+ const hasTime = dateStr.includes(':');
+ if (hasTime) {
+ return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
+ }
+ return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
+ } catch { return dateStr; }
+}
+
+function orderStatusBadge(status) {
+ switch ((status || '').toUpperCase()) {
+ case 'IMPORTED': return 'Importat';
+ case 'SKIPPED': return 'Omis';
+ case 'ERROR': return 'Eroare';
+ default: return `${esc(status)}`;
+ }
+}
+
+function renderCodmatCell(item) {
+ if (!item.codmat_details || item.codmat_details.length === 0) {
+ return `${esc(item.codmat || '-')}`;
+ }
+ if (item.codmat_details.length === 1) {
+ const d = item.codmat_details[0];
+ return `${esc(d.codmat)}`;
+ }
+ return item.codmat_details.map(d =>
+ `${esc(d.codmat)} \xd7${d.cantitate_roa} (${d.procent_pret}%)
`
+ ).join('');
+}
+
+// ── Order Detail Modal ───────────────────────────
+
+async function openDashOrderDetail(orderNumber) {
+ document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
+ document.getElementById('detailCustomer').textContent = '...';
+ document.getElementById('detailDate').textContent = '';
+ document.getElementById('detailStatus').innerHTML = '';
+ document.getElementById('detailIdComanda').textContent = '-';
+ document.getElementById('detailIdPartener').textContent = '-';
+ document.getElementById('detailIdAdresaFact').textContent = '-';
+ document.getElementById('detailIdAdresaLivr').textContent = '-';
+ document.getElementById('detailItemsBody').innerHTML = '| Se incarca... |
';
+ document.getElementById('detailError').style.display = 'none';
+
+ const modalEl = document.getElementById('orderDetailModal');
+ const existing = bootstrap.Modal.getInstance(modalEl);
+ if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
+
+ try {
+ const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
+ const data = await res.json();
+
+ if (data.error) {
+ document.getElementById('detailError').textContent = data.error;
+ document.getElementById('detailError').style.display = '';
return;
}
- 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 */ }
+ const order = data.order || {};
+ document.getElementById('detailCustomer').textContent = order.customer_name || '-';
+ document.getElementById('detailDate').textContent = fmtDate(order.order_date);
+ document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
+ document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
+ document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
+ document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
+ document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
+
+ if (order.error_message) {
+ document.getElementById('detailError').textContent = order.error_message;
+ document.getElementById('detailError').style.display = '';
+ }
+
+ const items = data.items || [];
+ if (items.length === 0) {
+ document.getElementById('detailItemsBody').innerHTML = '| Niciun articol |
';
+ return;
+ }
+
+ document.getElementById('detailItemsBody').innerHTML = items.map(item => {
+ let statusBadge;
+ switch (item.mapping_status) {
+ case 'mapped': statusBadge = 'Mapat'; break;
+ case 'direct': statusBadge = 'Direct'; break;
+ case 'missing': statusBadge = 'Lipsa'; break;
+ default: statusBadge = '?';
+ }
+
+ const action = item.mapping_status === 'missing'
+ ? ``
+ : '';
return `
- ${esc(s.sku)} |
- ${esc(s.product_name || '-')} |
- ${s.order_count != null ? s.order_count : '-'} |
- ${esc(firstCustomer)} |
-
-
- |
+ ${esc(item.sku)} |
+ ${esc(item.product_name || '-')} |
+ ${item.quantity || 0} |
+ ${item.price != null ? Number(item.price).toFixed(2) : '-'} |
+ ${item.vat != null ? Number(item.vat).toFixed(2) : '-'} |
+ ${renderCodmatCell(item)} |
+ ${statusBadge} |
+ ${action} |
`;
}).join('');
} catch (err) {
- console.error('loadMissingSkus error:', err);
+ document.getElementById('detailError').textContent = err.message;
+ document.getElementById('detailError').style.display = '';
}
}
-async function loadSchedulerStatus() {
- try {
- const res = await fetch('/api/sync/schedule');
- const data = await res.json();
+// ── Quick Map Modal ──────────────────────────────
- document.getElementById('schedulerToggle').checked = data.enabled || false;
- if (data.interval_minutes) {
- document.getElementById('schedulerInterval').value = data.interval_minutes;
+function openQuickMap(sku, productName, orderNumber) {
+ currentQmSku = sku;
+ currentQmOrderNumber = orderNumber;
+ document.getElementById('qmSku').textContent = sku;
+ document.getElementById('qmProductName').textContent = productName || '-';
+ document.getElementById('qmPctWarning').style.display = 'none';
+
+ const container = document.getElementById('qmCodmatLines');
+ container.innerHTML = '';
+ addQmCodmatLine();
+
+ new bootstrap.Modal(document.getElementById('quickMapModal')).show();
+}
+
+function addQmCodmatLine() {
+ const container = document.getElementById('qmCodmatLines');
+ const idx = container.children.length;
+ const div = document.createElement('div');
+ div.className = 'border rounded p-2 mb-2 qm-line';
+ div.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${idx > 0 ? `` : ''}
+
+
+ `;
+ container.appendChild(div);
+
+ const input = div.querySelector('.qm-codmat');
+ const dropdown = div.querySelector('.qm-ac-dropdown');
+ const selected = div.querySelector('.qm-selected');
+
+ input.addEventListener('input', () => {
+ clearTimeout(qmAcTimeout);
+ qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250);
+ });
+ input.addEventListener('blur', () => {
+ setTimeout(() => dropdown.classList.add('d-none'), 200);
+ });
+}
+
+async function qmAutocomplete(input, dropdown, selectedEl) {
+ const q = input.value;
+ 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 =>
+ `
+ ${esc(r.codmat)} — ${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''}
+
`
+ ).join('');
+ dropdown.classList.remove('d-none');
+ } catch { dropdown.classList.add('d-none'); }
+}
+
+function qmSelectArticle(el, codmat, label) {
+ const line = el.closest('.qm-line');
+ line.querySelector('.qm-codmat').value = codmat;
+ line.querySelector('.qm-selected').textContent = label;
+ line.querySelector('.qm-ac-dropdown').classList.add('d-none');
+}
+
+async function saveQuickMapping() {
+ const lines = document.querySelectorAll('.qm-line');
+ const mappings = [];
+
+ for (const line of lines) {
+ const codmat = line.querySelector('.qm-codmat').value.trim();
+ const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
+ const procent = parseFloat(line.querySelector('.qm-procent').value) || 100;
+ if (!codmat) continue;
+ mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
+ }
+
+ if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
+
+ if (mappings.length > 1) {
+ const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
+ if (Math.abs(totalPct - 100) > 0.01) {
+ document.getElementById('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
+ document.getElementById('qmPctWarning').style.display = '';
+ return;
+ }
+ }
+ document.getElementById('qmPctWarning').style.display = 'none';
+
+ try {
+ let res;
+ if (mappings.length === 1) {
+ res = await fetch('/api/mappings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sku: currentQmSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
+ });
+ } else {
+ res = await fetch('/api/mappings/batch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sku: currentQmSku, mappings })
+ });
+ }
+ const data = await res.json();
+ if (data.success) {
+ bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
+ if (currentQmOrderNumber) openDashOrderDetail(currentQmOrderNumber);
+ loadDashOrders();
+ } else {
+ alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
- console.error('loadSchedulerStatus error:', err);
+ alert('Eroare: ' + err.message);
}
}
+// ── Sync Controls ────────────────────────────────
+
async function startSync() {
try {
const res = await fetch('/api/sync/start', { method: 'POST' });
@@ -194,7 +511,6 @@ async function startSync() {
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');
@@ -202,61 +518,72 @@ async function startSync() {
link.href = '/logs?run=' + encodeURIComponent(data.run_id);
banner.classList.remove('d-none');
}
+ // Subscribe to SSE for live progress + auto-refresh on completion
+ listenToSyncStream(data.run_id);
}
- loadDashboard();
+ loadSyncStatus();
} catch (err) {
alert('Eroare: ' + err.message);
}
}
+function listenToSyncStream(runId) {
+ // Close any previous SSE connection
+ if (syncEventSource) { syncEventSource.close(); syncEventSource = null; }
+
+ syncEventSource = new EventSource('/api/sync/stream');
+
+ syncEventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+
+ if (data.type === 'phase') {
+ document.getElementById('syncProgressText').textContent = data.message || '';
+ }
+
+ if (data.type === 'order_result') {
+ // Update progress text with current order info
+ const status = data.status === 'IMPORTED' ? 'OK' : data.status === 'SKIPPED' ? 'OMIS' : 'ERR';
+ document.getElementById('syncProgressText').textContent =
+ `[${data.progress || ''}] #${data.order_number} ${data.customer_name || ''} → ${status}`;
+ }
+
+ if (data.type === 'completed' || data.type === 'failed') {
+ syncEventSource.close();
+ syncEventSource = null;
+ // Refresh all dashboard sections
+ loadLastSync();
+ loadDashOrders();
+ loadSyncStatus();
+ // Hide banner after 5s
+ setTimeout(() => {
+ document.getElementById('syncStartedBanner')?.classList.add('d-none');
+ }, 5000);
+ }
+ } catch (e) {
+ console.error('SSE parse error:', e);
+ }
+ };
+
+ syncEventSource.onerror = () => {
+ syncEventSource.close();
+ syncEventSource = null;
+ // Refresh anyway — sync may have finished
+ loadLastSync();
+ loadDashOrders();
+ loadSyncStatus();
+ };
+}
+
async function stopSync() {
try {
await fetch('/api/sync/stop', { method: 'POST' });
- loadDashboard();
+ loadSyncStatus();
} catch (err) {
alert('Eroare: ' + err.message);
}
}
-async function scanOrders() {
- const btn = document.getElementById('btnScan');
- btn.disabled = true;
- btn.innerHTML = ' Scanning...';
-
- try {
- const res = await fetch('/api/validate/scan', { method: 'POST' });
- const data = await res.json();
-
- // Persist scan results so auto-refresh doesn't overwrite them
- saveScanData(data);
-
- // 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`;
- }
- alert(msg);
- loadDashboard();
- } catch (err) {
- alert('Eroare scan: ' + err.message);
- } finally {
- btn.disabled = false;
- btn.innerHTML = ' Scan';
- }
-}
-
async function toggleScheduler() {
const enabled = document.getElementById('schedulerToggle').checked;
const interval = parseInt(document.getElementById('schedulerInterval').value) || 5;
@@ -279,106 +606,19 @@ 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; }
-
+async function loadSchedulerStatus() {
try {
- const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
+ const res = await fetch('/api/sync/schedule');
const data = await res.json();
-
- if (!data.results || data.results.length === 0) {
- dropdown.classList.add('d-none');
- return;
- }
-
- dropdown.innerHTML = data.results.map(r => `
-
- ${esc(r.codmat)}
-
${esc(r.denumire)}
-
- `).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'));
+ document.getElementById('schedulerToggle').checked = data.enabled || false;
+ if (data.interval_minutes) {
+ document.getElementById('schedulerInterval').value = data.interval_minutes;
}
} catch (err) {
- alert('Eroare: ' + err.message);
+ console.error('loadSchedulerStatus error:', err);
}
}
-// --- sessionStorage helpers for scan data ---
-
-function saveScanData(data) {
- try {
- sessionStorage.setItem('lastScanData', JSON.stringify(data));
- sessionStorage.setItem('lastScanTime', Date.now().toString());
- } catch (e) { /* ignore */ }
-}
-
-function getScanData() {
- try {
- const t = parseInt(sessionStorage.getItem('lastScanTime') || '0');
- // Expire scan data after 5 minutes
- if (Date.now() - t > 5 * 60 * 1000) return null;
- const raw = sessionStorage.getItem('lastScanData');
- return raw ? JSON.parse(raw) : null;
- } catch (e) { return null; }
-}
-
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
diff --git a/api/app/static/js/logs.js b/api/app/static/js/logs.js
index 1d1fda3..6bdac6a 100644
--- a/api/app/static/js/logs.js
+++ b/api/app/static/js/logs.js
@@ -1,9 +1,14 @@
-// logs.js - Unified Logs page with SSE live feed
+// logs.js - Structured order viewer with text log fallback
let currentRunId = null;
-let eventSource = null;
let runsPage = 1;
-let liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
+let logPollTimer = null;
+let currentFilter = 'all';
+let ordersPage = 1;
+let currentQmSku = '';
+let currentQmOrderNumber = '';
+let ordersSortColumn = 'created_at';
+let ordersSortDirection = 'asc';
function esc(s) {
if (s == null) return '';
@@ -13,23 +18,6 @@ function esc(s) {
.replace(/'/g, ''');
}
-function fmtTime(iso) {
- if (!iso) return '';
- try {
- return new Date(iso).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
- } catch (e) { return ''; }
-}
-
-function fmtDatetime(iso) {
- if (!iso) return '-';
- try {
- return new Date(iso).toLocaleString('ro-RO', {
- day: '2-digit', month: '2-digit', year: 'numeric',
- hour: '2-digit', minute: '2-digit'
- });
- } catch (e) { return iso; }
-}
-
function fmtDuration(startedAt, finishedAt) {
if (!startedAt || !finishedAt) return '-';
const diffMs = new Date(finishedAt) - new Date(startedAt);
@@ -39,13 +27,16 @@ function fmtDuration(startedAt, finishedAt) {
return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
}
-function statusBadge(status) {
- switch ((status || '').toUpperCase()) {
- case 'IMPORTED': return 'IMPORTED';
- case 'SKIPPED': return 'SKIPPED';
- case 'ERROR': return 'ERROR';
- default: return `${esc(status || '-')}`;
- }
+function fmtDate(dateStr) {
+ if (!dateStr) return '-';
+ try {
+ const d = new Date(dateStr);
+ const hasTime = dateStr.includes(':');
+ if (hasTime) {
+ return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ }
+ return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
+ } catch { return dateStr; }
}
function runStatusBadge(status) {
@@ -57,323 +48,461 @@ function runStatusBadge(status) {
}
}
-// ── 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 = '' +
- 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 ``;
- }).join('');
-
- // Populate table
- const tbody = document.getElementById('runsTableBody');
- if (runs.length === 0) {
- tbody.innerHTML = '| Niciun sync run |
';
- } 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 `
- | ${started} |
- ${esc(r.status)} |
- ${r.total_orders || 0} |
- ${r.imported || 0} |
- ${r.skipped || 0} |
- ${r.errors || 0} |
- ${duration} |
-
`;
- }).join('');
- }
-
- // Pagination
- const pagDiv = document.getElementById('runsTablePagination');
- const totalPages = Math.ceil(total / perPage);
- if (totalPages > 1) {
- pagDiv.innerHTML = `
-
- ${runsPage} / ${totalPages}
-
- `;
- } else {
- pagDiv.innerHTML = '';
- }
- } catch (err) {
- document.getElementById('runsTableBody').innerHTML = `| ${esc(err.message)} |
`;
+function orderStatusBadge(status) {
+ switch ((status || '').toUpperCase()) {
+ case 'IMPORTED': return 'Importat';
+ case 'SKIPPED': return 'Omis';
+ case 'ERROR': return 'Eroare';
+ default: return `${esc(status)}`;
}
}
-// ── Run Selection ───────────────────────────────
+// ── Runs Dropdown ────────────────────────────────
+
+async function loadRuns() {
+ // Load all recent runs for dropdown
+ try {
+ const res = await fetch(`/api/sync/history?page=1&per_page=100`);
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ const data = await res.json();
+ const runs = data.runs || [];
+
+ const dd = document.getElementById('runsDropdown');
+ if (runs.length === 0) {
+ dd.innerHTML = '';
+ } else {
+ dd.innerHTML = '' +
+ runs.map(r => {
+ const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '?';
+ const st = (r.status || '').toUpperCase();
+ const statusEmoji = st === 'COMPLETED' ? '✓' : st === 'RUNNING' ? '⟳' : '✗';
+ const imp = r.imported || 0;
+ const skip = r.skipped || 0;
+ const err = r.errors || 0;
+ const label = `${started} — ${statusEmoji} ${r.status} (${imp} imp, ${skip} skip, ${err} err)`;
+ const selected = r.run_id === currentRunId ? 'selected' : '';
+ return ``;
+ }).join('');
+ }
+ } catch (err) {
+ const dd = document.getElementById('runsDropdown');
+ dd.innerHTML = ``;
+ }
+}
+
+// ── Run Selection ────────────────────────────────
async function selectRun(runId) {
- if (eventSource) { eventSource.close(); eventSource = null; }
- currentRunId = runId;
+ if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
+
+ currentRunId = runId;
+ currentFilter = 'all';
+ ordersPage = 1;
- // 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 || '';
+ // Sync dropdown selection
+ const dd = document.getElementById('runsDropdown');
+ if (dd && dd.value !== runId) dd.value = runId;
if (!runId) {
- document.getElementById('runDetailSection').style.display = 'none';
+ document.getElementById('logViewerSection').style.display = 'none';
return;
}
- document.getElementById('runDetailSection').style.display = '';
+ document.getElementById('logViewerSection').style.display = '';
+ document.getElementById('logRunId').textContent = runId;
+ document.getElementById('logStatusBadge').innerHTML = '...';
+ document.getElementById('textLogSection').style.display = 'none';
- // 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 */ }
+ await loadRunOrders(runId, 'all', 1);
- // Load historical data
- document.getElementById('liveFeedCard').style.display = 'none';
- await loadRunLog(runId);
+ // Also load text log in background
+ fetchTextLog(runId);
}
-// ── Live SSE Feed ───────────────────────────────
+// ── Per-Order Filtering (R1) ─────────────────────
-function startLiveFeed(runId) {
- liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
+async function loadRunOrders(runId, statusFilter, page) {
+ if (statusFilter != null) currentFilter = statusFilter;
+ if (page != null) ordersPage = page;
- // 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 = `${now}${icon}${esc(message)}`;
- 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 = `${esc(event.error_message)}`;
- }
- if (event.missing_skus && Array.isArray(event.missing_skus) && event.missing_skus.length > 0) {
- details += `${event.missing_skus.map(s => `${esc(s)}`).join('')}
`;
- }
- if (event.id_comanda) {
- details += `ID: ${event.id_comanda}`;
- }
- if (!details) details = '-';
-
- const tr = document.createElement('tr');
- tr.setAttribute('data-status', status);
- tr.innerHTML = `
- ${esc(event.order_number || '-')} |
- ${esc(event.customer_name || '-')} |
- ${event.items_count ?? '-'} |
- ${statusBadge(status)} |
- ${details} |
- `;
- 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 = '| Se incarca... |
';
+ // Update filter button styles
+ document.querySelectorAll('#orderFilterBtns button').forEach(btn => {
+ btn.className = btn.className.replace(' btn-primary', ' btn-outline-primary')
+ .replace(' btn-success', ' btn-outline-success')
+ .replace(' btn-warning', ' btn-outline-warning')
+ .replace(' btn-danger', ' btn-outline-danger');
+ });
try {
- const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`);
+ const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/orders?status=${currentFilter}&page=${ordersPage}&per_page=50&sort_by=${ordersSortColumn}&sort_dir=${ordersSortDirection}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
- const run = data.run || {};
- const orders = data.orders || [];
+ const counts = data.counts || {};
+ document.getElementById('countAll').textContent = counts.total || 0;
+ document.getElementById('countImported').textContent = counts.imported || 0;
+ document.getElementById('countSkipped').textContent = counts.skipped || 0;
+ document.getElementById('countError').textContent = counts.error || 0;
- // 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
- ? `| ${esc(run.error_message)} |
`
- : '| Nicio comanda in acest sync run |
';
- tbody.innerHTML = runError;
- updateFilterCount();
- return;
+ // Highlight active filter
+ const filterMap = { 'all': 0, 'IMPORTED': 1, 'SKIPPED': 2, 'ERROR': 3 };
+ const btns = document.querySelectorAll('#orderFilterBtns button');
+ const idx = filterMap[currentFilter] || 0;
+ if (btns[idx]) {
+ const colorMap = ['primary', 'success', 'warning', 'danger'];
+ btns[idx].className = btns[idx].className.replace(`btn-outline-${colorMap[idx]}`, `btn-${colorMap[idx]}`);
}
- 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 = '' +
- skus.map(s => `${esc(s)}`).join('') + '
';
- }
- } catch (e) { /* skip */ }
- }
- const details = order.error_message
- ? `${esc(order.error_message)}${missingSkuTags}`
- : missingSkuTags || '-';
+ const tbody = document.getElementById('runOrdersBody');
+ const orders = data.orders || [];
- return `
- ${esc(order.order_number || '-')} |
- ${esc(order.customer_name || '-')} |
- ${order.items_count ?? '-'} |
- ${statusBadge(status)} |
- ${details} |
-
`;
- }).join('');
+ if (orders.length === 0) {
+ tbody.innerHTML = '| Nicio comanda |
';
+ } else {
+ tbody.innerHTML = orders.map((o, i) => {
+ const dateStr = fmtDate(o.order_date);
+ return `
+ | ${(ordersPage - 1) * 50 + i + 1} |
+ ${dateStr} |
+ ${esc(o.order_number)} |
+ ${esc(o.customer_name)} |
+ ${o.items_count || 0} |
+ ${orderStatusBadge(o.status)} |
+
`;
+ }).join('');
+ }
- // Reset filter
- document.querySelectorAll('[data-filter]').forEach(btn => {
- btn.classList.toggle('active', btn.dataset.filter === 'all');
- });
- applyFilter('all');
+ // Orders pagination
+ const totalPages = data.pages || 1;
+ const infoEl = document.getElementById('ordersPageInfo');
+ infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`;
+ const pagDiv = document.getElementById('ordersPagination');
+ if (totalPages > 1) {
+ pagDiv.innerHTML = `
+
+ ${ordersPage} / ${totalPages}
+
+ `;
+ } else {
+ pagDiv.innerHTML = '';
+ }
+
+ // Update run status badge
+ const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`);
+ const runData = await runRes.json();
+ if (runData.run) {
+ document.getElementById('logStatusBadge').innerHTML = runStatusBadge(runData.run.status);
+ }
} catch (err) {
- tbody.innerHTML = `| ${esc(err.message)} |
`;
+ document.getElementById('runOrdersBody').innerHTML =
+ `| ${esc(err.message)} |
`;
}
}
-// ── 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 filterOrders(status) {
+ loadRunOrders(currentRunId, status, 1);
}
-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`;
+function sortOrdersBy(col) {
+ if (ordersSortColumn === col) {
+ ordersSortDirection = ordersSortDirection === 'asc' ? 'desc' : 'asc';
+ } else {
+ ordersSortColumn = col;
+ ordersSortDirection = 'asc';
+ }
+ // Update sort icons
+ document.querySelectorAll('#logViewerSection .sort-icon').forEach(span => {
+ const c = span.dataset.col;
+ span.textContent = c === ordersSortColumn ? (ordersSortDirection === 'asc' ? '\u2191' : '\u2193') : '';
+ });
+ loadRunOrders(currentRunId, null, 1);
+}
+
+// ── Text Log (collapsible) ──────────────────────
+
+function toggleTextLog() {
+ const section = document.getElementById('textLogSection');
+ section.style.display = section.style.display === 'none' ? '' : 'none';
+ if (section.style.display !== 'none' && currentRunId) {
+ fetchTextLog(currentRunId);
+ }
+}
+
+async function fetchTextLog(runId) {
+ // Clear any existing poll timer to prevent accumulation
+ if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
+
+ try {
+ const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/text-log`);
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ const data = await res.json();
+
+ document.getElementById('logContent').textContent = data.text || '(log gol)';
+
+ if (!data.finished) {
+ if (document.getElementById('autoRefreshToggle')?.checked) {
+ logPollTimer = setInterval(async () => {
+ try {
+ const r = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/text-log`);
+ const d = await r.json();
+ if (currentRunId !== runId) { clearInterval(logPollTimer); return; }
+ document.getElementById('logContent').textContent = d.text || '(log gol)';
+ const el = document.getElementById('logContent');
+ el.scrollTop = el.scrollHeight;
+ if (d.finished) {
+ clearInterval(logPollTimer);
+ logPollTimer = null;
+ loadRuns();
+ loadRunOrders(runId, currentFilter, ordersPage);
+ }
+ } catch (e) { console.error('Poll error:', e); }
+ }, 2500);
+ }
+ }
+ } catch (err) {
+ document.getElementById('logContent').textContent = 'Eroare: ' + err.message;
+ }
+}
+
+// ── Multi-CODMAT helper (D1) ─────────────────────
+
+function renderCodmatCell(item) {
+ if (!item.codmat_details || item.codmat_details.length === 0) {
+ return `${esc(item.codmat || '-')}`;
+ }
+ if (item.codmat_details.length === 1) {
+ const d = item.codmat_details[0];
+ return `${esc(d.codmat)}`;
+ }
+ // Multi-CODMAT: compact list
+ return item.codmat_details.map(d =>
+ `${esc(d.codmat)} \xd7${d.cantitate_roa} (${d.procent_pret}%)
`
+ ).join('');
+}
+
+// ── Order Detail Modal (R9) ─────────────────────
+
+async function openOrderDetail(orderNumber) {
+ document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
+ document.getElementById('detailCustomer').textContent = '...';
+ document.getElementById('detailDate').textContent = '';
+ document.getElementById('detailStatus').innerHTML = '';
+ document.getElementById('detailIdComanda').textContent = '-';
+ document.getElementById('detailIdPartener').textContent = '-';
+ document.getElementById('detailIdAdresaFact').textContent = '-';
+ document.getElementById('detailIdAdresaLivr').textContent = '-';
+ document.getElementById('detailItemsBody').innerHTML = '| Se incarca... |
';
+ document.getElementById('detailError').style.display = 'none';
+
+ const modalEl = document.getElementById('orderDetailModal');
+ const existing = bootstrap.Modal.getInstance(modalEl);
+ if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
+
+ try {
+ const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
+ const data = await res.json();
+
+ if (data.error) {
+ document.getElementById('detailError').textContent = data.error;
+ document.getElementById('detailError').style.display = '';
+ return;
+ }
+
+ const order = data.order || {};
+ document.getElementById('detailCustomer').textContent = order.customer_name || '-';
+ document.getElementById('detailDate').textContent = fmtDate(order.order_date);
+ document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
+ document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
+ document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
+ document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
+ document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
+
+ if (order.error_message) {
+ document.getElementById('detailError').textContent = order.error_message;
+ document.getElementById('detailError').style.display = '';
+ }
+
+ const items = data.items || [];
+ if (items.length === 0) {
+ document.getElementById('detailItemsBody').innerHTML = '| Niciun articol |
';
+ return;
+ }
+
+ document.getElementById('detailItemsBody').innerHTML = items.map(item => {
+ let statusBadge;
+ switch (item.mapping_status) {
+ case 'mapped': statusBadge = 'Mapat'; break;
+ case 'direct': statusBadge = 'Direct'; break;
+ case 'missing': statusBadge = 'Lipsa'; break;
+ default: statusBadge = '?';
+ }
+
+ const action = item.mapping_status === 'missing'
+ ? ``
+ : '';
+
+ return `
+ ${esc(item.sku)} |
+ ${esc(item.product_name || '-')} |
+ ${item.quantity || 0} |
+ ${item.price != null ? Number(item.price).toFixed(2) : '-'} |
+ ${item.vat != null ? Number(item.vat).toFixed(2) : '-'} |
+ ${renderCodmatCell(item)} |
+ ${statusBadge} |
+ ${action} |
+
`;
+ }).join('');
+ } catch (err) {
+ document.getElementById('detailError').textContent = err.message;
+ document.getElementById('detailError').style.display = '';
+ }
+}
+
+// ── Quick Map Modal (from order detail) ──────────
+
+let qmAcTimeout = null;
+
+function openQuickMap(sku, productName, orderNumber) {
+ currentQmSku = sku;
+ currentQmOrderNumber = orderNumber;
+ document.getElementById('qmSku').textContent = sku;
+ document.getElementById('qmProductName').textContent = productName || '-';
+ document.getElementById('qmPctWarning').style.display = 'none';
+
+ // Reset CODMAT lines
+ const container = document.getElementById('qmCodmatLines');
+ container.innerHTML = '';
+ addQmCodmatLine();
+
+ // Show quick map on top of order detail (modal stacking)
+ new bootstrap.Modal(document.getElementById('quickMapModal')).show();
+}
+
+function addQmCodmatLine() {
+ const container = document.getElementById('qmCodmatLines');
+ const idx = container.children.length;
+ const div = document.createElement('div');
+ div.className = 'border rounded p-2 mb-2 qm-line';
+ div.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${idx > 0 ? `` : ''}
+
+
+ `;
+ container.appendChild(div);
+
+ // Setup autocomplete on the new input
+ const input = div.querySelector('.qm-codmat');
+ const dropdown = div.querySelector('.qm-ac-dropdown');
+ const selected = div.querySelector('.qm-selected');
+
+ input.addEventListener('input', () => {
+ clearTimeout(qmAcTimeout);
+ qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250);
+ });
+ input.addEventListener('blur', () => {
+ setTimeout(() => dropdown.classList.add('d-none'), 200);
+ });
+}
+
+async function qmAutocomplete(input, dropdown, selectedEl) {
+ const q = input.value;
+ 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 =>
+ `
+ ${esc(r.codmat)} — ${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''}
+
`
+ ).join('');
+ dropdown.classList.remove('d-none');
+ } catch { dropdown.classList.add('d-none'); }
+}
+
+function qmSelectArticle(el, codmat, label) {
+ const line = el.closest('.qm-line');
+ line.querySelector('.qm-codmat').value = codmat;
+ line.querySelector('.qm-selected').textContent = label;
+ line.querySelector('.qm-ac-dropdown').classList.add('d-none');
+}
+
+async function saveQuickMapping() {
+ const lines = document.querySelectorAll('.qm-line');
+ const mappings = [];
+
+ for (const line of lines) {
+ const codmat = line.querySelector('.qm-codmat').value.trim();
+ const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
+ const procent = parseFloat(line.querySelector('.qm-procent').value) || 100;
+ if (!codmat) continue;
+ mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
+ }
+
+ if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
+
+ // Validate percentage sum for multi-line
+ if (mappings.length > 1) {
+ const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
+ if (Math.abs(totalPct - 100) > 0.01) {
+ document.getElementById('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
+ document.getElementById('qmPctWarning').style.display = '';
+ return;
+ }
+ }
+ document.getElementById('qmPctWarning').style.display = 'none';
+
+ try {
+ let res;
+ if (mappings.length === 1) {
+ res = await fetch('/api/mappings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sku: currentQmSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
+ });
+ } else {
+ res = await fetch('/api/mappings/batch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sku: currentQmSku, mappings })
+ });
+ }
+ const data = await res.json();
+ if (data.success) {
+ bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
+ // Refresh order detail items in the still-open modal
+ if (currentQmOrderNumber) openOrderDetail(currentQmOrderNumber);
+ // Refresh orders view
+ loadRunOrders(currentRunId, currentFilter, ordersPage);
+ } else {
+ alert('Eroare: ' + (data.error || 'Unknown'));
+ }
+ } catch (err) {
+ alert('Eroare: ' + err.message);
+ }
}
// ── Init ────────────────────────────────────────
@@ -381,25 +510,20 @@ function updateFilterCount(visible, total, filter) {
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);
}
+
+ document.getElementById('autoRefreshToggle')?.addEventListener('change', (e) => {
+ if (e.target.checked) {
+ // Resume polling if we have an active run
+ if (currentRunId) fetchTextLog(currentRunId);
+ } else {
+ // Pause polling
+ if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
+ }
+ });
});
diff --git a/api/app/static/js/mappings.js b/api/app/static/js/mappings.js
index 71a147c..55672e3 100644
--- a/api/app/static/js/mappings.js
+++ b/api/app/static/js/mappings.js
@@ -1,9 +1,16 @@
let currentPage = 1;
let currentSearch = '';
let searchTimeout = null;
+let sortColumn = 'sku';
+let sortDirection = 'asc';
+let editingMapping = null; // {sku, codmat} when editing
// Load on page ready
-document.addEventListener('DOMContentLoaded', loadMappings);
+document.addEventListener('DOMContentLoaded', () => {
+ loadMappings();
+ initAddModal();
+ initDeleteModal();
+});
function debounceSearch() {
clearTimeout(searchTimeout);
@@ -14,52 +21,132 @@ function debounceSearch() {
}, 300);
}
+// ── Sorting (R7) ─────────────────────────────────
+
+function sortBy(col) {
+ if (sortColumn === col) {
+ sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
+ } else {
+ sortColumn = col;
+ sortDirection = 'asc';
+ }
+ currentPage = 1;
+ loadMappings();
+}
+
+function updateSortIcons() {
+ document.querySelectorAll('.sort-icon').forEach(span => {
+ const col = span.dataset.col;
+ if (col === sortColumn) {
+ span.textContent = sortDirection === 'asc' ? '\u2191' : '\u2193';
+ } else {
+ span.textContent = '';
+ }
+ });
+}
+
+// ── Load & Render ────────────────────────────────
+
async function loadMappings() {
+ const showInactive = document.getElementById('showInactive')?.checked;
+ const showDeleted = document.getElementById('showDeleted')?.checked;
const params = new URLSearchParams({
search: currentSearch,
page: currentPage,
- per_page: 50
+ per_page: 50,
+ sort_by: sortColumn,
+ sort_dir: sortDirection
});
+ if (showDeleted) params.set('show_deleted', 'true');
try {
const res = await fetch(`/api/mappings?${params}`);
const data = await res.json();
- renderTable(data.mappings);
+
+ let mappings = data.mappings || [];
+
+ // Client-side filter for inactive unless toggle is on
+ // (keep deleted rows visible when showDeleted is on, even if inactive)
+ if (!showInactive) {
+ mappings = mappings.filter(m => m.activ || m.sters);
+ }
+
+ renderTable(mappings, showDeleted);
renderPagination(data);
+ updateSortIcons();
} catch (err) {
document.getElementById('mappingsBody').innerHTML =
- `| Eroare: ${err.message} |
`;
+ `| Eroare: ${err.message} |
`;
}
}
-function renderTable(mappings) {
+function renderTable(mappings, showDeleted) {
const tbody = document.getElementById('mappingsBody');
if (!mappings || mappings.length === 0) {
- tbody.innerHTML = '| Nu exista mapari |
';
+ tbody.innerHTML = '| Nu exista mapari |
';
return;
}
- tbody.innerHTML = mappings.map(m => `
-
- | ${esc(m.sku)} |
+ // Group by SKU for visual grouping (R6)
+ let html = '';
+ let prevSku = null;
+ let groupIdx = 0;
+ let skuGroupCounts = {};
+
+ // Count items per SKU
+ mappings.forEach(m => {
+ skuGroupCounts[m.sku] = (skuGroupCounts[m.sku] || 0) + 1;
+ });
+
+ mappings.forEach((m, i) => {
+ const isNewGroup = m.sku !== prevSku;
+ if (isNewGroup) groupIdx++;
+ const groupClass = groupIdx % 2 === 0 ? 'sku-group-even' : 'sku-group-odd';
+ const isMulti = skuGroupCounts[m.sku] > 1;
+ const inactiveClass = !m.activ && !m.sters ? 'table-secondary opacity-75' : '';
+ const deletedClass = m.sters ? 'mapping-deleted' : '';
+
+ // SKU cell: show only on first row of group
+ let skuCell, productCell;
+ if (isNewGroup) {
+ const badge = isMulti ? ` Set (${skuGroupCounts[m.sku]})` : '';
+ skuCell = `${esc(m.sku)}${badge} | `;
+ productCell = `${esc(m.product_name || '-')} | `;
+ } else {
+ skuCell = '';
+ productCell = '';
+ }
+
+ html += `
+ ${skuCell}
+ ${productCell}
${esc(m.codmat)} |
${esc(m.denumire || '-')} |
- ${m.cantitate_roa} |
- ${m.procent_pret}% |
+ ${esc(m.um || '-')} |
+ ${m.cantitate_roa} |
+ ${m.procent_pret}% |
-
+
${m.activ ? 'Activ' : 'Inactiv'}
|
- |
-
- `).join('');
+ `;
+
+ prevSku = m.sku;
+ });
+
+ tbody.innerHTML = html;
}
function renderPagination(data) {
@@ -70,11 +157,9 @@ function renderPagination(data) {
if (data.pages <= 1) { ul.innerHTML = ''; return; }
let html = '';
- // Previous
html += `
«`;
- // Pages (show max 7)
let start = Math.max(1, data.page - 3);
let end = Math.min(data.pages, start + 6);
start = Math.max(1, end - 6);
@@ -84,7 +169,6 @@ function renderPagination(data) {
${i}`;
}
- // Next
html += `
»`;
@@ -96,73 +180,186 @@ function goPage(p) {
loadMappings();
}
-// Autocomplete for CODMAT
-let acTimeout = null;
-document.addEventListener('DOMContentLoaded', () => {
- const input = document.getElementById('inputCodmat');
- if (!input) return;
+// ── Multi-CODMAT Add Modal (R11) ─────────────────
+
+let acTimeouts = {};
+
+function initAddModal() {
+ const modal = document.getElementById('addModal');
+ if (!modal) return;
+
+ modal.addEventListener('show.bs.modal', () => {
+ if (!editingMapping) {
+ clearAddForm();
+ }
+ });
+ modal.addEventListener('hidden.bs.modal', () => {
+ editingMapping = null;
+ document.getElementById('addModalTitle').textContent = 'Adauga Mapare';
+ });
+}
+
+function clearAddForm() {
+ document.getElementById('inputSku').value = '';
+ document.getElementById('inputSku').readOnly = false;
+ document.getElementById('addModalProductName').style.display = 'none';
+ document.getElementById('pctWarning').style.display = 'none';
+ document.getElementById('addModalTitle').textContent = 'Adauga Mapare';
+ const container = document.getElementById('codmatLines');
+ container.innerHTML = '';
+ addCodmatLine();
+}
+
+function openEditModal(sku, codmat, cantitate, procent) {
+ editingMapping = { sku, codmat };
+ document.getElementById('addModalTitle').textContent = 'Editare Mapare';
+ document.getElementById('inputSku').value = sku;
+ document.getElementById('inputSku').readOnly = false;
+ document.getElementById('pctWarning').style.display = 'none';
+
+ const container = document.getElementById('codmatLines');
+ container.innerHTML = '';
+ addCodmatLine();
+
+ // Pre-fill the CODMAT line
+ const line = container.querySelector('.codmat-line');
+ if (line) {
+ line.querySelector('.cl-codmat').value = codmat;
+ line.querySelector('.cl-cantitate').value = cantitate;
+ line.querySelector('.cl-procent').value = procent;
+ }
+
+ new bootstrap.Modal(document.getElementById('addModal')).show();
+}
+
+function addCodmatLine() {
+ const container = document.getElementById('codmatLines');
+ const idx = container.children.length;
+ const div = document.createElement('div');
+ div.className = 'border rounded p-2 mb-2 codmat-line';
+ div.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${idx > 0 ? `` : ''}
+
+
+ `;
+ container.appendChild(div);
+
+ // Setup autocomplete
+ const input = div.querySelector('.cl-codmat');
+ const dropdown = div.querySelector('.cl-ac-dropdown');
+ const selected = div.querySelector('.cl-selected');
input.addEventListener('input', () => {
- clearTimeout(acTimeout);
- acTimeout = setTimeout(() => autocomplete(input.value), 250);
+ const key = 'cl_' + idx;
+ clearTimeout(acTimeouts[key]);
+ acTimeouts[key] = setTimeout(() => clAutocomplete(input, dropdown, selected), 250);
});
-
input.addEventListener('blur', () => {
- setTimeout(() => document.getElementById('autocompleteDropdown').classList.add('d-none'), 200);
+ setTimeout(() => dropdown.classList.add('d-none'), 200);
});
-});
+}
-async function autocomplete(q) {
- const dropdown = document.getElementById('autocompleteDropdown');
+async function clAutocomplete(input, dropdown, selectedEl) {
+ const q = input.value;
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; }
- if (!data.results || data.results.length === 0) {
- dropdown.classList.add('d-none');
- return;
- }
-
- dropdown.innerHTML = data.results.map(r => `
-
- ${esc(r.codmat)}
-
${esc(r.denumire)}
-
- `).join('');
+ dropdown.innerHTML = data.results.map(r =>
+ `
+ ${esc(r.codmat)} — ${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''}
+
`
+ ).join('');
dropdown.classList.remove('d-none');
- } catch (err) {
- dropdown.classList.add('d-none');
- }
+ } catch { dropdown.classList.add('d-none'); }
}
-function selectArticle(codmat, denumire) {
- document.getElementById('inputCodmat').value = codmat;
- document.getElementById('selectedArticle').textContent = denumire;
- document.getElementById('autocompleteDropdown').classList.add('d-none');
+function clSelectArticle(el, codmat, label) {
+ const line = el.closest('.codmat-line');
+ line.querySelector('.cl-codmat').value = codmat;
+ line.querySelector('.cl-selected').textContent = label;
+ line.querySelector('.cl-ac-dropdown').classList.add('d-none');
}
-// Save mapping (create)
async function saveMapping() {
const sku = document.getElementById('inputSku').value.trim();
- const codmat = document.getElementById('inputCodmat').value.trim();
- const cantitate = parseFloat(document.getElementById('inputCantitate').value) || 1;
- const procent = parseFloat(document.getElementById('inputProcent').value) || 100;
+ if (!sku) { alert('SKU este obligatoriu'); return; }
- if (!sku || !codmat) { alert('SKU si CODMAT sunt obligatorii'); return; }
+ const lines = document.querySelectorAll('.codmat-line');
+ const mappings = [];
+
+ for (const line of lines) {
+ const codmat = line.querySelector('.cl-codmat').value.trim();
+ const cantitate = parseFloat(line.querySelector('.cl-cantitate').value) || 1;
+ const procent = parseFloat(line.querySelector('.cl-procent').value) || 100;
+ if (!codmat) continue;
+ mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
+ }
+
+ if (mappings.length === 0) { alert('Adauga cel putin un CODMAT'); return; }
+
+ // Validate percentage for multi-line
+ if (mappings.length > 1) {
+ const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
+ if (Math.abs(totalPct - 100) > 0.01) {
+ document.getElementById('pctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
+ document.getElementById('pctWarning').style.display = '';
+ return;
+ }
+ }
+ document.getElementById('pctWarning').style.display = 'none';
try {
- const res = await fetch('/api/mappings', {
- method: 'POST',
- headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
- });
- const data = await res.json();
+ let res;
+ if (editingMapping) {
+ // Edit mode: use PUT /api/mappings/{old_sku}/{old_codmat}/edit
+ res = await fetch(`/api/mappings/${encodeURIComponent(editingMapping.sku)}/${encodeURIComponent(editingMapping.codmat)}/edit`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ new_sku: sku,
+ new_codmat: mappings[0].codmat,
+ cantitate_roa: mappings[0].cantitate_roa,
+ procent_pret: mappings[0].procent_pret
+ })
+ });
+ } else if (mappings.length === 1) {
+ res = await fetch('/api/mappings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
+ });
+ } else {
+ res = await fetch('/api/mappings/batch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sku, mappings })
+ });
+ }
+ const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
- clearForm();
+ editingMapping = null;
loadMappings();
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
@@ -172,17 +369,117 @@ async function saveMapping() {
}
}
-function clearForm() {
- document.getElementById('inputSku').value = '';
- document.getElementById('inputCodmat').value = '';
- document.getElementById('inputCantitate').value = '1';
- document.getElementById('inputProcent').value = '100';
- document.getElementById('selectedArticle').textContent = '';
+// ── Inline Add Row ──────────────────────────────
+
+let inlineAddVisible = false;
+
+function showInlineAddRow() {
+ if (inlineAddVisible) return;
+ inlineAddVisible = true;
+
+ const tbody = document.getElementById('mappingsBody');
+ const row = document.createElement('tr');
+ row.id = 'inlineAddRow';
+ row.className = 'table-info';
+ row.innerHTML = `
+
+
+ |
+
+
+
+
+ |
+ - |
+
+
+ |
+
+
+ |
+ - |
+
+
+
+ |
+ `;
+ tbody.insertBefore(row, tbody.firstChild);
+ document.getElementById('inlineSku').focus();
+
+ // Setup autocomplete for inline CODMAT
+ const input = document.getElementById('inlineCodmat');
+ const dropdown = document.getElementById('inlineAcDropdown');
+ const selected = document.getElementById('inlineSelected');
+ let inlineAcTimeout = null;
+
+ input.addEventListener('input', () => {
+ clearTimeout(inlineAcTimeout);
+ inlineAcTimeout = setTimeout(() => inlineAutocomplete(input, dropdown, selected), 250);
+ });
+ input.addEventListener('blur', () => {
+ setTimeout(() => dropdown.classList.add('d-none'), 200);
+ });
}
-// Inline edit
+async function inlineAutocomplete(input, dropdown, selectedEl) {
+ const q = input.value;
+ 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 =>
+ `
+ ${esc(r.codmat)} — ${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''}
+
`
+ ).join('');
+ dropdown.classList.remove('d-none');
+ } catch { dropdown.classList.add('d-none'); }
+}
+
+function inlineSelectArticle(codmat, label) {
+ document.getElementById('inlineCodmat').value = codmat;
+ document.getElementById('inlineSelected').textContent = label;
+ document.getElementById('inlineAcDropdown').classList.add('d-none');
+}
+
+async function saveInlineMapping() {
+ const sku = document.getElementById('inlineSku').value.trim();
+ const codmat = document.getElementById('inlineCodmat').value.trim();
+ const cantitate = parseFloat(document.getElementById('inlineCantitate').value) || 1;
+ const procent = parseFloat(document.getElementById('inlineProcent').value) || 100;
+
+ if (!sku) { alert('SKU este obligatoriu'); return; }
+ if (!codmat) { alert('CODMAT este obligatoriu'); return; }
+
+ try {
+ const res = await fetch('/api/mappings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
+ });
+ const data = await res.json();
+ if (data.success) {
+ cancelInlineAdd();
+ loadMappings();
+ } else {
+ alert('Eroare: ' + (data.error || 'Unknown'));
+ }
+ } catch (err) {
+ alert('Eroare: ' + err.message);
+ }
+}
+
+function cancelInlineAdd() {
+ const row = document.getElementById('inlineAddRow');
+ if (row) row.remove();
+ inlineAddVisible = false;
+}
+
+// ── Inline Edit ──────────────────────────────────
+
function editCell(td, sku, codmat, field, currentValue) {
- if (td.querySelector('input')) return; // Already editing
+ if (td.querySelector('input')) return;
const input = document.createElement('input');
input.type = 'number';
@@ -203,25 +500,18 @@ function editCell(td, sku, codmat, field, currentValue) {
td.textContent = originalText;
return;
}
-
try {
const body = {};
body[field] = newValue;
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
- headers: {'Content-Type': 'application/json'},
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
- if (data.success) {
- loadMappings();
- } else {
- td.textContent = originalText;
- alert('Eroare: ' + (data.error || 'Update failed'));
- }
- } catch (err) {
- td.textContent = originalText;
- }
+ if (data.success) { loadMappings(); }
+ else { td.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
+ } catch (err) { td.textContent = originalText; }
};
input.addEventListener('blur', save);
@@ -231,34 +521,100 @@ function editCell(td, sku, codmat, field, currentValue) {
});
}
-// Toggle active
+// ── Toggle Active with Toast Undo ────────────────
+
async function toggleActive(sku, codmat, currentActive) {
+ const newActive = currentActive ? 0 : 1;
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
- headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({ activ: currentActive ? 0 : 1 })
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ activ: newActive })
+ });
+ const data = await res.json();
+ if (!data.success) return;
+
+ loadMappings();
+
+ // Show toast with undo
+ const action = newActive ? 'activata' : 'dezactivata';
+ showUndoToast(`Mapare ${sku} \u2192 ${codmat} ${action}.`, () => {
+ fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ activ: currentActive })
+ }).then(() => loadMappings());
+ });
+ } catch (err) { alert('Eroare: ' + err.message); }
+}
+
+function showUndoToast(message, undoCallback) {
+ document.getElementById('toastMessage').textContent = message;
+ const undoBtn = document.getElementById('toastUndoBtn');
+ // Clone to remove old listeners
+ const newBtn = undoBtn.cloneNode(true);
+ undoBtn.parentNode.replaceChild(newBtn, undoBtn);
+ newBtn.id = 'toastUndoBtn';
+ newBtn.addEventListener('click', () => {
+ undoCallback();
+ const toastEl = document.getElementById('undoToast');
+ const inst = bootstrap.Toast.getInstance(toastEl);
+ if (inst) inst.hide();
+ });
+ const toast = new bootstrap.Toast(document.getElementById('undoToast'));
+ toast.show();
+}
+
+// ── Delete with Modal Confirmation ──────────────
+
+let pendingDelete = null;
+
+function initDeleteModal() {
+ const btn = document.getElementById('confirmDeleteBtn');
+ if (!btn) return;
+ btn.addEventListener('click', async () => {
+ if (!pendingDelete) return;
+ const { sku, codmat } = pendingDelete;
+ try {
+ const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
+ method: 'DELETE'
+ });
+ const data = await res.json();
+ bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal')).hide();
+ if (data.success) loadMappings();
+ else alert('Eroare: ' + (data.error || 'Delete failed'));
+ } catch (err) {
+ bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal')).hide();
+ alert('Eroare: ' + err.message);
+ }
+ pendingDelete = null;
+ });
+}
+
+function deleteMappingConfirm(sku, codmat) {
+ pendingDelete = { sku, codmat };
+ document.getElementById('deleteSkuText').textContent = sku;
+ document.getElementById('deleteCodmatText').textContent = codmat;
+ new bootstrap.Modal(document.getElementById('deleteConfirmModal')).show();
+}
+
+// ── Restore Deleted ──────────────────────────────
+
+async function restoreMapping(sku, codmat) {
+ try {
+ const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}/restore`, {
+ method: 'POST'
});
const data = await res.json();
if (data.success) loadMappings();
+ else alert('Eroare: ' + (data.error || 'Restore failed'));
} catch (err) {
alert('Eroare: ' + err.message);
}
}
-// Delete (soft)
-function deleteMappingConfirm(sku, codmat) {
- if (confirm(`Dezactivezi maparea ${sku} -> ${codmat}?`)) {
- fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
- method: 'DELETE'
- }).then(r => r.json()).then(d => {
- if (d.success) loadMappings();
- else alert('Eroare: ' + (d.error || 'Delete failed'));
- });
- }
-}
+// ── CSV ──────────────────────────────────────────
-// CSV import
async function importCsv() {
const fileInput = document.getElementById('csvFile');
if (!fileInput.files.length) { alert('Selecteaza un fisier CSV'); return; }
@@ -267,12 +623,8 @@ async function importCsv() {
formData.append('file', fileInput.files[0]);
try {
- const res = await fetch('/api/mappings/import-csv', {
- method: 'POST',
- body: formData
- });
+ const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData });
const data = await res.json();
-
let html = `Procesate: ${data.processed}
`;
if (data.errors && data.errors.length > 0) {
html += `Erori:
${data.errors.map(e => `- ${esc(e)}
`).join('')}
`;
@@ -284,15 +636,9 @@ async function importCsv() {
}
}
-function exportCsv() {
- window.location.href = '/api/mappings/export-csv';
-}
+function exportCsv() { window.location.href = '/api/mappings/export-csv'; }
+function downloadTemplate() { window.location.href = '/api/mappings/csv-template'; }
-function downloadTemplate() {
- window.location.href = '/api/mappings/csv-template';
-}
-
-// Escape HTML
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
diff --git a/api/app/templates/dashboard.html b/api/app/templates/dashboard.html
index 022b9ed..96b2932 100644
--- a/api/app/templates/dashboard.html
+++ b/api/app/templates/dashboard.html
@@ -3,76 +3,13 @@
{% block nav_dashboard %}active{% endblock %}
{% block content %}
-Dashboard
-
-
-
-
-
-
-
-
-
-
-
Total SKU Scanate
-
-
-
-
-
-
-
+Panou de Comanda
@@ -80,9 +17,6 @@
Start Sync
-
- Scan
-
Stop
@@ -113,86 +47,161 @@
-
+
+
+
+
+
+
+
Data
-
+
Status
-
+
Importate
0
+
Omise
0
+
Erori
0
+
Durata
-
+
+
+
+
+
+
-
-
-
-
-
-
- | Data |
- Status |
- Total |
- OK |
- Fără mapare |
- Erori |
- Durata |
-
-
-
- | Se incarca... |
-
-
-
-
-
-
-
-
+
+
+
+ Toate 0
+
+
+ Importate 0
+
+
+ Omise 0
+
+
+ Erori 0
+
+
+ Nefacturate 0
+
+
- | SKU |
- Produs |
- Nr. Comenzi |
- Primul Client |
- Acțiune |
+ Nr Comanda |
+ Data |
+ Client |
+ Art. |
+ Status Import |
+ ID ROA |
+ Factura |
+ Total |
-
- | Se incarca... |
+
+ | Se incarca... |
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+ Client:
+ Data comanda:
+ Status:
-
-
-
+
+ ID Comanda ROA: -
+ ID Partener: -
+ ID Adr. Facturare: -
+ ID Adr. Livrare: -
+
+
+
+
+ | SKU |
+ Produs |
+ Cant. |
+ Pret |
+ TVA |
+ CODMAT |
+ Status |
+ Actiune |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Produs web:
+
+
+
+
+
+ Adauga CODMAT
+
+
diff --git a/api/app/templates/logs.html b/api/app/templates/logs.html
index 22fe4c7..9876bf7 100644
--- a/api/app/templates/logs.html
+++ b/api/app/templates/logs.html
@@ -3,132 +3,165 @@
{% block nav_logs %}active{% endblock %}
{% block content %}
-
-
Jurnale Import
-
-
-
-
-
-
-
+
Jurnale Import
-
+
-
-
-
-
-
-
- | Data |
- Status |
- Total |
- OK |
- Fara mapare |
- Erori |
- Durata |
-
-
-
- | Se incarca... |
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
Total
+
+
+
+
+
-
-
-
-
-
Importate
+
+
+
+ Toate 0
+
+
+ Importate 0
+
+
+ Omise 0
+
+
+ Erori 0
+
-
-
-
-
-
-
-
-
-
-
-
-
- Toate
-
-
- Importate
-
-
- Fara Mapare
-
-
- Erori
-
-
-
-
+
-
+
- | Nr. Comanda |
- Client |
- Nr. Articole |
- Status |
- Eroare / Detalii |
+ # |
+ Data comanda |
+ Nr. comanda |
+ Client |
+ Articole |
+ Status |
-
+
+ | Selecteaza un sync run |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Client:
+ Data comanda:
+ Status:
+
+
+ ID Comanda ROA: -
+ ID Partener: -
+ ID Adr. Facturare: -
+ ID Adr. Livrare: -
+
+
+
+
+
+
+ | SKU |
+ Produs |
+ Cant. |
+ Pret |
+ TVA |
+ CODMAT |
+ Status |
+ Actiune |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Produs web:
+
+
+
+
+
+ Adauga CODMAT
+
+
+
+
+
diff --git a/api/app/templates/mappings.html b/api/app/templates/mappings.html
index cb8f66a..bc1e301 100644
--- a/api/app/templates/mappings.html
+++ b/api/app/templates/mappings.html
@@ -9,7 +9,8 @@
Template CSV
Export CSV
Import CSV
- Adauga Mapare
+ Adauga Mapare
+ Formular complet
@@ -23,6 +24,18 @@
+
+
+
@@ -30,17 +43,19 @@
- | SKU |
- CODMAT |
- Denumire |
- Cantitate ROA |
- Procent Pret |
- Activ |
+ SKU |
+ Produs Web |
+ CODMAT |
+ Denumire |
+ UM |
+ Cantitate ROA |
+ Procent Pret |
+ Activ |
Actiuni |
- | Se incarca... |
+ | Se incarca... |
@@ -53,9 +68,9 @@
-
+
-
+
-
-
-
-
-
+
+ Produs web:
-
-
-
-
-
-
-
-
-
+
+
+
+
+ Adauga CODMAT
+
+
+
+
+
+
+
+
+ Sigur vrei sa stergi maparea?
+ SKU:
+ CODMAT:
+
+
+
+
+
+
+
+
{% endblock %}
{% block scripts %}
diff --git a/api/app/templates/missing_skus.html b/api/app/templates/missing_skus.html
index 903741c..4ffb89c 100644
--- a/api/app/templates/missing_skus.html
+++ b/api/app/templates/missing_skus.html
@@ -15,6 +15,19 @@
+
+
+
+ Nerezolvate
+
+
+ Rezolvate
+
+
+ Toate
+
+
+
@@ -45,7 +58,7 @@
-
+
@@ -54,22 +67,16 @@
-
-
-
-
-
+
+ Produs web:
-
-
-
-
-
-
-
-
-
+
+
+
+ Adauga CODMAT
+
+