import re from collections import Counter from typing import Any, Dict, Iterable, List, Optional from core.errors import SAPServiceLayerError from core.sap import SAPServiceLayerClient from domain.items import identificar_skus_faltantes def diagnosticar_error(sap: SAPServiceLayerClient, exc: SAPServiceLayerError, item_codes: Optional[List[str]] = None) -> str: """ Si el error de SAP corresponde a un tipo conocido, devuelve un mensaje en lenguaje que el usuario entienda; si no, devuelve el mensaje original de SAP tal cual (para no ocultar errores no identificados). """ if "exchange rate" in (exc.detail or "").lower(): moneda = re.search(r"'(\w+)'", exc.detail or "") moneda = moneda.group(1) if moneda else "?" return f"No hay tipo de cambio cargado en SAP para la moneda {moneda}" if item_codes: faltantes = identificar_skus_faltantes(sap, item_codes) if faltantes: return f"SKU inexistente(s) en SAP: {', '.join(faltantes)} (mensaje original de SAP: {exc.detail})" return exc.detail def buscar_documento_existente( sap: SAPServiceLayerClient, entidad: str, store_code: str, shopify_order_id: Any, campo_lineas: str, lineas_esperadas: Optional[List[Dict[str, Any]]], excluir: Iterable[int] = (), solo_vigentes: bool = False, ) -> Optional[Dict[str, Any]]: """ Resguardo de idempotencia contra SAP: busca un documento ya creado para esta orden (UDFs U_ShopifyOrderId + U_ShopifyStore) cuyas líneas (ItemCode, Quantity) coincidan con las que se iban a crear. Sirve cuando la BD local perdió el registro de un documento que sí existe en SAP (evita duplicarlo). Devuelve el documento completo o None. `excluir`: DocEntry que ya están asignados a otro paso de la misma orden. `solo_vigentes`: descarta anulados y documentos de anulación (Invoices). Con `lineas_esperadas=None` se acepta el primer documento encontrado sin comparar líneas (para la Invoice, que es única por orden). """ filtro = f"U_ShopifyOrderId eq '{shopify_order_id}' and U_ShopifyStore eq '{store_code}'" if solo_vigentes: filtro += " and CancelStatus eq 'csNo'" candidatos = sap.forward("GET", entidad, params={"$filter": filtro, "$select": "DocEntry"}).json().get("value", []) esperadas = Counter((l["ItemCode"], float(l["Quantity"])) for l in lineas_esperadas) if lineas_esperadas is not None else None excluidos = set(excluir) for candidato in sorted(candidatos, key=lambda c: c["DocEntry"]): if candidato["DocEntry"] in excluidos: continue doc = sap.forward("GET", f"{entidad}({candidato['DocEntry']})").json() if esperadas is None or Counter((l["ItemCode"], float(l["Quantity"])) for l in doc.get(campo_lineas, [])) == esperadas: return doc return None def crear_documento_sap( sap: SAPServiceLayerClient, entidad: str, payload: Dict[str, Any], item_codes: Optional[List[str]] = None, ) -> Dict[str, Any]: """ POST genérico a cualquier entidad de documentos de SAP (Invoices, IncomingPayments, InventoryTransferRequests, StockTransfers, DeliveryNotes). Si falla, intenta identificar la causa real (ver diagnosticar_error) antes de relanzar el error con ese mensaje; si no se reconoce el error, se relanza el mensaje original de SAP. """ try: response = sap.forward("POST", entidad, json_data=payload) return response.json() except SAPServiceLayerError as exc: mensaje = diagnosticar_error(sap, exc, item_codes) if mensaje != exc.detail: raise SAPServiceLayerError(exc.status_code, mensaje, code=exc.code) from exc raise