feat(flow): map SKU + auto-retry consolidated banner

After saving a SKU mapping, check for SKIPPED orders containing that
SKU and show a floating banner with count + "Importa" button. Batch
retries up to 20 orders and shows result feedback.

Backend:
- get_skipped_orders_with_sku() in sqlite_service.py
- GET /api/orders/by-sku/{sku}/pending endpoint
- POST /api/orders/batch-retry endpoint (max 20, sequential)

Frontend:
- Auto-retry banner after quickMap save with batch import button
- Success/error feedback, auto-dismiss after 15s

Cache-bust: shared.js?v=19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-27 12:38:10 +00:00
parent 7a789b4fe7
commit b52313faf6
4 changed files with 90 additions and 2 deletions

View File

@@ -494,6 +494,42 @@ async def retry_order(order_number: str):
return result return result
@router.get("/api/orders/by-sku/{sku}/pending")
async def get_pending_orders_for_sku(sku: str):
"""Get SKIPPED orders that contain the given SKU."""
order_numbers = await sqlite_service.get_skipped_orders_with_sku(sku)
return {"sku": sku, "order_numbers": order_numbers, "count": len(order_numbers)}
@router.post("/api/orders/batch-retry")
async def batch_retry_orders(request: Request):
"""Batch retry multiple orders."""
from ..services import retry_service
body = await request.json()
order_numbers = body.get("order_numbers", [])
if not order_numbers:
return {"success": False, "message": "No orders specified"}
app_settings = await sqlite_service.get_app_settings()
results = {"imported": 0, "errors": 0, "messages": []}
for on in order_numbers[:20]: # Limit to 20 to avoid timeout
result = await retry_service.retry_single_order(str(on), app_settings)
if result.get("success"):
results["imported"] += 1
else:
results["errors"] += 1
results["messages"].append(f"{on}: {result.get('message', 'Error')}")
return {
"success": results["imported"] > 0,
"imported": results["imported"],
"errors": results["errors"],
"message": f"{results['imported']} importate, {results['errors']} erori" if results["errors"] else f"{results['imported']} importate cu succes",
"details": results["messages"][:5],
}
@router.get("/api/dashboard/orders") @router.get("/api/dashboard/orders")
async def dashboard_orders(page: int = 1, per_page: int = 50, async def dashboard_orders(page: int = 1, per_page: int = 50,
search: str = "", status: str = "all", search: str = "", status: str = "all",

View File

@@ -960,6 +960,24 @@ async def set_app_setting(key: str, value: str):
await db.close() await db.close()
# ── SKU-based order lookup ────────────────────────
async def get_skipped_orders_with_sku(sku: str) -> list[str]:
"""Get order_numbers of SKIPPED orders that contain the given SKU."""
db = await get_sqlite()
try:
cursor = await db.execute("""
SELECT DISTINCT oi.order_number
FROM order_items oi
JOIN orders o ON o.order_number = oi.order_number
WHERE oi.sku = ? AND o.status = 'SKIPPED'
""", (sku,))
rows = await cursor.fetchall()
return [row[0] for row in rows]
finally:
await db.close()
# ── Price Sync Runs ─────────────────────────────── # ── Price Sync Runs ───────────────────────────────
async def get_price_sync_runs(page: int = 1, per_page: int = 20): async def get_price_sync_runs(page: int = 1, per_page: int = 20):

View File

@@ -344,6 +344,40 @@ async function saveQuickMapping() {
if (data.success) { if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
if (_qmOnSave) _qmOnSave(sku, mappings); if (_qmOnSave) _qmOnSave(sku, mappings);
// Check for SKIPPED orders that can now be imported
try {
const pendingRes = await fetch(`/api/orders/by-sku/${encodeURIComponent(sku)}/pending`);
const pendingData = await pendingRes.json();
if (pendingData.count > 0) {
const banner = document.createElement('div');
banner.className = 'alert alert-info d-flex align-items-center gap-2 mt-2';
banner.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1060;min-width:300px;max-width:500px;box-shadow:var(--card-shadow)';
banner.innerHTML = `<i class="bi bi-arrow-clockwise"></i> <span>${pendingData.count} comenzi SKIPPED pot fi importate acum</span> <button class="btn btn-sm btn-primary ms-auto" id="batchRetryBtn">Importa</button> <button class="btn btn-sm btn-outline-secondary" onclick="this.parentElement.remove()">✕</button>`;
document.body.appendChild(banner);
document.getElementById('batchRetryBtn').onclick = async function() {
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const retryRes = await fetch('/api/orders/batch-retry', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({order_numbers: pendingData.order_numbers})
});
const retryData = await retryRes.json();
banner.className = retryData.errors > 0 ? 'alert alert-warning d-flex align-items-center gap-2 mt-2' : 'alert alert-success d-flex align-items-center gap-2 mt-2';
banner.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1060;min-width:300px;max-width:500px;box-shadow:var(--card-shadow)';
banner.innerHTML = `<i class="bi bi-check-circle"></i> ${esc(retryData.message)} <button class="btn btn-sm btn-outline-secondary ms-auto" onclick="this.parentElement.remove()">✕</button>`;
setTimeout(() => banner.remove(), 5000);
if (typeof loadDashOrders === 'function') loadDashOrders();
} catch(e) {
banner.innerHTML = `Eroare: ${esc(e.message)} <button class="btn btn-sm btn-outline-secondary ms-auto" onclick="this.parentElement.remove()">✕</button>`;
}
};
setTimeout(() => { if (banner.parentElement) banner.remove(); }, 15000);
}
} catch(e) { /* ignore */ }
} else { } else {
alert('Eroare: ' + (data.error || 'Unknown')); alert('Eroare: ' + (data.error || 'Unknown'));
} }

View File

@@ -19,7 +19,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
{% set rp = request.scope.get('root_path', '') %} {% set rp = request.scope.get('root_path', '') %}
<link href="{{ rp }}/static/css/style.css?v=21" rel="stylesheet"> <link href="{{ rp }}/static/css/style.css?v=22" rel="stylesheet">
</head> </head>
<body> <body>
<!-- Top Navbar (hidden on mobile via CSS) --> <!-- Top Navbar (hidden on mobile via CSS) -->
@@ -144,7 +144,7 @@
<script>window.ROOT_PATH = "{{ rp }}";</script> <script>window.ROOT_PATH = "{{ rp }}";</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ rp }}/static/js/shared.js?v=18"></script> <script src="{{ rp }}/static/js/shared.js?v=19"></script>
<script> <script>
// Dark mode toggle // Dark mode toggle
function toggleDarkMode() { function toggleDarkMode() {