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 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-21 11:42:43 +00:00
parent 3bcb26b0bd
commit d15f8b085d
5 changed files with 117 additions and 2 deletions

View File

@@ -8,7 +8,7 @@ from typing import Optional
import io import io
import asyncio import asyncio
from ..services import mapping_service, sqlite_service from ..services import mapping_service, sqlite_service, validation_service
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -168,6 +168,7 @@ async def import_csv(file: UploadFile = File(...)):
content = await file.read() content = await file.read()
text = content.decode("utf-8-sig") text = content.decode("utf-8-sig")
result = mapping_service.import_csv(text) result = mapping_service.import_csv(text)
await validation_service.reconcile_unresolved_missing_skus()
return result return result
@router.get("/api/mappings/export-csv") @router.get("/api/mappings/export-csv")

View File

@@ -58,6 +58,8 @@ async def scan_and_validate():
if tracked: if tracked:
new_missing += 1 new_missing += 1
rec = await validation_service.reconcile_unresolved_missing_skus()
total_skus_scanned = len(all_skus) total_skus_scanned = len(all_skus)
new_missing_count = len(result["missing"]) new_missing_count = len(result["missing"])
unchanged = total_skus_scanned - new_missing_count 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 # Fields consumed by the rescan progress banner in missing_skus.html
"total_skus_scanned": total_skus_scanned, "total_skus_scanned": total_skus_scanned,
"new_missing": new_missing_count, "new_missing": new_missing_count,
"auto_resolved": 0, "auto_resolved": rec["resolved"],
"unchanged": unchanged, "unchanged": unchanged,
"skus": { "skus": {
"mapped": len(result["mapped"]), "mapped": len(result["mapped"]),

View File

@@ -468,6 +468,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
if resolved_count: if resolved_count:
_log_line(run_id, f"Auto-resolved {resolved_count} previously missing SKUs") _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 # Step 2d: Pre-validate prices for importable articles
if id_pol and (truly_importable or already_in_roa): if id_pol and (truly_importable or already_in_roa):
_update_progress("validation", "Validating prices...", 0, len(truly_importable)) _update_progress("validation", "Validating prices...", 0, len(truly_importable))

View File

@@ -1,8 +1,39 @@
import asyncio
import logging import logging
from .. import database from .. import database
from . import sqlite_service
logger = logging.getLogger(__name__) 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: def check_orders_in_roa(min_date, conn) -> dict:
"""Check which orders already exist in Oracle COMENZI by date range. """Check which orders already exist in Oracle COMENZI by date range.
Returns: {comanda_externa: id_comanda} for all existing orders. Returns: {comanda_externa: id_comanda} for all existing orders.

View File

@@ -618,3 +618,79 @@ def test_get_all_skus():
] ]
skus = get_all_skus(orders) skus = get_all_skus(orders)
assert skus == {"A", "B", "C"} 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()