feat(dashboard): redesign UI with smart polling, unified sync card, filter bar

Replace SSE with smart polling (30s idle / 3s when running). Unify sync
panel into single two-row card with live progress text. Add unified filter
bar (period dropdown, status pills, search) with period-total counts.
Add Client/Cont tooltip for different shipping/billing persons. Add SKU
mappings pct_total badges + complete/incomplete filter + 409 duplicate
check. Add missing SKUs search + rescan progress UX. Migrate SQLite
orders schema (shipping_name, billing_name, payment_method,
delivery_method). Fix JSON_OUTPUT_DIR path for server running from
project root. Fix pagination controls showing top+bottom with per-page
selector (25/50/100/250).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:55:36 +02:00
parent 82196b9dc0
commit 5f8b9b6003
14 changed files with 1235 additions and 648 deletions

View File

@@ -106,7 +106,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
raise RuntimeError("Oracle pool not initialized")
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Step 1: Process partner
# Step 1: Process partner — use shipping person data for name
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
if order.billing.is_company:
@@ -115,9 +115,15 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
registru = clean_web_text(order.billing.company_reg) or None
is_pj = 1
else:
denumire = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).upper()
# Use shipping person for partner name (person on shipping label)
if order.shipping and (order.shipping.lastname or order.shipping.firstname):
denumire = clean_web_text(
f"{order.shipping.lastname} {order.shipping.firstname}"
).upper()
else:
denumire = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).upper()
cod_fiscal = None
registru = None
is_pj = 0
@@ -133,20 +139,31 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
result["id_partener"] = int(partner_id)
# Step 2: Process billing address
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
billing_addr = format_address_for_oracle(
order.billing.address, order.billing.city, order.billing.region
# Determine if billing and shipping are different persons
billing_name = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).strip().upper()
shipping_name = ""
if order.shipping:
shipping_name = clean_web_text(
f"{order.shipping.lastname} {order.shipping.firstname}"
).strip().upper()
different_person = bool(
shipping_name and billing_name and shipping_name != billing_name
)
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
partner_id, billing_addr,
order.billing.phone or "",
order.billing.email or "",
id_adresa_fact
])
addr_fact_id = id_adresa_fact.getvalue()
# Step 3: Process shipping address (if different)
# Step 2: Process shipping address (primary — person on shipping label)
# Use shipping person phone/email for partner contact
shipping_phone = ""
shipping_email = ""
if order.shipping:
shipping_phone = order.shipping.phone or ""
shipping_email = order.shipping.email or ""
if not shipping_phone:
shipping_phone = order.billing.phone or ""
if not shipping_email:
shipping_email = order.billing.email or ""
addr_livr_id = None
if order.shipping:
id_adresa_livr = cur.var(oracledb.DB_TYPE_NUMBER)
@@ -156,12 +173,30 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
)
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
partner_id, shipping_addr,
order.shipping.phone or "",
order.shipping.email or "",
shipping_phone,
shipping_email,
id_adresa_livr
])
addr_livr_id = id_adresa_livr.getvalue()
# Step 3: Process billing address
if different_person:
# Different person: use shipping address for BOTH billing and shipping in ROA
addr_fact_id = addr_livr_id
else:
# Same person: use billing address as-is
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
billing_addr = format_address_for_oracle(
order.billing.address, order.billing.city, order.billing.region
)
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
partner_id, billing_addr,
order.billing.phone or "",
order.billing.email or "",
id_adresa_fact
])
addr_fact_id = id_adresa_fact.getvalue()
if addr_fact_id is not None:
result["id_adresa_facturare"] = int(addr_fact_id)
if addr_livr_id is not None:

View File

