diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py
index 3b702ce..3769afc 100644
--- a/api/app/routers/sync.py
+++ b/api/app/routers/sync.py
@@ -476,6 +476,23 @@ async def retry_order(order_number: str):
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")
async def resync_partner(order_number: str):
"""Manual partner resync for invoiced orders with partner_mismatch=1.
diff --git a/api/app/services/retry_service.py b/api/app/services/retry_service.py
index 2f72d14..be831fd 100644
--- a/api/app/services/retry_service.py
+++ b/api/app/services/retry_service.py
@@ -7,37 +7,13 @@ 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
+async def _download_and_reimport(order_number: str, order_date_str: str, customer_name: str, app_settings: dict) -> dict:
+ """Download order from GoMag and re-import it into Oracle.
+ Does NOT check status guard — caller is responsible.
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", "")
+ from . import sqlite_service, gomag_client, import_service, order_reader
# Parse order date for narrow download window
try:
@@ -129,3 +105,180 @@ async def retry_single_order(order_number: str, app_settings: dict) -> dict:
error_message=f"Retry: {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"}
diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css
index 5a58b7e..6e3a819 100644
--- a/api/app/static/css/style.css
+++ b/api/app/static/css/style.css
@@ -367,6 +367,47 @@ input[type="checkbox"] {
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 ───────────────────────────────────────── */
.form-control, .form-select {
font-size: 0.9375rem;
diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js
index c1320e7..f11616c 100644
--- a/api/app/static/js/dashboard.js
+++ b/api/app/static/js/dashboard.js
@@ -375,7 +375,7 @@ async function loadDashOrders() {
${o.items_count || 0} |
${fmtCost(o.delivery_cost)} |
${fmtCost(o.discount_total)} |
- ${orderTotal} |
+ ${orderTotal}${(o.status === 'IMPORTED' || o.status === 'ALREADY_IMPORTED') && !(o.invoice && o.invoice.facturat) ? '' : ''} |
`;
}).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 = '';
+ b.className = 'btn btn-xs btn-success';
+ setTimeout(() => loadDashOrders(), 1500);
+ } else {
+ b.innerHTML = '';
+ b.className = 'btn btn-xs btn-danger';
+ b.title = data.message || 'Eroare';
+ setTimeout(() => {
+ b.innerHTML = '';
+ b.className = 'btn btn-xs btn-outline-warning';
+ b.disabled = false;
+ b.title = 'Resync';
+ }, 3000);
+ }
+ } catch (err) {
+ b.innerHTML = '';
+ b.disabled = false;
+ }
+ }, {
+ defaultHtml: '',
+ 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 = '';
+ b.className = 'btn btn-xs btn-success';
+ setTimeout(() => loadDashOrders(), 1500);
+ } else {
+ b.innerHTML = '';
+ b.className = 'btn btn-xs btn-danger';
+ b.title = data.message || 'Eroare';
+ setTimeout(() => {
+ b.innerHTML = '';
+ b.className = 'btn btn-xs btn-outline-danger';
+ b.disabled = false;
+ b.title = 'Sterge din ROA';
+ }, 3000);
+ }
+ } catch (err) {
+ b.innerHTML = '';
+ b.disabled = false;
+ }
+ }, {
+ defaultHtml: '',
+ loadingText: '',
+ confirmClass: 'btn-danger',
+ defaultBtnClass: 'btn-outline-danger'
+ });
+}
+
diff --git a/api/app/static/js/shared.js b/api/app/static/js/shared.js
index f1efda9..149ee60 100644
--- a/api/app/static/js/shared.js
+++ b/api/app/static/js/shared.js
@@ -735,7 +735,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
// Retry button (only for ERROR/SKIPPED orders)
const retryBtn = document.getElementById('detailRetryBtn');
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';
if (canRetry) {
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 = ' Reimportat';
+ btn.className = 'btn btn-sm btn-success';
+ setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
+ } else {
+ btn.innerHTML = ' ' + (data.message || 'Eroare');
+ btn.className = 'btn btn-sm btn-danger';
+ setTimeout(() => {
+ btn.innerHTML = ' Resync';
+ btn.className = 'btn btn-sm btn-outline-warning';
+ btn.disabled = false;
+ }, 3000);
+ }
+ } catch (err) {
+ btn.innerHTML = 'Eroare: ' + err.message;
+ btn.disabled = false;
+ }
+ }, {
+ defaultHtml: ' 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 = ' Sters';
+ btn.className = 'btn btn-sm btn-success';
+ setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
+ } else {
+ btn.innerHTML = ' ' + (data.message || 'Eroare');
+ btn.className = 'btn btn-sm btn-danger';
+ setTimeout(() => {
+ btn.innerHTML = ' 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: ' Sterge din ROA',
+ loadingText: 'Stergere...',
+ confirmClass: 'btn-danger',
+ defaultBtnClass: 'btn-outline-danger'
+ });
+ };
+ }
+ }
+ }
+
if (opts.onAfterRender) opts.onAfterRender(order, items);
} catch (err) {
document.getElementById('detailError').textContent = err.message;
@@ -779,6 +879,27 @@ function _sharedModalQuickMap(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 = ' ' + 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 ────────────────────────────────────
function statusDot(status) {
switch ((status || '').toUpperCase()) {
diff --git a/api/app/templates/base.html b/api/app/templates/base.html
index a4e0342..ba26405 100644
--- a/api/app/templates/base.html
+++ b/api/app/templates/base.html
@@ -19,7 +19,7 @@
{% set rp = request.scope.get('root_path', '') %}
-
+
@@ -157,8 +157,10 @@
-
@@ -167,7 +169,7 @@
-
+
+
{% endblock %}
diff --git a/api/tests/test_business_rules.py b/api/tests/test_business_rules.py
index 46c9c5f..9ef2182 100644
--- a/api/tests/test_business_rules.py
+++ b/api/tests/test_business_rules.py
@@ -899,3 +899,190 @@ class TestRefreshOrderAddress:
data = res.json()
assert data["adresa_livrare_roa"]["strada"] == "VASILE GOLDIS"
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()