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:
@@ -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",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user