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 from pathlib import Path
import os 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): class Settings(BaseSettings):
# Oracle # Oracle
ORACLE_USER: str = "MARIUSM_AUTO" ORACLE_USER: str = "MARIUSM_AUTO"
@@ -35,6 +38,6 @@ class Settings(BaseSettings):
ID_GESTIUNE: int = 0 ID_GESTIUNE: int = 0
ID_SECTIE: 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() settings = Settings()

View File

@@ -76,19 +76,31 @@ CREATE TABLE IF NOT EXISTS sync_runs (
error_message TEXT error_message TEXT
); );
CREATE TABLE IF NOT EXISTS import_orders ( CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT, order_number TEXT PRIMARY KEY,
sync_run_id TEXT REFERENCES sync_runs(run_id), order_date TEXT,
order_number TEXT, customer_name TEXT,
order_date TEXT, status TEXT,
customer_name TEXT, id_comanda INTEGER,
status TEXT, id_partener INTEGER,
id_comanda INTEGER, id_adresa_facturare INTEGER,
id_partener INTEGER, id_adresa_livrare INTEGER,
error_message TEXT, error_message TEXT,
missing_skus TEXT, missing_skus TEXT,
items_count INTEGER, items_count INTEGER,
created_at TEXT DEFAULT (datetime('now')) 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 ( CREATE TABLE IF NOT EXISTS missing_skus (
@@ -106,6 +118,30 @@ CREATE TABLE IF NOT EXISTS scheduler_config (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT 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 _sqlite_db_path = None
@@ -122,6 +158,101 @@ def init_sqlite():
# Create tables synchronously # Create tables synchronously
conn = sqlite3.connect(_sqlite_db_path) 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) conn.executescript(SQLITE_SCHEMA)
# Migrate: add columns if missing (for existing databases) # Migrate: add columns if missing (for existing databases)
@@ -140,6 +271,7 @@ def init_sqlite():
if "error_message" not in sync_cols: if "error_message" not in sync_cols:
conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT") conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT")
logger.info("Migrated sync_runs: added column error_message") logger.info("Migrated sync_runs: added column error_message")
conn.commit() conn.commit()
except Exception as e: except Exception as e:
logger.warning(f"Migration check failed: {e}") logger.warning(f"Migration check failed: {e}")

View File

@@ -8,6 +8,9 @@ import io
from ..services import mapping_service, sqlite_service from ..services import mapping_service, sqlite_service
import logging
logger = logging.getLogger(__name__)
router = APIRouter(tags=["mappings"]) router = APIRouter(tags=["mappings"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@@ -22,6 +25,21 @@ class MappingUpdate(BaseModel):
procent_pret: Optional[float] = None procent_pret: Optional[float] = None
activ: Optional[int] = 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 # HTML page
@router.get("/mappings", response_class=HTMLResponse) @router.get("/mappings", response_class=HTMLResponse)
async def mappings_page(request: Request): async def mappings_page(request: Request):
@@ -29,8 +47,18 @@ async def mappings_page(request: Request):
# API endpoints # API endpoints
@router.get("/api/mappings") @router.get("/api/mappings")
def list_mappings(search: str = "", page: int = 1, per_page: int = 50): async 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) 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") @router.post("/api/mappings")
async def create_mapping(data: MappingCreate): async def create_mapping(data: MappingCreate):
@@ -50,6 +78,15 @@ def update_mapping(sku: str, codmat: str, data: MappingUpdate):
except Exception as e: except Exception as e:
return {"success": False, "error": str(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}") @router.delete("/api/mappings/{sku}/{codmat}")
def delete_mapping(sku: str, codmat: str): def delete_mapping(sku: str, codmat: str):
try: try:
@@ -58,6 +95,38 @@ def delete_mapping(sku: str, codmat: str):
except Exception as e: except Exception as e:
return {"success": False, "error": str(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") @router.post("/api/mappings/import-csv")
async def import_csv(file: UploadFile = File(...)): async def import_csv(file: UploadFile = File(...)):
content = await file.read() content = await file.read()

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
from datetime import datetime
from fastapi import APIRouter, Request, BackgroundTasks from fastapi import APIRouter, Request, BackgroundTasks
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -9,7 +10,7 @@ from pydantic import BaseModel
from pathlib import Path from pathlib import Path
from typing import Optional 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"]) router = APIRouter(tags=["sync"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@@ -110,9 +111,12 @@ async def sync_run_log(run_id: str):
"orders": [ "orders": [
{ {
"order_number": o.get("order_number"), "order_number": o.get("order_number"),
"order_date": o.get("order_date"),
"customer_name": o.get("customer_name"), "customer_name": o.get("customer_name"),
"items_count": o.get("items_count"), "items_count": o.get("items_count"),
"status": o.get("status"), "status": o.get("status"),
"id_comanda": o.get("id_comanda"),
"id_partener": o.get("id_partener"),
"error_message": o.get("error_message"), "error_message": o.get("error_message"),
"missing_skus": o.get("missing_skus"), "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") @router.put("/api/sync/schedule")
async def update_schedule(config: ScheduleConfig): async def update_schedule(config: ScheduleConfig):
"""Update scheduler configuration.""" """Update scheduler configuration."""

View File

@@ -88,9 +88,9 @@ async def scan_and_validate():
async def get_missing_skus( async def get_missing_skus(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100), 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) result = await sqlite_service.get_missing_skus_paginated(page, per_page, resolved)
# Backward compat: also include 'unresolved' count # Backward compat: also include 'unresolved' count
db = await get_sqlite() db = await get_sqlite()
@@ -106,7 +106,7 @@ async def get_missing_skus(
@router.get("/missing-skus-csv") @router.get("/missing-skus-csv")
async def export_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() db = await get_sqlite()
try: try:
cursor = await db.execute(""" cursor = await db.execute("""
@@ -120,9 +120,9 @@ async def export_missing_skus_csv():
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output) 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: for row in rows:
writer.writerow([row["sku"], row["product_name"], row["first_seen"]]) writer.writerow([row["sku"], "", "", "", row["product_name"] or ""])
return StreamingResponse( return StreamingResponse(
io.BytesIO(output.getvalue().encode("utf-8-sig")), 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 database.pool.acquire() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute("""
SELECT id_articol, codmat, denumire SELECT id_articol, codmat, denumire, um
FROM nom_articole FROM nom_articole
WHERE (UPPER(codmat) LIKE UPPER(:q) || '%' WHERE (UPPER(codmat) LIKE UPPER(:q) || '%'
OR UPPER(denumire) LIKE '%' || UPPER(:q) || '%') OR UPPER(denumire) LIKE '%' || UPPER(:q) || '%')
AND sters = 0 AND inactiv = 0
AND ROWNUM <= :lim AND ROWNUM <= :lim
ORDER BY CASE WHEN UPPER(codmat) LIKE UPPER(:q) || '%' THEN 0 ELSE 1 END, codmat ORDER BY CASE WHEN UPPER(codmat) LIKE UPPER(:q) || '%' THEN 0 ELSE 1 END, codmat
""", {"q": query, "lim": limit}) """, {"q": query, "lim": limit})

View File

@@ -44,9 +44,12 @@ def convert_web_date(date_str: str) -> datetime:
if not date_str: if not date_str:
return datetime.now() return datetime.now()
try: try:
return datetime.strptime(date_str[:10], '%Y-%m-%d') return datetime.strptime(date_str.strip(), '%Y-%m-%d %H:%M:%S')
except ValueError: 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: 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 success: bool
id_comanda: int or None id_comanda: int or None
id_partener: int or None id_partener: int or None
id_adresa_facturare: int or None
id_adresa_livrare: int or None
error: str or None error: str or None
""" """
result = { result = {
"success": False, "success": False,
"id_comanda": None, "id_comanda": None,
"id_partener": None, "id_partener": None,
"id_adresa_facturare": None,
"id_adresa_livrare": None,
"error": None "error": None
} }
try: try:
order_number = clean_web_text(order.number) order_number = clean_web_text(order.number)
order_date = convert_web_date(order.date) order_date = convert_web_date(order.date)
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: if database.pool is None:
raise RuntimeError("Oracle pool not initialized") 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) id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
if order.billing.is_company: 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 cod_fiscal = clean_web_text(order.billing.company_code) or None
registru = clean_web_text(order.billing.company_reg) or None registru = clean_web_text(order.billing.company_reg) or None
is_pj = 1 is_pj = 1
else: else:
denumire = clean_web_text( denumire = clean_web_text(
f"{order.billing.firstname} {order.billing.lastname}" f"{order.billing.lastname} {order.billing.firstname}"
) ).upper()
cod_fiscal = None cod_fiscal = None
registru = None registru = None
is_pj = 0 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() 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 # Step 4: Build articles JSON and import order
articles_json = build_articles_json(order.items) 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__) logger = logging.getLogger(__name__)
def get_mappings(search: str = "", page: int = 1, per_page: int = 50): def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
"""Get paginated mappings with optional search.""" 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: if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable") raise HTTPException(status_code=503, detail="Oracle unavailable")
offset = (page - 1) * per_page 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 database.pool.acquire() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
# Build WHERE clause # Build WHERE clause
where = "" where_clauses = []
params = {} params = {}
if not show_deleted:
where_clauses.append("at.sters = 0")
if search: 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(at.codmat) LIKE '%' || UPPER(:search) || '%'
OR UPPER(na.denumire) LIKE '%' || UPPER(:search) || '%')""" OR UPPER(na.denumire) LIKE '%' || UPPER(:search) || '%')""")
params["search"] = search params["search"] = search
where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# Count total # Count total
count_sql = f""" count_sql = f"""
@@ -36,13 +60,13 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50):
# Get page # Get page
data_sql = f""" data_sql = f"""
SELECT at.sku, at.codmat, na.denumire, at.cantitate_roa, SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa,
at.procent_pret, at.activ, at.procent_pret, at.activ, at.sters,
TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare
FROM ARTICOLE_TERTI at FROM ARTICOLE_TERTI at
LEFT JOIN nom_articole na ON na.codmat = at.codmat LEFT JOIN nom_articole na ON na.codmat = at.codmat
{where} {where}
ORDER BY at.sku, at.codmat ORDER BY {order_clause}
OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY
""" """
params["offset"] = offset 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 database.pool.acquire() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute("""
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, data_creare, id_util_creare) 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, SYSDATE, -3) VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa, "procent_pret": procent_pret}) """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
conn.commit() conn.commit()
return {"sku": sku, "codmat": codmat} 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 return cur.rowcount > 0
def delete_mapping(sku: str, codmat: str): def delete_mapping(sku: str, codmat: str):
"""Soft delete (set activ=0).""" """Soft delete (set sters=1)."""
return update_mapping(sku, codmat, activ=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 = 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): def import_csv(file_content: str):
"""Import mappings from CSV content. Returns summary.""" """Import mappings from CSV content. Returns summary."""
@@ -143,10 +227,11 @@ def import_csv(file_content: str):
cantitate_roa = :cantitate_roa, cantitate_roa = :cantitate_roa,
procent_pret = :procent_pret, procent_pret = :procent_pret,
activ = 1, activ = 1,
sters = 0,
data_modif = SYSDATE data_modif = SYSDATE
WHEN NOT MATCHED THEN INSERT WHEN NOT MATCHED THEN INSERT
(sku, codmat, cantitate_roa, procent_pret, activ, data_creare, id_util_creare) (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, SYSDATE, -3) VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent}) """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent})
# Check if it was insert or update by rowcount # Check if it was insert or update by rowcount
@@ -172,7 +257,7 @@ def export_csv():
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute("""
SELECT sku, codmat, cantitate_roa, procent_pret, activ 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: for row in cur:
writer.writerow(row) 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() await db.close()
async def add_import_order(sync_run_id: str, order_number: str, order_date: str, async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
customer_name: str, status: str, id_comanda: int = None, customer_name: str, status: str, id_comanda: int = None,
id_partener: int = None, error_message: str = None, id_partener: int = None, error_message: str = None,
missing_skus: list = None, items_count: int = 0): missing_skus: list = None, items_count: int = 0):
"""Record an individual order import result.""" """Upsert a single order — one row per order_number, status updated in place."""
db = await get_sqlite() db = await get_sqlite()
try: try:
await db.execute(""" await db.execute("""
INSERT INTO import_orders INSERT INTO orders
(sync_run_id, order_number, order_date, customer_name, status, (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count) id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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, 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() await db.commit()
finally: finally:
await db.close() 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) INSERT OR IGNORE INTO missing_skus (sku, product_name)
VALUES (?, ?) VALUES (?, ?)
""", (sku, product_name)) """, (sku, product_name))
# Update context columns (always update with latest data)
if order_count or order_numbers or customers: if order_count or order_numbers or customers:
await db.execute(""" await db.execute("""
UPDATE missing_skus SET 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): 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() db = await get_sqlite()
try: try:
offset = (page - 1) * per_page offset = (page - 1) * per_page
cursor = await db.execute( if resolved == -1:
"SELECT COUNT(*) FROM missing_skus WHERE resolved = ?", (resolved,) cursor = await db.execute("SELECT COUNT(*) FROM missing_skus")
) total = (await cursor.fetchone())[0]
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() rows = await cursor.fetchall()
return { 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): 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() db = await get_sqlite()
try: try:
cursor = await db.execute( cursor = await db.execute(
@@ -168,9 +205,10 @@ async def get_sync_run_detail(run_id: str):
return None return None
cursor = await db.execute(""" cursor = await db.execute("""
SELECT * FROM import_orders SELECT o.* FROM orders o
WHERE sync_run_id = ? INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number
ORDER BY created_at WHERE sro.sync_run_id = ?
ORDER BY o.order_date
""", (run_id,)) """, (run_id,))
orders = await cursor.fetchall() orders = await cursor.fetchall()
@@ -186,42 +224,34 @@ async def get_dashboard_stats():
"""Get stats for the dashboard.""" """Get stats for the dashboard."""
db = await get_sqlite() db = await get_sqlite()
try: try:
# Total imported
cursor = await db.execute( cursor = await db.execute(
"SELECT COUNT(*) FROM import_orders WHERE status = 'IMPORTED'" "SELECT COUNT(*) FROM orders WHERE status = 'IMPORTED'"
) )
imported = (await cursor.fetchone())[0] imported = (await cursor.fetchone())[0]
# Total skipped
cursor = await db.execute( cursor = await db.execute(
"SELECT COUNT(*) FROM import_orders WHERE status = 'SKIPPED'" "SELECT COUNT(*) FROM orders WHERE status = 'SKIPPED'"
) )
skipped = (await cursor.fetchone())[0] skipped = (await cursor.fetchone())[0]
# Total errors
cursor = await db.execute( cursor = await db.execute(
"SELECT COUNT(*) FROM import_orders WHERE status = 'ERROR'" "SELECT COUNT(*) FROM orders WHERE status = 'ERROR'"
) )
errors = (await cursor.fetchone())[0] errors = (await cursor.fetchone())[0]
# Missing SKUs (unresolved)
cursor = await db.execute( cursor = await db.execute(
"SELECT COUNT(*) FROM missing_skus WHERE resolved = 0" "SELECT COUNT(*) FROM missing_skus WHERE resolved = 0"
) )
missing = (await cursor.fetchone())[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] total_missing_skus = (await cursor.fetchone())[0]
cursor = await db.execute(""" cursor = await db.execute(
SELECT COUNT(DISTINCT sku) FROM missing_skus WHERE resolved = 0 "SELECT COUNT(DISTINCT sku) FROM missing_skus WHERE resolved = 0"
""") )
unresolved_skus = (await cursor.fetchone())[0] unresolved_skus = (await cursor.fetchone())[0]
# Last sync run
cursor = await db.execute(""" cursor = await db.execute("""
SELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 1 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() await db.commit()
finally: finally:
await db.close() 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 # SSE subscriber system
_subscribers: list[asyncio.Queue] = [] _subscribers: list[asyncio.Queue] = []
# In-memory text log buffer per run
_run_logs: dict[str, list[str]] = {}
def subscribe() -> asyncio.Queue: def subscribe() -> asyncio.Queue:
"""Subscribe to sync events. Returns a queue that will receive event dicts.""" """Subscribe to sync events. Returns a queue that will receive event dicts."""
@@ -32,6 +35,22 @@ def unsubscribe(q: asyncio.Queue):
pass 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): async def _emit(event: dict):
"""Push an event to all subscriber queues.""" """Push an event to all subscriber queues."""
for q in _subscribers: 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..." _current_sync["progress"] = "Reading JSON files..."
await _emit({"type": "phase", "run_id": run_id, "message": "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: try:
# Step 1: Read orders # Step 1: Read orders and sort chronologically (oldest first - R3)
orders, json_count = order_reader.read_json_orders() 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 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"}) 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: if not orders:
_log_line(run_id, "Nicio comanda gasita.")
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0) await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count} summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
await _emit({"type": "completed", "run_id": run_id, "summary": summary}) 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) 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)"}) 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 # Step 2c: Build SKU context from skipped orders
sku_context = {} # {sku: {"orders": [], "customers": []}} 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 # Step 2d: Pre-validate prices for importable articles
id_pol = id_pol or settings.ID_POL 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: if id_pol and importable:
_current_sync["progress"] = "Validating prices..." _current_sync["progress"] = "Validating prices..."
await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."}) await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."})
_log_line(run_id, "Validare preturi...")
# Gather all CODMATs from importable orders # Gather all CODMATs from importable orders
all_codmats = set() all_codmats = set()
for order in importable: for order in importable:
@@ -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 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: for order, missing_skus in skipped:
customer = order.billing.company_name or \ customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}" f"{order.billing.firstname} {order.billing.lastname}"
await sqlite_service.add_import_order( await sqlite_service.upsert_order(
sync_run_id=run_id, sync_run_id=run_id,
order_number=order.number, order_number=order.number,
order_date=order.date, 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, missing_skus=missing_skus,
items_count=len(order.items) 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({ await _emit({
"type": "order_result", "run_id": run_id, "type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer, "order_number": order.number, "customer_name": customer,
"order_date": order.date,
"status": "SKIPPED", "missing_skus": missing_skus, "status": "SKIPPED", "missing_skus": missing_skus,
"items_count": len(order.items), "progress": f"0/{len(importable)}" "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 \ customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}" 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"]: if result["success"]:
imported_count += 1 imported_count += 1
await sqlite_service.add_import_order( await sqlite_service.upsert_order(
sync_run_id=run_id, sync_run_id=run_id,
order_number=order.number, order_number=order.number,
order_date=order.date, 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"], id_partener=result["id_partener"],
items_count=len(order.items) 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({ await _emit({
"type": "order_result", "run_id": run_id, "type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer, "order_number": order.number, "customer_name": customer,
"order_date": order.date,
"status": "IMPORTED", "items_count": len(order.items), "status": "IMPORTED", "items_count": len(order.items),
"id_comanda": result["id_comanda"], "progress": progress_str "id_comanda": result["id_comanda"], "progress": progress_str
}) })
else: else:
error_count += 1 error_count += 1
await sqlite_service.add_import_order( await sqlite_service.upsert_order(
sync_run_id=run_id, sync_run_id=run_id,
order_number=order.number, order_number=order.number,
order_date=order.date, 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"], error_message=result["error"],
items_count=len(order.items) 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({ await _emit({
"type": "order_result", "run_id": run_id, "type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer, "order_number": order.number, "customer_name": customer,
"order_date": order.date,
"status": "ERROR", "error_message": result["error"], "status": "ERROR", "error_message": result["error"],
"items_count": len(order.items), "progress": progress_str "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" f"{len(skipped)} skipped, {error_count} errors"
) )
await _emit({"type": "completed", "run_id": run_id, "summary": summary}) 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 return summary
except Exception as e: except Exception as e:
logger.error(f"Sync {run_id} failed: {e}") logger.error(f"Sync {run_id} failed: {e}")
_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)) await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e))
_current_sync["error"] = str(e) _current_sync["error"] = str(e)
await _emit({"type": "failed", "run_id": run_id, "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 _current_sync = None
asyncio.ensure_future(_clear_current_sync()) 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(): def stop_sync():
"""Signal sync to stop. Currently sync runs to completion.""" """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 # Check ARTICOLE_TERTI
cur.execute(f""" cur.execute(f"""
SELECT DISTINCT sku FROM ARTICOLE_TERTI SELECT DISTINCT sku FROM ARTICOLE_TERTI
WHERE sku IN ({placeholders}) AND activ = 1 WHERE sku IN ({placeholders}) AND activ = 1 AND sters = 0
""", params) """, params)
for row in cur: for row in cur:
mapped.add(row[0]) 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)} params2 = {f"n{j}": sku for j, sku in enumerate(remaining)}
cur.execute(f""" cur.execute(f"""
SELECT DISTINCT codmat FROM NOM_ARTICOLE SELECT DISTINCT codmat FROM NOM_ARTICOLE
WHERE codmat IN ({placeholders2}) WHERE codmat IN ({placeholders2}) AND sters = 0 AND inactiv = 0
""", params2) """, params2)
for row in cur: for row in cur:
direct.add(row[0]) direct.add(row[0])

View File

@@ -111,25 +111,6 @@ body {
.badge-pending { background-color: #94a3b8; } .badge-pending { background-color: #94a3b8; }
.badge-ready { background-color: #3b82f6; } .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 */ /* Tables */
.table { .table {
font-size: 0.875rem; font-size: 0.875rem;
@@ -213,65 +194,20 @@ body {
justify-content: center; justify-content: center;
} }
/* Live Feed */ /* Log viewer */
.live-feed { .log-viewer {
max-height: 300px;
overflow-y: auto;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.8125rem; font-size: 0.8125rem;
scroll-behavior: smooth; line-height: 1.5;
} max-height: 600px;
overflow-y: auto;
.feed-entry { padding: 1rem;
padding: 0.35rem 0.75rem; margin: 0;
border-bottom: 1px solid #f1f5f9; background-color: #1e293b;
display: flex; color: #e2e8f0;
align-items: baseline; white-space: pre-wrap;
gap: 0.5rem; word-wrap: break-word;
} border-radius: 0 0 0.5rem 0.5rem;
.feed-entry:last-child {
border-bottom: none;
}
.feed-entry.phase {
background-color: #eff6ff;
color: #1e40af;
}
.feed-entry.error {
background-color: #fef2f2;
color: #991b1b;
}
.feed-entry.success {
color: #166534;
}
.feed-entry .feed-time {
color: #94a3b8;
white-space: nowrap;
min-width: 5rem;
}
.feed-entry .feed-icon {
min-width: 1.25rem;
text-align: center;
}
.feed-entry .feed-msg {
flex: 1;
word-break: break-word;
}
/* Live pulse animation */
.live-pulse {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
} }
/* Clickable table rows */ /* Clickable table rows */
@@ -282,3 +218,87 @@ body {
.table-hover tbody tr[data-href]:hover { .table-hover tbody tr[data-href]:hover {
background-color: #e2e8f0; 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 refreshInterval = null;
let currentMapSku = ''; let dashPage = 1;
let acTimeout = null; 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', () => { document.addEventListener('DOMContentLoaded', () => {
loadDashboard(); loadSchedulerStatus();
// Auto-refresh every 10 seconds loadSyncStatus();
refreshInterval = setInterval(loadDashboard, 10000); loadLastSync();
loadDashOrders();
const input = document.getElementById('mapCodmat'); refreshInterval = setInterval(() => {
if (input) { loadSyncStatus();
input.addEventListener('input', () => { }, 10000);
clearTimeout(acTimeout);
acTimeout = setTimeout(() => autocompleteMap(input.value), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => document.getElementById('mapAutocomplete').classList.add('d-none'), 200);
});
}
}); });
async function loadDashboard() { // ── Sync Status ──────────────────────────────────
await Promise.all([
loadSyncStatus(),
loadSyncHistory(),
loadMissingSkus(),
loadSchedulerStatus()
]);
}
async function loadSyncStatus() { async function loadSyncStatus() {
try { try {
const res = await fetch('/api/sync/status'); const res = await fetch('/api/sync/status');
const data = await res.json(); 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 badge = document.getElementById('syncStatusBadge');
const status = data.status || 'idle'; const status = data.status || 'idle';
badge.textContent = status; badge.textContent = status;
badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary'); badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary');
// Show/hide start/stop buttons
if (status === 'running') { if (status === 'running') {
document.getElementById('btnStartSync').classList.add('d-none'); document.getElementById('btnStartSync').classList.add('d-none');
document.getElementById('btnStopSync').classList.remove('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('btnStartSync').classList.remove('d-none');
document.getElementById('btnStopSync').classList.add('d-none'); document.getElementById('btnStopSync').classList.add('d-none');
// Show last run info const stats = data.stats || {};
if (stats.last_run) { if (stats.last_run) {
const lr = stats.last_run; const lr = stats.last_run;
const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : ''; const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : '';
document.getElementById('syncProgressText').textContent = 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 { } else {
document.getElementById('syncProgressText').textContent = ''; document.getElementById('syncProgressText').textContent = '';
} }
@@ -94,98 +56,453 @@ async function loadSyncStatus() {
} }
} }
async function loadSyncHistory() { // ── Last Sync Summary Card ───────────────────────
try {
const res = await fetch('/api/sync/history?per_page=10');
const data = await res.json();
const tbody = document.getElementById('syncRunsBody');
if (!data.runs || data.runs.length === 0) { async function loadLastSync() {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>'; 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; return;
} }
tbody.innerHTML = data.runs.map(r => { const r = runs[0];
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'}) : '-'; document.getElementById('lastSyncDate').textContent = r.started_at
let duration = '-'; ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})
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';
return `<tr style="cursor:pointer" onclick="window.location='/logs?run=${esc(r.run_id)}'"> const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
<td>${started}</td> document.getElementById('lastSyncStatus').innerHTML = `<span class="badge ${statusClass}">${esc(r.status)}</span>`;
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td> document.getElementById('lastSyncImported').textContent = r.imported || 0;
<td>${r.total_orders || 0}</td> document.getElementById('lastSyncSkipped').textContent = r.skipped || 0;
<td class="text-success">${r.imported || 0}</td> document.getElementById('lastSyncErrors').textContent = r.errors || 0;
<td class="text-warning">${r.skipped || 0}</td>
<td class="text-danger">${r.errors || 0}</td> if (r.started_at && r.finished_at) {
<td>${duration}</td> const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000);
</tr>`; document.getElementById('lastSyncDuration').textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
}).join(''); } else {
document.getElementById('lastSyncDuration').textContent = '-';
}
} catch (err) { } catch (err) {
console.error('loadSyncHistory error:', err); console.error('loadLastSync error:', err);
} }
} }
async function loadMissingSkus() { // ── Dashboard Orders Table ───────────────────────
try {
const res = await fetch('/api/validate/missing-skus?page=1&per_page=10');
const data = await res.json();
const tbody = document.getElementById('missingSkusBody');
// Update article-level stat card (unresolved count) function debounceDashSearch() {
if (data.total != null) { clearTimeout(dashSearchTimeout);
document.getElementById('stat-missing-skus').textContent = data.total; 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) { const pagDiv = document.getElementById('dashPagination');
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Toate SKU-urile sunt mapate</td></tr>'; 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; return;
} }
tbody.innerHTML = unresolved.slice(0, 10).map(s => { const order = data.order || {};
let firstCustomer = '-'; document.getElementById('detailCustomer').textContent = order.customer_name || '-';
try { document.getElementById('detailDate').textContent = fmtDate(order.order_date);
const customers = JSON.parse(s.customers || '[]'); document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
if (customers.length > 0) firstCustomer = customers[0]; document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
} catch (e) { /* ignore */ } 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> return `<tr>
<td><code>${esc(s.sku)}</code></td> <td><code>${esc(item.sku)}</code></td>
<td>${esc(s.product_name || '-')}</td> <td>${esc(item.product_name || '-')}</td>
<td>${s.order_count != null ? s.order_count : '-'}</td> <td>${item.quantity || 0}</td>
<td><small>${esc(firstCustomer)}</small></td> <td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
<td> <td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td>
<button class="btn btn-sm btn-outline-primary" title="Creeaza mapare" <td>${renderCodmatCell(item)}</td>
onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')"> <td>${statusBadge}</td>
<i class="bi bi-link-45deg"></i> <td>${action}</td>
</button>
</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} catch (err) { } catch (err) {
console.error('loadMissingSkus error:', err); document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = '';
} }
} }
async function loadSchedulerStatus() { // ── Quick Map Modal ──────────────────────────────
try {
const res = await fetch('/api/sync/schedule');
const data = await res.json();
document.getElementById('schedulerToggle').checked = data.enabled || false; function openQuickMap(sku, productName, orderNumber) {
if (data.interval_minutes) { currentQmSku = sku;
document.getElementById('schedulerInterval').value = data.interval_minutes; 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) { } catch (err) {
console.error('loadSchedulerStatus error:', err); alert('Eroare: ' + err.message);
} }
} }
// ── Sync Controls ────────────────────────────────
async function startSync() { async function startSync() {
try { try {
const res = await fetch('/api/sync/start', { method: 'POST' }); const res = await fetch('/api/sync/start', { method: 'POST' });
@@ -194,7 +511,6 @@ async function startSync() {
alert(data.error); alert(data.error);
return; return;
} }
// Show banner with link to live logs
if (data.run_id) { if (data.run_id) {
const banner = document.getElementById('syncStartedBanner'); const banner = document.getElementById('syncStartedBanner');
const link = document.getElementById('syncRunLink'); const link = document.getElementById('syncRunLink');
@@ -202,61 +518,72 @@ async function startSync() {
link.href = '/logs?run=' + encodeURIComponent(data.run_id); link.href = '/logs?run=' + encodeURIComponent(data.run_id);
banner.classList.remove('d-none'); banner.classList.remove('d-none');
} }
// Subscribe to SSE for live progress + auto-refresh on completion
listenToSyncStream(data.run_id);
} }
loadDashboard(); loadSyncStatus();
} catch (err) { } catch (err) {
alert('Eroare: ' + err.message); 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() { async function stopSync() {
try { try {
await fetch('/api/sync/stop', { method: 'POST' }); await fetch('/api/sync/stop', { method: 'POST' });
loadDashboard(); loadSyncStatus();
} catch (err) { } catch (err) {
alert('Eroare: ' + err.message); 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() { async function toggleScheduler() {
const enabled = document.getElementById('schedulerToggle').checked; const enabled = document.getElementById('schedulerToggle').checked;
const interval = parseInt(document.getElementById('schedulerInterval').value) || 5; const interval = parseInt(document.getElementById('schedulerInterval').value) || 5;
@@ -279,106 +606,19 @@ async function updateSchedulerInterval() {
} }
} }
// --- Map Modal --- async function loadSchedulerStatus() {
function openMapModal(sku, productName) {
currentMapSku = sku;
document.getElementById('mapSku').textContent = sku;
document.getElementById('mapCodmat').value = productName || '';
document.getElementById('mapCantitate').value = '1';
document.getElementById('mapProcent').value = '100';
document.getElementById('mapSelectedArticle').textContent = '';
document.getElementById('mapAutocomplete').classList.add('d-none');
if (productName) {
autocompleteMap(productName);
}
new bootstrap.Modal(document.getElementById('mapModal')).show();
}
async function autocompleteMap(q) {
const dropdown = document.getElementById('mapAutocomplete');
if (!dropdown) return;
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
try { try {
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`); const res = await fetch('/api/sync/schedule');
const data = await res.json(); const data = await res.json();
document.getElementById('schedulerToggle').checked = data.enabled || false;
if (!data.results || data.results.length === 0) { if (data.interval_minutes) {
dropdown.classList.add('d-none'); document.getElementById('schedulerInterval').value = data.interval_minutes;
return;
}
dropdown.innerHTML = data.results.map(r => `
<div class="autocomplete-item" onmousedown="selectMapArticle('${esc(r.codmat)}', '${esc(r.denumire)}')">
<span class="codmat">${esc(r.codmat)}</span>
<br><span class="denumire">${esc(r.denumire)}</span>
</div>
`).join('');
dropdown.classList.remove('d-none');
} catch (err) {
dropdown.classList.add('d-none');
}
}
function selectMapArticle(codmat, denumire) {
document.getElementById('mapCodmat').value = codmat;
document.getElementById('mapSelectedArticle').textContent = denumire;
document.getElementById('mapAutocomplete').classList.add('d-none');
}
async function saveQuickMap() {
const codmat = document.getElementById('mapCodmat').value.trim();
const cantitate = parseFloat(document.getElementById('mapCantitate').value) || 1;
const procent = parseFloat(document.getElementById('mapProcent').value) || 100;
if (!codmat) { alert('Selecteaza un CODMAT'); return; }
try {
const res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sku: currentMapSku,
codmat: codmat,
cantitate_roa: cantitate,
procent_pret: procent
})
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissingSkus();
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
} }
} catch (err) { } 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) { function esc(s) {
if (s == null) return ''; if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 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 currentRunId = null;
let eventSource = null;
let runsPage = 1; 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) { function esc(s) {
if (s == null) return ''; if (s == null) return '';
@@ -13,23 +18,6 @@ function esc(s) {
.replace(/'/g, '&#39;'); .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) { function fmtDuration(startedAt, finishedAt) {
if (!startedAt || !finishedAt) return '-'; if (!startedAt || !finishedAt) return '-';
const diffMs = new Date(finishedAt) - new Date(startedAt); 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'; return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
} }
function statusBadge(status) { function fmtDate(dateStr) {
switch ((status || '').toUpperCase()) { if (!dateStr) return '-';
case 'IMPORTED': return '<span class="badge bg-success">IMPORTED</span>'; try {
case 'SKIPPED': return '<span class="badge bg-warning text-dark">SKIPPED</span>'; const d = new Date(dateStr);
case 'ERROR': return '<span class="badge bg-danger">ERROR</span>'; const hasTime = dateStr.includes(':');
default: return `<span class="badge bg-secondary">${esc(status || '-')}</span>`; 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) { function runStatusBadge(status) {
@@ -57,323 +48,461 @@ function runStatusBadge(status) {
} }
} }
// ── Runs Table ────────────────────────────────── function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
async function loadRuns(page) { case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
if (page != null) runsPage = page; case 'SKIPPED': return '<span class="badge bg-warning text-dark">Omis</span>';
const perPage = 20; case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
try {
const res = await fetch(`/api/sync/history?page=${runsPage}&per_page=${perPage}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const runs = data.runs || [];
const total = data.total || runs.length;
// Populate dropdown
const sel = document.getElementById('runSelector');
sel.innerHTML = '<option value="">-- Selecteaza un sync run --</option>' +
runs.map(r => {
const date = fmtDatetime(r.started_at);
const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`;
return `<option value="${esc(r.run_id)}"${r.run_id === currentRunId ? ' selected' : ''}>[${(r.status||'').toUpperCase()}] ${date}${stats}</option>`;
}).join('');
// Populate table
const tbody = document.getElementById('runsTableBody');
if (runs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
} else {
tbody.innerHTML = runs.map(r => {
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
const duration = fmtDuration(r.started_at, r.finished_at);
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
const activeClass = r.run_id === currentRunId ? 'table-active' : '';
return `<tr class="${activeClass}" data-href="/logs?run=${esc(r.run_id)}" onclick="selectRun('${esc(r.run_id)}')">
<td>${started}</td>
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
<td>${r.total_orders || 0}</td>
<td class="text-success">${r.imported || 0}</td>
<td class="text-warning">${r.skipped || 0}</td>
<td class="text-danger">${r.errors || 0}</td>
<td>${duration}</td>
</tr>`;
}).join('');
}
// Pagination
const pagDiv = document.getElementById('runsTablePagination');
const totalPages = Math.ceil(total / perPage);
if (totalPages > 1) {
pagDiv.innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${runsPage <= 1 ? 'disabled' : ''} onclick="loadRuns(${runsPage - 1})"><i class="bi bi-chevron-left"></i></button>
<small class="text-muted">${runsPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${runsPage >= totalPages ? 'disabled' : ''} onclick="loadRuns(${runsPage + 1})"><i class="bi bi-chevron-right"></i></button>
`;
} else {
pagDiv.innerHTML = '';
}
} catch (err) {
document.getElementById('runsTableBody').innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${esc(err.message)}</td></tr>`;
} }
} }
// ── Run Selection ─────────────────────────────── // ── 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) { async function selectRun(runId) {
if (eventSource) { eventSource.close(); eventSource = null; } if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
currentRunId = runId;
currentRunId = runId;
currentFilter = 'all';
ordersPage = 1;
// Update URL without reload
const url = new URL(window.location); const url = new URL(window.location);
if (runId) { url.searchParams.set('run', runId); } else { url.searchParams.delete('run'); } if (runId) { url.searchParams.set('run', runId); } else { url.searchParams.delete('run'); }
history.replaceState(null, '', url); history.replaceState(null, '', url);
// Highlight active row in table // Sync dropdown selection
document.querySelectorAll('#runsTableBody tr').forEach(tr => { const dd = document.getElementById('runsDropdown');
tr.classList.toggle('table-active', tr.getAttribute('data-href') === `/logs?run=${runId}`); if (dd && dd.value !== runId) dd.value = runId;
});
// Update dropdown
document.getElementById('runSelector').value = runId || '';
if (!runId) { if (!runId) {
document.getElementById('runDetailSection').style.display = 'none'; document.getElementById('logViewerSection').style.display = 'none';
return; 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 await loadRunOrders(runId, 'all', 1);
try {
const statusRes = await fetch('/api/sync/status');
const statusData = await statusRes.json();
if (statusData.status === 'running' && statusData.run_id === runId) {
startLiveFeed(runId);
return;
}
} catch (e) { /* fall through to historical load */ }
// Load historical data // Also load text log in background
document.getElementById('liveFeedCard').style.display = 'none'; fetchTextLog(runId);
await loadRunLog(runId);
} }
// ── Live SSE Feed ─────────────────────────────── // ── Per-Order Filtering (R1) ─────────────────────
function startLiveFeed(runId) { async function loadRunOrders(runId, statusFilter, page) {
liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 }; if (statusFilter != null) currentFilter = statusFilter;
if (page != null) ordersPage = page;
// Show live feed card, clear it // Update filter button styles
const feedCard = document.getElementById('liveFeedCard'); document.querySelectorAll('#orderFilterBtns button').forEach(btn => {
feedCard.style.display = ''; btn.className = btn.className.replace(' btn-primary', ' btn-outline-primary')
document.getElementById('liveFeed').innerHTML = ''; .replace(' btn-success', ' btn-outline-success')
document.getElementById('logsBody').innerHTML = ''; .replace(' btn-warning', ' btn-outline-warning')
.replace(' btn-danger', ' btn-outline-danger');
// Reset summary });
document.getElementById('sum-total').textContent = '-';
document.getElementById('sum-imported').textContent = '0';
document.getElementById('sum-skipped').textContent = '0';
document.getElementById('sum-errors').textContent = '0';
document.getElementById('sum-duration').textContent = 'live...';
connectSSE();
}
function connectSSE() {
if (eventSource) eventSource.close();
eventSource = new EventSource('/api/sync/stream');
eventSource.onmessage = function(e) {
let event;
try { event = JSON.parse(e.data); } catch (err) { return; }
if (event.type === 'keepalive') return;
if (event.type === 'phase') {
appendFeedEntry('phase', event.message);
}
else if (event.type === 'order_result') {
const icon = event.status === 'IMPORTED' ? '✅' : event.status === 'SKIPPED' ? '⏭️' : '❌';
const progressText = event.progress ? `[${event.progress}]` : '';
appendFeedEntry(
event.status === 'ERROR' ? 'error' : event.status === 'IMPORTED' ? 'success' : '',
`${progressText} #${event.order_number} ${event.customer_name || ''}${icon} ${event.status}${event.error_message ? ' — ' + event.error_message : ''}`
);
addOrderRow(event);
updateLiveSummary(event);
}
else if (event.type === 'completed') {
appendFeedEntry('phase', '🏁 Sync completed');
eventSource.close();
eventSource = null;
document.querySelector('.live-pulse')?.remove();
// Reload full data from REST after short delay
setTimeout(() => {
loadRunLog(currentRunId);
loadRuns();
}, 500);
}
else if (event.type === 'failed') {
appendFeedEntry('error', '💥 Sync failed: ' + (event.error || 'Unknown error'));
eventSource.close();
eventSource = null;
document.querySelector('.live-pulse')?.remove();
setTimeout(() => {
loadRunLog(currentRunId);
loadRuns();
}, 500);
}
};
eventSource.onerror = function() {
// SSE disconnected — try to load historical data
eventSource.close();
eventSource = null;
setTimeout(() => loadRunLog(currentRunId), 1000);
};
}
function appendFeedEntry(type, message) {
const feed = document.getElementById('liveFeed');
const now = new Date().toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const typeClass = type ? ` ${type}` : '';
const iconMap = { phase: '', error: '❌', success: '✅' };
const icon = iconMap[type] || '▶';
const entry = document.createElement('div');
entry.className = `feed-entry${typeClass}`;
entry.innerHTML = `<span class="feed-time">${now}</span><span class="feed-icon">${icon}</span><span class="feed-msg">${esc(message)}</span>`;
feed.appendChild(entry);
// Auto-scroll to bottom
feed.scrollTop = feed.scrollHeight;
}
function addOrderRow(event) {
const tbody = document.getElementById('logsBody');
const status = (event.status || '').toUpperCase();
let details = '';
if (event.error_message) {
details = `<span class="text-danger">${esc(event.error_message)}</span>`;
}
if (event.missing_skus && Array.isArray(event.missing_skus) && event.missing_skus.length > 0) {
details += `<div class="mt-1">${event.missing_skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('')}</div>`;
}
if (event.id_comanda) {
details += `<small class="text-success">ID: ${event.id_comanda}</small>`;
}
if (!details) details = '<span class="text-muted">-</span>';
const tr = document.createElement('tr');
tr.setAttribute('data-status', status);
tr.innerHTML = `
<td><code>${esc(event.order_number || '-')}</code></td>
<td>${esc(event.customer_name || '-')}</td>
<td class="text-center">${event.items_count ?? '-'}</td>
<td>${statusBadge(status)}</td>
<td>${details}</td>
`;
tbody.appendChild(tr);
}
function updateLiveSummary(event) {
liveCounts.total++;
if (event.status === 'IMPORTED') liveCounts.imported++;
else if (event.status === 'SKIPPED') liveCounts.skipped++;
else if (event.status === 'ERROR') liveCounts.errors++;
document.getElementById('sum-total').textContent = liveCounts.total;
document.getElementById('sum-imported').textContent = liveCounts.imported;
document.getElementById('sum-skipped').textContent = liveCounts.skipped;
document.getElementById('sum-errors').textContent = liveCounts.errors;
}
// ── Historical Run Log ──────────────────────────
async function loadRunLog(runId) {
const tbody = document.getElementById('logsBody');
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2"></div>Se incarca...</td></tr>';
try { 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); if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json(); const data = await res.json();
const run = data.run || {}; const counts = data.counts || {};
const orders = data.orders || []; 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 // Highlight active filter
document.getElementById('sum-total').textContent = run.total_orders ?? '-'; const filterMap = { 'all': 0, 'IMPORTED': 1, 'SKIPPED': 2, 'ERROR': 3 };
document.getElementById('sum-imported').textContent = run.imported ?? '-'; const btns = document.querySelectorAll('#orderFilterBtns button');
document.getElementById('sum-skipped').textContent = run.skipped ?? '-'; const idx = filterMap[currentFilter] || 0;
document.getElementById('sum-errors').textContent = run.errors ?? '-'; if (btns[idx]) {
document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at); const colorMap = ['primary', 'success', 'warning', 'danger'];
btns[idx].className = btns[idx].className.replace(`btn-outline-${colorMap[idx]}`, `btn-${colorMap[idx]}`);
if (orders.length === 0) {
const runError = run.error_message
? `<tr><td colspan="5" class="text-center py-4"><span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${esc(run.error_message)}</span></td></tr>`
: '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
tbody.innerHTML = runError;
updateFilterCount();
return;
} }
tbody.innerHTML = orders.map(order => { const tbody = document.getElementById('runOrdersBody');
const status = (order.status || '').toUpperCase(); const orders = data.orders || [];
let missingSkuTags = '';
if (order.missing_skus) {
try {
const skus = typeof order.missing_skus === 'string' ? JSON.parse(order.missing_skus) : order.missing_skus;
if (Array.isArray(skus) && skus.length > 0) {
missingSkuTags = '<div class="mt-1">' +
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') + '</div>';
}
} catch (e) { /* skip */ }
}
const details = order.error_message
? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}`
: missingSkuTags || '<span class="text-muted">-</span>';
return `<tr data-status="${esc(status)}"> if (orders.length === 0) {
<td><code>${esc(order.order_number || '-')}</code></td> tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">Nicio comanda</td></tr>';
<td>${esc(order.customer_name || '-')}</td> } else {
<td class="text-center">${order.items_count ?? '-'}</td> tbody.innerHTML = orders.map((o, i) => {
<td>${statusBadge(status)}</td> const dateStr = fmtDate(o.order_date);
<td>${details}</td> return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
</tr>`; <td>${(ordersPage - 1) * 50 + i + 1}</td>
}).join(''); <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 // Orders pagination
document.querySelectorAll('[data-filter]').forEach(btn => { const totalPages = data.pages || 1;
btn.classList.toggle('active', btn.dataset.filter === 'all'); const infoEl = document.getElementById('ordersPageInfo');
}); infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`;
applyFilter('all');
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) { } 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 filterOrders(status) {
loadRunOrders(currentRunId, status, 1);
function applyFilter(filter) {
const rows = document.querySelectorAll('#logsBody tr[data-status]');
let visible = 0;
rows.forEach(row => {
const show = filter === 'all' || row.dataset.status === filter;
row.style.display = show ? '' : 'none';
if (show) visible++;
});
updateFilterCount(visible, rows.length, filter);
} }
function updateFilterCount(visible, total, filter) { function sortOrdersBy(col) {
const el = document.getElementById('filterCount'); if (ordersSortColumn === col) {
if (!el) return; ordersSortDirection = ordersSortDirection === 'asc' ? 'desc' : 'asc';
if (visible == null) { el.textContent = ''; return; } } else {
el.textContent = filter === 'all' ? `${total} comenzi` : `${visible} din ${total} comenzi`; 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 ──────────────────────────────────────── // ── Init ────────────────────────────────────────
@@ -381,25 +510,20 @@ function updateFilterCount(visible, total, filter) {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadRuns(); 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 preselected = document.getElementById('preselectedRun');
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : ''); const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
if (runFromUrl) { if (runFromUrl) {
selectRun(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 currentPage = 1;
let currentSearch = ''; let currentSearch = '';
let searchTimeout = null; let searchTimeout = null;
let sortColumn = 'sku';
let sortDirection = 'asc';
let editingMapping = null; // {sku, codmat} when editing
// Load on page ready // Load on page ready
document.addEventListener('DOMContentLoaded', loadMappings); document.addEventListener('DOMContentLoaded', () => {
loadMappings();
initAddModal();
initDeleteModal();
});
function debounceSearch() { function debounceSearch() {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
@@ -14,52 +21,132 @@ function debounceSearch() {
}, 300); }, 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() { async function loadMappings() {
const showInactive = document.getElementById('showInactive')?.checked;
const showDeleted = document.getElementById('showDeleted')?.checked;
const params = new URLSearchParams({ const params = new URLSearchParams({
search: currentSearch, search: currentSearch,
page: currentPage, page: currentPage,
per_page: 50 per_page: 50,
sort_by: sortColumn,
sort_dir: sortDirection
}); });
if (showDeleted) params.set('show_deleted', 'true');
try { try {
const res = await fetch(`/api/mappings?${params}`); const res = await fetch(`/api/mappings?${params}`);
const data = await res.json(); 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); renderPagination(data);
updateSortIcons();
} catch (err) { } catch (err) {
document.getElementById('mappingsBody').innerHTML = 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'); const tbody = document.getElementById('mappingsBody');
if (!mappings || mappings.length === 0) { 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; return;
} }
tbody.innerHTML = mappings.map(m => ` // Group by SKU for visual grouping (R6)
<tr> let html = '';
<td><strong>${esc(m.sku)}</strong></td> 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><code>${esc(m.codmat)}</code></td>
<td>${esc(m.denumire || '-')}</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>${esc(m.um || '-')}</td>
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})">${m.procent_pret}%</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> <td>
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" style="cursor:pointer" <span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" ${m.sters ? '' : 'style="cursor:pointer"'}
onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"> ${m.sters ? '' : `onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}>
${m.activ ? 'Activ' : 'Inactiv'} ${m.activ ? 'Activ' : 'Inactiv'}
</span> </span>
</td> </td>
<td> <td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Dezactiveaza"> ${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>` : `
<i class="bi bi-trash"></i> <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>
<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> </td>
</tr> </tr>`;
`).join('');
prevSku = m.sku;
});
tbody.innerHTML = html;
} }
function renderPagination(data) { function renderPagination(data) {
@@ -70,11 +157,9 @@ function renderPagination(data) {
if (data.pages <= 1) { ul.innerHTML = ''; return; } if (data.pages <= 1) { ul.innerHTML = ''; return; }
let html = ''; let html = '';
// Previous
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}"> html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">&laquo;</a></li>`; <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 start = Math.max(1, data.page - 3);
let end = Math.min(data.pages, start + 6); let end = Math.min(data.pages, start + 6);
start = Math.max(1, end - 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>`; <a class="page-link" href="#" onclick="goPage(${i}); return false;">${i}</a></li>`;
} }
// Next
html += `<li class="page-item ${data.page >= data.pages ? 'disabled' : ''}"> 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>`; <a class="page-link" href="#" onclick="goPage(${data.page + 1}); return false;">&raquo;</a></li>`;
@@ -96,73 +180,186 @@ function goPage(p) {
loadMappings(); loadMappings();
} }
// Autocomplete for CODMAT // ── Multi-CODMAT Add Modal (R11) ─────────────────
let acTimeout = null;
document.addEventListener('DOMContentLoaded', () => { let acTimeouts = {};
const input = document.getElementById('inputCodmat');
if (!input) return; 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', () => { input.addEventListener('input', () => {
clearTimeout(acTimeout); const key = 'cl_' + idx;
acTimeout = setTimeout(() => autocomplete(input.value), 250); clearTimeout(acTimeouts[key]);
acTimeouts[key] = setTimeout(() => clAutocomplete(input, dropdown, selected), 250);
}); });
input.addEventListener('blur', () => { input.addEventListener('blur', () => {
setTimeout(() => document.getElementById('autocompleteDropdown').classList.add('d-none'), 200); setTimeout(() => dropdown.classList.add('d-none'), 200);
}); });
}); }
async function autocomplete(q) { async function clAutocomplete(input, dropdown, selectedEl) {
const dropdown = document.getElementById('autocompleteDropdown'); const q = input.value;
if (q.length < 2) { dropdown.classList.add('d-none'); return; } if (q.length < 2) { dropdown.classList.add('d-none'); return; }
try { try {
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`); const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
const data = await res.json(); 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.innerHTML = data.results.map(r =>
dropdown.classList.add('d-none'); `<div class="autocomplete-item" onmousedown="clSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
return; <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.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.classList.remove('d-none'); dropdown.classList.remove('d-none');
} catch (err) { } catch { dropdown.classList.add('d-none'); }
dropdown.classList.add('d-none');
}
} }
function selectArticle(codmat, denumire) { function clSelectArticle(el, codmat, label) {
document.getElementById('inputCodmat').value = codmat; const line = el.closest('.codmat-line');
document.getElementById('selectedArticle').textContent = denumire; line.querySelector('.cl-codmat').value = codmat;
document.getElementById('autocompleteDropdown').classList.add('d-none'); line.querySelector('.cl-selected').textContent = label;
line.querySelector('.cl-ac-dropdown').classList.add('d-none');
} }
// Save mapping (create)
async function saveMapping() { async function saveMapping() {
const sku = document.getElementById('inputSku').value.trim(); const sku = document.getElementById('inputSku').value.trim();
const codmat = document.getElementById('inputCodmat').value.trim(); if (!sku) { alert('SKU este obligatoriu'); return; }
const cantitate = parseFloat(document.getElementById('inputCantitate').value) || 1;
const procent = parseFloat(document.getElementById('inputProcent').value) || 100;
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 { try {
const res = await fetch('/api/mappings', { let res;
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
});
const data = await res.json();
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) { if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
clearForm(); editingMapping = null;
loadMappings(); loadMappings();
} else { } else {
alert('Eroare: ' + (data.error || 'Unknown')); alert('Eroare: ' + (data.error || 'Unknown'));
@@ -172,17 +369,117 @@ async function saveMapping() {
} }
} }
function clearForm() { // ── Inline Add Row ──────────────────────────────
document.getElementById('inputSku').value = '';
document.getElementById('inputCodmat').value = ''; let inlineAddVisible = false;
document.getElementById('inputCantitate').value = '1';
document.getElementById('inputProcent').value = '100'; function showInlineAddRow() {
document.getElementById('selectedArticle').textContent = ''; 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) { function editCell(td, sku, codmat, field, currentValue) {
if (td.querySelector('input')) return; // Already editing if (td.querySelector('input')) return;
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'number'; input.type = 'number';
@@ -203,25 +500,18 @@ function editCell(td, sku, codmat, field, currentValue) {
td.textContent = originalText; td.textContent = originalText;
return; return;
} }
try { try {
const body = {}; const body = {};
body[field] = newValue; body[field] = newValue;
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, { const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT', method: 'PUT',
headers: {'Content-Type': 'application/json'}, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) { loadMappings(); }
loadMappings(); else { td.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
} else { } catch (err) { td.textContent = originalText; }
td.textContent = originalText;
alert('Eroare: ' + (data.error || 'Update failed'));
}
} catch (err) {
td.textContent = originalText;
}
}; };
input.addEventListener('blur', save); 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) { async function toggleActive(sku, codmat, currentActive) {
const newActive = currentActive ? 0 : 1;
try { try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, { const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT', method: 'PUT',
headers: {'Content-Type': 'application/json'}, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activ: currentActive ? 0 : 1 }) 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(); const data = await res.json();
if (data.success) loadMappings(); if (data.success) loadMappings();
else alert('Eroare: ' + (data.error || 'Restore failed'));
} catch (err) { } catch (err) {
alert('Eroare: ' + err.message); alert('Eroare: ' + err.message);
} }
} }
// Delete (soft) // ── CSV ──────────────────────────────────────────
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 import
async function importCsv() { async function importCsv() {
const fileInput = document.getElementById('csvFile'); const fileInput = document.getElementById('csvFile');
if (!fileInput.files.length) { alert('Selecteaza un fisier CSV'); return; } if (!fileInput.files.length) { alert('Selecteaza un fisier CSV'); return; }
@@ -267,12 +623,8 @@ async function importCsv() {
formData.append('file', fileInput.files[0]); formData.append('file', fileInput.files[0]);
try { try {
const res = await fetch('/api/mappings/import-csv', { const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData });
method: 'POST',
body: formData
});
const data = await res.json(); const data = await res.json();
let html = `<div class="alert alert-success">Procesate: ${data.processed}</div>`; let html = `<div class="alert alert-success">Procesate: ${data.processed}</div>`;
if (data.errors && data.errors.length > 0) { 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>`; 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() { function exportCsv() { window.location.href = '/api/mappings/export-csv'; }
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) { function esc(s) {
if (s == null) return ''; if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 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 nav_dashboard %}active{% endblock %}
{% block content %} {% block content %}
<h4 class="mb-4">Dashboard</h4> <h4 class="mb-4">Panou de Comanda</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>
<!-- Sync Control --> <!-- Sync Control -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<span>Sync Control</span> <span>Sync Control</span>
<div class="d-flex align-items-center gap-2"> <span class="badge bg-secondary" id="syncStatusBadge">idle</span>
<a href="/logs" class="btn btn-sm btn-outline-info">
<i class="bi bi-journal-text"></i> Jurnale Import
</a>
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row align-items-center"> <div class="row align-items-center">
@@ -80,9 +17,6 @@
<button class="btn btn-success btn-sm" id="btnStartSync" onclick="startSync()"> <button class="btn btn-success btn-sm" id="btnStartSync" onclick="startSync()">
<i class="bi bi-play-fill"></i> Start Sync <i class="bi bi-play-fill"></i> Start Sync
</button> </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()"> <button class="btn btn-danger btn-sm d-none" id="btnStopSync" onclick="stopSync()">
<i class="bi bi-stop-fill"></i> Stop <i class="bi bi-stop-fill"></i> Stop
</button> </button>
@@ -113,86 +47,161 @@
</div> </div>
</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 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"> <div class="card-header d-flex justify-content-between align-items-center">
<span>SKU-uri Lipsa</span> <div class="d-flex align-items-center gap-2">
<a href="/missing-skus" class="btn btn-sm btn-outline-primary">Vezi toate</a> <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>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th>SKU</th> <th class="sortable" onclick="dashSortBy('order_number')">Nr Comanda <span class="sort-icon" data-col="order_number"></span></th>
<th>Produs</th> <th class="sortable" onclick="dashSortBy('order_date')">Data <span class="sort-icon" data-col="order_date"></span></th>
<th>Nr. Comenzi</th> <th class="sortable" onclick="dashSortBy('customer_name')">Client <span class="sort-icon" data-col="customer_name"></span></th>
<th>Primul Client</th> <th class="sortable" onclick="dashSortBy('items_count')">Art. <span class="sort-icon" data-col="items_count"></span></th>
<th colspan="2">Acțiune</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> </tr>
</thead> </thead>
<tbody id="missingSkusBody"> <tbody id="dashOrdersBody">
<tr><td colspan="5" class="text-center text-muted py-3">Se incarca...</td></tr> <tr><td colspan="8" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</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> </div>
<!-- Map SKU Modal (copied from missing_skus.html) --> <!-- Order Detail Modal -->
<div class="modal fade" id="mapModal" tabindex="-1"> <div class="modal fade" id="orderDetailModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <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> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3 position-relative"> <div class="row mb-3">
<label class="form-label">CODMAT (Articol ROA)</label> <div class="col-md-6">
<input type="text" class="form-control" id="mapCodmat" placeholder="Cauta codmat sau denumire..." autocomplete="off"> <small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<div class="autocomplete-dropdown d-none" id="mapAutocomplete"></div> <small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted" id="mapSelectedArticle"></small> <small class="text-muted">Status:</small> <span id="detailStatus"></span>
</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>
<div class="col-6 mb-3"> <div class="col-md-6">
<label class="form-label">Procent Pret (%)</label> <small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
<input type="number" class="form-control" id="mapProcent" value="100" step="0.01" min="0" max="100"> <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> </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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button> <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> </div>
</div> </div>

View File

@@ -3,132 +3,165 @@
{% block nav_logs %}active{% endblock %} {% block nav_logs %}active{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <h4 class="mb-4">Jurnale Import</h4>
<h4 class="mb-0">Jurnale Import</h4>
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" id="runSelector" style="min-width: 320px;">
<option value="">-- Selecteaza un sync run --</option>
</select>
<button class="btn btn-sm btn-outline-secondary" onclick="loadRuns()" title="Reincarca lista">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<!-- Sync Runs Table (always visible) --> <!-- Sync Run Selector -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-body py-2">
<span>Sync Runs</span> <div class="d-flex align-items-center gap-3">
<div id="runsTablePagination" class="d-flex align-items-center gap-2"></div> <label class="form-label mb-0 fw-bold text-nowrap">Sync Run:</label>
</div> <select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)">
<div class="card-body p-0"> <option value="">Se incarca...</option>
<div class="table-responsive"> </select>
<table class="table table-hover mb-0"> <button class="btn btn-sm btn-outline-secondary text-nowrap" onclick="loadRuns()" title="Reincarca lista"><i class="bi bi-arrow-clockwise"></i></button>
<thead>
<tr>
<th>Data</th>
<th>Status</th>
<th>Total</th>
<th>OK</th>
<th>Fara mapare</th>
<th>Erori</th>
<th>Durata</th>
</tr>
</thead>
<tbody id="runsTableBody">
<tr><td colspan="7" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
<!-- Run Detail Section (shown when run selected or live sync) --> <!-- Detail Viewer (shown when run selected) -->
<div id="runDetailSection" style="display:none;"> <div id="logViewerSection" style="display:none;">
<!-- Filter bar -->
<!-- Run Summary Bar --> <div class="card mb-3">
<div class="row g-3 mb-3" id="runSummary"> <div class="card-header d-flex justify-content-between align-items-center">
<div class="col-auto"> <span>Run: <code id="logRunId"></code> <span class="badge bg-secondary" id="logStatusBadge">-</span></span>
<div class="card stat-card px-3 py-2"> <div class="d-flex align-items-center gap-3">
<div class="stat-value text-primary" id="sum-total" style="font-size:1.25rem;">-</div> <div class="form-check form-switch mb-0">
<div class="stat-label">Total</div> <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> </div>
<div class="col-auto"> <div class="card-body py-2">
<div class="card stat-card px-3 py-2"> <div class="btn-group" role="group" id="orderFilterBtns">
<div class="stat-value text-success" id="sum-imported" style="font-size:1.25rem;">-</div> <button type="button" class="btn btn-sm btn-primary" onclick="filterOrders('all')">
<div class="stat-label">Importate</div> 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> </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> </div>
<!-- Orders table --> <!-- Orders table -->
<div class="card"> <div class="card mb-3">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0" id="logsTable"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th style="width:140px;">Nr. Comanda</th> <th>#</th>
<th>Client</th> <th class="sortable" onclick="sortOrdersBy('order_date')">Data comanda <span class="sort-icon" data-col="order_date"></span></th>
<th style="width:100px;" class="text-center">Nr. Articole</th> <th class="sortable" onclick="sortOrdersBy('order_number')">Nr. comanda <span class="sort-icon" data-col="order_number"></span></th>
<th style="width:120px;">Status</th> <th class="sortable" onclick="sortOrdersBy('customer_name')">Client <span class="sort-icon" data-col="customer_name"></span></th>
<th>Eroare / Detalii</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> </tr>
</thead> </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> </tbody>
</table> </table>
</div> </div>
</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>
</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="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-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-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>
</div> </div>
@@ -23,6 +24,18 @@
</div> </div>
</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 --> <!-- Table -->
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
@@ -30,17 +43,19 @@
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th>SKU</th> <th class="sortable" onclick="sortBy('sku')">SKU <span class="sort-icon" data-col="sku"></span></th>
<th>CODMAT</th> <th>Produs Web</th>
<th>Denumire</th> <th class="sortable" onclick="sortBy('codmat')">CODMAT <span class="sort-icon" data-col="codmat"></span></th>
<th>Cantitate ROA</th> <th class="sortable" onclick="sortBy('denumire')">Denumire <span class="sort-icon" data-col="denumire"></span></th>
<th>Procent Pret</th> <th>UM</th>
<th>Activ</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> <th style="width:100px">Actiuni</th>
</tr> </tr>
</thead> </thead>
<tbody id="mappingsBody"> <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> </tbody>
</table> </table>
</div> </div>
@@ -53,9 +68,9 @@
</div> </div>
</div> </div>
<!-- Add/Edit Modal --> <!-- Add/Edit Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="addModal" tabindex="-1"> <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-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="addModalTitle">Adauga Mapare</h5> <h5 class="modal-title" id="addModalTitle">Adauga Mapare</h5>
@@ -66,22 +81,17 @@
<label class="form-label">SKU</label> <label class="form-label">SKU</label>
<input type="text" class="form-control" id="inputSku" placeholder="Ex: 8714858124284"> <input type="text" class="form-control" id="inputSku" placeholder="Ex: 8714858124284">
</div> </div>
<div class="mb-3 position-relative"> <div class="mb-2" id="addModalProductName" style="display:none;">
<label class="form-label">CODMAT (Articol ROA)</label> <small class="text-muted">Produs web:</small> <strong id="inputProductName"></strong>
<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> </div>
<div class="row"> <hr>
<div class="col-6 mb-3"> <div id="codmatLines">
<label class="form-label">Cantitate ROA</label> <!-- Dynamic CODMAT lines will be added here -->
<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>
</div> </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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
@@ -111,6 +121,36 @@
</div> </div>
</div> </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 %} {% endblock %}
{% block scripts %} {% block scripts %}

View File

@@ -15,6 +15,19 @@
</div> </div>
</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">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
@@ -45,7 +58,7 @@
<ul class="pagination justify-content-center" id="paginationControls"></ul> <ul class="pagination justify-content-center" id="paginationControls"></ul>
</nav> </nav>
<!-- Map SKU Modal --> <!-- Map SKU Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="mapModal" tabindex="-1"> <div class="modal fade" id="mapModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@@ -54,22 +67,16 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3 position-relative"> <div class="mb-2">
<label class="form-label">CODMAT (Articol ROA)</label> <small class="text-muted">Produs web:</small> <strong id="mapProductName"></strong>
<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>
<div class="row"> <div id="mapCodmatLines">
<div class="col-6 mb-3"> <!-- Dynamic CODMAT lines -->
<label class="form-label">Cantitate ROA</label>
<input type="number" class="form-control" id="mapCantitate" value="1" step="0.001" min="0.001">
</div>
<div class="col-6 mb-3">
<label class="form-label">Procent Pret (%)</label>
<input type="number" class="form-control" id="mapProcent" value="100" step="0.01" min="0" max="100">
</div>
</div> </div>
<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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
@@ -83,27 +90,29 @@
{% block scripts %} {% block scripts %}
<script> <script>
let currentMapSku = ''; let currentMapSku = '';
let acTimeout = null; let mapAcTimeout = null;
let currentPage = 1; let currentPage = 1;
let currentResolved = 0;
const perPage = 20; const perPage = 20;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadMissing(1); 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) { async function loadMissing(page) {
currentPage = page || 1; currentPage = page || 1;
try { 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 data = await res.json();
const tbody = document.getElementById('missingBody'); const tbody = document.getElementById('missingBody');
@@ -112,7 +121,9 @@ async function loadMissing(page) {
const skus = data.missing_skus || []; const skus = data.missing_skus || [];
if (skus.length === 0) { 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); renderPagination(data);
return; return;
} }
@@ -126,7 +137,7 @@ async function loadMissing(page) {
try { try {
const customers = JSON.parse(s.customers || '[]'); const customers = JSON.parse(s.customers || '[]');
if (customers.length > 0) firstCustomer = customers[0]; if (customers.length > 0) firstCustomer = customers[0];
} catch (e) { /* ignore parse errors */ } } catch (e) { /* ignore */ }
const orderCount = s.order_count != null ? s.order_count : '-'; const orderCount = s.order_count != null ? s.order_count : '-';
@@ -139,9 +150,9 @@ async function loadMissing(page) {
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td> <td>
${!s.resolved ${!s.resolved
? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')"> ? `<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> Mapeaza <i class="bi bi-link-45deg"></i>
</button>` </a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`} : `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
</td> </td>
</tr>`; </tr>`;
@@ -158,103 +169,160 @@ function renderPagination(data) {
const ul = document.getElementById('paginationControls'); const ul = document.getElementById('paginationControls');
const total = data.pages || 1; const total = data.pages || 1;
const page = data.page || 1; const page = data.page || 1;
if (total <= 1) { ul.innerHTML = ''; return; }
if (total <= 1) {
ul.innerHTML = '';
return;
}
let html = ''; let html = '';
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}"> html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a> <a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a></li>`;
</li>`;
const range = 2; const range = 2;
for (let i = 1; i <= total; i++) { for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= page - range && i <= page + range)) { if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
html += `<li class="page-item ${i === page ? 'active' : ''}"> html += `<li class="page-item ${i === page ? 'active' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${i}); return false;">${i}</a> <a class="page-link" href="#" onclick="loadMissing(${i}); return false;">${i}</a></li>`;
</li>`;
} else if (i === page - range - 1 || i === page + range + 1) { } 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 disabled"><span class="page-link">…</span></li>`;
} }
} }
html += `<li class="page-item ${page >= total ? 'disabled' : ''}"> html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${page + 1}); return false;">Urmator</a> <a class="page-link" href="#" onclick="loadMissing(${page + 1}); return false;">Urmator</a></li>`;
</li>`;
ul.innerHTML = html; ul.innerHTML = html;
} }
// ── Multi-CODMAT Map Modal ───────────────────────
function openMapModal(sku, productName) { function openMapModal(sku, productName) {
currentMapSku = sku; currentMapSku = sku;
document.getElementById('mapSku').textContent = sku; document.getElementById('mapSku').textContent = sku;
document.getElementById('mapCodmat').value = productName || ''; document.getElementById('mapProductName').textContent = productName || '-';
document.getElementById('mapCantitate').value = '1'; document.getElementById('mapPctWarning').style.display = 'none';
document.getElementById('mapProcent').value = '100';
document.getElementById('mapSelectedArticle').textContent = '';
document.getElementById('mapAutocomplete').classList.add('d-none');
const container = document.getElementById('mapCodmatLines');
container.innerHTML = '';
addMapCodmatLine();
// Pre-search with product name
if (productName) { 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(); new bootstrap.Modal(document.getElementById('mapModal')).show();
} }
async function autocompleteMap(q) { function addMapCodmatLine() {
const dropdown = document.getElementById('mapAutocomplete'); const container = document.getElementById('mapCodmatLines');
if (q.length < 2) { dropdown.classList.add('d-none'); return; } 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 { try {
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`); const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
const data = await res.json(); 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.innerHTML = data.results.map(r =>
dropdown.classList.add('d-none'); `<div class="autocomplete-item" onmousedown="mcSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
return; <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.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'); dropdown.classList.remove('d-none');
} catch (err) { } catch { dropdown.classList.add('d-none'); }
dropdown.classList.add('d-none');
}
} }
function selectMapArticle(codmat, denumire) { function mcSelectArticle(el, codmat, label) {
document.getElementById('mapCodmat').value = codmat; const line = el.closest('.mc-line');
document.getElementById('mapSelectedArticle').textContent = denumire; line.querySelector('.mc-codmat').value = codmat;
document.getElementById('mapAutocomplete').classList.add('d-none'); line.querySelector('.mc-selected').textContent = label;
line.querySelector('.mc-ac-dropdown').classList.add('d-none');
} }
async function saveQuickMap() { async function saveQuickMap() {
const codmat = document.getElementById('mapCodmat').value.trim(); const lines = document.querySelectorAll('.mc-line');
const cantitate = parseFloat(document.getElementById('mapCantitate').value) || 1; const mappings = [];
const procent = parseFloat(document.getElementById('mapProcent').value) || 100;
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 { try {
const res = await fetch('/api/mappings', { let res;
method: 'POST', if (mappings.length === 1) {
headers: { 'Content-Type': 'application/json' }, res = await fetch('/api/mappings', {
body: JSON.stringify({ method: 'POST',
sku: currentMapSku, headers: { 'Content-Type': 'application/json' },
codmat: codmat, body: JSON.stringify({ sku: currentMapSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
cantitate_roa: cantitate, });
procent_pret: procent } 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(); const data = await res.json();
if (data.success) { if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissing(currentPage); loadMissing(currentPage);

View File

@@ -538,7 +538,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
END IF; END IF;
v_cod_fiscal_curat := TRIM(p_cod_fiscal); 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) -- STEP 1: Cautare dupa cod fiscal (prioritate 1)
IF v_cod_fiscal_curat IS NOT NULL AND 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 IF v_este_persoana_fizica = 1 THEN
-- pINFO('Detectata persoana fizica (CUI 13 cifre)', 'IMPORT_PARTENERI'); -- pINFO('Detectata persoana fizica (CUI 13 cifre)', 'IMPORT_PARTENERI');
separa_nume_prenume(v_denumire_curata, v_nume, v_prenume); 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'); -- pINFO('Nume separat: NUME=' || NVL(v_nume, 'NULL') || ', PRENUME=' || NVL(v_prenume, 'NULL'), 'IMPORT_PARTENERI');
END IF; END IF;
@@ -591,7 +593,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
BEGIN BEGIN
IF v_este_persoana_fizica = 1 THEN IF v_este_persoana_fizica = 1 THEN
-- Pentru persoane fizice -- 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, tcNume => v_nume,
tcPrenume => v_prenume, tcPrenume => v_prenume,
tcCod_fiscal => v_cod_fiscal_curat, 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 JOIN nom_articole na ON na.codmat = at.codmat
WHERE at.sku = v_sku WHERE at.sku = v_sku
AND at.activ = 1 AND at.activ = 1
AND at.sters = 0
ORDER BY at.procent_pret DESC) LOOP ORDER BY at.procent_pret DESC) LOOP
v_found_mapping := TRUE; 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 python-dotenv==1.0.1
pydantic-settings==2.7.1 pydantic-settings==2.7.1
httpx==0.28.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 touch venv/.deps_installed
fi 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 # Oracle config
export TNS_ADMIN="$(pwd)/api" export TNS_ADMIN="$(pwd)/api"
export LD_LIBRARY_PATH=/opt/oracle/instantclient_21_15:$LD_LIBRARY_PATH export LD_LIBRARY_PATH=/opt/oracle/instantclient_21_15:$LD_LIBRARY_PATH
cd api cd api
echo "Starting GoMag Import Manager on http://0.0.0.0:5003" 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