feat(flow): retry failed orders
Add ability to re-import individual ERROR/SKIPPED orders directly from
the order detail modal. Downloads narrow date range from GoMag API,
finds the specific order, and re-runs import_single_order().
Backend:
- New retry_service.py with retry_single_order() — downloads order_date
±1 day from GoMag, finds order by number, imports via import_service
- Guard: blocks retry during active sync (_sync_lock check)
- POST /api/orders/{order_number}/retry endpoint
Frontend:
- "Reimporta" button in modal footer (visible only for ERROR/SKIPPED)
- Spinner during retry, success/error feedback with auto-refresh
Cache-bust: shared.js?v=18
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -484,6 +484,16 @@ async def order_detail(order_number: str):
|
||||
return detail
|
||||
|
||||
|
||||
@router.post("/api/orders/{order_number}/retry")
|
||||
async def retry_order(order_number: str):
|
||||
"""Retry importing a failed/skipped order."""
|
||||
from ..services import retry_service
|
||||
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
result = await retry_service.retry_single_order(order_number, app_settings)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/dashboard/orders")
|
||||
async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
search: str = "", status: str = "all",
|
||||
|
||||
131
api/app/services/retry_service.py
Normal file
131
api/app/services/retry_service.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Retry service — re-import individual failed/skipped orders."""
|
||||
import asyncio
|
||||
import logging
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def retry_single_order(order_number: str, app_settings: dict) -> dict:
|
||||
"""Re-download and re-import a single order from GoMag.
|
||||
|
||||
Steps:
|
||||
1. Read order from SQLite to get order_date / customer_name
|
||||
2. Check sync lock (no retry during active sync)
|
||||
3. Download narrow date range from GoMag (order_date ± 1 day)
|
||||
4. Find the specific order in downloaded data
|
||||
5. Run import_single_order()
|
||||
6. Update status in SQLite
|
||||
|
||||
Returns: {"success": bool, "message": str, "status": str|None}
|
||||
"""
|
||||
from . import sqlite_service, sync_service, gomag_client, import_service, order_reader
|
||||
|
||||
# Check sync lock
|
||||
if sync_service._sync_lock.locked():
|
||||
return {"success": False, "message": "Sync in curs — asteapta finalizarea"}
|
||||
|
||||
# Get order from SQLite
|
||||
detail = await sqlite_service.get_order_detail(order_number)
|
||||
if not detail:
|
||||
return {"success": False, "message": "Comanda nu a fost gasita"}
|
||||
|
||||
order_data = detail["order"]
|
||||
status = order_data.get("status", "")
|
||||
if status not in ("ERROR", "SKIPPED"):
|
||||
return {"success": False, "message": f"Retry permis doar pentru ERROR/SKIPPED (status actual: {status})"}
|
||||
|
||||
order_date_str = order_data.get("order_date", "")
|
||||
customer_name = order_data.get("customer_name", "")
|
||||
|
||||
# Parse order date for narrow download window
|
||||
try:
|
||||
order_date = datetime.fromisoformat(order_date_str.replace("Z", "+00:00")).date()
|
||||
except (ValueError, AttributeError):
|
||||
order_date = datetime.now().date() - timedelta(days=1)
|
||||
|
||||
gomag_key = app_settings.get("gomag_api_key") or None
|
||||
gomag_shop = app_settings.get("gomag_api_shop") or None
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
try:
|
||||
today = datetime.now().date()
|
||||
days_back = (today - order_date).days + 1
|
||||
if days_back < 2:
|
||||
days_back = 2
|
||||
|
||||
await gomag_client.download_orders(
|
||||
tmp_dir, days_back=days_back,
|
||||
api_key=gomag_key, api_shop=gomag_shop,
|
||||
limit=200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Retry download failed for {order_number}: {e}")
|
||||
return {"success": False, "message": f"Eroare download GoMag: {e}"}
|
||||
|
||||
# Find the specific order in downloaded data
|
||||
target_order = None
|
||||
orders, _ = order_reader.read_json_orders(json_dir=tmp_dir)
|
||||
for o in orders:
|
||||
if str(o.number) == str(order_number):
|
||||
target_order = o
|
||||
break
|
||||
|
||||
if not target_order:
|
||||
return {"success": False, "message": f"Comanda {order_number} nu a fost gasita in GoMag API"}
|
||||
|
||||
# Import the order
|
||||
id_pol = int(app_settings.get("id_pol") or 0)
|
||||
id_sectie = int(app_settings.get("id_sectie") or 0)
|
||||
id_gestiune = app_settings.get("id_gestiune", "")
|
||||
id_gestiuni = [int(g.strip()) for g in id_gestiune.split(",") if g.strip()] if id_gestiune else None
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
import_service.import_single_order,
|
||||
target_order, id_pol=id_pol, id_sectie=id_sectie,
|
||||
app_settings=app_settings, id_gestiuni=id_gestiuni
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Retry import failed for {order_number}: {e}")
|
||||
await sqlite_service.upsert_order(
|
||||
sync_run_id="retry",
|
||||
order_number=order_number,
|
||||
order_date=order_date_str,
|
||||
customer_name=customer_name,
|
||||
status="ERROR",
|
||||
error_message=f"Retry failed: {e}",
|
||||
)
|
||||
return {"success": False, "message": f"Eroare import: {e}"}
|
||||
|
||||
if result.get("success"):
|
||||
await sqlite_service.upsert_order(
|
||||
sync_run_id="retry",
|
||||
order_number=order_number,
|
||||
order_date=order_date_str,
|
||||
customer_name=customer_name,
|
||||
status="IMPORTED",
|
||||
id_comanda=result.get("id_comanda"),
|
||||
id_partener=result.get("id_partener"),
|
||||
error_message=None,
|
||||
)
|
||||
if result.get("id_adresa_facturare") or result.get("id_adresa_livrare"):
|
||||
await sqlite_service.update_import_order_addresses(
|
||||
order_number=order_number,
|
||||
id_adresa_facturare=result.get("id_adresa_facturare"),
|
||||
id_adresa_livrare=result.get("id_adresa_livrare"),
|
||||
)
|
||||
logger.info(f"Retry successful for order {order_number} → IMPORTED")
|
||||
return {"success": True, "message": "Comanda reimportata cu succes", "status": "IMPORTED"}
|
||||
else:
|
||||
error = result.get("error", "Unknown error")
|
||||
await sqlite_service.upsert_order(
|
||||
sync_run_id="retry",
|
||||
order_number=order_number,
|
||||
order_date=order_date_str,
|
||||
customer_name=customer_name,
|
||||
status="ERROR",
|
||||
error_message=f"Retry: {error}",
|
||||
)
|
||||
return {"success": False, "message": f"Import esuat: {error}", "status": "ERROR"}
|
||||
@@ -471,6 +471,8 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailError').style.display = 'none';
|
||||
const retryBtn = document.getElementById('detailRetryBtn');
|
||||
if (retryBtn) { retryBtn.style.display = 'none'; retryBtn.disabled = false; retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta'; retryBtn.className = 'btn btn-sm btn-outline-primary'; }
|
||||
const receiptEl = document.getElementById('detailReceipt');
|
||||
if (receiptEl) receiptEl.innerHTML = '';
|
||||
const receiptMEl = document.getElementById('detailReceiptMobile');
|
||||
@@ -712,6 +714,40 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
||||
document.getElementById('detailItemsBody').innerHTML = tableHtml;
|
||||
_renderReceipt(items, order);
|
||||
|
||||
// Retry button (only for ERROR/SKIPPED orders)
|
||||
const retryBtn = document.getElementById('detailRetryBtn');
|
||||
if (retryBtn) {
|
||||
const canRetry = ['ERROR', 'SKIPPED'].includes((order.status || '').toUpperCase());
|
||||
retryBtn.style.display = canRetry ? '' : 'none';
|
||||
if (canRetry) {
|
||||
retryBtn.onclick = async () => {
|
||||
retryBtn.disabled = true;
|
||||
retryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Reimportare...';
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/retry`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
retryBtn.innerHTML = '<i class="bi bi-check-circle"></i> ' + (data.message || 'Reimportat');
|
||||
retryBtn.className = 'btn btn-sm btn-success';
|
||||
// Refresh modal after short delay
|
||||
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||
} else {
|
||||
retryBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||
retryBtn.className = 'btn btn-sm btn-danger';
|
||||
setTimeout(() => {
|
||||
retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta';
|
||||
retryBtn.className = 'btn btn-sm btn-outline-primary';
|
||||
retryBtn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
retryBtn.innerHTML = 'Eroare: ' + err.message;
|
||||
retryBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.onAfterRender) opts.onAfterRender(order, items);
|
||||
} catch (err) {
|
||||
document.getElementById('detailError').textContent = err.message;
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="detailRetryBtn" class="btn btn-sm btn-outline-primary" style="display:none"><i class="bi bi-arrow-clockwise"></i> Reimporta</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +144,7 @@
|
||||
|
||||
<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="{{ rp }}/static/js/shared.js?v=17"></script>
|
||||
<script src="{{ rp }}/static/js/shared.js?v=18"></script>
|
||||
<script>
|
||||
// Dark mode toggle
|
||||
function toggleDarkMode() {
|
||||
|
||||
Reference in New Issue
Block a user