@@ -9,8 +9,14 @@ logger = logging.getLogger(__name__)
def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
sort_by: str = "sku", sort_dir: str = "asc",
show_deleted: bool = False):
"""Get paginated mappings with optional search and sorting."""
show_deleted: bool = False, pct_filter: str = None):
"""Get paginated mappings with optional search, sorting, and pct_filter.
pct_filter values:
'complete' only SKU groups where sum(procent_pret for active rows) == 100
'incomplete' only SKU groups where sum < 100
None / 'all' no filter
"""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -49,16 +55,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
params["search"] = search
where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# Count total
count_sql = f"""
SELECT COUNT(*) FROM ARTICOLE_TERTI at
LEFT JOIN nom_articole na ON na.codmat = at.codmat
{where}
"""
cur.execute(count_sql, params)
total = cur.fetchone()[0]
# Get page
# Fetch ALL matching rows (no pagination yet — we need to group by SKU first)
data_sql = f"""
SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa,
at.procent_pret, at.activ, at.sters,
@@ -67,30 +64,114 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
LEFT JOIN nom_articole na ON na.codmat = at.codmat
{where}
ORDER BY {order_clause}
OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY
"""
params["offset"] = offset
params["per_page"] = per_page
cur.execute(data_sql, params)
columns = [col[0].lower() for col in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
all_rows = [dict(zip(columns, row)) for row in cur.fetchall()]
# Group by SKU and compute pct_total for each group
from collections import OrderedDict
groups = OrderedDict()
for row in all_rows:
sku = row["sku"]
if sku not in groups:
groups[sku] = []
groups[sku].append(row)
# Compute counts across ALL groups (before pct_filter)
total_skus = len(groups)
complete_skus = 0
incomplete_skus = 0
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
if pct_total >= 99.99:
complete_skus += 1
else:
incomplete_skus += 1
counts = {
"total": total_skus,
"complete": complete_skus,
"incomplete": incomplete_skus,
}
# Apply pct_filter
if pct_filter in ("complete", "incomplete"):
filtered_groups = {}
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
is_complete = pct_total >= 99.99
if pct_filter == "complete" and is_complete:
filtered_groups[sku] = rows
elif pct_filter == "incomplete" and not is_complete:
filtered_groups[sku] = rows
groups = filtered_groups
# Flatten back to rows for pagination (paginate by raw row count)
filtered_rows = [row for rows in groups.values() for row in rows]
total = len(filtered_rows)
page_rows = filtered_rows[offset: offset + per_page]
# Attach pct_total and is_complete to each row for the renderer
# Re-compute per visible group
sku_pct = {}
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
sku_pct[sku] = {"pct_total": pct_total, "is_complete": pct_total >= 99.99}
for row in page_rows:
meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False})
row["pct_total"] = meta["pct_total"]
row["is_complete"] = meta["is_complete"]
return {
"mappings": rows,
"mappings": page_rows,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
"counts": counts,
}
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100):
"""Create a new mapping."""
"""Create a new mapping. Returns dict or raises HTTPException on duplicate."""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Check for active duplicate
cur.execute("""
SELECT COUNT(*) FROM ARTICOLE_TERTI
WHERE sku = :sku AND codmat = :codmat AND NVL(sters, 0) = 0
""", {"sku": sku, "codmat": codmat})
if cur.fetchone()[0] > 0:
raise HTTPException(status_code=409, detail="Maparea SKU-CODMAT există deja")
# Check for soft-deleted record that could be restored
cur.execute("""
SELECT COUNT(*) FROM ARTICOLE_TERTI
WHERE sku = :sku AND codmat = :codmat AND sters = 1
""", {"sku": sku, "codmat": codmat})
if cur.fetchone()[0] > 0:
raise HTTPException(
status_code=409,
detail="Maparea a fost ștearsă anterior",
headers={"X-Can-Restore": "true"}
)
cur.execute("""
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)

View File

@@ -44,7 +44,9 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
customer_name: str, status: str, id_comanda: int = None,
id_partener: int = None, error_message: str = None,
missing_skus: list = None, items_count: int = 0):
missing_skus: list = None, items_count: int = 0,
shipping_name: str = None, billing_name: str = None,
payment_method: str = None, delivery_method: str = None):
"""Upsert a single order — one row per order_number, status updated in place."""
db = await get_sqlite()
try:
@@ -52,8 +54,9 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
INSERT INTO orders
(order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET
status = excluded.status,
error_message = excluded.error_message,
@@ -65,11 +68,16 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
THEN orders.times_skipped + 1
ELSE orders.times_skipped END,
last_sync_run_id = excluded.last_sync_run_id,
shipping_name = COALESCE(excluded.shipping_name, orders.shipping_name),
billing_name = COALESCE(excluded.billing_name, orders.billing_name),
payment_method = COALESCE(excluded.payment_method, orders.payment_method),
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
updated_at = datetime('now')
""", (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message,
json.dumps(missing_skus) if missing_skus else None,
items_count, sync_run_id))
items_count, sync_run_id, shipping_name, billing_name,
payment_method, delivery_method))
await db.commit()
finally:
await db.close()
@@ -124,35 +132,52 @@ async def resolve_missing_sku(sku: str):
await db.close()
async def get_missing_skus_paginated(page: int = 1, per_page: int = 20, resolved: int = 0):
"""Get paginated missing SKUs. resolved=-1 means show all."""
async def get_missing_skus_paginated(page: int = 1, per_page: int = 20,
resolved: int = 0, search: str = None):
"""Get paginated missing SKUs. resolved=-1 means show all.
Optional search filters by sku or product_name (LIKE)."""
db = await get_sqlite()
try:
offset = (page - 1) * per_page
if resolved == -1:
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus")
total = (await cursor.fetchone())[0]
cursor = await db.execute("""
SELECT sku, product_name, first_seen, resolved, resolved_at,
order_count, order_numbers, customers
FROM missing_skus
ORDER BY resolved ASC, order_count DESC, first_seen DESC
LIMIT ? OFFSET ?
""", (per_page, offset))
else:
cursor = await db.execute(
"SELECT COUNT(*) FROM missing_skus WHERE resolved = ?", (resolved,)
)
total = (await cursor.fetchone())[0]
cursor = await db.execute("""
SELECT sku, product_name, first_seen, resolved, resolved_at,
order_count, order_numbers, customers
FROM missing_skus
WHERE resolved = ?
ORDER BY order_count DESC, first_seen DESC
LIMIT ? OFFSET ?
""", (resolved, per_page, offset))
# Build WHERE clause parts
where_parts = []
params_count = []
params_data = []
if resolved != -1:
where_parts.append("resolved = ?")
params_count.append(resolved)
params_data.append(resolved)
if search:
like = f"%{search}%"
where_parts.append("(LOWER(sku) LIKE LOWER(?) OR LOWER(COALESCE(product_name,'')) LIKE LOWER(?))")
params_count.extend([like, like])
params_data.extend([like, like])
where_clause = ("WHERE " + " AND ".join(where_parts)) if where_parts else ""
order_clause = (
"ORDER BY resolved ASC, order_count DESC, first_seen DESC"
if resolved == -1
else "ORDER BY order_count DESC, first_seen DESC"
)
cursor = await db.execute(
f"SELECT COUNT(*) FROM missing_skus {where_clause}",
params_count
)
total = (await cursor.fetchone())[0]
cursor = await db.execute(f"""
SELECT sku, product_name, first_seen, resolved, resolved_at,
order_count, order_numbers, customers
FROM missing_skus
{where_clause}
{order_clause}
LIMIT ? OFFSET ?
""", params_data + [per_page, offset])
rows = await cursor.fetchall()
@@ -474,8 +499,13 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
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."""
period_days: int = 7,
period_start: str = "", period_end: str = ""):
"""Get orders with filters, sorting, and period.
period_days=0 with period_start/period_end uses custom date range.
period_days=0 without dates means all time.
"""
db = await get_sqlite()
try:
where_clauses = []
@@ -484,6 +514,9 @@ async def get_orders(page: int = 1, per_page: int = 50,
if period_days and period_days > 0:
where_clauses.append("order_date >= date('now', ?)")
params.append(f"-{period_days} days")
elif period_days == 0 and period_start and period_end:
where_clauses.append("order_date BETWEEN ? AND ?")
params.extend([period_start, period_end])
if search:
where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")

View File

@@ -13,28 +13,10 @@ logger = logging.getLogger(__name__)
_sync_lock = asyncio.Lock()
_current_sync = None # dict with run_id, status, progress info
# SSE subscriber system
_subscribers: list[asyncio.Queue] = []
# In-memory text log buffer per run
_run_logs: dict[str, list[str]] = {}
def subscribe() -> asyncio.Queue:
"""Subscribe to sync events. Returns a queue that will receive event dicts."""
q = asyncio.Queue()
_subscribers.append(q)
return q
def unsubscribe(q: asyncio.Queue):
"""Unsubscribe from sync events."""
try:
_subscribers.remove(q)
except ValueError:
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:
@@ -51,13 +33,17 @@ def get_run_text_log(run_id: str) -> str | None:
return "\n".join(lines)
async def _emit(event: dict):
"""Push an event to all subscriber queues."""
for q in _subscribers:
try:
q.put_nowait(event)
except asyncio.QueueFull:
pass
def _update_progress(phase: str, phase_text: str, current: int = 0, total: int = 0,
counts: dict = None):
"""Update _current_sync with progress details for polling."""
global _current_sync
if _current_sync is None:
return
_current_sync["phase"] = phase
_current_sync["phase_text"] = phase_text
_current_sync["progress_current"] = current
_current_sync["progress_total"] = total
_current_sync["counts"] = counts or {"imported": 0, "skipped": 0, "errors": 0}
async def get_sync_status():
@@ -80,7 +66,12 @@ async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict:
"run_id": run_id,
"status": "running",
"started_at": datetime.now().isoformat(),
"progress": "Starting..."
"finished_at": None,
"phase": "starting",
"phase_text": "Starting...",
"progress_current": 0,
"progress_total": 0,
"counts": {"imported": 0, "skipped": 0, "errors": 0},
}
return {"run_id": run_id, "status": "starting"}
@@ -100,11 +91,15 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"run_id": run_id,
"status": "running",
"started_at": datetime.now().isoformat(),
"progress": "Reading JSON files..."
"finished_at": None,
"phase": "reading",
"phase_text": "Reading JSON files...",
"progress_current": 0,
"progress_total": 0,
"counts": {"imported": 0, "skipped": 0, "errors": 0},
}
_current_sync["progress"] = "Reading JSON files..."
await _emit({"type": "phase", "run_id": run_id, "message": "Reading JSON files..."})
_update_progress("reading", "Reading JSON files...")
started_dt = datetime.now()
_run_logs[run_id] = [
@@ -119,7 +114,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
orders, json_count = order_reader.read_json_orders()
orders.sort(key=lambda o: o.date or '')
await sqlite_service.create_sync_run(run_id, json_count)
await _emit({"type": "phase", "run_id": run_id, "message": f"Found {len(orders)} orders in {json_count} files"})
_update_progress("reading", f"Found {len(orders)} orders in {json_count} files", 0, len(orders))
_log_line(run_id, f"Gasite {len(orders)} comenzi in {json_count} fisiere")
# Populate web_products catalog from all orders (R4)
@@ -131,12 +126,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
if not orders:
_log_line(run_id, "Nicio comanda gasita.")
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
_update_progress("completed", "No orders found")
summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
return summary
_current_sync["progress"] = f"Validating {len(orders)} orders..."
await _emit({"type": "phase", "run_id": run_id, "message": f"Validating {len(orders)} orders..."})
_update_progress("validation", f"Validating {len(orders)} orders...", 0, len(orders))
# Step 2a: Find new orders (not yet in Oracle)
all_order_numbers = [o.number for o in orders]
@@ -149,7 +143,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
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)"})
_update_progress("validation", f"{len(importable)} importable, {len(skipped)} skipped (missing SKUs)",
0, len(importable))
_log_line(run_id, f"Validare SKU-uri: {len(importable)} importabile, {len(skipped)} nemapate")
# Step 2c: Build SKU context from skipped orders
@@ -189,8 +184,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
_log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
if id_pol and importable:
_current_sync["progress"] = "Validating prices..."
await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."})
_update_progress("validation", "Validating prices...", 0, len(importable))
_log_line(run_id, "Validare preturi...")
# Gather all CODMATs from importable orders
all_codmats = set()
@@ -216,10 +210,21 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
price_result["missing_price"], id_pol
)
# Step 3: Record skipped orders + emit events + store items
# Step 3: Record skipped orders + store items
skipped_count = 0
for order, missing_skus in skipped:
customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}"
skipped_count += 1
# Derive shipping / billing names
shipping_name = ""
if order.shipping:
shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip()
billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip()
if not shipping_name:
shipping_name = billing_name
customer = shipping_name or order.billing.company_name or billing_name
payment_method = getattr(order, 'payment_name', None) or None
delivery_method = getattr(order, 'delivery_name', None) or None
await sqlite_service.upsert_order(
sync_run_id=run_id,
order_number=order.number,
@@ -227,7 +232,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
customer_name=customer,
status="SKIPPED",
missing_skus=missing_skus,
items_count=len(order.items)
items_count=len(order.items),
shipping_name=shipping_name,
billing_name=billing_name,
payment_method=payment_method,
delivery_method=delivery_method,
)
await sqlite_service.add_sync_run_order(run_id, order.number, "SKIPPED")
# Store order items with mapping status (R9)
@@ -243,28 +252,35 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
})
await sqlite_service.add_order_items(order.number, order_items_data)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"order_date": order.date,
"status": "SKIPPED", "missing_skus": missing_skus,
"items_count": len(order.items), "progress": f"0/{len(importable)}"
})
_update_progress("skipped", f"Skipped {skipped_count}/{len(skipped)}: #{order.number} {customer}",
0, len(importable),
{"imported": 0, "skipped": skipped_count, "errors": 0})
# Step 4: Import valid orders
imported_count = 0
error_count = 0
for i, order in enumerate(importable):
progress_str = f"{i+1}/{len(importable)}"
_current_sync["progress"] = f"Importing {progress_str}: #{order.number}"
# Derive shipping / billing names
shipping_name = ""
if order.shipping:
shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip()
billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip()
if not shipping_name:
shipping_name = billing_name
customer = shipping_name or order.billing.company_name or billing_name
payment_method = getattr(order, 'payment_name', None) or None
delivery_method = getattr(order, 'delivery_name', None) or None
_update_progress("import",
f"Import {i+1}/{len(importable)}: #{order.number} {customer}",
i + 1, len(importable),
{"imported": imported_count, "skipped": len(skipped), "errors": error_count})
result = await asyncio.to_thread(
import_service.import_single_order,
order, id_pol=id_pol, id_sectie=id_sectie
)
customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}"
# Build order items data for storage (R9)
order_items_data = []
@@ -287,7 +303,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
status="IMPORTED",
id_comanda=result["id_comanda"],
id_partener=result["id_partener"],
items_count=len(order.items)
items_count=len(order.items),
shipping_name=shipping_name,
billing_name=billing_name,
payment_method=payment_method,
delivery_method=delivery_method,
)
await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
# Store ROA address IDs (R9)
@@ -298,13 +318,6 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
)
await sqlite_service.add_order_items(order.number, order_items_data)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → IMPORTAT (ID: {result['id_comanda']})")
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"order_date": order.date,
"status": "IMPORTED", "items_count": len(order.items),
"id_comanda": result["id_comanda"], "progress": progress_str
})
else:
error_count += 1
await sqlite_service.upsert_order(
@@ -315,18 +328,15 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
status="ERROR",
id_partener=result.get("id_partener"),
error_message=result["error"],
items_count=len(order.items)
items_count=len(order.items),
shipping_name=shipping_name,
billing_name=billing_name,
payment_method=payment_method,
delivery_method=delivery_method,
)
await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR")
await sqlite_service.add_order_items(order.number, order_items_data)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → EROARE: {result['error']}")
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"order_date": order.date,
"status": "ERROR", "error_message": result["error"],
"items_count": len(order.items), "progress": progress_str
})
# Safety: stop if too many errors
if error_count > 10:
@@ -351,11 +361,18 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"missing_skus": len(validation["missing"])
}
_update_progress("completed",
f"Completed: {imported_count} imported, {len(skipped)} skipped, {error_count} errors",
len(importable), len(importable),
{"imported": imported_count, "skipped": len(skipped), "errors": error_count})
if _current_sync:
_current_sync["status"] = status
_current_sync["finished_at"] = datetime.now().isoformat()
logger.info(
f"Sync {run_id} completed: {imported_count} imported, "
f"{len(skipped)} skipped, {error_count} errors"
)
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
duration = (datetime.now() - started_dt).total_seconds()
_log_line(run_id, "")
@@ -367,8 +384,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
logger.error(f"Sync {run_id} failed: {e}")
_log_line(run_id, f"EROARE FATALA: {e}")
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e))
_current_sync["error"] = str(e)
await _emit({"type": "failed", "run_id": run_id, "error": str(e)})
if _current_sync:
_current_sync["status"] = "failed"
_current_sync["finished_at"] = datetime.now().isoformat()
_current_sync["error"] = str(e)
return {"run_id": run_id, "status": "failed", "error": str(e)}
finally:
# Keep _current_sync for 10 seconds so status endpoint can show final result