feat(sqlite): refactor orders schema + dashboard period filter
Replace import_orders (insert-per-run) with orders table (one row per order, upsert on conflict). Eliminates dedup CTE on every dashboard query and prevents unbounded row growth at 4-500 orders/sync. Key changes: - orders table: PK order_number, upsert via ON CONFLICT DO UPDATE; COALESCE preserves id_comanda once set; times_skipped auto-increments - sync_run_orders: lightweight junction (sync_run_id, order_number) replaces sync_run_id column on orders - order_items: PK changed to (order_number, sku), INSERT OR IGNORE - Auto-migration in init_sqlite(): import_orders → orders on first boot, old table renamed to import_orders_bak - /api/dashboard/orders: period_days param (3/7/30/0=all, default 7) - Dashboard: period selector buttons in orders card header - start.sh: stop existing process on port 5003 before restart; remove --reload (broken on WSL2 /mnt/e/) - Add invoice_service, E2E Playwright tests, Oracle package updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
43
api/app/services/invoice_service.py
Normal file
43
api/app/services/invoice_service.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
|
||||
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 `<tr style="cursor:pointer" onclick="window.location='/logs?run=${esc(r.run_id)}'">
|
||||
<td>${started}</td>
|
||||
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
|
||||
<td>${r.total_orders || 0}</td>
|
||||
<td class="text-success">${r.imported || 0}</td>
|
||||
<td class="text-warning">${r.skipped || 0}</td>
|
||||
<td class="text-danger">${r.errors || 0}</td>
|
||||
<td>${duration}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
||||
document.getElementById('lastSyncStatus').innerHTML = `<span class="badge ${statusClass}">${esc(r.status)}</span>`;
|
||||
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 = '<tr><td colspan="8" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||
} 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 = '<span class="text-muted">-</span>';
|
||||
} else if (o.invoice && o.invoice.facturat) {
|
||||
invoiceBadge = `<span class="badge bg-success">Facturat</span>`;
|
||||
if (o.invoice.serie_act || o.invoice.numar_act) {
|
||||
invoiceBadge += `<br><small>${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}</small>`;
|
||||
}
|
||||
invoiceTotal = o.invoice.total_cu_tva ? Number(o.invoice.total_cu_tva).toFixed(2) : '-';
|
||||
} else {
|
||||
invoiceBadge = '<span class="badge bg-danger">Nefacturat</span>';
|
||||
}
|
||||
|
||||
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
|
||||
<td><code>${esc(o.order_number)}</code></td>
|
||||
<td>${dateStr}</td>
|
||||
<td>${esc(o.customer_name)}</td>
|
||||
<td>${o.items_count || 0}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${o.id_comanda || '-'}</td>
|
||||
<td>${invoiceBadge}</td>
|
||||
<td>${invoiceTotal}</td>
|
||||
</tr>`;
|
||||
}).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 = '<tr><td colspan="5" class="text-center text-muted py-3">Toate SKU-urile sunt mapate</td></tr>';
|
||||
const pagDiv = document.getElementById('dashPagination');
|
||||
if (totalPages > 1) {
|
||||
pagDiv.innerHTML = `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${dashPage <= 1 ? 'disabled' : ''} onclick="dashGoPage(${dashPage - 1})"><i class="bi bi-chevron-left"></i></button>
|
||||
<small class="text-muted">${dashPage} / ${totalPages}</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" ${dashPage >= totalPages ? 'disabled' : ''} onclick="dashGoPage(${dashPage + 1})"><i class="bi bi-chevron-right"></i></button>
|
||||
`;
|
||||
} 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 =
|
||||
`<tr><td colspan="8" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 '<span class="badge bg-success">Importat</span>';
|
||||
case 'SKIPPED': return '<span class="badge bg-warning text-dark">Omis</span>';
|
||||
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCodmatCell(item) {
|
||||
if (!item.codmat_details || item.codmat_details.length === 0) {
|
||||
return `<code>${esc(item.codmat || '-')}</code>`;
|
||||
}
|
||||
if (item.codmat_details.length === 1) {
|
||||
const d = item.codmat_details[0];
|
||||
return `<code>${esc(d.codmat)}</code>`;
|
||||
}
|
||||
return item.codmat_details.map(d =>
|
||||
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa} (${d.procent_pret}%)</span></div>`
|
||||
).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 = '<tr><td colspan="8" class="text-center">Se incarca...</td></tr>';
|
||||
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 = '<tr><td colspan="8" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
||||
let statusBadge;
|
||||
switch (item.mapping_status) {
|
||||
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
|
||||
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
|
||||
case 'missing': statusBadge = '<span class="badge bg-warning text-dark">Lipsa</span>'; break;
|
||||
default: statusBadge = '<span class="badge bg-secondary">?</span>';
|
||||
}
|
||||
|
||||
const action = item.mapping_status === 'missing'
|
||||
? `<a href="#" class="btn-map-icon" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}'); return false;" title="Mapeaza"><i class="bi bi-link-45deg"></i></a>`
|
||||
: '';
|
||||
|
||||
return `<tr>
|
||||
<td><code>${esc(s.sku)}</code></td>
|
||||
<td>${esc(s.product_name || '-')}</td>
|
||||
<td>${s.order_count != null ? s.order_count : '-'}</td>
|
||||
<td><small>${esc(firstCustomer)}</small></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary" title="Creeaza mapare"
|
||||
onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td><code>${esc(item.sku)}</code></td>
|
||||
<td>${esc(item.product_name || '-')}</td>
|
||||
<td>${item.quantity || 0}</td>
|
||||
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
||||
<td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td>
|
||||
<td>${renderCodmatCell(item)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${action}</td>
|
||||
</tr>`;
|
||||
}).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 = `
|
||||
<div class="mb-2 position-relative">
|
||||
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
|
||||
<small class="text-muted qm-selected"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
|
||||
<input type="number" class="form-control form-control-sm qm-cantitate" value="1" step="0.001" min="0.001">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control form-control-sm qm-procent" value="100" step="0.01" min="0" max="100">
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-end">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 =>
|
||||
`<div class="autocomplete-item" onmousedown="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
||||
</div>`
|
||||
).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 = '<span class="spinner-border spinner-border-sm"></span> 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 = '<i class="bi bi-search"></i> 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 => `
|
||||
<div class="autocomplete-item" onmousedown="selectMapArticle('${esc(r.codmat)}', '${esc(r.denumire)}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span>
|
||||
<br><span class="denumire">${esc(r.denumire)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch (err) {
|
||||
dropdown.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function selectMapArticle(codmat, denumire) {
|
||||
document.getElementById('mapCodmat').value = codmat;
|
||||
document.getElementById('mapSelectedArticle').textContent = denumire;
|
||||
document.getElementById('mapAutocomplete').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function saveQuickMap() {
|
||||
const codmat = document.getElementById('mapCodmat').value.trim();
|
||||
const cantitate = parseFloat(document.getElementById('mapCantitate').value) || 1;
|
||||
const procent = parseFloat(document.getElementById('mapProcent').value) || 100;
|
||||
|
||||
if (!codmat) { alert('Selecteaza un CODMAT'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mappings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sku: currentMapSku,
|
||||
codmat: codmat,
|
||||
cantitate_roa: cantitate,
|
||||
procent_pret: procent
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
|
||||
loadMissingSkus();
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
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, '"').replace(/'/g, ''');
|
||||
|
||||
@@ -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 '<span class="badge bg-success">IMPORTED</span>';
|
||||
case 'SKIPPED': return '<span class="badge bg-warning text-dark">SKIPPED</span>';
|
||||
case 'ERROR': return '<span class="badge bg-danger">ERROR</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status || '-')}</span>`;
|
||||
}
|
||||
function 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 = '<option value="">-- Selecteaza un sync run --</option>' +
|
||||
runs.map(r => {
|
||||
const date = fmtDatetime(r.started_at);
|
||||
const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`;
|
||||
return `<option value="${esc(r.run_id)}"${r.run_id === currentRunId ? ' selected' : ''}>[${(r.status||'').toUpperCase()}] ${date} — ${stats}</option>`;
|
||||
}).join('');
|
||||
|
||||
// Populate table
|
||||
const tbody = document.getElementById('runsTableBody');
|
||||
if (runs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = runs.map(r => {
|
||||
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
|
||||
const duration = fmtDuration(r.started_at, r.finished_at);
|
||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
||||
const activeClass = r.run_id === currentRunId ? 'table-active' : '';
|
||||
return `<tr class="${activeClass}" data-href="/logs?run=${esc(r.run_id)}" onclick="selectRun('${esc(r.run_id)}')">
|
||||
<td>${started}</td>
|
||||
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
|
||||
<td>${r.total_orders || 0}</td>
|
||||
<td class="text-success">${r.imported || 0}</td>
|
||||
<td class="text-warning">${r.skipped || 0}</td>
|
||||
<td class="text-danger">${r.errors || 0}</td>
|
||||
<td>${duration}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const pagDiv = document.getElementById('runsTablePagination');
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
if (totalPages > 1) {
|
||||
pagDiv.innerHTML = `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${runsPage <= 1 ? 'disabled' : ''} onclick="loadRuns(${runsPage - 1})"><i class="bi bi-chevron-left"></i></button>
|
||||
<small class="text-muted">${runsPage} / ${totalPages}</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" ${runsPage >= totalPages ? 'disabled' : ''} onclick="loadRuns(${runsPage + 1})"><i class="bi bi-chevron-right"></i></button>
|
||||
`;
|
||||
} else {
|
||||
pagDiv.innerHTML = '';
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('runsTableBody').innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${esc(err.message)}</td></tr>`;
|
||||
function orderStatusBadge(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
|
||||
case 'SKIPPED': return '<span class="badge bg-warning text-dark">Omis</span>';
|
||||
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 = '<option value="">Niciun sync run</option>';
|
||||
} else {
|
||||
dd.innerHTML = '<option value="">-- Selecteaza un run --</option>' +
|
||||
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 `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
const dd = document.getElementById('runsDropdown');
|
||||
dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 = '<span class="badge bg-secondary">...</span>';
|
||||
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 = `<span class="feed-time">${now}</span><span class="feed-icon">${icon}</span><span class="feed-msg">${esc(message)}</span>`;
|
||||
feed.appendChild(entry);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
function addOrderRow(event) {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
const status = (event.status || '').toUpperCase();
|
||||
|
||||
let details = '';
|
||||
if (event.error_message) {
|
||||
details = `<span class="text-danger">${esc(event.error_message)}</span>`;
|
||||
}
|
||||
if (event.missing_skus && Array.isArray(event.missing_skus) && event.missing_skus.length > 0) {
|
||||
details += `<div class="mt-1">${event.missing_skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('')}</div>`;
|
||||
}
|
||||
if (event.id_comanda) {
|
||||
details += `<small class="text-success">ID: ${event.id_comanda}</small>`;
|
||||
}
|
||||
if (!details) details = '<span class="text-muted">-</span>';
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-status', status);
|
||||
tr.innerHTML = `
|
||||
<td><code>${esc(event.order_number || '-')}</code></td>
|
||||
<td>${esc(event.customer_name || '-')}</td>
|
||||
<td class="text-center">${event.items_count ?? '-'}</td>
|
||||
<td>${statusBadge(status)}</td>
|
||||
<td>${details}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function updateLiveSummary(event) {
|
||||
liveCounts.total++;
|
||||
if (event.status === 'IMPORTED') liveCounts.imported++;
|
||||
else if (event.status === 'SKIPPED') liveCounts.skipped++;
|
||||
else if (event.status === 'ERROR') liveCounts.errors++;
|
||||
|
||||
document.getElementById('sum-total').textContent = liveCounts.total;
|
||||
document.getElementById('sum-imported').textContent = liveCounts.imported;
|
||||
document.getElementById('sum-skipped').textContent = liveCounts.skipped;
|
||||
document.getElementById('sum-errors').textContent = liveCounts.errors;
|
||||
}
|
||||
|
||||
// ── Historical Run Log ──────────────────────────
|
||||
|
||||
async function loadRunLog(runId) {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2"></div>Se incarca...</td></tr>';
|
||||
// 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
|
||||
? `<tr><td colspan="5" class="text-center py-4"><span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${esc(run.error_message)}</span></td></tr>`
|
||||
: '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
|
||||
tbody.innerHTML = runError;
|
||||
updateFilterCount();
|
||||
return;
|
||||
// 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 = '<div class="mt-1">' +
|
||||
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') + '</div>';
|
||||
}
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
const details = order.error_message
|
||||
? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}`
|
||||
: missingSkuTags || '<span class="text-muted">-</span>';
|
||||
const tbody = document.getElementById('runOrdersBody');
|
||||
const orders = data.orders || [];
|
||||
|
||||
return `<tr data-status="${esc(status)}">
|
||||
<td><code>${esc(order.order_number || '-')}</code></td>
|
||||
<td>${esc(order.customer_name || '-')}</td>
|
||||
<td class="text-center">${order.items_count ?? '-'}</td>
|
||||
<td>${statusBadge(status)}</td>
|
||||
<td>${details}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
if (orders.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = orders.map((o, i) => {
|
||||
const dateStr = fmtDate(o.order_date);
|
||||
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
|
||||
<td>${(ordersPage - 1) * 50 + i + 1}</td>
|
||||
<td>${dateStr}</td>
|
||||
<td><code>${esc(o.order_number)}</code></td>
|
||||
<td>${esc(o.customer_name)}</td>
|
||||
<td>${o.items_count || 0}</td>
|
||||
<td>${orderStatusBadge(o.status)}</td>
|
||||
</tr>`;
|
||||
}).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 = `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${ordersPage <= 1 ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage - 1})"><i class="bi bi-chevron-left"></i></button>
|
||||
<small class="text-muted">${ordersPage} / ${totalPages}</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" ${ordersPage >= totalPages ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage + 1})"><i class="bi bi-chevron-right"></i></button>
|
||||
`;
|
||||
} 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 = `<tr><td colspan="5" class="text-center text-danger py-3"><i class="bi bi-exclamation-triangle me-1"></i>${esc(err.message)}</td></tr>`;
|
||||
document.getElementById('runOrdersBody').innerHTML =
|
||||
`<tr><td colspan="6" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filters ─────────────────────────────────────
|
||||
|
||||
function applyFilter(filter) {
|
||||
const rows = document.querySelectorAll('#logsBody tr[data-status]');
|
||||
let visible = 0;
|
||||
rows.forEach(row => {
|
||||
const show = filter === 'all' || row.dataset.status === filter;
|
||||
row.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
updateFilterCount(visible, rows.length, filter);
|
||||
function 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 `<code>${esc(item.codmat || '-')}</code>`;
|
||||
}
|
||||
if (item.codmat_details.length === 1) {
|
||||
const d = item.codmat_details[0];
|
||||
return `<code>${esc(d.codmat)}</code>`;
|
||||
}
|
||||
// Multi-CODMAT: compact list
|
||||
return item.codmat_details.map(d =>
|
||||
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa} (${d.procent_pret}%)</span></div>`
|
||||
).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 = '<tr><td colspan="8" class="text-center">Se incarca...</td></tr>';
|
||||
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 = '<tr><td colspan="8" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
||||
let statusBadge;
|
||||
switch (item.mapping_status) {
|
||||
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
|
||||
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
|
||||
case 'missing': statusBadge = '<span class="badge bg-warning text-dark">Lipsa</span>'; break;
|
||||
default: statusBadge = '<span class="badge bg-secondary">?</span>';
|
||||
}
|
||||
|
||||
const action = item.mapping_status === 'missing'
|
||||
? `<a href="#" class="btn-map-icon" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}'); return false;" title="Mapeaza"><i class="bi bi-link-45deg"></i></a>`
|
||||
: '';
|
||||
|
||||
return `<tr>
|
||||
<td><code>${esc(item.sku)}</code></td>
|
||||
<td>${esc(item.product_name || '-')}</td>
|
||||
<td>${item.quantity || 0}</td>
|
||||
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
||||
<td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td>
|
||||
<td>${renderCodmatCell(item)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${action}</td>
|
||||
</tr>`;
|
||||
}).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 = `
|
||||
<div class="mb-2 position-relative">
|
||||
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
|
||||
<small class="text-muted qm-selected"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
|
||||
<input type="number" class="form-control form-control-sm qm-cantitate" value="1" step="0.001" min="0.001">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control form-control-sm qm-procent" value="100" step="0.01" min="0" max="100">
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-end">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 =>
|
||||
`<div class="autocomplete-item" onmousedown="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
||||
</div>`
|
||||
).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; }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
`<tr><td colspan="7" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
|
||||
`<tr><td colspan="9" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable(mappings) {
|
||||
function renderTable(mappings, showDeleted) {
|
||||
const tbody = document.getElementById('mappingsBody');
|
||||
|
||||
if (!mappings || mappings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Nu exista mapari</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-4">Nu exista mapari</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = mappings.map(m => `
|
||||
<tr>
|
||||
<td><strong>${esc(m.sku)}</strong></td>
|
||||
// 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 ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
|
||||
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}</td>`;
|
||||
productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`;
|
||||
} else {
|
||||
skuCell = '';
|
||||
productCell = '';
|
||||
}
|
||||
|
||||
html += `<tr class="${groupClass} ${inactiveClass} ${deletedClass}">
|
||||
${skuCell}
|
||||
${productCell}
|
||||
<td><code>${esc(m.codmat)}</code></td>
|
||||
<td>${esc(m.denumire || '-')}</td>
|
||||
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})">${m.cantitate_roa}</td>
|
||||
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})">${m.procent_pret}%</td>
|
||||
<td>${esc(m.um || '-')}</td>
|
||||
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>${m.cantitate_roa}</td>
|
||||
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</td>
|
||||
<td>
|
||||
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" style="cursor:pointer"
|
||||
onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})">
|
||||
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" ${m.sters ? '' : 'style="cursor:pointer"'}
|
||||
${m.sters ? '' : `onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}>
|
||||
${m.activ ? 'Activ' : 'Inactiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Dezactiveaza">
|
||||
<i class="bi bi-trash"></i>
|
||||
${m.sters ? `<button class="btn btn-sm btn-outline-success" onclick="restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza"><i class="bi bi-arrow-counterclockwise"></i></button>` : `
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditModal('${esc(m.sku)}', '${esc(m.codmat)}', ${m.cantitate_roa}, ${m.procent_pret})" title="Editeaza">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Sterge">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
</tr>`;
|
||||
|
||||
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 += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">«</a></li>`;
|
||||
|
||||
// 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) {
|
||||
<a class="page-link" href="#" onclick="goPage(${i}); return false;">${i}</a></li>`;
|
||||
}
|
||||
|
||||
// Next
|
||||
html += `<li class="page-item ${data.page >= data.pages ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="goPage(${data.page + 1}); return false;">»</a></li>`;
|
||||
|
||||
@@ -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 = `
|
||||
<div class="mb-2 position-relative">
|
||||
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off" data-idx="${idx}">
|
||||
<div class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
|
||||
<small class="text-muted cl-selected"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
|
||||
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control form-control-sm cl-procent" value="100" step="0.01" min="0" max="100">
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-end">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 => `
|
||||
<div class="autocomplete-item" onmousedown="selectArticle('${esc(r.codmat)}', '${esc(r.denumire)}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span>
|
||||
<br><span class="denumire">${esc(r.denumire)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
dropdown.innerHTML = data.results.map(r =>
|
||||
`<div class="autocomplete-item" onmousedown="clSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
||||
</div>`
|
||||
).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 = `
|
||||
<td colspan="2">
|
||||
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:160px">
|
||||
</td>
|
||||
<td colspan="2" class="position-relative">
|
||||
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
|
||||
<small class="text-muted" id="inlineSelected"></small>
|
||||
</td>
|
||||
<td>-</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:80px">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:80px">
|
||||
</td>
|
||||
<td>-</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-success me-1" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
|
||||
</td>
|
||||
`;
|
||||
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 =>
|
||||
`<div class="autocomplete-item" onmousedown="inlineSelectArticle('${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
||||
</div>`
|
||||
).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 = `<div class="alert alert-success">Procesate: ${data.processed}</div>`;
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
html += `<div class="alert alert-warning">Erori: <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
|
||||
@@ -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, '"').replace(/'/g, ''');
|
||||
|
||||
@@ -3,76 +3,13 @@
|
||||
{% block nav_dashboard %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Dashboard</h4>
|
||||
|
||||
<!-- Stat cards - Row 1: Comenzi -->
|
||||
<div class="row g-3 mb-2" id="statsRow">
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-info" id="stat-new">-</div>
|
||||
<div class="stat-label">Comenzi Noi</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-primary" id="stat-ready">-</div>
|
||||
<div class="stat-label">Ready</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-success" id="stat-imported">-</div>
|
||||
<div class="stat-label">Importate</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-warning" id="stat-skipped">-</div>
|
||||
<div class="stat-label">Fără Mapare</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-danger" id="stat-errors">-</div>
|
||||
<div class="stat-label">Erori Import</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards - Row 2: Articole -->
|
||||
<div class="row g-3 mb-4" id="statsRowArticles">
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-secondary" id="stat-total-skus">-</div>
|
||||
<div class="stat-label">Total SKU Scanate</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-success" id="stat-mapped-skus">-</div>
|
||||
<div class="stat-label">Cu Mapare</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-warning" id="stat-missing-skus">-</div>
|
||||
<div class="stat-label">Fără Mapare</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col d-none d-md-block"></div>
|
||||
<div class="col d-none d-md-block"></div>
|
||||
</div>
|
||||
<h4 class="mb-4">Panou de Comanda</h4>
|
||||
|
||||
<!-- Sync Control -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Sync Control</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<a href="/logs" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-journal-text"></i> Jurnale Import
|
||||
</a>
|
||||
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
|
||||
</div>
|
||||
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
@@ -80,9 +17,6 @@
|
||||
<button class="btn btn-success btn-sm" id="btnStartSync" onclick="startSync()">
|
||||
<i class="bi bi-play-fill"></i> Start Sync
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="btnScan" onclick="scanOrders()">
|
||||
<i class="bi bi-search"></i> Scan
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm d-none" id="btnStopSync" onclick="stopSync()">
|
||||
<i class="bi bi-stop-fill"></i> Stop
|
||||
</button>
|
||||
@@ -113,86 +47,161 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sync Runs -->
|
||||
<!-- Last Sync Summary Card -->
|
||||
<div class="card mb-4" id="lastSyncCard">
|
||||
<div class="card-header d-flex justify-content-between align-items-center cursor-pointer" data-bs-toggle="collapse" data-bs-target="#lastSyncBody">
|
||||
<span>Ultimul Sync</span>
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
</div>
|
||||
<div class="collapse show" id="lastSyncBody">
|
||||
<div class="card-body">
|
||||
<div class="row text-center" id="lastSyncRow">
|
||||
<div class="col last-sync-col"><small class="text-muted">Data</small><br><strong id="lastSyncDate">-</strong></div>
|
||||
<div class="col last-sync-col"><small class="text-muted">Status</small><br><span id="lastSyncStatus">-</span></div>
|
||||
<div class="col last-sync-col"><small class="text-muted">Importate</small><br><strong class="text-success" id="lastSyncImported">0</strong></div>
|
||||
<div class="col last-sync-col"><small class="text-muted">Omise</small><br><strong class="text-warning" id="lastSyncSkipped">0</strong></div>
|
||||
<div class="col last-sync-col"><small class="text-muted">Erori</small><br><strong class="text-danger" id="lastSyncErrors">0</strong></div>
|
||||
<div class="col"><small class="text-muted">Durata</small><br><strong id="lastSyncDuration">-</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders Table -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Ultimele Sync Runs</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<th>OK</th>
|
||||
<th>Fără mapare</th>
|
||||
<th>Erori</th>
|
||||
<th>Durata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="syncRunsBody">
|
||||
<tr><td colspan="7" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missing SKUs (quick resolve) -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>SKU-uri Lipsa</span>
|
||||
<a href="/missing-skus" class="btn btn-sm btn-outline-primary">Vezi toate</a>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span>Comenzi</span>
|
||||
<div class="btn-group btn-group-sm" role="group" id="dashPeriodBtns">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="3" onclick="dashSetPeriod(3)">3 zile</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-days="7" onclick="dashSetPeriod(7)">7 zile</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="30" onclick="dashSetPeriod(30)">30 zile</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="0" onclick="dashSetPeriod(0)">Toate</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group input-group-sm" style="width:250px">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="dashSearchInput" placeholder="Cauta..." oninput="debounceDashSearch()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="btn-group" role="group" id="dashFilterBtns">
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="dashFilterOrders('all')">
|
||||
Toate <span class="badge bg-light text-dark ms-1" id="dashCountAll">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="dashFilterOrders('IMPORTED')">
|
||||
Importate <span class="badge bg-light text-dark ms-1" id="dashCountImported">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning" onclick="dashFilterOrders('SKIPPED')">
|
||||
Omise <span class="badge bg-light text-dark ms-1" id="dashCountSkipped">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="dashFilterOrders('ERROR')">
|
||||
Erori <span class="badge bg-light text-dark ms-1" id="dashCountError">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-info" onclick="dashFilterOrders('UNINVOICED')">
|
||||
Nefacturate <span class="badge bg-light text-dark ms-1" id="dashCountUninvoiced">0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>Nr. Comenzi</th>
|
||||
<th>Primul Client</th>
|
||||
<th colspan="2">Acțiune</th>
|
||||
<th class="sortable" onclick="dashSortBy('order_number')">Nr Comanda <span class="sort-icon" data-col="order_number"></span></th>
|
||||
<th class="sortable" onclick="dashSortBy('order_date')">Data <span class="sort-icon" data-col="order_date"></span></th>
|
||||
<th class="sortable" onclick="dashSortBy('customer_name')">Client <span class="sort-icon" data-col="customer_name"></span></th>
|
||||
<th class="sortable" onclick="dashSortBy('items_count')">Art. <span class="sort-icon" data-col="items_count"></span></th>
|
||||
<th class="sortable" onclick="dashSortBy('status')">Status Import <span class="sort-icon" data-col="status"></span></th>
|
||||
<th>ID ROA</th>
|
||||
<th>Factura</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="missingSkusBody">
|
||||
<tr><td colspan="5" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
<tbody id="dashOrdersBody">
|
||||
<tr><td colspan="8" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted" id="dashPageInfo"></small>
|
||||
<div id="dashPagination" class="d-flex align-items-center gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map SKU Modal (copied from missing_skus.html) -->
|
||||
<div class="modal fade" id="mapModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<!-- Order Detail Modal -->
|
||||
<div class="modal fade" id="orderDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mapeaza SKU: <code id="mapSku"></code></h5>
|
||||
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3 position-relative">
|
||||
<label class="form-label">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control" id="mapCodmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none" id="mapAutocomplete"></div>
|
||||
<small class="text-muted" id="mapSelectedArticle"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<label class="form-label">Cantitate ROA</label>
|
||||
<input type="number" class="form-control" id="mapCantitate" value="1" step="0.001" min="0.001">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
|
||||
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
|
||||
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<label class="form-label">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control" id="mapProcent" value="100" step="0.01" min="0" max="100">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
|
||||
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
|
||||
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
|
||||
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>Cant.</th>
|
||||
<th>Pret</th>
|
||||
<th>TVA</th>
|
||||
<th>CODMAT</th>
|
||||
<th>Status</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailItemsBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Map Modal (used from order detail) -->
|
||||
<div class="modal fade" id="quickMapModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mapeaza SKU: <code id="qmSku"></code></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Produs web:</small> <strong id="qmProductName"></strong>
|
||||
</div>
|
||||
<div id="qmCodmatLines">
|
||||
<!-- Dynamic CODMAT lines -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addQmCodmatLine()">
|
||||
<i class="bi bi-plus"></i> Adauga CODMAT
|
||||
</button>
|
||||
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveQuickMap()">Salveaza</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveQuickMapping()">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,132 +3,165 @@
|
||||
{% block nav_logs %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">Jurnale Import</h4>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select class="form-select form-select-sm" id="runSelector" style="min-width: 320px;">
|
||||
<option value="">-- Selecteaza un sync run --</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadRuns()" title="Reincarca lista">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="mb-4">Jurnale Import</h4>
|
||||
|
||||
<!-- Sync Runs Table (always visible) -->
|
||||
<!-- Sync Run Selector -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Sync Runs</span>
|
||||
<div id="runsTablePagination" class="d-flex align-items-center gap-2"></div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<th>OK</th>
|
||||
<th>Fara mapare</th>
|
||||
<th>Erori</th>
|
||||
<th>Durata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="runsTableBody">
|
||||
<tr><td colspan="7" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<label class="form-label mb-0 fw-bold text-nowrap">Sync Run:</label>
|
||||
<select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)">
|
||||
<option value="">Se incarca...</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-secondary text-nowrap" onclick="loadRuns()" title="Reincarca lista"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run Detail Section (shown when run selected or live sync) -->
|
||||
<div id="runDetailSection" style="display:none;">
|
||||
|
||||
<!-- Run Summary Bar -->
|
||||
<div class="row g-3 mb-3" id="runSummary">
|
||||
<div class="col-auto">
|
||||
<div class="card stat-card px-3 py-2">
|
||||
<div class="stat-value text-primary" id="sum-total" style="font-size:1.25rem;">-</div>
|
||||
<div class="stat-label">Total</div>
|
||||
<!-- Detail Viewer (shown when run selected) -->
|
||||
<div id="logViewerSection" style="display:none;">
|
||||
<!-- Filter bar -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Run: <code id="logRunId"></code> <span class="badge bg-secondary" id="logStatusBadge">-</span></span>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="autoRefreshToggle" checked>
|
||||
<label class="form-check-label small" for="autoRefreshToggle">Auto-refresh</label>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btnShowTextLog" onclick="toggleTextLog()">
|
||||
<i class="bi bi-file-text"></i> Log text brut
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card stat-card px-3 py-2">
|
||||
<div class="stat-value text-success" id="sum-imported" style="font-size:1.25rem;">-</div>
|
||||
<div class="stat-label">Importate</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="btn-group" role="group" id="orderFilterBtns">
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="filterOrders('all')">
|
||||
Toate <span class="badge bg-light text-dark ms-1" id="countAll">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="filterOrders('IMPORTED')">
|
||||
Importate <span class="badge bg-light text-dark ms-1" id="countImported">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning" onclick="filterOrders('SKIPPED')">
|
||||
Omise <span class="badge bg-light text-dark ms-1" id="countSkipped">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="filterOrders('ERROR')">
|
||||
Erori <span class="badge bg-light text-dark ms-1" id="countError">0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card stat-card px-3 py-2">
|
||||
<div class="stat-value text-warning" id="sum-skipped" style="font-size:1.25rem;">-</div>
|
||||
<div class="stat-label">Omise</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card stat-card px-3 py-2">
|
||||
<div class="stat-value text-danger" id="sum-errors" style="font-size:1.25rem;">-</div>
|
||||
<div class="stat-label">Erori</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card stat-card px-3 py-2">
|
||||
<div class="stat-value text-secondary" id="sum-duration" style="font-size:1.25rem;">-</div>
|
||||
<div class="stat-label">Durata</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Feed (visible only during active sync) -->
|
||||
<div class="card mb-3" id="liveFeedCard" style="display:none;">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-broadcast"></i> Live Feed
|
||||
<span class="badge bg-danger ms-2 live-pulse">LIVE</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="live-feed" id="liveFeed"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter buttons -->
|
||||
<div class="mb-3" id="filterRow">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary active" data-filter="all">
|
||||
<i class="bi bi-list-ul"></i> Toate
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-success" data-filter="IMPORTED">
|
||||
<i class="bi bi-check-circle"></i> Importate
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning" data-filter="SKIPPED">
|
||||
<i class="bi bi-skip-forward"></i> Fara Mapare
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" data-filter="ERROR">
|
||||
<i class="bi bi-x-circle"></i> Erori
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted ms-3" id="filterCount"></small>
|
||||
</div>
|
||||
|
||||
<!-- Orders table -->
|
||||
<div class="card">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" id="logsTable">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:140px;">Nr. Comanda</th>
|
||||
<th>Client</th>
|
||||
<th style="width:100px;" class="text-center">Nr. Articole</th>
|
||||
<th style="width:120px;">Status</th>
|
||||
<th>Eroare / Detalii</th>
|
||||
<th>#</th>
|
||||
<th class="sortable" onclick="sortOrdersBy('order_date')">Data comanda <span class="sort-icon" data-col="order_date"></span></th>
|
||||
<th class="sortable" onclick="sortOrdersBy('order_number')">Nr. comanda <span class="sort-icon" data-col="order_number"></span></th>
|
||||
<th class="sortable" onclick="sortOrdersBy('customer_name')">Client <span class="sort-icon" data-col="customer_name"></span></th>
|
||||
<th class="sortable" onclick="sortOrdersBy('items_count')">Articole <span class="sort-icon" data-col="items_count"></span></th>
|
||||
<th class="sortable" onclick="sortOrdersBy('status')">Status <span class="sort-icon" data-col="status"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsBody">
|
||||
<tbody id="runOrdersBody">
|
||||
<tr><td colspan="6" class="text-center text-muted py-3">Selecteaza un sync run</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted" id="ordersPageInfo"></small>
|
||||
<div id="ordersPagination" class="d-flex align-items-center gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible text log -->
|
||||
<div id="textLogSection" style="display:none;">
|
||||
<div class="card">
|
||||
<div class="card-header">Log text brut</div>
|
||||
<pre class="log-viewer" id="logContent">Se incarca...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Detail Modal -->
|
||||
<div class="modal fade" id="orderDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
|
||||
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
|
||||
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
|
||||
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
|
||||
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
|
||||
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>Cant.</th>
|
||||
<th>Pret</th>
|
||||
<th>TVA</th>
|
||||
<th>CODMAT</th>
|
||||
<th>Status</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailItemsBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Map Modal (used from order detail) -->
|
||||
<div class="modal fade" id="quickMapModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mapeaza SKU: <code id="qmSku"></code></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Produs web:</small> <strong id="qmProductName"></strong>
|
||||
</div>
|
||||
<div id="qmCodmatLines">
|
||||
<!-- Dynamic CODMAT lines -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addQmCodmatLine()">
|
||||
<i class="bi bi-plus"></i> Adauga CODMAT
|
||||
</button>
|
||||
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveQuickMapping()">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
|
||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-plus-lg"></i> Adauga Mapare</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> Adauga Mapare</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +24,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter controls -->
|
||||
<div class="d-flex align-items-center mb-3 gap-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="showInactive" onchange="loadMappings()">
|
||||
<label class="form-check-label" for="showInactive">Arata inactive</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="showDeleted" onchange="loadMappings()">
|
||||
<label class="form-check-label" for="showDeleted">Arata sterse</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
@@ -30,17 +43,19 @@
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>CODMAT</th>
|
||||
<th>Denumire</th>
|
||||
<th>Cantitate ROA</th>
|
||||
<th>Procent Pret</th>
|
||||
<th>Activ</th>
|
||||
<th class="sortable" onclick="sortBy('sku')">SKU <span class="sort-icon" data-col="sku"></span></th>
|
||||
<th>Produs Web</th>
|
||||
<th class="sortable" onclick="sortBy('codmat')">CODMAT <span class="sort-icon" data-col="codmat"></span></th>
|
||||
<th class="sortable" onclick="sortBy('denumire')">Denumire <span class="sort-icon" data-col="denumire"></span></th>
|
||||
<th>UM</th>
|
||||
<th class="sortable" onclick="sortBy('cantitate_roa')">Cantitate ROA <span class="sort-icon" data-col="cantitate_roa"></span></th>
|
||||
<th class="sortable" onclick="sortBy('procent_pret')">Procent Pret <span class="sort-icon" data-col="procent_pret"></span></th>
|
||||
<th class="sortable" onclick="sortBy('activ')">Activ <span class="sort-icon" data-col="activ"></span></th>
|
||||
<th style="width:100px">Actiuni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mappingsBody">
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
|
||||
<tr><td colspan="9" class="text-center text-muted py-4">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -53,9 +68,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<!-- Add/Edit Modal with multi-CODMAT support (R11) -->
|
||||
<div class="modal fade" id="addModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addModalTitle">Adauga Mapare</h5>
|
||||
@@ -66,22 +81,17 @@
|
||||
<label class="form-label">SKU</label>
|
||||
<input type="text" class="form-control" id="inputSku" placeholder="Ex: 8714858124284">
|
||||
</div>
|
||||
<div class="mb-3 position-relative">
|
||||
<label class="form-label">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control" id="inputCodmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none" id="autocompleteDropdown"></div>
|
||||
<small class="text-muted" id="selectedArticle"></small>
|
||||
<div class="mb-2" id="addModalProductName" style="display:none;">
|
||||
<small class="text-muted">Produs web:</small> <strong id="inputProductName"></strong>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<label class="form-label">Cantitate ROA</label>
|
||||
<input type="number" class="form-control" id="inputCantitate" value="1" step="0.001" min="0.001">
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<label class="form-label">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control" id="inputProcent" value="100" step="0.01" min="0" max="100">
|
||||
</div>
|
||||
<hr>
|
||||
<div id="codmatLines">
|
||||
<!-- Dynamic CODMAT lines will be added here -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addCodmatLine()">
|
||||
<i class="bi bi-plus"></i> Adauga CODMAT
|
||||
</button>
|
||||
<div id="pctWarning" class="text-danger mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
@@ -111,6 +121,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteConfirmModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirmare stergere</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Sigur vrei sa stergi maparea?<br>
|
||||
SKU: <code id="deleteSkuText"></code><br>
|
||||
CODMAT: <code id="deleteCodmatText"></code>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">Sterge</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container for undo actions -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index:1080">
|
||||
<div id="undoToast" class="toast" role="alert" data-bs-autohide="true" data-bs-delay="5000">
|
||||
<div class="toast-body d-flex align-items-center gap-2">
|
||||
<span id="toastMessage"></span>
|
||||
<button class="btn btn-sm btn-outline-primary ms-auto" id="toastUndoBtn">Anuleaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
|
||||
@@ -15,6 +15,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolved toggle (R10) -->
|
||||
<div class="btn-group mb-3" role="group">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="btnUnresolved" onclick="setResolvedFilter(0)">
|
||||
Nerezolvate
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" id="btnResolved" onclick="setResolvedFilter(1)">
|
||||
Rezolvate
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnAll" onclick="setResolvedFilter(-1)">
|
||||
Toate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
@@ -45,7 +58,7 @@
|
||||
<ul class="pagination justify-content-center" id="paginationControls"></ul>
|
||||
</nav>
|
||||
|
||||
<!-- Map SKU Modal -->
|
||||
<!-- Map SKU Modal with multi-CODMAT support (R11) -->
|
||||
<div class="modal fade" id="mapModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
@@ -54,22 +67,16 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3 position-relative">
|
||||
<label class="form-label">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control" id="mapCodmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none" id="mapAutocomplete"></div>
|
||||
<small class="text-muted" id="mapSelectedArticle"></small>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Produs web:</small> <strong id="mapProductName"></strong>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<label class="form-label">Cantitate ROA</label>
|
||||
<input type="number" class="form-control" id="mapCantitate" value="1" step="0.001" min="0.001">
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<label class="form-label">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control" id="mapProcent" value="100" step="0.01" min="0" max="100">
|
||||
</div>
|
||||
<div id="mapCodmatLines">
|
||||
<!-- Dynamic CODMAT lines -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addMapCodmatLine()">
|
||||
<i class="bi bi-plus"></i> Adauga CODMAT
|
||||
</button>
|
||||
<div id="mapPctWarning" class="text-danger mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
@@ -83,27 +90,29 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let currentMapSku = '';
|
||||
let acTimeout = null;
|
||||
let mapAcTimeout = null;
|
||||
let currentPage = 1;
|
||||
let currentResolved = 0;
|
||||
const perPage = 20;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadMissing(1);
|
||||
|
||||
const input = document.getElementById('mapCodmat');
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(acTimeout);
|
||||
acTimeout = setTimeout(() => autocompleteMap(input.value), 250);
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => document.getElementById('mapAutocomplete').classList.add('d-none'), 200);
|
||||
});
|
||||
});
|
||||
|
||||
function setResolvedFilter(val) {
|
||||
currentResolved = val;
|
||||
currentPage = 1;
|
||||
// Update button styles
|
||||
document.getElementById('btnUnresolved').className = 'btn btn-sm ' + (val === 0 ? 'btn-primary' : 'btn-outline-primary');
|
||||
document.getElementById('btnResolved').className = 'btn btn-sm ' + (val === 1 ? 'btn-success' : 'btn-outline-success');
|
||||
document.getElementById('btnAll').className = 'btn btn-sm ' + (val === -1 ? 'btn-secondary' : 'btn-outline-secondary');
|
||||
loadMissing(1);
|
||||
}
|
||||
|
||||
async function loadMissing(page) {
|
||||
currentPage = page || 1;
|
||||
try {
|
||||
const res = await fetch(`/api/validate/missing-skus?page=${currentPage}&per_page=${perPage}`);
|
||||
const res = await fetch(`/api/validate/missing-skus?page=${currentPage}&per_page=${perPage}&resolved=${currentResolved}`);
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('missingBody');
|
||||
|
||||
@@ -112,7 +121,9 @@ async function loadMissing(page) {
|
||||
|
||||
const skus = data.missing_skus || [];
|
||||
if (skus.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Toate SKU-urile sunt mapate!</td></tr>';
|
||||
const msg = currentResolved === 0 ? 'Toate SKU-urile sunt mapate!' :
|
||||
currentResolved === 1 ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`;
|
||||
renderPagination(data);
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +137,7 @@ async function loadMissing(page) {
|
||||
try {
|
||||
const customers = JSON.parse(s.customers || '[]');
|
||||
if (customers.length > 0) firstCustomer = customers[0];
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
const orderCount = s.order_count != null ? s.order_count : '-';
|
||||
|
||||
@@ -139,9 +150,9 @@ async function loadMissing(page) {
|
||||
<td>${statusBadge}</td>
|
||||
<td>
|
||||
${!s.resolved
|
||||
? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')">
|
||||
<i class="bi bi-link-45deg"></i> Mapeaza
|
||||
</button>`
|
||||
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</a>`
|
||||
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
|
||||
</td>
|
||||
</tr>`;
|
||||
@@ -158,103 +169,160 @@ function renderPagination(data) {
|
||||
const ul = document.getElementById('paginationControls');
|
||||
const total = data.pages || 1;
|
||||
const page = data.page || 1;
|
||||
|
||||
if (total <= 1) {
|
||||
ul.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
if (total <= 1) { ul.innerHTML = ''; return; }
|
||||
|
||||
let html = '';
|
||||
|
||||
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a>
|
||||
</li>`;
|
||||
<a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a></li>`;
|
||||
|
||||
const range = 2;
|
||||
for (let i = 1; i <= total; i++) {
|
||||
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
|
||||
html += `<li class="page-item ${i === page ? 'active' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadMissing(${i}); return false;">${i}</a>
|
||||
</li>`;
|
||||
<a class="page-link" href="#" onclick="loadMissing(${i}); return false;">${i}</a></li>`;
|
||||
} else if (i === page - range - 1 || i === page + range + 1) {
|
||||
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadMissing(${page + 1}); return false;">Urmator</a>
|
||||
</li>`;
|
||||
|
||||
<a class="page-link" href="#" onclick="loadMissing(${page + 1}); return false;">Urmator</a></li>`;
|
||||
ul.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Multi-CODMAT 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');
|
||||
document.getElementById('mapProductName').textContent = productName || '-';
|
||||
document.getElementById('mapPctWarning').style.display = 'none';
|
||||
|
||||
const container = document.getElementById('mapCodmatLines');
|
||||
container.innerHTML = '';
|
||||
addMapCodmatLine();
|
||||
|
||||
// Pre-search with product name
|
||||
if (productName) {
|
||||
autocompleteMap(productName);
|
||||
setTimeout(() => {
|
||||
const input = container.querySelector('.mc-codmat');
|
||||
if (input) {
|
||||
input.value = productName;
|
||||
mcAutocomplete(input,
|
||||
container.querySelector('.mc-ac-dropdown'),
|
||||
container.querySelector('.mc-selected'));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('mapModal')).show();
|
||||
}
|
||||
|
||||
async function autocompleteMap(q) {
|
||||
const dropdown = document.getElementById('mapAutocomplete');
|
||||
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
||||
function addMapCodmatLine() {
|
||||
const container = document.getElementById('mapCodmatLines');
|
||||
const idx = container.children.length;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border rounded p-2 mb-2 mc-line';
|
||||
div.innerHTML = `
|
||||
<div class="mb-2 position-relative">
|
||||
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control form-control-sm mc-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none mc-ac-dropdown"></div>
|
||||
<small class="text-muted mc-selected"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
|
||||
<input type="number" class="form-control form-control-sm mc-cantitate" value="1" step="0.001" min="0.001">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control form-control-sm mc-procent" value="100" step="0.01" min="0" max="100">
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-end">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.mc-line').remove()"><i class="bi bi-x"></i></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
const input = div.querySelector('.mc-codmat');
|
||||
const dropdown = div.querySelector('.mc-ac-dropdown');
|
||||
const selected = div.querySelector('.mc-selected');
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(mapAcTimeout);
|
||||
mapAcTimeout = setTimeout(() => mcAutocomplete(input, dropdown, selected), 250);
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
||||
});
|
||||
}
|
||||
|
||||
async function mcAutocomplete(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 => `
|
||||
<div class="autocomplete-item" onmousedown="selectMapArticle('${esc(r.codmat)}', '${esc(r.denumire)}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span>
|
||||
<br><span class="denumire">${esc(r.denumire)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
dropdown.innerHTML = data.results.map(r =>
|
||||
`<div class="autocomplete-item" onmousedown="mcSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch (err) {
|
||||
dropdown.classList.add('d-none');
|
||||
}
|
||||
} catch { 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');
|
||||
function mcSelectArticle(el, codmat, label) {
|
||||
const line = el.closest('.mc-line');
|
||||
line.querySelector('.mc-codmat').value = codmat;
|
||||
line.querySelector('.mc-selected').textContent = label;
|
||||
line.querySelector('.mc-ac-dropdown').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;
|
||||
const lines = document.querySelectorAll('.mc-line');
|
||||
const mappings = [];
|
||||
|
||||
if (!codmat) { alert('Selecteaza un CODMAT'); return; }
|
||||
for (const line of lines) {
|
||||
const codmat = line.querySelector('.mc-codmat').value.trim();
|
||||
const cantitate = parseFloat(line.querySelector('.mc-cantitate').value) || 1;
|
||||
const procent = parseFloat(line.querySelector('.mc-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('mapPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
|
||||
document.getElementById('mapPctWarning').style.display = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.getElementById('mapPctWarning').style.display = 'none';
|
||||
|
||||
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
|
||||
})
|
||||
});
|
||||
let res;
|
||||
if (mappings.length === 1) {
|
||||
res = await fetch('/api/mappings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sku: currentMapSku, 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: currentMapSku, mappings })
|
||||
});
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
|
||||
loadMissing(currentPage);
|
||||
|
||||
Reference in New Issue
Block a user