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:
Claude Agent
2026-04-09 13:10:01 +00:00
parent 90a4906d87
commit 25f73db64d
8 changed files with 621 additions and 34 deletions

View File

@@ -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.

View File

@@ -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"}

View File

@@ -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;

View File

@@ -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'
});
}

View File

@@ -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()) {

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=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() {

View File

@@ -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 %}

View File

@@ -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()