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>
309 lines
12 KiB
Python
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"}
|