feat(orders): add resync and delete order buttons
Resync soft-deletes from Oracle then re-imports from GoMag with fresh article data. Delete soft-deletes and marks DELETED_IN_ROA. Both have invoice safety gates (refuse if invoiced or Oracle unavailable). UI: split modal footer (Delete left, Resync+Close right), inline confirm pattern (no native confirm()), dashboard row hover action icons, disabled+tooltip for invoiced orders. 8 unit tests for safety gates and happy paths. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -476,6 +476,23 @@ async def retry_order(order_number: str):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/orders/{order_number}/resync")
|
||||||
|
async def resync_order(order_number: str):
|
||||||
|
"""Resync an imported order: soft-delete from Oracle then re-import from GoMag."""
|
||||||
|
from ..services import retry_service
|
||||||
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
|
result = await retry_service.resync_single_order(order_number, app_settings)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/orders/{order_number}/delete")
|
||||||
|
async def delete_order(order_number: str):
|
||||||
|
"""Delete an imported order from Oracle (soft-delete)."""
|
||||||
|
from ..services import retry_service
|
||||||
|
result = await retry_service.delete_single_order(order_number)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/orders/{order_number}/resync-partner")
|
@router.post("/api/orders/{order_number}/resync-partner")
|
||||||
async def resync_partner(order_number: str):
|
async def resync_partner(order_number: str):
|
||||||
"""Manual partner resync for invoiced orders with partner_mismatch=1.
|
"""Manual partner resync for invoiced orders with partner_mismatch=1.
|
||||||
|
|||||||
@@ -7,37 +7,13 @@ from datetime import datetime, timedelta
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def retry_single_order(order_number: str, app_settings: dict) -> dict:
|
async def _download_and_reimport(order_number: str, order_date_str: str, customer_name: str, app_settings: dict) -> dict:
|
||||||
"""Re-download and re-import a single order from GoMag.
|
"""Download order from GoMag and re-import it into Oracle.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
Does NOT check status guard — caller is responsible.
|
||||||
Returns: {"success": bool, "message": str, "status": str|None}
|
Returns: {"success": bool, "message": str, "status": str|None}
|
||||||
"""
|
"""
|
||||||
from . import sqlite_service, sync_service, gomag_client, import_service, order_reader
|
from . import sqlite_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
|
# Parse order date for narrow download window
|
||||||
try:
|
try:
|
||||||
@@ -129,3 +105,180 @@ async def retry_single_order(order_number: str, app_settings: dict) -> dict:
|
|||||||
error_message=f"Retry: {error}",
|
error_message=f"Retry: {error}",
|
||||||
)
|
)
|
||||||
return {"success": False, "message": f"Import esuat: {error}", "status": "ERROR"}
|
return {"success": False, "message": f"Import esuat: {error}", "status": "ERROR"}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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", "DELETED_IN_ROA"):
|
||||||
|
return {"success": False, "message": f"Retry permis doar pentru ERROR/SKIPPED/DELETED_IN_ROA (status actual: {status})"}
|
||||||
|
|
||||||
|
order_date_str = order_data.get("order_date", "")
|
||||||
|
customer_name = order_data.get("customer_name", "")
|
||||||
|
|
||||||
|
return await _download_and_reimport(order_number, order_date_str, customer_name, app_settings)
|
||||||
|
|
||||||
|
|
||||||
|
async def resync_single_order(order_number: str, app_settings: dict) -> dict:
|
||||||
|
"""Soft-delete an imported order from Oracle then re-import it from GoMag.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Check sync lock
|
||||||
|
2. Load order from SQLite
|
||||||
|
3. Validate status is IMPORTED/ALREADY_IMPORTED with id_comanda
|
||||||
|
4. Invoice safety gate (check Oracle for invoices)
|
||||||
|
5. Soft-delete from Oracle
|
||||||
|
6. Mark DELETED_IN_ROA in SQLite
|
||||||
|
7. Re-import via _download_and_reimport
|
||||||
|
|
||||||
|
Returns: {"success": bool, "message": str, "status": str|None}
|
||||||
|
"""
|
||||||
|
from . import sqlite_service, sync_service, import_service, invoice_service
|
||||||
|
from .. import database
|
||||||
|
|
||||||
|
# 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", "")
|
||||||
|
id_comanda = order_data.get("id_comanda")
|
||||||
|
|
||||||
|
if status not in ("IMPORTED", "ALREADY_IMPORTED") or not id_comanda:
|
||||||
|
return {"success": False, "message": f"Resync permis doar pentru IMPORTED/ALREADY_IMPORTED cu id_comanda (status actual: {status})"}
|
||||||
|
|
||||||
|
# Invoice safety gate
|
||||||
|
if database.pool is None:
|
||||||
|
return {"success": False, "message": "Oracle indisponibil"}
|
||||||
|
|
||||||
|
if order_data.get("factura_numar"):
|
||||||
|
return {"success": False, "message": "Comanda este facturata"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
invoice_result = await asyncio.to_thread(
|
||||||
|
invoice_service.check_invoices_for_orders, [id_comanda]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Invoice check failed for {order_number}: {e}")
|
||||||
|
return {"success": False, "message": "Nu se poate verifica factura — Oracle indisponibil"}
|
||||||
|
|
||||||
|
if invoice_result.get(id_comanda):
|
||||||
|
return {"success": False, "message": "Comanda este facturata"}
|
||||||
|
|
||||||
|
# Soft-delete from Oracle
|
||||||
|
try:
|
||||||
|
delete_result = await asyncio.to_thread(
|
||||||
|
import_service.soft_delete_order_in_roa, id_comanda
|
||||||
|
)
|
||||||
|
if not delete_result.get("success"):
|
||||||
|
return {"success": False, "message": f"Eroare stergere din Oracle: {delete_result.get('error', 'Unknown')}"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Soft-delete failed for {order_number} (id_comanda={id_comanda}): {e}")
|
||||||
|
return {"success": False, "message": f"Eroare stergere din Oracle: {e}"}
|
||||||
|
|
||||||
|
# Mark deleted in SQLite
|
||||||
|
await sqlite_service.mark_order_deleted_in_roa(order_number)
|
||||||
|
|
||||||
|
order_date_str = order_data.get("order_date", "")
|
||||||
|
customer_name = order_data.get("customer_name", "")
|
||||||
|
|
||||||
|
# Re-import
|
||||||
|
reimport_result = await _download_and_reimport(order_number, order_date_str, customer_name, app_settings)
|
||||||
|
if not reimport_result.get("success"):
|
||||||
|
logger.warning(f"Resync: order {order_number} deleted from Oracle but reimport failed")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Comanda stearsa din Oracle dar reimportul a esuat — foloseste Reimporta pentru a reincerca",
|
||||||
|
}
|
||||||
|
|
||||||
|
return reimport_result
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_single_order(order_number: str) -> dict:
|
||||||
|
"""Soft-delete an imported order from Oracle without re-importing.
|
||||||
|
|
||||||
|
Same invoice safety gate as resync_single_order.
|
||||||
|
|
||||||
|
Returns: {"success": bool, "message": str}
|
||||||
|
"""
|
||||||
|
from . import sqlite_service, sync_service, import_service, invoice_service
|
||||||
|
from .. import database
|
||||||
|
|
||||||
|
# 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", "")
|
||||||
|
id_comanda = order_data.get("id_comanda")
|
||||||
|
|
||||||
|
if status not in ("IMPORTED", "ALREADY_IMPORTED") or not id_comanda:
|
||||||
|
return {"success": False, "message": f"Stergere permisa doar pentru IMPORTED/ALREADY_IMPORTED cu id_comanda (status actual: {status})"}
|
||||||
|
|
||||||
|
# Invoice safety gate
|
||||||
|
if database.pool is None:
|
||||||
|
return {"success": False, "message": "Oracle indisponibil"}
|
||||||
|
|
||||||
|
if order_data.get("factura_numar"):
|
||||||
|
return {"success": False, "message": "Comanda este facturata"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
invoice_result = await asyncio.to_thread(
|
||||||
|
invoice_service.check_invoices_for_orders, [id_comanda]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Invoice check failed for {order_number}: {e}")
|
||||||
|
return {"success": False, "message": "Nu se poate verifica factura — Oracle indisponibil"}
|
||||||
|
|
||||||
|
if invoice_result.get(id_comanda):
|
||||||
|
return {"success": False, "message": "Comanda este facturata"}
|
||||||
|
|
||||||
|
# Soft-delete from Oracle
|
||||||
|
try:
|
||||||
|
delete_result = await asyncio.to_thread(
|
||||||
|
import_service.soft_delete_order_in_roa, id_comanda
|
||||||
|
)
|
||||||
|
if not delete_result.get("success"):
|
||||||
|
return {"success": False, "message": f"Eroare stergere din Oracle: {delete_result.get('error', 'Unknown')}"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Soft-delete failed for {order_number} (id_comanda={id_comanda}): {e}")
|
||||||
|
return {"success": False, "message": f"Eroare stergere din Oracle: {e}"}
|
||||||
|
|
||||||
|
# Mark deleted in SQLite
|
||||||
|
await sqlite_service.mark_order_deleted_in_roa(order_number)
|
||||||
|
|
||||||
|
logger.info(f"Order {order_number} (id_comanda={id_comanda}) deleted from ROA")
|
||||||
|
return {"success": True, "message": "Comanda stearsa din ROA"}
|
||||||
|
|||||||
@@ -367,6 +367,47 @@ input[type="checkbox"] {
|
|||||||
border-color: var(--info-hover);
|
border-color: var(--info-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-outline-warning {
|
||||||
|
color: var(--warning);
|
||||||
|
border-color: var(--warning);
|
||||||
|
}
|
||||||
|
.btn-outline-warning:hover {
|
||||||
|
background: var(--warning);
|
||||||
|
border-color: var(--warning);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-outline-danger {
|
||||||
|
color: var(--error);
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
.btn-outline-danger:hover {
|
||||||
|
background: var(--error);
|
||||||
|
border-color: var(--error);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact button for dashboard row actions */
|
||||||
|
.btn-xs {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard row hover actions */
|
||||||
|
#dashOrdersBody tr { position: relative; }
|
||||||
|
#dashOrdersBody tr .row-actions {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
#dashOrdersBody tr:hover .row-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Forms ───────────────────────────────────────── */
|
/* ── Forms ───────────────────────────────────────── */
|
||||||
.form-control, .form-select {
|
.form-control, .form-select {
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ async function loadDashOrders() {
|
|||||||
<td>${o.items_count || 0}</td>
|
<td>${o.items_count || 0}</td>
|
||||||
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
|
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
|
||||||
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
||||||
<td class="text-end fw-bold">${orderTotal}</td>
|
<td class="text-end fw-bold" style="position:relative">${orderTotal}${(o.status === 'IMPORTED' || o.status === 'ALREADY_IMPORTED') && !(o.invoice && o.invoice.facturat) ? '<span class="row-actions"><button class="btn btn-xs btn-outline-warning" aria-label="Resync comanda" title="Resync" onclick="event.stopPropagation(); dashResyncOrder(\'' + esc(o.order_number) + '\', this)"><i class="bi bi-arrow-repeat"></i></button><button class="btn btn-xs btn-outline-danger" aria-label="Sterge din ROA" title="Sterge din ROA" onclick="event.stopPropagation(); dashDeleteOrder(\'' + esc(o.order_number) + '\', this)"><i class="bi bi-trash"></i></button></span>' : ''}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -595,3 +595,69 @@ function openDashQuickMap(sku, productName, orderNumber, itemIdx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dashboard row action handlers ────────────────
|
||||||
|
|
||||||
|
function dashResyncOrder(orderNumber, btn) {
|
||||||
|
inlineConfirmAction(btn, '?', async (b) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/resync`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
b.innerHTML = '<i class="bi bi-check-circle"></i>';
|
||||||
|
b.className = 'btn btn-xs btn-success';
|
||||||
|
setTimeout(() => loadDashOrders(), 1500);
|
||||||
|
} else {
|
||||||
|
b.innerHTML = '<i class="bi bi-exclamation-triangle"></i>';
|
||||||
|
b.className = 'btn btn-xs btn-danger';
|
||||||
|
b.title = data.message || 'Eroare';
|
||||||
|
setTimeout(() => {
|
||||||
|
b.innerHTML = '<i class="bi bi-arrow-repeat"></i>';
|
||||||
|
b.className = 'btn btn-xs btn-outline-warning';
|
||||||
|
b.disabled = false;
|
||||||
|
b.title = 'Resync';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
b.innerHTML = '<i class="bi bi-exclamation-triangle"></i>';
|
||||||
|
b.disabled = false;
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
defaultHtml: '<i class="bi bi-arrow-repeat"></i>',
|
||||||
|
loadingText: '',
|
||||||
|
confirmClass: 'btn-warning',
|
||||||
|
defaultBtnClass: 'btn-outline-warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashDeleteOrder(orderNumber, btn) {
|
||||||
|
inlineConfirmAction(btn, '?', async (b) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/delete`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
b.innerHTML = '<i class="bi bi-check-circle"></i>';
|
||||||
|
b.className = 'btn btn-xs btn-success';
|
||||||
|
setTimeout(() => loadDashOrders(), 1500);
|
||||||
|
} else {
|
||||||
|
b.innerHTML = '<i class="bi bi-exclamation-triangle"></i>';
|
||||||
|
b.className = 'btn btn-xs btn-danger';
|
||||||
|
b.title = data.message || 'Eroare';
|
||||||
|
setTimeout(() => {
|
||||||
|
b.innerHTML = '<i class="bi bi-trash"></i>';
|
||||||
|
b.className = 'btn btn-xs btn-outline-danger';
|
||||||
|
b.disabled = false;
|
||||||
|
b.title = 'Sterge din ROA';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
b.innerHTML = '<i class="bi bi-exclamation-triangle"></i>';
|
||||||
|
b.disabled = false;
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
defaultHtml: '<i class="bi bi-trash"></i>',
|
||||||
|
loadingText: '',
|
||||||
|
confirmClass: 'btn-danger',
|
||||||
|
defaultBtnClass: 'btn-outline-danger'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -735,7 +735,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
// Retry button (only for ERROR/SKIPPED orders)
|
// Retry button (only for ERROR/SKIPPED orders)
|
||||||
const retryBtn = document.getElementById('detailRetryBtn');
|
const retryBtn = document.getElementById('detailRetryBtn');
|
||||||
if (retryBtn) {
|
if (retryBtn) {
|
||||||
const canRetry = ['ERROR', 'SKIPPED'].includes((order.status || '').toUpperCase());
|
const canRetry = ['ERROR', 'SKIPPED', 'DELETED_IN_ROA'].includes((order.status || '').toUpperCase());
|
||||||
retryBtn.style.display = canRetry ? '' : 'none';
|
retryBtn.style.display = canRetry ? '' : 'none';
|
||||||
if (canRetry) {
|
if (canRetry) {
|
||||||
retryBtn.onclick = async () => {
|
retryBtn.onclick = async () => {
|
||||||
@@ -766,6 +766,106 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resync button (IMPORTED/ALREADY_IMPORTED only)
|
||||||
|
const resyncBtn = document.getElementById('detailResyncBtn');
|
||||||
|
if (resyncBtn) {
|
||||||
|
const canResync = ['IMPORTED', 'ALREADY_IMPORTED'].includes((order.status || '').toUpperCase());
|
||||||
|
resyncBtn.style.display = canResync ? '' : 'none';
|
||||||
|
if (canResync) {
|
||||||
|
const isInvoiced = !!(order.factura_numar);
|
||||||
|
if (isInvoiced) {
|
||||||
|
resyncBtn.disabled = true;
|
||||||
|
resyncBtn.style.opacity = '0.5';
|
||||||
|
resyncBtn.style.pointerEvents = 'none';
|
||||||
|
resyncBtn.title = 'Comanda facturata';
|
||||||
|
} else {
|
||||||
|
resyncBtn.disabled = false;
|
||||||
|
resyncBtn.style.opacity = '';
|
||||||
|
resyncBtn.style.pointerEvents = '';
|
||||||
|
resyncBtn.title = '';
|
||||||
|
resyncBtn.onclick = () => {
|
||||||
|
inlineConfirmAction(resyncBtn, 'Confirmi resync?', async (btn) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/resync`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-circle"></i> Reimportat';
|
||||||
|
btn.className = 'btn btn-sm btn-success';
|
||||||
|
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||||
|
btn.className = 'btn btn-sm btn-danger';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Resync';
|
||||||
|
btn.className = 'btn btn-sm btn-outline-warning';
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
btn.innerHTML = 'Eroare: ' + err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
defaultHtml: '<i class="bi bi-arrow-repeat"></i> Resync',
|
||||||
|
loadingText: 'Resync...',
|
||||||
|
confirmClass: 'btn-warning',
|
||||||
|
defaultBtnClass: 'btn-outline-warning'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete button (IMPORTED/ALREADY_IMPORTED only)
|
||||||
|
const deleteBtn = document.getElementById('detailDeleteBtn');
|
||||||
|
if (deleteBtn) {
|
||||||
|
const canDelete = ['IMPORTED', 'ALREADY_IMPORTED'].includes((order.status || '').toUpperCase());
|
||||||
|
deleteBtn.style.display = canDelete ? '' : 'none';
|
||||||
|
if (canDelete) {
|
||||||
|
const isInvoiced = !!(order.factura_numar);
|
||||||
|
if (isInvoiced) {
|
||||||
|
deleteBtn.disabled = true;
|
||||||
|
deleteBtn.style.opacity = '0.5';
|
||||||
|
deleteBtn.style.pointerEvents = 'none';
|
||||||
|
deleteBtn.title = 'Comanda facturata';
|
||||||
|
} else {
|
||||||
|
deleteBtn.disabled = false;
|
||||||
|
deleteBtn.style.opacity = '';
|
||||||
|
deleteBtn.style.pointerEvents = '';
|
||||||
|
deleteBtn.title = '';
|
||||||
|
deleteBtn.onclick = () => {
|
||||||
|
inlineConfirmAction(deleteBtn, 'Confirmi stergerea?', async (btn) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/delete`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-circle"></i> Sters';
|
||||||
|
btn.className = 'btn btn-sm btn-success';
|
||||||
|
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||||
|
btn.className = 'btn btn-sm btn-danger';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash"></i> Sterge din ROA';
|
||||||
|
btn.className = 'btn btn-sm btn-outline-danger';
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
btn.innerHTML = 'Eroare: ' + err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
defaultHtml: '<i class="bi bi-trash"></i> Sterge din ROA',
|
||||||
|
loadingText: 'Stergere...',
|
||||||
|
confirmClass: 'btn-danger',
|
||||||
|
defaultBtnClass: 'btn-outline-danger'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.onAfterRender) opts.onAfterRender(order, items);
|
if (opts.onAfterRender) opts.onAfterRender(order, items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('detailError').textContent = err.message;
|
document.getElementById('detailError').textContent = err.message;
|
||||||
@@ -779,6 +879,27 @@ function _sharedModalQuickMap(sku, productName, orderNumber, itemIdx) {
|
|||||||
if (_sharedModalQuickMapFn) _sharedModalQuickMapFn(sku, productName, orderNumber, itemIdx);
|
if (_sharedModalQuickMapFn) _sharedModalQuickMapFn(sku, productName, orderNumber, itemIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inline confirm helper (shared: modal + dashboard) ──────
|
||||||
|
function inlineConfirmAction(btn, confirmText, actionFn, opts) {
|
||||||
|
if (btn.dataset.confirming === 'true') {
|
||||||
|
btn.dataset.confirming = 'false';
|
||||||
|
clearTimeout(btn._resetTimer);
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> ' + opts.loadingText;
|
||||||
|
actionFn(btn);
|
||||||
|
} else {
|
||||||
|
btn.dataset.confirming = 'true';
|
||||||
|
btn._origClass = btn.className;
|
||||||
|
btn.innerHTML = confirmText;
|
||||||
|
btn.className = btn.className.replace(/btn-outline-\w+/, opts.confirmClass);
|
||||||
|
btn._resetTimer = setTimeout(() => {
|
||||||
|
btn.dataset.confirming = 'false';
|
||||||
|
btn.innerHTML = opts.defaultHtml;
|
||||||
|
btn.className = btn._origClass;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dot helper ────────────────────────────────────
|
// ── Dot helper ────────────────────────────────────
|
||||||
function statusDot(status) {
|
function statusDot(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
|
|||||||
@@ -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=43" rel="stylesheet">
|
<link href="{{ rp }}/static/css/style.css?v=44" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Top Navbar (hidden on mobile via CSS) -->
|
<!-- Top Navbar (hidden on mobile via CSS) -->
|
||||||
@@ -157,8 +157,10 @@
|
|||||||
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
|
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
|
||||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer d-flex">
|
||||||
|
<button type="button" id="detailDeleteBtn" class="btn btn-sm btn-outline-danger me-auto" style="display:none"><i class="bi bi-trash"></i> Sterge din ROA</button>
|
||||||
<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" id="detailRetryBtn" class="btn btn-sm btn-outline-primary" style="display:none"><i class="bi bi-arrow-clockwise"></i> Reimporta</button>
|
||||||
|
<button type="button" id="detailResyncBtn" class="btn btn-sm btn-outline-warning" style="display:none"><i class="bi bi-arrow-repeat"></i> Resync</button>
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,7 +169,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=42"></script>
|
<script src="{{ rp }}/static/js/shared.js?v=43"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark mode toggle
|
// Dark mode toggle
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
|
|||||||
@@ -114,5 +114,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=46"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=47"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -899,3 +899,190 @@ class TestRefreshOrderAddress:
|
|||||||
data = res.json()
|
data = res.json()
|
||||||
assert data["adresa_livrare_roa"]["strada"] == "VASILE GOLDIS"
|
assert data["adresa_livrare_roa"]["strada"] == "VASILE GOLDIS"
|
||||||
assert data["adresa_livrare_roa"]["bloc"] == "30"
|
assert data["adresa_livrare_roa"]["bloc"] == "30"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group N: Resync / Delete — safety gates and happy paths
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock # noqa: E402 (already imported MagicMock/patch above)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_order_detail(status='IMPORTED', id_comanda=12345, factura_numar=None):
|
||||||
|
return {
|
||||||
|
"order": {
|
||||||
|
"order_number": "1001",
|
||||||
|
"status": status,
|
||||||
|
"id_comanda": id_comanda,
|
||||||
|
"order_date": "2025-01-15T10:00:00",
|
||||||
|
"customer_name": "Test Client",
|
||||||
|
"factura_numar": factura_numar,
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _unlocked_lock():
|
||||||
|
m = MagicMock()
|
||||||
|
m.locked.return_value = False
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
class TestResyncDeleteSafetyGates:
|
||||||
|
"""Safety gates: invoiced orders and Oracle-down must be refused."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_refuses_invoiced_order(self):
|
||||||
|
"""delete_single_order → success=False when factura_numar is set."""
|
||||||
|
from app.services import retry_service
|
||||||
|
|
||||||
|
with patch('app.services.sqlite_service.get_order_detail',
|
||||||
|
new=AsyncMock(return_value=_make_order_detail(factura_numar='FAC-001'))), \
|
||||||
|
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
|
||||||
|
patch('app.database.pool', new=MagicMock()):
|
||||||
|
result = await retry_service.delete_single_order("1001")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "facturata" in result["message"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_refuses_when_oracle_down(self):
|
||||||
|
"""delete_single_order → success=False when database.pool is None."""
|
||||||
|
from app.services import retry_service
|
||||||
|
|
||||||
|
with patch('app.services.sqlite_service.get_order_detail',
|
||||||
|
new=AsyncMock(return_value=_make_order_detail())), \
|
||||||
|
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
|
||||||
|
patch('app.database.pool', new=None):
|
||||||
|
result = await retry_service.delete_single_order("1001")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "indisponibil" in result["message"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_resync_refuses_invoiced_order(self):
|
||||||
|
"""resync_single_order → success=False when factura_numar is set."""
|
||||||
|
from app.services import retry_service
|
||||||
|
|
||||||
|
with patch('app.services.sqlite_service.get_order_detail',
|
||||||
|
new=AsyncMock(return_value=_make_order_detail(factura_numar='FAC-001'))), \
|
||||||
|
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
|
||||||
|
patch('app.database.pool', new=MagicMock()):
|
||||||
|
result = await retry_service.resync_single_order("1001", {})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "facturata" in result["message"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_resync_refuses_wrong_status(self):
|
||||||
|
"""resync_single_order → success=False when status is ERROR (not IMPORTED)."""
|
||||||
|
from app.services import retry_service
|
||||||
|
|
||||||
|
with patch('app.services.sqlite_service.get_order_detail',
|
||||||
|
new=AsyncMock(return_value=_make_order_detail(status='ERROR'))), \
|
||||||
|
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()):
|
||||||
|
result = await retry_service.resync_single_order("1001", {})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "ERROR" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestResyncDeleteHappyPaths:
|
||||||
|
"""Happy paths and partial-failure recovery for resync / delete / retry."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_resync_happy_path(self):
|
||||||
|
"""resync_single_order succeeds: IMPORTED, no invoice, soft-delete OK, reimport OK."""
|
||||||
|
from app.services import retry_service
|
||||||
|
|
||||||
|
async def _fake_to_thread(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
with patch('app.services.sqlite_service.get_order_detail',
|
||||||
|
new=AsyncMock(return_value=_make_order_detail())), \
|
||||||
|
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
|
||||||
|
patch('app.database.pool', new=MagicMock()), \
|
||||||
|
patch('app.services.invoice_service.check_invoices_for_orders',
|
||||||
|
new=MagicMock(return_value={})), \
|
||||||
|
patch('app.services.import_service.soft_delete_order_in_roa',
|
||||||
|
new=MagicMock(return_value={"success": True})), \
|
||||||
|
patch('app.services.sqlite_service.mark_order_deleted_in_roa', new=AsyncMock()), \
|
||||||
|
patch('asyncio.to_thread', new=_fake_to_thread), \
|
||||||
|
patch('app.services.retry_service._download_and_reimport',
|
||||||
|
new=AsyncMock(return_value={"success": True,
|
||||||
|
"message": "Comanda reimportata cu succes"})):
|
||||||
|
result = await retry_service.resync_single_order("1001", {})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_happy_path(self):
|
||||||
|
"""delete_single_order succeeds: IMPORTED, no invoice, soft-delete OK."""
|
||||||
|
from app.services import retry_service
|
||||||
|
|
||||||
|
async def _fake_to_thread(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
with patch('app.services.sqlite_service.get_order_detail',
|
||||||
|
new=AsyncMock(return_value=_make_order_detail())), \
|
||||||
|
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
|
||||||
|
patch('app.database.pool', new=MagicMock()), \
|
||||||
|
patch('app.services.invoice_service.check_invoices_for_orders',
|
||||||
|
new=MagicMock(return_value={})), \
|
||||||
|
patch('app.services.import_service.soft_delete_order_in_roa',
|
||||||
|
new=MagicMock(return_value={"success": True})), \
|
||||||
|
patch('app.services.sqlite_service.mark_order_deleted_in_roa', new=AsyncMock()), \
|
||||||
|
patch('asyncio.to_thread', new=_fake_to_thread):
|
||||||
|
result = await retry_service.delete_single_order("1001")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "stearsa" in result["message"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_retry_accepts_deleted_in_roa(self):
|
||||||
|
"""retry_single_order proceeds (not refused) for DELETED_IN_ROA status."""
|
||||||
|
from app.services import retry_service
|
||||||
|
|
||||||
|
with patch('app.services.sqlite_service.get_order_detail',
|
||||||
|
new=AsyncMock(return_value=_make_order_detail(status='DELETED_IN_ROA'))), \
|
||||||
|
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
|
||||||
|
patch('app.services.retry_service._download_and_reimport',
|
||||||
|
new=AsyncMock(return_value={"success": True, "message": "ok"})):
|
||||||
|
result = await retry_service.retry_single_order("1001", {})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
# Must NOT be refused with the "retry permis" status guard message
|
||||||
|
assert "retry permis" not in result.get("message", "").lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_resync_partial_failure(self):
|
||||||
|
"""resync returns success=False when soft-delete OK but reimport fails."""
|
||||||
|
from app.services import retry_service
|
||||||
|
|
||||||
|
async def _fake_to_thread(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
with patch('app.services.sqlite_service.get_order_detail',
|
||||||
|
new=AsyncMock(return_value=_make_order_detail())), \
|
||||||
|
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
|
||||||
|
patch('app.database.pool', new=MagicMock()), \
|
||||||
|
patch('app.services.invoice_service.check_invoices_for_orders',
|
||||||
|
new=MagicMock(return_value={})), \
|
||||||
|
patch('app.services.import_service.soft_delete_order_in_roa',
|
||||||
|
new=MagicMock(return_value={"success": True})), \
|
||||||
|
patch('app.services.sqlite_service.mark_order_deleted_in_roa', new=AsyncMock()), \
|
||||||
|
patch('asyncio.to_thread', new=_fake_to_thread), \
|
||||||
|
patch('app.services.retry_service._download_and_reimport',
|
||||||
|
new=AsyncMock(return_value={"success": False, "message": "download failed"})):
|
||||||
|
result = await retry_service.resync_single_order("1001", {})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "reimport" in result["message"].lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user