From d15f8b085d1838a60aa7683d99ae1b0530114ca8 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 21 Apr 2026 11:42:43 +0000 Subject: [PATCH] fix(missing-skus): reconcile stale false positives against Oracle SKUs mapped externally (via SSH script or direct SQL) never triggered resolve_missing_sku(), leaving them stuck as unresolved=0 indefinitely. New reconcile_unresolved_missing_skus() revalidates ALL unresolved SKUs against Oracle at sync, rescan, and CSV import time. Fail-soft on Oracle down. Clears the 7 prod false positives on next sync or manual rescan. Co-Authored-By: Claude Sonnet 4.6 --- api/app/routers/mappings.py | 3 +- api/app/routers/validation.py | 4 +- api/app/services/sync_service.py | 5 ++ api/app/services/validation_service.py | 31 +++++++++++ api/tests/test_requirements.py | 76 ++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 2 deletions(-) diff --git a/api/app/routers/mappings.py b/api/app/routers/mappings.py index be37943..0f1eb5a 100644 --- a/api/app/routers/mappings.py +++ b/api/app/routers/mappings.py @@ -8,7 +8,7 @@ from typing import Optional import io import asyncio -from ..services import mapping_service, sqlite_service +from ..services import mapping_service, sqlite_service, validation_service import logging logger = logging.getLogger(__name__) @@ -168,6 +168,7 @@ async def import_csv(file: UploadFile = File(...)): content = await file.read() text = content.decode("utf-8-sig") result = mapping_service.import_csv(text) + await validation_service.reconcile_unresolved_missing_skus() return result @router.get("/api/mappings/export-csv") diff --git a/api/app/routers/validation.py b/api/app/routers/validation.py index cabda1b..8e2cffc 100644 --- a/api/app/routers/validation.py +++ b/api/app/routers/validation.py @@ -58,6 +58,8 @@ async def scan_and_validate(): if tracked: new_missing += 1 + rec = await validation_service.reconcile_unresolved_missing_skus() + total_skus_scanned = len(all_skus) new_missing_count = len(result["missing"]) unchanged = total_skus_scanned - new_missing_count @@ -72,7 +74,7 @@ async def scan_and_validate(): # Fields consumed by the rescan progress banner in missing_skus.html "total_skus_scanned": total_skus_scanned, "new_missing": new_missing_count, - "auto_resolved": 0, + "auto_resolved": rec["resolved"], "unchanged": unchanged, "skus": { "mapped": len(result["mapped"]), diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index e4a6bea..42b56b5 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -468,6 +468,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None if resolved_count: _log_line(run_id, f"Auto-resolved {resolved_count} previously missing SKUs") + # Reconcile stale unresolved SKUs that got mappings outside the current JSON batch + rec = await validation_service.reconcile_unresolved_missing_skus(conn=conn) + if rec["resolved"]: + _log_line(run_id, f"Reconciliere: {rec['resolved']} SKU rezolvate suplimentar") + # Step 2d: Pre-validate prices for importable articles if id_pol and (truly_importable or already_in_roa): _update_progress("validation", "Validating prices...", 0, len(truly_importable)) diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index 2eee3f9..88c2242 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -1,8 +1,39 @@ +import asyncio import logging from .. import database +from . import sqlite_service logger = logging.getLogger(__name__) + +async def reconcile_unresolved_missing_skus(conn=None) -> dict: + """Revalidate all resolved=0 SKUs in missing_skus against Oracle. + Fail-soft: logs warning and returns zero if Oracle is unavailable. + Returns {"checked": N, "resolved": M, "error": str|None}. + """ + db = await sqlite_service.get_sqlite() + try: + cursor = await db.execute("SELECT sku FROM missing_skus WHERE resolved = 0") + rows = await cursor.fetchall() + finally: + await db.close() + + if not rows: + return {"checked": 0, "resolved": 0, "error": None} + + unresolved_set = {row[0] for row in rows} + + try: + result = await asyncio.to_thread(validate_skus, unresolved_set, conn) + except Exception as e: + logger.warning(f"reconcile_unresolved_missing_skus: Oracle unavailable — {e}") + return {"checked": len(unresolved_set), "resolved": 0, "error": str(e)} + + resolved_set = result["mapped"] | result["direct"] + resolved_count = await sqlite_service.resolve_missing_skus_batch(resolved_set) + logger.info(f"reconcile_unresolved_missing_skus: checked={len(unresolved_set)}, resolved={resolved_count}") + return {"checked": len(unresolved_set), "resolved": resolved_count, "error": None} + def check_orders_in_roa(min_date, conn) -> dict: """Check which orders already exist in Oracle COMENZI by date range. Returns: {comanda_externa: id_comanda} for all existing orders. diff --git a/api/tests/test_requirements.py b/api/tests/test_requirements.py index 404e091..1f6e248 100644 --- a/api/tests/test_requirements.py +++ b/api/tests/test_requirements.py @@ -618,3 +618,79 @@ def test_get_all_skus(): ] skus = get_all_skus(orders) assert skus == {"A", "B", "C"} + + +# ── reconcile_unresolved_missing_skus unit tests ────────────────────────────── + +@pytest.mark.asyncio +async def test_reconcile_empty_unresolved(): + """reconcile returns zeros immediately when no unresolved SKUs exist.""" + from app.services import sqlite_service, validation_service + + # Ensure any previously tracked SKUs are resolved + db = await sqlite_service.get_sqlite() + try: + await db.execute("UPDATE missing_skus SET resolved = 1 WHERE resolved = 0") + await db.commit() + finally: + await db.close() + + rec = await validation_service.reconcile_unresolved_missing_skus() + assert rec == {"checked": 0, "resolved": 0, "error": None} + + +@pytest.mark.asyncio +async def test_reconcile_oracle_down(monkeypatch): + """reconcile is fail-soft: returns resolved=0 and error string when Oracle raises.""" + from app.services import sqlite_service, validation_service + + await sqlite_service.track_missing_sku("ORACLE_DOWN_SKU", "Test product") + + def _raise(*args, **kwargs): + raise RuntimeError("Oracle unavailable") + + monkeypatch.setattr(validation_service, "validate_skus", _raise) + + rec = await validation_service.reconcile_unresolved_missing_skus() + assert rec["resolved"] == 0 + assert rec["error"] is not None + assert "Oracle" in rec["error"] or "unavailable" in rec["error"] + + # Cleanup + db = await sqlite_service.get_sqlite() + try: + await db.execute("DELETE FROM missing_skus WHERE sku = 'ORACLE_DOWN_SKU'") + await db.commit() + finally: + await db.close() + + +@pytest.mark.asyncio +async def test_reconcile_resolves_stale(monkeypatch): + """reconcile marks resolved=1 for SKUs that validate_skus says are mapped.""" + from app.services import sqlite_service, validation_service + + await sqlite_service.track_missing_sku("STALE_MAPPED_SKU", "Stale product") + + def _mock_validate(skus, conn=None, id_gestiuni=None): + return { + "mapped": {"STALE_MAPPED_SKU"}, + "direct": set(), + "missing": set(), + "direct_id_map": {}, + } + + monkeypatch.setattr(validation_service, "validate_skus", _mock_validate) + + rec = await validation_service.reconcile_unresolved_missing_skus() + assert rec["resolved"] >= 1 + + db = await sqlite_service.get_sqlite() + try: + cursor = await db.execute( + "SELECT resolved FROM missing_skus WHERE sku = 'STALE_MAPPED_SKU'" + ) + row = await cursor.fetchone() + assert row is not None and row[0] == 1 + finally: + await db.close()