From 25f73db64d5740c590a5202eb993ebbb469ec7fc Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 9 Apr 2026 13:10:01 +0000 Subject: [PATCH] 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 --- api/app/routers/sync.py | 17 +++ api/app/services/retry_service.py | 209 ++++++++++++++++++++++++++---- api/app/static/css/style.css | 41 ++++++ api/app/static/js/dashboard.js | 68 +++++++++- api/app/static/js/shared.js | 123 +++++++++++++++++- api/app/templates/base.html | 8 +- api/app/templates/dashboard.html | 2 +- api/tests/test_business_rules.py | 187 ++++++++++++++++++++++++++ 8 files changed, 621 insertions(+), 34 deletions(-) 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()