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:
2026-03-13 16:18:57 +02:00
parent 650e98539e
commit 82196b9dc0
32 changed files with 4164 additions and 1192 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');

View File

@@ -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, '&#39;');
}
function fmtTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch (e) { return ''; }
}
function fmtDatetime(iso) {
if (!iso) return '-';
try {
return new Date(iso).toLocaleString('ro-RO', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch (e) { return iso; }
}
function fmtDuration(startedAt, finishedAt) {
if (!startedAt || !finishedAt) return '-';
const diffMs = new Date(finishedAt) - new Date(startedAt);
@@ -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; }
}
});
});

View File

@@ -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;">&laquo;</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;">&raquo;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');

View File

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

View File

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

View File

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

View File

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

View File

@@ -538,7 +538,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
END IF;
v_cod_fiscal_curat := TRIM(p_cod_fiscal);
v_denumire_curata := TRIM(p_denumire);
v_denumire_curata := UPPER(TRIM(p_denumire));
-- STEP 1: Cautare dupa cod fiscal (prioritate 1)
IF v_cod_fiscal_curat IS NOT NULL AND
@@ -584,6 +584,8 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
IF v_este_persoana_fizica = 1 THEN
-- pINFO('Detectata persoana fizica (CUI 13 cifre)', 'IMPORT_PARTENERI');
separa_nume_prenume(v_denumire_curata, v_nume, v_prenume);
v_nume := UPPER(v_nume);
v_prenume := UPPER(v_prenume);
-- pINFO('Nume separat: NUME=' || NVL(v_nume, 'NULL') || ', PRENUME=' || NVL(v_prenume, 'NULL'), 'IMPORT_PARTENERI');
END IF;
@@ -591,7 +593,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
BEGIN
IF v_este_persoana_fizica = 1 THEN
-- Pentru persoane fizice
pack_def.adauga_partener(tcDenumire => v_nume, -- nume de familie pentru persoane fizice
pack_def.adauga_partener(tcDenumire => v_nume || ' ' || v_prenume,
tcNume => v_nume,
tcPrenume => v_prenume,
tcCod_fiscal => v_cod_fiscal_curat,

View File

@@ -198,6 +198,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
JOIN nom_articole na ON na.codmat = at.codmat
WHERE at.sku = v_sku
AND at.activ = 1
AND at.sters = 0
ORDER BY at.procent_pret DESC) LOOP
v_found_mapping := TRUE;

View File

@@ -0,0 +1,12 @@
-- ====================================================================
-- 07_alter_articole_terti_sters.sql
-- Adauga coloana "sters" in ARTICOLE_TERTI pentru soft-delete real
-- (separat de "activ" care e toggle business)
-- ====================================================================
ALTER TABLE ARTICOLE_TERTI ADD sters NUMBER(1) DEFAULT 0;
ALTER TABLE ARTICOLE_TERTI ADD CONSTRAINT chk_art_terti_sters CHECK (sters IN (0, 1));
-- Verifica ca toate randurile existente au sters=0
-- SELECT COUNT(*) FROM ARTICOLE_TERTI WHERE sters IS NULL;
-- UPDATE ARTICOLE_TERTI SET sters = 0 WHERE sters IS NULL;

View File

@@ -8,3 +8,5 @@ apscheduler==3.10.4
python-dotenv==1.0.1
pydantic-settings==2.7.1
httpx==0.28.1
pytest>=8.0.0
pytest-asyncio>=0.23.0

82
api/tests/e2e/conftest.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Playwright E2E test fixtures.
Starts the FastAPI app on a random port with test SQLite, no Oracle.
"""
import os
import sys
import tempfile
import pytest
import subprocess
import time
import socket
def _free_port():
with socket.socket() as s:
s.bind(('', 0))
return s.getsockname()[1]
@pytest.fixture(scope="session")
def app_url():
"""Start the FastAPI app as a subprocess and return its URL."""
port = _free_port()
tmpdir = tempfile.mkdtemp()
sqlite_path = os.path.join(tmpdir, "e2e_test.db")
env = os.environ.copy()
env.update({
"FORCE_THIN_MODE": "true",
"SQLITE_DB_PATH": sqlite_path,
"ORACLE_DSN": "dummy",
"ORACLE_USER": "dummy",
"ORACLE_PASSWORD": "dummy",
"JSON_OUTPUT_DIR": tmpdir,
})
api_dir = os.path.join(os.path.dirname(__file__), "..", "..")
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(port)],
cwd=api_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Wait for startup (up to 15 seconds)
url = f"http://127.0.0.1:{port}"
for _ in range(30):
try:
import urllib.request
urllib.request.urlopen(f"{url}/health", timeout=1)
break
except Exception:
time.sleep(0.5)
else:
proc.kill()
stdout, stderr = proc.communicate()
raise RuntimeError(
f"App failed to start on port {port}.\n"
f"STDOUT: {stdout.decode()[-2000:]}\n"
f"STDERR: {stderr.decode()[-2000:]}"
)
yield url
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="session")
def seed_test_data(app_url):
"""
Seed SQLite with test data via API calls.
Oracle is unavailable in E2E tests — only SQLite-backed pages are
fully functional. This fixture exists as a hook for future seeding;
for now E2E tests validate UI structure on empty-state pages.
"""
return app_url

View File

@@ -0,0 +1,171 @@
"""
E2E verification: Dashboard page against the live app (localhost:5003).
Run with:
python -m pytest api/tests/e2e/test_dashboard_live.py -v --headed
This tests the LIVE app, not a test instance. Requires the app to be running.
"""
import pytest
from playwright.sync_api import sync_playwright, Page, expect
BASE_URL = "http://localhost:5003"
@pytest.fixture(scope="module")
def browser_page():
"""Launch browser and yield a page connected to the live app."""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 900})
page = context.new_page()
yield page
browser.close()
class TestDashboardPageLoad:
"""Verify dashboard page loads and shows expected structure."""
def test_dashboard_loads(self, browser_page: Page):
browser_page.goto(f"{BASE_URL}/")
browser_page.wait_for_load_state("networkidle")
expect(browser_page.locator("h4")).to_contain_text("Panou de Comanda")
def test_sync_control_visible(self, browser_page: Page):
expect(browser_page.locator("#btnStartSync")).to_be_visible()
expect(browser_page.locator("#syncStatusBadge")).to_be_visible()
def test_last_sync_card_populated(self, browser_page: Page):
"""The lastSyncBody should show data from previous runs."""
last_sync_date = browser_page.locator("#lastSyncDate")
expect(last_sync_date).to_be_visible()
text = last_sync_date.text_content()
assert text and text != "-", f"Expected last sync date to be populated, got: '{text}'"
def test_last_sync_imported_count(self, browser_page: Page):
imported_el = browser_page.locator("#lastSyncImported")
text = imported_el.text_content()
count = int(text) if text and text.isdigit() else 0
assert count >= 0, f"Expected imported count >= 0, got: {text}"
def test_last_sync_status_badge(self, browser_page: Page):
status_el = browser_page.locator("#lastSyncStatus .badge")
expect(status_el).to_be_visible()
text = status_el.text_content()
assert text in ("completed", "running", "failed"), f"Unexpected status: {text}"
class TestDashboardOrdersTable:
"""Verify orders table displays data from SQLite."""
def test_orders_table_has_rows(self, browser_page: Page):
"""Dashboard should show orders from previous sync runs."""
browser_page.goto(f"{BASE_URL}/")
browser_page.wait_for_load_state("networkidle")
# Wait for the orders to load (async fetch)
browser_page.wait_for_timeout(2000)
rows = browser_page.locator("#dashOrdersBody tr")
count = rows.count()
assert count > 0, "Expected at least 1 order row in dashboard table"
def test_orders_count_badges(self, browser_page: Page):
"""Filter badges should show counts."""
all_count = browser_page.locator("#dashCountAll").text_content()
assert all_count and int(all_count) > 0, f"Expected total count > 0, got: {all_count}"
def test_first_order_has_columns(self, browser_page: Page):
"""First row should have order number, date, customer, etc."""
first_row = browser_page.locator("#dashOrdersBody tr").first
cells = first_row.locator("td")
assert cells.count() >= 6, f"Expected at least 6 columns, got: {cells.count()}"
# Order number should be a code element
order_code = first_row.locator("td code").first
expect(order_code).to_be_visible()
def test_filter_imported(self, browser_page: Page):
"""Click 'Importate' filter and verify table updates."""
browser_page.locator("#dashFilterBtns button", has_text="Importate").click()
browser_page.wait_for_timeout(1000)
imported_count = browser_page.locator("#dashCountImported").text_content()
if imported_count and int(imported_count) > 0:
rows = browser_page.locator("#dashOrdersBody tr")
assert rows.count() > 0, "Expected imported orders to show"
# All visible rows should have 'Importat' badge
badges = browser_page.locator("#dashOrdersBody .badge.bg-success")
assert badges.count() > 0, "Expected green 'Importat' badges"
def test_filter_all_reset(self, browser_page: Page):
"""Click 'Toate' to reset filter."""
browser_page.locator("#dashFilterBtns button", has_text="Toate").click()
browser_page.wait_for_timeout(1000)
rows = browser_page.locator("#dashOrdersBody tr")
assert rows.count() > 0, "Expected orders after resetting filter"
class TestDashboardOrderDetail:
"""Verify order detail modal opens and shows data."""
def test_click_order_opens_modal(self, browser_page: Page):
browser_page.goto(f"{BASE_URL}/")
browser_page.wait_for_load_state("networkidle")
browser_page.wait_for_timeout(2000)
# Click the first order row
first_row = browser_page.locator("#dashOrdersBody tr").first
first_row.click()
browser_page.wait_for_timeout(1500)
# Modal should be visible
modal = browser_page.locator("#orderDetailModal")
expect(modal).to_be_visible()
# Order number should be populated
order_num = browser_page.locator("#detailOrderNumber").text_content()
assert order_num and order_num != "#", f"Expected order number in modal, got: {order_num}"
def test_modal_shows_customer(self, browser_page: Page):
customer = browser_page.locator("#detailCustomer").text_content()
assert customer and customer not in ("...", "-"), f"Expected customer name, got: {customer}"
def test_modal_shows_items(self, browser_page: Page):
items_rows = browser_page.locator("#detailItemsBody tr")
assert items_rows.count() > 0, "Expected at least 1 item in order detail"
def test_close_modal(self, browser_page: Page):
browser_page.locator("#orderDetailModal .btn-close").click()
browser_page.wait_for_timeout(500)
class TestDashboardAPIEndpoints:
"""Verify API endpoints return expected data."""
def test_api_dashboard_orders(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/dashboard/orders")
assert response.ok, f"API returned {response.status}"
data = response.json()
assert "orders" in data, "Expected 'orders' key in response"
assert "counts" in data, "Expected 'counts' key in response"
assert len(data["orders"]) > 0, "Expected at least 1 order"
def test_api_sync_status(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/sync/status")
assert response.ok
data = response.json()
assert "status" in data
assert "stats" in data
def test_api_sync_history(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/sync/history?per_page=5")
assert response.ok
data = response.json()
assert "runs" in data
assert len(data["runs"]) > 0, "Expected at least 1 sync run"
def test_api_missing_skus(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/validate/missing-skus")
assert response.ok
data = response.json()
assert "missing_skus" in data

View File

@@ -0,0 +1,57 @@
"""E2E: Logs page with per-order filtering and date display."""
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(autouse=True)
def navigate_to_logs(page: Page, app_url: str):
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
def test_logs_page_loads(page: Page):
"""Verify the logs page renders with sync runs table."""
expect(page.locator("h4")).to_contain_text("Jurnale Import")
expect(page.locator("#runsTableBody")).to_be_visible()
def test_sync_runs_table_headers(page: Page):
"""Verify table has correct column headers."""
headers = page.locator("thead th")
texts = headers.all_text_contents()
assert "Data" in texts, f"Expected 'Data' header, got: {texts}"
assert "Status" in texts, f"Expected 'Status' header, got: {texts}"
assert "Comenzi" in texts, f"Expected 'Comenzi' header, got: {texts}"
def test_filter_buttons_exist(page: Page):
"""Verify the log viewer section is initially hidden (no run selected yet)."""
viewer = page.locator("#logViewerSection")
expect(viewer).to_be_hidden()
def test_order_detail_modal_structure(page: Page):
"""Verify the order detail modal exists in DOM with required fields."""
modal = page.locator("#orderDetailModal")
expect(modal).to_be_attached()
expect(page.locator("#detailOrderNumber")).to_be_attached()
expect(page.locator("#detailCustomer")).to_be_attached()
expect(page.locator("#detailDate")).to_be_attached()
expect(page.locator("#detailItemsBody")).to_be_attached()
def test_quick_map_modal_structure(page: Page):
"""Verify quick map modal exists with multi-CODMAT support."""
modal = page.locator("#quickMapModal")
expect(modal).to_be_attached()
expect(page.locator("#qmSku")).to_be_attached()
expect(page.locator("#qmProductName")).to_be_attached()
expect(page.locator("#qmCodmatLines")).to_be_attached()
def test_text_log_toggle(page: Page):
"""Verify text log section is hidden initially and toggle button is in DOM."""
section = page.locator("#textLogSection")
expect(section).to_be_hidden()
# Toggle button lives inside logViewerSection which is also hidden
expect(page.locator("#btnShowTextLog")).to_be_attached()

View File

@@ -0,0 +1,81 @@
"""E2E: Mappings page with sortable headers, grouping, multi-CODMAT modal."""
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(autouse=True)
def navigate_to_mappings(page: Page, app_url: str):
page.goto(f"{app_url}/mappings")
page.wait_for_load_state("networkidle")
def test_mappings_page_loads(page: Page):
"""Verify mappings page renders."""
expect(page.locator("h4")).to_contain_text("Mapari SKU")
def test_sortable_headers_present(page: Page):
"""R7: Verify sortable column headers with sort icons."""
sortable_ths = page.locator("th.sortable")
count = sortable_ths.count()
assert count >= 5, f"Expected at least 5 sortable columns, got {count}"
sort_icons = page.locator(".sort-icon")
assert sort_icons.count() >= 5, f"Expected at least 5 sort-icon spans, got {sort_icons.count()}"
def test_product_name_column_exists(page: Page):
"""R4: Verify 'Produs Web' column exists in header."""
headers = page.locator("thead th")
texts = headers.all_text_contents()
assert any("Produs Web" in t for t in texts), f"'Produs Web' column not found in headers: {texts}"
def test_um_column_exists(page: Page):
"""R12: Verify 'UM' column exists in header."""
headers = page.locator("thead th")
texts = headers.all_text_contents()
assert any("UM" in t for t in texts), f"'UM' column not found in headers: {texts}"
def test_show_inactive_toggle_exists(page: Page):
"""R5: Verify 'Arata inactive' toggle is present."""
toggle = page.locator("#showInactive")
expect(toggle).to_be_visible()
label = page.locator("label[for='showInactive']")
expect(label).to_contain_text("Arata inactive")
def test_sort_click_changes_icon(page: Page):
"""R7: Clicking a sortable header should display a sort direction arrow."""
sku_header = page.locator("th.sortable", has_text="SKU")
sku_header.click()
page.wait_for_timeout(500)
icon = page.locator(".sort-icon[data-col='sku']")
text = icon.text_content()
assert text in ("", ""), f"Expected sort arrow (↑ or ↓), got '{text}'"
def test_add_modal_multi_codmat(page: Page):
"""R11: Verify the add mapping modal supports multiple CODMAT lines."""
page.locator("button", has_text="Adauga Mapare").click()
page.wait_for_timeout(500)
codmat_lines = page.locator(".codmat-line")
assert codmat_lines.count() >= 1, "Expected at least one CODMAT line in modal"
page.locator("button", has_text="Adauga CODMAT").click()
page.wait_for_timeout(300)
assert codmat_lines.count() >= 2, "Expected a second CODMAT line after clicking Adauga CODMAT"
# Second line must have a remove button
remove_btns = page.locator(".codmat-line:nth-child(2) button.btn-outline-danger")
assert remove_btns.count() >= 1, "Second CODMAT line is missing remove button"
def test_search_input_exists(page: Page):
"""Verify search input is present with the correct placeholder."""
search = page.locator("#searchInput")
expect(search).to_be_visible()
expect(search).to_have_attribute("placeholder", "Cauta SKU, CODMAT sau denumire...")

View File

@@ -0,0 +1,68 @@
"""E2E: Missing SKUs page with resolved toggle and multi-CODMAT modal."""
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(autouse=True)
def navigate_to_missing(page: Page, app_url: str):
page.goto(f"{app_url}/missing-skus")
page.wait_for_load_state("networkidle")
def test_missing_skus_page_loads(page: Page):
"""Verify the page renders with the correct heading."""
expect(page.locator("h4")).to_contain_text("SKU-uri Lipsa")
def test_resolved_toggle_buttons(page: Page):
"""R10: Verify resolved filter buttons exist and Nerezolvate is active by default."""
expect(page.locator("#btnUnresolved")).to_be_visible()
expect(page.locator("#btnResolved")).to_be_visible()
expect(page.locator("#btnAll")).to_be_visible()
classes = page.locator("#btnUnresolved").get_attribute("class")
assert "btn-primary" in classes, f"Expected #btnUnresolved to be active (btn-primary), got classes: {classes}"
def test_resolved_toggle_switches(page: Page):
"""R10: Clicking resolved/all toggles changes active state correctly."""
# Click "Rezolvate"
page.locator("#btnResolved").click()
page.wait_for_timeout(500)
classes_res = page.locator("#btnResolved").get_attribute("class")
assert "btn-success" in classes_res, f"Expected #btnResolved to be active (btn-success), got: {classes_res}"
classes_unr = page.locator("#btnUnresolved").get_attribute("class")
assert "btn-outline" in classes_unr, f"Expected #btnUnresolved to be outline after deactivation, got: {classes_unr}"
# Click "Toate"
page.locator("#btnAll").click()
page.wait_for_timeout(500)
classes_all = page.locator("#btnAll").get_attribute("class")
assert "btn-secondary" in classes_all, f"Expected #btnAll to be active (btn-secondary), got: {classes_all}"
def test_map_modal_multi_codmat(page: Page):
"""R11: Verify the mapping modal supports multiple CODMATs."""
modal = page.locator("#mapModal")
expect(modal).to_be_attached()
add_btn = page.locator("#mapModal button", has_text="Adauga CODMAT")
expect(add_btn).to_be_attached()
expect(page.locator("#mapProductName")).to_be_attached()
expect(page.locator("#mapPctWarning")).to_be_attached()
def test_export_csv_button(page: Page):
"""Verify Export CSV button is visible on the page."""
btn = page.locator("button", has_text="Export CSV")
expect(btn).to_be_visible()
def test_rescan_button(page: Page):
"""Verify Re-Scan button is visible on the page."""
btn = page.locator("button", has_text="Re-Scan")
expect(btn).to_be_visible()

View File

@@ -0,0 +1,52 @@
"""E2E: Order detail modal structure and inline mapping."""
import pytest
from playwright.sync_api import Page, expect
def test_order_detail_modal_has_roa_ids(page: Page, app_url: str):
"""R9: Verify order detail modal contains all ROA ID labels."""
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
modal = page.locator("#orderDetailModal")
expect(modal).to_be_attached()
modal_html = modal.inner_html()
assert "ID Comanda ROA" in modal_html, "Missing 'ID Comanda ROA' label in order detail modal"
assert "ID Partener" in modal_html, "Missing 'ID Partener' label in order detail modal"
assert "ID Adr. Facturare" in modal_html, "Missing 'ID Adr. Facturare' label in order detail modal"
assert "ID Adr. Livrare" in modal_html, "Missing 'ID Adr. Livrare' label in order detail modal"
def test_order_detail_items_table_columns(page: Page, app_url: str):
"""R9: Verify items table has all required columns."""
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
headers = page.locator("#orderDetailModal thead th")
texts = headers.all_text_contents()
required_columns = ["SKU", "Produs", "Cant.", "Pret", "TVA", "CODMAT", "Status", "Actiune"]
for col in required_columns:
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
def test_quick_map_from_order_detail(page: Page, app_url: str):
"""R9+R11: Verify quick map modal is reachable from order detail context."""
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
modal = page.locator("#quickMapModal")
expect(modal).to_be_attached()
expect(page.locator("#qmCodmatLines")).to_be_attached()
expect(page.locator("#qmPctWarning")).to_be_attached()
def test_dashboard_navigates_to_logs(page: Page, app_url: str):
"""Verify the sidebar on the dashboard contains a link to the logs page."""
page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle")
logs_link = page.locator("a[href='/logs']")
expect(logs_link).to_be_visible()

View File

@@ -0,0 +1,613 @@
"""
Test Phase 5.1: Backend Functionality Tests (no Oracle required)
================================================================
Tests all new backend features: web_products, order_items, order detail,
run orders filtered, address updates, missing SKUs toggle, and API endpoints.
Run:
cd api && python -m pytest tests/test_requirements.py -v
"""
import os
import sys
import tempfile
# --- Set env vars BEFORE any app import ---
_tmpdir = tempfile.mkdtemp()
_sqlite_path = os.path.join(_tmpdir, "test_import.db")
os.environ["FORCE_THIN_MODE"] = "true"
os.environ["SQLITE_DB_PATH"] = _sqlite_path
os.environ["ORACLE_DSN"] = "dummy"
os.environ["ORACLE_USER"] = "dummy"
os.environ["ORACLE_PASSWORD"] = "dummy"
os.environ["JSON_OUTPUT_DIR"] = _tmpdir
# Add api/ to path so we can import app
_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _api_dir not in sys.path:
sys.path.insert(0, _api_dir)
import pytest
import pytest_asyncio
from app.database import init_sqlite
from app.services import sqlite_service
# Initialize SQLite once before any tests run
init_sqlite()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def client():
"""TestClient with lifespan (startup/shutdown) so SQLite routes work."""
from fastapi.testclient import TestClient
from app.main import app
with TestClient(app, raise_server_exceptions=False) as c:
yield c
@pytest.fixture(autouse=True, scope="module")
def seed_baseline_data():
"""
Seed the sync run and orders used by multiple tests so they run in any order.
We use asyncio.run() because this is a synchronous fixture but needs to call
async service functions.
"""
import asyncio
async def _seed():
# Create sync run RUN001
await sqlite_service.create_sync_run("RUN001", 1)
# Add the first order (IMPORTED) with items
await sqlite_service.add_import_order(
"RUN001", "ORD001", "2025-01-15", "Test Client", "IMPORTED",
id_comanda=100, id_partener=200, items_count=2
)
items = [
{
"sku": "SKU1",
"product_name": "Prod 1",
"quantity": 2.0,
"price": 10.0,
"vat": 1.9,
"mapping_status": "direct",
"codmat": "SKU1",
"id_articol": 500,
"cantitate_roa": 2.0,
},
{
"sku": "SKU2",
"product_name": "Prod 2",
"quantity": 1.0,
"price": 20.0,
"vat": 3.8,
"mapping_status": "missing",
"codmat": None,
"id_articol": None,
"cantitate_roa": None,
},
]
await sqlite_service.add_order_items("RUN001", "ORD001", items)
# Add more orders for filter tests
await sqlite_service.add_import_order(
"RUN001", "ORD002", "2025-01-16", "Client 2", "SKIPPED",
missing_skus=["SKU99"], items_count=1
)
await sqlite_service.add_import_order(
"RUN001", "ORD003", "2025-01-17", "Client 3", "ERROR",
error_message="Test error", items_count=3
)
asyncio.run(_seed())
yield
# ---------------------------------------------------------------------------
# Section 1: web_products CRUD
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_upsert_web_product():
"""First upsert creates the row; second increments order_count."""
await sqlite_service.upsert_web_product("SKU001", "Product One")
name = await sqlite_service.get_web_product_name("SKU001")
assert name == "Product One"
# Second upsert should increment order_count (no assertion on count here,
# but must not raise and batch lookup should still find it)
await sqlite_service.upsert_web_product("SKU001", "Product One")
batch = await sqlite_service.get_web_products_batch(["SKU001", "NONEXIST"])
assert "SKU001" in batch
assert "NONEXIST" not in batch
@pytest.mark.asyncio
async def test_web_product_name_update():
"""Empty name should NOT overwrite an existing product name."""
await sqlite_service.upsert_web_product("SKU002", "Good Name")
await sqlite_service.upsert_web_product("SKU002", "")
name = await sqlite_service.get_web_product_name("SKU002")
assert name == "Good Name"
@pytest.mark.asyncio
async def test_get_web_product_name_missing():
"""Lookup for an SKU that was never inserted should return empty string."""
name = await sqlite_service.get_web_product_name("DEFINITELY_NOT_THERE_XYZ")
assert name == ""
@pytest.mark.asyncio
async def test_get_web_products_batch_empty():
"""Batch lookup with empty list should return empty dict without error."""
result = await sqlite_service.get_web_products_batch([])
assert result == {}
# ---------------------------------------------------------------------------
# Section 2: order_items CRUD
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_add_and_get_order_items():
"""Verify the items seeded in baseline data are retrievable."""
fetched = await sqlite_service.get_order_items("ORD001")
assert len(fetched) == 2
assert fetched[0]["sku"] == "SKU1"
assert fetched[1]["mapping_status"] == "missing"
@pytest.mark.asyncio
async def test_get_order_items_mapping_status():
"""First item should be 'direct', second should be 'missing'."""
fetched = await sqlite_service.get_order_items("ORD001")
assert fetched[0]["mapping_status"] == "direct"
assert fetched[1]["codmat"] is None
assert fetched[1]["id_articol"] is None
@pytest.mark.asyncio
async def test_get_order_items_for_nonexistent_order():
"""Items query for an unknown order should return an empty list."""
fetched = await sqlite_service.get_order_items("NONEXIST_ORDER")
assert fetched == []
# ---------------------------------------------------------------------------
# Section 3: order detail
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_order_detail():
"""Order detail returns order metadata plus its line items."""
detail = await sqlite_service.get_order_detail("ORD001")
assert detail is not None
assert detail["order"]["order_number"] == "ORD001"
assert len(detail["items"]) == 2
@pytest.mark.asyncio
async def test_get_order_detail_not_found():
"""Non-existent order returns None."""
detail = await sqlite_service.get_order_detail("NONEXIST")
assert detail is None
@pytest.mark.asyncio
async def test_get_order_detail_status():
"""Seeded ORD001 should have IMPORTED status."""
detail = await sqlite_service.get_order_detail("ORD001")
assert detail["order"]["status"] == "IMPORTED"
# ---------------------------------------------------------------------------
# Section 4: run orders filtered
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_run_orders_filtered_all():
"""All orders in run should total 3 with correct status counts."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "all", 1, 50)
assert result["total"] == 3
assert result["counts"]["imported"] == 1
assert result["counts"]["skipped"] == 1
assert result["counts"]["error"] == 1
@pytest.mark.asyncio
async def test_get_run_orders_filtered_imported():
"""Filter IMPORTED should return only ORD001."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "IMPORTED", 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD001"
@pytest.mark.asyncio
async def test_get_run_orders_filtered_skipped():
"""Filter SKIPPED should return only ORD002."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "SKIPPED", 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD002"
@pytest.mark.asyncio
async def test_get_run_orders_filtered_error():
"""Filter ERROR should return only ORD003."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "ERROR", 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD003"
@pytest.mark.asyncio
async def test_get_run_orders_filtered_unknown_run():
"""Unknown run_id should return zero orders without error."""
result = await sqlite_service.get_run_orders_filtered("NO_SUCH_RUN", "all", 1, 50)
assert result["total"] == 0
assert result["orders"] == []
@pytest.mark.asyncio
async def test_get_run_orders_filtered_pagination():
"""Pagination: page 1 with per_page=1 should return 1 order."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "all", 1, 1)
assert len(result["orders"]) == 1
assert result["total"] == 3
assert result["pages"] == 3
# ---------------------------------------------------------------------------
# Section 5: update_import_order_addresses
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_import_order_addresses():
"""Address IDs should be persisted and retrievable via get_order_detail."""
await sqlite_service.update_import_order_addresses(
"ORD001", "RUN001",
id_adresa_facturare=300,
id_adresa_livrare=400
)
detail = await sqlite_service.get_order_detail("ORD001")
assert detail["order"]["id_adresa_facturare"] == 300
assert detail["order"]["id_adresa_livrare"] == 400
@pytest.mark.asyncio
async def test_update_import_order_addresses_null():
"""Updating with None should be accepted without error."""
await sqlite_service.update_import_order_addresses(
"ORD001", "RUN001",
id_adresa_facturare=None,
id_adresa_livrare=None
)
detail = await sqlite_service.get_order_detail("ORD001")
assert detail is not None # row still exists
# ---------------------------------------------------------------------------
# Section 6: missing SKUs resolved toggle (R10)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_missing_skus_resolved_toggle():
"""resolved=-1 returns all; resolved=0/1 returns only matching rows."""
await sqlite_service.track_missing_sku("MISS1", "Missing Product 1")
await sqlite_service.track_missing_sku("MISS2", "Missing Product 2")
await sqlite_service.resolve_missing_sku("MISS2")
# Unresolved only (default)
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=0)
assert all(s["resolved"] == 0 for s in result["missing_skus"])
# Resolved only
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=1)
assert all(s["resolved"] == 1 for s in result["missing_skus"])
# All (resolved=-1)
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=-1)
assert result["total"] >= 2
@pytest.mark.asyncio
async def test_track_missing_sku_idempotent():
"""Tracking the same SKU twice should not raise (INSERT OR IGNORE)."""
await sqlite_service.track_missing_sku("IDEMPOTENT_SKU", "Some Product")
await sqlite_service.track_missing_sku("IDEMPOTENT_SKU", "Some Product")
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=0)
sku_list = [s["sku"] for s in result["missing_skus"]]
assert sku_list.count("IDEMPOTENT_SKU") == 1
@pytest.mark.asyncio
async def test_missing_skus_pagination():
"""Pagination response includes total, page, per_page, pages fields."""
result = await sqlite_service.get_missing_skus_paginated(1, 1, resolved=-1)
assert "total" in result
assert "page" in result
assert "per_page" in result
assert "pages" in result
assert len(result["missing_skus"]) <= 1
# ---------------------------------------------------------------------------
# Section 7: API endpoints via TestClient
# ---------------------------------------------------------------------------
def test_api_sync_run_orders(client):
"""R1: GET /api/sync/run/{run_id}/orders returns orders and counts."""
resp = client.get("/api/sync/run/RUN001/orders?status=all&page=1&per_page=50")
assert resp.status_code == 200
data = resp.json()
assert "orders" in data
assert "counts" in data
def test_api_sync_run_orders_filtered(client):
"""R1: Filtering by status=IMPORTED returns only IMPORTED orders."""
resp = client.get("/api/sync/run/RUN001/orders?status=IMPORTED")
assert resp.status_code == 200
data = resp.json()
assert all(o["status"] == "IMPORTED" for o in data["orders"])
def test_api_sync_run_orders_pagination_fields(client):
"""R1: Paginated response includes total, page, per_page, pages."""
resp = client.get("/api/sync/run/RUN001/orders?status=all&page=1&per_page=10")
assert resp.status_code == 200
data = resp.json()
assert "total" in data
assert "page" in data
assert "per_page" in data
assert "pages" in data
def test_api_sync_run_orders_unknown_run(client):
"""R1: Unknown run_id returns empty orders list, not 4xx/5xx."""
resp = client.get("/api/sync/run/NO_SUCH_RUN/orders")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
def test_api_order_detail(client):
"""R9: GET /api/sync/order/{order_number} returns order and items."""
resp = client.get("/api/sync/order/ORD001")
assert resp.status_code == 200
data = resp.json()
assert "order" in data
assert "items" in data
def test_api_order_detail_not_found(client):
"""R9: Non-existent order number returns error key."""
resp = client.get("/api/sync/order/NONEXIST")
assert resp.status_code == 200
data = resp.json()
assert "error" in data
def test_api_missing_skus_resolved_toggle(client):
"""R10: resolved=-1 returns all missing SKUs."""
resp = client.get("/api/validate/missing-skus?resolved=-1")
assert resp.status_code == 200
data = resp.json()
assert "missing_skus" in data
def test_api_missing_skus_resolved_unresolved(client):
"""R10: resolved=0 returns only unresolved SKUs."""
resp = client.get("/api/validate/missing-skus?resolved=0")
assert resp.status_code == 200
data = resp.json()
assert "missing_skus" in data
assert all(s["resolved"] == 0 for s in data["missing_skus"])
def test_api_missing_skus_resolved_only(client):
"""R10: resolved=1 returns only resolved SKUs."""
resp = client.get("/api/validate/missing-skus?resolved=1")
assert resp.status_code == 200
data = resp.json()
assert "missing_skus" in data
assert all(s["resolved"] == 1 for s in data["missing_skus"])
def test_api_missing_skus_csv_format(client):
"""R8: CSV export has mapping-compatible columns."""
resp = client.get("/api/validate/missing-skus-csv")
assert resp.status_code == 200
content = resp.content.decode("utf-8-sig")
header_line = content.split("\n")[0].strip()
assert header_line == "sku,codmat,cantitate_roa,procent_pret,product_name"
def test_api_mappings_sort_params(client):
"""R7: Sort params accepted - no 422 validation error even without Oracle."""
resp = client.get("/api/mappings?sort_by=sku&sort_dir=desc")
# 200 if Oracle available, 503 if not - but never 422 (invalid params)
assert resp.status_code in [200, 503]
def test_api_mappings_sort_params_asc(client):
"""R7: sort_dir=asc is also accepted without 422."""
resp = client.get("/api/mappings?sort_by=codmat&sort_dir=asc")
assert resp.status_code in [200, 503]
def test_api_batch_mappings_validation_percentage(client):
"""R11: Batch endpoint rejects procent_pret that does not sum to 100."""
resp = client.post("/api/mappings/batch", json={
"sku": "TESTSKU",
"mappings": [
{"codmat": "COD1", "cantitate_roa": 1, "procent_pret": 60},
{"codmat": "COD2", "cantitate_roa": 1, "procent_pret": 30},
]
})
data = resp.json()
# 60 + 30 = 90, not 100 -> must fail validation
assert data.get("success") is False
assert "100%" in data.get("error", "")
def test_api_batch_mappings_validation_exact_100(client):
"""R11: Batch with procent_pret summing to exactly 100 passes validation layer."""
resp = client.post("/api/mappings/batch", json={
"sku": "TESTSKU_VALID",
"mappings": [
{"codmat": "COD1", "cantitate_roa": 1, "procent_pret": 60},
{"codmat": "COD2", "cantitate_roa": 1, "procent_pret": 40},
]
})
data = resp.json()
# Validation passes; may fail with 503/error if Oracle is unavailable,
# but must NOT return the percentage error message
assert "100%" not in data.get("error", "")
def test_api_batch_mappings_no_mappings(client):
"""R11: Batch endpoint rejects empty mappings list."""
resp = client.post("/api/mappings/batch", json={
"sku": "TESTSKU",
"mappings": []
})
data = resp.json()
assert data.get("success") is False
def test_api_sync_status(client):
"""GET /api/sync/status returns status and stats keys."""
resp = client.get("/api/sync/status")
assert resp.status_code == 200
data = resp.json()
assert "stats" in data
def test_api_sync_history(client):
"""GET /api/sync/history returns paginated run history."""
resp = client.get("/api/sync/history")
assert resp.status_code == 200
data = resp.json()
assert "runs" in data
assert "total" in data
def test_api_missing_skus_pagination_params(client):
"""Pagination params page and per_page are respected."""
resp = client.get("/api/validate/missing-skus?page=1&per_page=2&resolved=-1")
assert resp.status_code == 200
data = resp.json()
assert len(data["missing_skus"]) <= 2
assert data["per_page"] == 2
def test_api_csv_template(client):
"""GET /api/mappings/csv-template returns a CSV file without Oracle."""
resp = client.get("/api/mappings/csv-template")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Section 8: Chronological sorting (R3)
# ---------------------------------------------------------------------------
def test_chronological_sort():
"""R3: Orders sorted oldest-first when sorted by date string."""
from app.services.order_reader import OrderData, OrderBilling
orders = [
OrderData(id="3", number="003", date="2025-03-01", billing=OrderBilling()),
OrderData(id="1", number="001", date="2025-01-01", billing=OrderBilling()),
OrderData(id="2", number="002", date="2025-02-01", billing=OrderBilling()),
]
orders.sort(key=lambda o: o.date or "")
assert orders[0].number == "001"
assert orders[1].number == "002"
assert orders[2].number == "003"
def test_chronological_sort_stable_on_equal_dates():
"""R3: Two orders with the same date preserve relative order."""
from app.services.order_reader import OrderData, OrderBilling
orders = [
OrderData(id="A", number="A01", date="2025-05-01", billing=OrderBilling()),
OrderData(id="B", number="B01", date="2025-05-01", billing=OrderBilling()),
]
orders.sort(key=lambda o: o.date or "")
# Both dates equal; stable sort preserves original order
assert orders[0].number == "A01"
assert orders[1].number == "B01"
def test_chronological_sort_empty_date_last():
"""R3: Orders with missing date (empty string) sort before dated orders."""
from app.services.order_reader import OrderData, OrderBilling
orders = [
OrderData(id="2", number="002", date="2025-06-01", billing=OrderBilling()),
OrderData(id="1", number="001", date="", billing=OrderBilling()),
]
orders.sort(key=lambda o: o.date or "")
# '' sorts before '2025-...' lexicographically
assert orders[0].number == "001"
assert orders[1].number == "002"
# ---------------------------------------------------------------------------
# Section 9: OrderData dataclass integrity
# ---------------------------------------------------------------------------
def test_order_data_defaults():
"""OrderData can be constructed with only id, number, date."""
from app.services.order_reader import OrderData, OrderBilling
order = OrderData(id="1", number="001", date="2025-01-01", billing=OrderBilling())
assert order.status == ""
assert order.items == []
assert order.shipping is None
def test_order_billing_defaults():
"""OrderBilling has sensible defaults."""
from app.services.order_reader import OrderBilling
b = OrderBilling()
assert b.is_company is False
assert b.company_name == ""
assert b.email == ""
def test_get_all_skus():
"""get_all_skus extracts a unique set of SKUs from all orders."""
from app.services.order_reader import OrderData, OrderBilling, OrderItem, get_all_skus
orders = [
OrderData(
id="1", number="001", date="2025-01-01",
billing=OrderBilling(),
items=[
OrderItem(sku="A", name="Prod A", price=10, quantity=1, vat=1.9),
OrderItem(sku="B", name="Prod B", price=20, quantity=2, vat=3.8),
]
),
OrderData(
id="2", number="002", date="2025-01-02",
billing=OrderBilling(),
items=[
OrderItem(sku="A", name="Prod A", price=10, quantity=1, vat=1.9),
OrderItem(sku="C", name="Prod C", price=5, quantity=3, vat=0.95),
]
),
]
skus = get_all_skus(orders)
assert skus == {"A", "B", "C"}

View File

@@ -18,10 +18,18 @@ if [ api/requirements.txt -nt venv/.deps_installed ] || [ ! -f venv/.deps_instal
touch venv/.deps_installed
fi
# Stop any existing instance on port 5003
EXISTING_PID=$(lsof -ti tcp:5003 2>/dev/null)
if [ -n "$EXISTING_PID" ]; then
echo "Stopping existing process on port 5003 (PID $EXISTING_PID)..."
kill "$EXISTING_PID"
sleep 2
fi
# Oracle config
export TNS_ADMIN="$(pwd)/api"
export LD_LIBRARY_PATH=/opt/oracle/instantclient_21_15:$LD_LIBRARY_PATH
cd api
echo "Starting GoMag Import Manager on http://0.0.0.0:5003"
python -m uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
python -m uvicorn app.main:app --host 0.0.0.0 --port 5003