From ab20856cd6f0694a44992baa4c43fae81b2f89f5 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 7 May 2026 12:57:32 +0000 Subject: [PATCH] fix(sync): sticky exclusion for DELETED_IN_ROA orders Orders deleted via "Sterge" button were re-imported on the next sync because classify step only checked Oracle (sters=0), not SQLite status. Adds a filter step after cancellation handling that drops orders already marked DELETED_IN_ROA before validation. "Reimporta" remains the explicit override. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/app/services/sqlite_service.py | 13 +++ api/app/services/sync_service.py | 12 ++ api/tests/test_sticky_deleted_filter.py | 140 ++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 api/tests/test_sticky_deleted_filter.py diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 1f264a6..3057ede 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -1253,6 +1253,19 @@ async def get_all_imported_orders() -> list: await db.close() +async def get_deleted_in_roa_order_numbers() -> set[str]: + """Return set of order_numbers marked DELETED_IN_ROA (sticky-excluded from auto-sync).""" + db = await get_sqlite() + try: + cursor = await db.execute( + f"SELECT order_number FROM orders WHERE status = '{OrderStatus.DELETED_IN_ROA.value}'" + ) + rows = await cursor.fetchall() + return {r[0] for r in rows} + finally: + await db.close() + + async def clear_order_invoice(order_number: str): """Clear cached invoice data when invoice was deleted in ROA.""" db = await get_sqlite() diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index d419b0a..a0ebdf5 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -476,6 +476,18 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None orders = active_orders + # ── Sticky exclusion: skip orders previously marked DELETED_IN_ROA ── + deleted_set = await sqlite_service.get_deleted_in_roa_order_numbers() + if deleted_set: + excluded_deleted = [o for o in orders if o.number in deleted_set] + orders = [o for o in orders if o.number not in deleted_set] + if excluded_deleted: + _log_line(run_id, + f"Excluse {len(excluded_deleted)} comenzi marcate DELETED_IN_ROA " + f"(stergeri sticky — foloseste 'Reimporta' pentru override)") + for o in excluded_deleted: + _log_line(run_id, f"#{o.number} [{o.date or '?'}] → IGNORAT (DELETED_IN_ROA)") + if not orders: _log_line(run_id, "Nicio comanda activa dupa filtrare anulate.") await sqlite_service.update_sync_run(run_id, "completed", cancelled_count, 0, 0, 0) diff --git a/api/tests/test_sticky_deleted_filter.py b/api/tests/test_sticky_deleted_filter.py new file mode 100644 index 0000000..bfa4898 --- /dev/null +++ b/api/tests/test_sticky_deleted_filter.py @@ -0,0 +1,140 @@ +""" +Sticky DELETED_IN_ROA Filter Tests +=================================== +Unit tests for get_deleted_in_roa_order_numbers() helper and integration +test for the sticky-exclusion filter applied in sync_service before +order classification. + +Run: + cd api && python -m pytest tests/test_sticky_deleted_filter.py -v +""" + +import os +import sys +import tempfile + +import pytest + +pytestmark = pytest.mark.unit + +_tmpdir = tempfile.mkdtemp() +os.environ.setdefault("FORCE_THIN_MODE", "true") +os.environ.setdefault("SQLITE_DB_PATH", os.path.join(_tmpdir, "test_sticky_deleted.db")) +os.environ.setdefault("ORACLE_DSN", "dummy") +os.environ.setdefault("ORACLE_USER", "dummy") +os.environ.setdefault("ORACLE_PASSWORD", "dummy") +os.environ.setdefault("JSON_OUTPUT_DIR", _tmpdir) + +_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _api_dir not in sys.path: + sys.path.insert(0, _api_dir) + +from app import database +from app.services import sqlite_service +from app.constants import OrderStatus + + +@pytest.fixture(autouse=True) +async def _clean_orders(): + """Ensure schema exists, clear orders table before each test.""" + database.init_sqlite() + db = await database.get_sqlite() + try: + await db.execute("DELETE FROM orders") + await db.commit() + finally: + await db.close() + yield + + +async def _insert_order(order_number: str, status: str, id_comanda: int | None = None): + db = await database.get_sqlite() + try: + await db.execute( + """ + INSERT INTO orders (order_number, order_date, customer_name, status, id_comanda) + VALUES (?, ?, ?, ?, ?) + """, + (order_number, "2026-04-22", "Test Customer", status, id_comanda), + ) + await db.commit() + finally: + await db.close() + + +@pytest.mark.asyncio +async def test_returns_empty_set_when_no_orders(): + """Helper unit: empty table → empty set.""" + result = await sqlite_service.get_deleted_in_roa_order_numbers() + assert result == set() + + +@pytest.mark.asyncio +async def test_returns_only_deleted_in_roa_status(): + """Helper unit: filters only DELETED_IN_ROA, ignores other statuses.""" + await _insert_order("ORD-1", OrderStatus.IMPORTED.value, id_comanda=100) + await _insert_order("ORD-2", OrderStatus.DELETED_IN_ROA.value) + await _insert_order("ORD-3", OrderStatus.CANCELLED.value) + await _insert_order("ORD-4", OrderStatus.ERROR.value) + await _insert_order("ORD-5", OrderStatus.DELETED_IN_ROA.value) + await _insert_order("ORD-6", OrderStatus.SKIPPED.value) + + result = await sqlite_service.get_deleted_in_roa_order_numbers() + assert result == {"ORD-2", "ORD-5"} + + +@pytest.mark.asyncio +async def test_mark_order_deleted_then_helper_returns_it(): + """Integration: mark_order_deleted_in_roa → helper picks it up.""" + await _insert_order("ORD-100", OrderStatus.IMPORTED.value, id_comanda=500) + + before = await sqlite_service.get_deleted_in_roa_order_numbers() + assert "ORD-100" not in before + + await sqlite_service.mark_order_deleted_in_roa("ORD-100") + + after = await sqlite_service.get_deleted_in_roa_order_numbers() + assert "ORD-100" in after + + +@pytest.mark.asyncio +async def test_filter_excludes_deleted_orders(): + """Integration: simulates sync filter step. + + Pre-mark ORD-2 as DELETED_IN_ROA, run the same filter logic from + sync_service:478-489, assert ORD-2 is excluded. + """ + await _insert_order("ORD-1", OrderStatus.IMPORTED.value, id_comanda=100) + await _insert_order("ORD-2", OrderStatus.DELETED_IN_ROA.value) + await _insert_order("ORD-3", OrderStatus.IMPORTED.value, id_comanda=300) + + incoming = [ + type("O", (), {"number": "ORD-1", "date": "2026-04-22"})(), + type("O", (), {"number": "ORD-2", "date": "2026-04-22"})(), + type("O", (), {"number": "ORD-3", "date": "2026-04-22"})(), + type("O", (), {"number": "ORD-NEW", "date": "2026-04-22"})(), + ] + + deleted_set = await sqlite_service.get_deleted_in_roa_order_numbers() + excluded = [o for o in incoming if o.number in deleted_set] + survivors = [o for o in incoming if o.number not in deleted_set] + + assert {o.number for o in excluded} == {"ORD-2"} + assert {o.number for o in survivors} == {"ORD-1", "ORD-3", "ORD-NEW"} + + +@pytest.mark.asyncio +async def test_filter_with_no_deleted_is_noop(): + """Integration: deleted_set empty → all orders pass through.""" + await _insert_order("ORD-1", OrderStatus.IMPORTED.value, id_comanda=100) + + incoming = [ + type("O", (), {"number": "ORD-1", "date": "2026-04-22"})(), + type("O", (), {"number": "ORD-NEW", "date": "2026-04-22"})(), + ] + + deleted_set = await sqlite_service.get_deleted_in_roa_order_numbers() + survivors = [o for o in incoming if o.number not in deleted_set] + + assert deleted_set == set() + assert {o.number for o in survivors} == {"ORD-1", "ORD-NEW"}