import json from pathlib import Path from typing import Any, Dict, List from core.config import cargar_config_tienda from core.db import ( ensure_order_document, ensure_order_fulfillment, get_doc_entries, get_last_shopify_order_id, get_order_document, get_pending_orders, insert_order_header, mark_document_created, mark_document_error, mark_orders_timeout, set_order_card_code, set_order_status, update_order_snapshot, ) from core.errors import SAPServiceLayerError from core.logger_manager import get_logger, order_context, log_event from core.sap import SAPServiceLayerClient from core.shopify_client import ShopifyClient from domain.business_partner import buscar_rut_en_order, resolver_card_code from domain.delivery import construir_delivery from domain.documents import buscar_documento_existente, crear_documento_sap from domain.invoice import agregar_referencia_documento, construir_invoice, crear_invoice, fulfillments_facturados from domain.inventory_transfer import construir_inventory_transfer_request, es_tienda, resolver_location from domain.payment import PaymentConfigError, construir_incoming_payment from domain.rut import formatear_rut from domain.stock_transfer import construir_stock_transfer logger = get_logger() STORES_DIR = Path(__file__).resolve().parent.parent / "config" / "stores" def _registrar_adoptado(document_sys_id: int, entidad: str, doc: Dict[str, Any]) -> None: """ Un documento que ya existía en SAP para esta orden (la BD local no lo tenía registrado) se adopta en vez de crear uno nuevo, para no duplicarlo. """ mark_document_created(document_sys_id, doc["DocEntry"], doc["DocNum"]) mensaje = f"{entidad} DocNum {doc['DocNum']} ya existía en SAP; se adopta y no se crea otro" logger.warning(mensaje) log_event("documento_adoptado", level="warning", message=mensaje, tipo=entidad, doc_entry=doc["DocEntry"]) # Una orden pagada y despachada se integra aunque después se haya reembolsado # (total o parcialmente): el reembolso es un tratamiento interno de comercial, # no se refleja en SAP. Las anuladas (cancelled_at) se descartan antes. ESTADOS_FINANCIEROS_FACTURABLES = ("paid", "partially_refunded", "refunded") # ============================================================================ def cargar_tiendas() -> List[Dict[str, Any]]: """Lee todos los JSON de configuración en config/stores/.""" tiendas = [] for path in sorted(STORES_DIR.glob("*.json")): if path.name.startswith("_"): continue tiendas.append(cargar_config_tienda(path)) return tiendas # ============================================================================ def descargar_cabeceras_nuevas(store_config: Dict[str, Any]) -> int: """ Descarga cabeceras nuevas desde Shopify (since_id = última cabecera guardada para esta tienda) y las guarda en la tabla orders. Devuelve la cantidad de cabeceras nuevas insertadas. """ store_code = store_config["store_code"] shopify_config = store_config["shopify"] # El punto de corte de la configuración actúa como piso: en la primera # corrida (tabla vacía) evita traer todo el historial desde el id=0, y si # se sube después, salta las órdenes anteriores aunque ya haya cabeceras # más antiguas guardadas en la BD. since_id = max(get_last_shopify_order_id(store_code), int(shopify_config.get("initial_since_id") or 0)) page_limit = shopify_config.get("page_limit", 50) shopify = ShopifyClient.from_store_config(shopify_config) ordenes = shopify.obtener_cabeceras_ordenes(since_id=since_id, limit=page_limit) nuevas = 0 for orden in ordenes: header = { "shopify_order_id": orden["id"], "order_number": orden.get("name"), "tags": orden.get("tags"), "shopify_created_at": orden.get("created_at"), "financial_status": orden.get("financial_status"), "fulfillment_status": orden.get("fulfillment_status"), } if insert_order_header(store_code, header): nuevas += 1 logger.info(f"[{store_code}] Cabeceras nuevas descargadas: {nuevas} (since_id={since_id}, recibidas={len(ordenes)})") log_event("headers_downloaded", store_code=store_code, since_id=since_id, count=nuevas) return nuevas # ============================================================================ def obtener_ordenes_pendientes(store_config: Dict[str, Any]) -> List[Dict[str, Any]]: """ Marca como timeout las órdenes vencidas de esta tienda y devuelve las que aún no llegaron a un estado terminal (integrated/timeout). """ store_code = store_config["store_code"] timeout_days = store_config.get("timeout_days", 30) marcadas = mark_orders_timeout(store_code, timeout_days) if marcadas: logger.warning(f"[{store_code}] Órdenes marcadas como timeout: {marcadas}") log_event("orders_timeout", store_code=store_code, count=marcadas) return get_pending_orders(store_code) # ============================================================================ def analizar_y_procesar_orden(store_config: Dict[str, Any], orden_row: Dict[str, Any]) -> bool: """ Descarga el detalle completo de la orden, valida paid+fulfillments, resuelve el BusinessPartner, y crea/continúa (por sys_id de documento, así se retoma donde quedó) Invoice -> Payment -> por cada fulfillment: ITR -> (si es tienda) StockTransfer -> Delivery. """ store_code = store_config["store_code"] order_sys_id = orden_row["sys_id"] shopify = ShopifyClient.from_store_config(store_config["shopify"]) sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"]) detalle = shopify.obtener_orden(orden_row["shopify_order_id"]) update_order_snapshot(order_sys_id, detalle) if detalle.get("cancelled_at"): # Orden anulada en Shopify: nunca se integra, aunque siga "paid" y con fulfillments. motivo = f"Anulada en Shopify el {detalle['cancelled_at'][:10]}: {detalle.get('cancel_reason') or 'sin motivo'}" set_order_status(order_sys_id, "cancelled", motivo) log_event("order_cancelled", store_code=store_code, shopify_order_id=orden_row["shopify_order_id"], message=motivo) return False if detalle.get("financial_status") not in ESTADOS_FINANCIEROS_FACTURABLES or not detalle.get("fulfillments"): set_order_status(order_sys_id, "not_eligible") return False envio = float(((detalle.get("total_shipping_price_set") or {}).get("shop_money") or {}).get("amount", 0) or 0) if envio > 0: mensaje = f"Orden tiene costo de envío ({envio}) y ese mapeo aún no está soportado" logger.error(mensaje) log_event("envio_no_soportado", level="error", message=mensaje, monto=envio) set_order_status(order_sys_id, "error", mensaje) return False rut = buscar_rut_en_order(detalle) rut_sap = formatear_rut(rut) if rut else None card_code = orden_row.get("card_code") if not card_code: resuelto = resolver_card_code(sap, store_config, rut, order=detalle) card_code = resuelto["card_code"] set_order_card_code(order_sys_id, card_code) # ---- Invoice ---- invoice_doc_sys_id = ensure_order_document(order_sys_id, "INVOICE", 10) invoice_row = get_order_document(invoice_doc_sys_id) if invoice_row["status"] == "created": invoice = sap.forward("GET", f"Invoices({invoice_row['sap_doc_entry']})").json() else: transacciones = shopify.obtener_transacciones(orden_row["shopify_order_id"]) payload = construir_invoice(detalle, store_config, card_code, federal_tax_id=rut_sap, transactions=transacciones) if not payload["DocumentLines"]: mensaje = "Orden sin líneas vigentes (todas canceladas antes de la venta)" logger.warning(mensaje) set_order_status(order_sys_id, "not_eligible", mensaje) return False try: invoice = buscar_documento_existente(sap, "Invoices", store_code, detalle["id"], "DocumentLines", None, solo_vigentes=True) if invoice: _registrar_adoptado(invoice_doc_sys_id, "Invoices", invoice) else: invoice = crear_invoice(sap, payload) mark_document_created(invoice_doc_sys_id, invoice["DocEntry"], invoice["DocNum"]) except SAPServiceLayerError as exc: mark_document_error(invoice_doc_sys_id, exc.detail) log_event("invoice_error", level="error", message=exc.detail, payload=payload) return False # ---- Payment ---- payment_doc_sys_id = ensure_order_document(order_sys_id, "INCOMING_PAYMENT", 20) payment_row = get_order_document(payment_doc_sys_id) if payment_row["status"] != "created" and invoice.get("DocTotal") and invoice.get("PaidToDate", 0) >= invoice["DocTotal"] - 0.5: # La Invoice ya figura pagada en SAP (el pago existe pero la BD local no lo registró): no se duplica. mark_document_created(payment_doc_sys_id, None, None) log_event("documento_adoptado", level="warning", message=f"Invoice {invoice['DocNum']} ya pagada en SAP; no se crea el pago", tipo="IncomingPayments") elif payment_row["status"] != "created": total = sum(line["Quantity"] * line["PriceAfterVAT"] for line in invoice["DocumentLines"]) try: transacciones = shopify.obtener_transacciones(orden_row["shopify_order_id"]) payload = construir_incoming_payment(detalle, transacciones, store_config, invoice, invoice["DocEntry"], total) except PaymentConfigError as exc: mark_document_error(payment_doc_sys_id, str(exc)) log_event("payment_error", level="error", message=str(exc)) return False try: data = crear_documento_sap(sap, "IncomingPayments", payload) mark_document_created(payment_doc_sys_id, data["DocEntry"], data["DocNum"]) except SAPServiceLayerError as exc: mark_document_error(payment_doc_sys_id, exc.detail) log_event("payment_error", level="error", message=exc.detail, payload=payload) return False # ---- Por cada fulfillment ---- todo_ok = True for fulfillment in fulfillments_facturados(detalle["fulfillments"], invoice): location = resolver_location(store_config, fulfillment["location_id"]) if not location: mensaje = f"location_id {fulfillment['location_id']} sin mapeo en config" logger.error(mensaje) log_event("location_sin_mapeo", level="error", message=mensaje, location_id=fulfillment["location_id"]) set_order_status(order_sys_id, "error", mensaje) todo_ok = False continue fulfillment_sys_id = ensure_order_fulfillment( order_sys_id, fulfillment["id"], str(fulfillment["location_id"]), location["is_store"], location["warehouse_code"], ) # ITR (siempre) itr_doc_sys_id = ensure_order_document(order_sys_id, "INVENTORY_TRANSFER_REQUEST", 30, fulfillment_sys_id) itr_row = get_order_document(itr_doc_sys_id) if itr_row["status"] == "created": itr = sap.forward("GET", f"InventoryTransferRequests({itr_row['sap_doc_entry']})").json() else: payload = construir_inventory_transfer_request(detalle, fulfillment, store_config, location["warehouse_code"], invoice) try: itr = buscar_documento_existente( sap, "InventoryTransferRequests", store_code, detalle["id"], "StockTransferLines", payload["StockTransferLines"], excluir=get_doc_entries(order_sys_id, "INVENTORY_TRANSFER_REQUEST"), ) if itr: _registrar_adoptado(itr_doc_sys_id, "InventoryTransferRequests", itr) else: item_codes = [line["ItemCode"] for line in payload["StockTransferLines"]] data = crear_documento_sap(sap, "InventoryTransferRequests", payload, item_codes=item_codes) mark_document_created(itr_doc_sys_id, data["DocEntry"], data["DocNum"]) agregar_referencia_documento(sap, invoice["DocEntry"], data["DocEntry"], data["DocNum"], "rot_InventoryTransferRequest") itr = sap.forward("GET", f"InventoryTransferRequests({data['DocEntry']})").json() except SAPServiceLayerError as exc: mark_document_error(itr_doc_sys_id, exc.detail) log_event("itr_error", level="error", message=exc.detail, payload=payload) todo_ok = False continue if not location["is_store"]: continue # CD: no sigue a StockTransfer/Delivery # StockTransfer (solo tienda) st_doc_sys_id = ensure_order_document(order_sys_id, "STOCK_TRANSFER", 40, fulfillment_sys_id) st_row = get_order_document(st_doc_sys_id) if st_row["status"] == "created": stock_transfer = sap.forward("GET", f"StockTransfers({st_row['sap_doc_entry']})").json() else: payload = construir_stock_transfer(itr, store_config) try: stock_transfer = buscar_documento_existente( sap, "StockTransfers", store_code, detalle["id"], "StockTransferLines", payload["StockTransferLines"], excluir=get_doc_entries(order_sys_id, "STOCK_TRANSFER"), ) if stock_transfer: _registrar_adoptado(st_doc_sys_id, "StockTransfers", stock_transfer) else: item_codes = [line["ItemCode"] for line in payload["StockTransferLines"]] data = crear_documento_sap(sap, "StockTransfers", payload, item_codes=item_codes) mark_document_created(st_doc_sys_id, data["DocEntry"], data["DocNum"]) agregar_referencia_documento(sap, invoice["DocEntry"], data["DocEntry"], data["DocNum"], "rot_InventoryTransfer") stock_transfer = sap.forward("GET", f"StockTransfers({data['DocEntry']})").json() except SAPServiceLayerError as exc: mark_document_error(st_doc_sys_id, exc.detail) log_event("stock_transfer_error", level="error", message=exc.detail, payload=payload) todo_ok = False continue # Delivery (solo tienda, base la Invoice) dn_doc_sys_id = ensure_order_document(order_sys_id, "DELIVERY", 50, fulfillment_sys_id) dn_row = get_order_document(dn_doc_sys_id) if dn_row["status"] != "created": payload = construir_delivery(invoice, stock_transfer, store_config) try: entrega = buscar_documento_existente( sap, "DeliveryNotes", store_code, detalle["id"], "DocumentLines", payload["DocumentLines"], excluir=get_doc_entries(order_sys_id, "DELIVERY"), ) if entrega: _registrar_adoptado(dn_doc_sys_id, "DeliveryNotes", entrega) else: item_codes = [line["ItemCode"] for line in payload["DocumentLines"]] data = crear_documento_sap(sap, "DeliveryNotes", payload, item_codes=item_codes) mark_document_created(dn_doc_sys_id, data["DocEntry"], data["DocNum"]) agregar_referencia_documento(sap, invoice["DocEntry"], data["DocEntry"], data["DocNum"], "rot_DeliveryNotes") except SAPServiceLayerError as exc: mark_document_error(dn_doc_sys_id, exc.detail) log_event("delivery_error", level="error", message=exc.detail, payload=payload) todo_ok = False continue if not todo_ok: return False if detalle.get("fulfillment_status") == "fulfilled": set_order_status(order_sys_id, "integrated") else: set_order_status(order_sys_id, "in_progress") log_event("order_processed", store_code=store_code, shopify_order_id=orden_row["shopify_order_id"]) return True # ============================================================================ def procesar_tienda(store_config: Dict[str, Any], modo: str) -> bool: store_code = store_config.get("store_code", "?") logger.info(f"Procesando tienda {store_code} [{modo}]") log_event("store_cycle_start", store_code=store_code, modo=modo) descargar_cabeceras_nuevas(store_config) pendientes = obtener_ordenes_pendientes(store_config) max_ordenes = store_config.get("max_orders_per_cycle") if max_ordenes: pendientes = pendientes[:max_ordenes] for orden_row in pendientes: with order_context(store_code, orden_row.get("shopify_order_id")): try: analizar_y_procesar_orden(store_config, orden_row) except Exception as exc: logger.error(f"Error inesperado procesando orden {orden_row.get('shopify_order_id')}: {exc}") return True # ============================================================================ def ejecutar_ciclo(modo: str) -> bool: """Punto de entrada que main.py llama en cada tick del scheduler.""" tiendas = cargar_tiendas() if not tiendas: logger.warning("No hay tiendas configuradas en config/stores/") return True for store_config in tiendas: try: procesar_tienda(store_config, modo) except Exception as exc: logger.error(f"Error procesando tienda {store_config.get('store_code', '?')}: {exc}") return True