Files
gomag-vending/api/app/services/retry_service.py
Claude Agent 819af221d8 fix(import): resolve duplicate article + order_items repopulation on retry
Two production bugs from VENDING (order 485224762, 2026-04-22):

1. Oracle: ORA-20000 when a GoMag order contains a kit SKU whose expansion
   includes CODMAT X plus a second item with SKU=X. Two article-insert
   call-sites in PACK_IMPORT_COMENZI bypassed merge_or_insert_articol —
   line 622 (NOM_ARTICOLE fallback) and line 538 (kit discount line).
   Both now use merge_or_insert_articol for consistent dedup semantics.
   Regression test added in test_complete_import.py covering the exact
   kit-plus-direct scenario.

2. SQLite: retry_service._download_and_reimport refreshed orders row but
   never repopulated order_items. Combined with mark_order_deleted_in_roa
   (which wipes items), any retry/resync left the UI showing "Niciun
   articol" despite successful Oracle import. Retry now rebuilds items
   from the fresh GoMag download on both success and error paths,
   mirroring sync_service.

Includes scripts/backfill_order_items.py — one-shot recovery for orders
already in this bad state. Reads settings, re-fetches from GoMag,
rewrites order_items without touching Oracle or order status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 06:58:20 +00:00

309 lines
12 KiB
Python

"""Retry service — re-import individual failed/skipped orders."""
import asyncio
import logging
import tempfile
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
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, gomag_client, import_service, order_reader, validation_service
# Parse order date for narrow download window
try:
order_date = datetime.fromisoformat(order_date_str.replace("Z", "+00:00")).date()
except (ValueError, AttributeError):
order_date = datetime.now().date() - timedelta(days=1)
gomag_key = app_settings.get("gomag_api_key") or None
gomag_shop = app_settings.get("gomag_api_shop") or None
with tempfile.TemporaryDirectory() as tmp_dir:
try:
today = datetime.now().date()
days_back = (today - order_date).days + 1
if days_back < 2:
days_back = 2
await gomag_client.download_orders(
tmp_dir, days_back=days_back,
api_key=gomag_key, api_shop=gomag_shop,
limit=200,
)
except Exception as e:
logger.error(f"Retry download failed for {order_number}: {e}")
return {"success": False, "message": f"Eroare download GoMag: {e}"}
# Find the specific order in downloaded data
target_order = None
orders, _ = order_reader.read_json_orders(json_dir=tmp_dir)
for o in orders:
if str(o.number) == str(order_number):
target_order = o
break
if not target_order:
return {"success": False, "message": f"Comanda {order_number} nu a fost gasita in GoMag API"}
# Import the order
id_pol = int(app_settings.get("id_pol") or 0)
id_sectie = int(app_settings.get("id_sectie") or 0)
id_gestiune = app_settings.get("id_gestiune", "")
id_gestiuni = [int(g.strip()) for g in id_gestiune.split(",") if g.strip()] if id_gestiune else None
try:
result = await asyncio.to_thread(
import_service.import_single_order,
target_order, id_pol=id_pol, id_sectie=id_sectie,
app_settings=app_settings, id_gestiuni=id_gestiuni
)
except Exception as e:
logger.error(f"Retry import failed for {order_number}: {e}")
await sqlite_service.upsert_order(
sync_run_id="retry",
order_number=order_number,
order_date=order_date_str,
customer_name=customer_name,
status="ERROR",
error_message=f"Retry failed: {e}",
)
return {"success": False, "message": f"Eroare import: {e}"}
# Build order_items data from fresh GoMag download (mirrors sync_service:882-891).
# Resolves ARTICOLE_TERTI mapping so UI shows mapped/direct badge.
try:
skus = {item.sku for item in target_order.items if item.sku}
validation = await asyncio.to_thread(
validation_service.validate_skus, skus, None, id_gestiuni
) if skus else {"mapped": set(), "direct": set()}
except Exception as e:
logger.warning(f"Retry: validate_skus failed for {order_number}, defaulting mapping_status=direct: {e}")
validation = {"mapped": set(), "direct": set()}
order_items_data = [
{
"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price,
"baseprice": item.baseprice, "vat": item.vat,
"mapping_status": "mapped" if item.sku in validation["mapped"] else "direct",
"codmat": None, "id_articol": None, "cantitate_roa": None,
}
for item in target_order.items
]
if result.get("success"):
await sqlite_service.upsert_order(
sync_run_id="retry",
order_number=order_number,
order_date=order_date_str,
customer_name=customer_name,
status="IMPORTED",
id_comanda=result.get("id_comanda"),
id_partener=result.get("id_partener"),
error_message=None,
)
if result.get("id_adresa_facturare") or result.get("id_adresa_livrare"):
await sqlite_service.update_import_order_addresses(
order_number=order_number,
id_adresa_facturare=result.get("id_adresa_facturare"),
id_adresa_livrare=result.get("id_adresa_livrare"),
)
await sqlite_service.add_order_items(order_number, order_items_data)
logger.info(f"Retry successful for order {order_number} → IMPORTED ({len(order_items_data)} items)")
return {"success": True, "message": "Comanda reimportata cu succes", "status": "IMPORTED"}
else:
error = result.get("error", "Unknown error")
await sqlite_service.upsert_order(
sync_run_id="retry",
order_number=order_number,
order_date=order_date_str,
customer_name=customer_name,
status="ERROR",
error_message=f"Retry: {error}",
)
await sqlite_service.add_order_items(order_number, order_items_data)
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"}