From 901dd8763d3975c0a5bcf248ba665d080bb4e91e Mon Sep 17 00:00:00 2001 From: george Date: Wed, 30 Sep 2026 19:22:40 -0500 Subject: [PATCH] =?UTF-8?q?Integraci=C3=B3n=20Shopify=20=E2=86=92=20SAP=20?= =?UTF-8?q?Business=20One=20(snobbycl=20y=20on-chile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline de integración de órdenes de Shopify a SAP Service Layer: Invoice de reserva, pago con tarjeta o transferencia por transacción, solicitud de traslado, traslado de stock y entrega. - Invoice igual a lo cobrado; fulfillments limitados a lo facturado - Estados terminales integrated, timeout, ignored y cancelled - Idempotencia contra SAP: adopta documentos existentes en vez de duplicar - Reintentos de red en el cliente de Shopify - Secretos fuera de los JSON de tienda (${VARIABLE} resuelta desde .env) - Herramientas de consulta, integración puntual y carga de pruebas - Documentación en docs/ (flujo, casos especiales, mapeo, configuración) Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 10 + .gitignore | 26 ++ README.md | 52 +++ config/stores/_example.json | 54 +++ config/stores/on-chile-distributor-prod.json | 104 ++++++ config/stores/snobbycl.json | 97 +++++ core/config.py | 52 +++ core/db.py | 352 ++++++++++++++++++ core/errors.py | 13 + core/lock.py | 21 ++ core/logger_manager.py | 183 ++++++++++ core/notifications.py | 80 ++++ core/sap.py | 211 +++++++++++ core/shopify_client.py | 114 ++++++ data/.gitkeep | 0 docs/analisis_tiempos.md | 84 +++++ docs/casos_especiales.md | 103 ++++++ docs/configuracion_tienda.md | 198 ++++++++++ docs/esquema_base_datos.md | 169 +++++++++ docs/flujo_pipeline.md | 281 ++++++++++++++ docs/mapeo_campos.md | 235 ++++++++++++ docs/pendientes.md | 87 +++++ domain/business_partner.py | 117 ++++++ domain/delivery.py | 38 ++ domain/documents.py | 86 +++++ domain/inventory_transfer.py | 61 ++++ domain/invoice.py | 222 ++++++++++++ domain/items.py | 29 ++ domain/payment.py | 136 +++++++ domain/rut.py | 47 +++ domain/stock_transfer.py | 38 ++ download/.gitkeep | 0 main.py | 144 ++++++++ pipeline/runner.py | 363 +++++++++++++++++++ requirements.txt | 5 + scheduler.json | 9 + tools/.env.example | 10 + tools/cargar_entrada_mercancia.py | 82 +++++ tools/cargar_items_faltantes.py | 108 ++++++ tools/consultar.py | 200 ++++++++++ tools/descargar_ordenes_especiales.py | 78 ++++ tools/entrada_mercancia_ordenes.py | 65 ++++ tools/integrar_ordenes.py | 122 +++++++ tools/probar_pago.py | 68 ++++ tools/sap.http | 46 +++ tools/shopify.http | 42 +++ tools/transferir_stock.py | 67 ++++ 47 files changed, 4709 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/stores/_example.json create mode 100644 config/stores/on-chile-distributor-prod.json create mode 100644 config/stores/snobbycl.json create mode 100644 core/config.py create mode 100644 core/db.py create mode 100644 core/errors.py create mode 100644 core/lock.py create mode 100644 core/logger_manager.py create mode 100644 core/notifications.py create mode 100644 core/sap.py create mode 100644 core/shopify_client.py create mode 100644 data/.gitkeep create mode 100644 docs/analisis_tiempos.md create mode 100644 docs/casos_especiales.md create mode 100644 docs/configuracion_tienda.md create mode 100644 docs/esquema_base_datos.md create mode 100644 docs/flujo_pipeline.md create mode 100644 docs/mapeo_campos.md create mode 100644 docs/pendientes.md create mode 100644 domain/business_partner.py create mode 100644 domain/delivery.py create mode 100644 domain/documents.py create mode 100644 domain/inventory_transfer.py create mode 100644 domain/invoice.py create mode 100644 domain/items.py create mode 100644 domain/payment.py create mode 100644 domain/rut.py create mode 100644 domain/stock_transfer.py create mode 100644 download/.gitkeep create mode 100644 main.py create mode 100644 pipeline/runner.py create mode 100644 requirements.txt create mode 100644 scheduler.json create mode 100644 tools/.env.example create mode 100644 tools/cargar_entrada_mercancia.py create mode 100644 tools/cargar_items_faltantes.py create mode 100644 tools/consultar.py create mode 100644 tools/descargar_ordenes_especiales.py create mode 100644 tools/entrada_mercancia_ordenes.py create mode 100644 tools/integrar_ordenes.py create mode 100644 tools/probar_pago.py create mode 100644 tools/sap.http create mode 100644 tools/shopify.http create mode 100644 tools/transferir_stock.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..56832d7 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copiar a .env y completar. Una pareja de variables por tienda (se referencian como ${VARIABLE} en config/stores/.json) +SNOBBYCL_SHOPIFY_ACCESS_TOKEN= +SNOBBYCL_SAP_PASSWORD= +ON_CHILE_SHOPIFY_ACCESS_TOKEN= +ON_CHILE_SAP_PASSWORD= + +# Opcional +# Zona horaria para mostrar horas (tools/consultar). Si se omite, se usa la del sistema. QA del cliente: America/Santiago +# APP_TIMEZONE=America/Santiago +# LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df5d74c --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Entorno y secretos +.venv/ +.env + +# Python +__pycache__/ +*.pyc + +# Datos y estado local (BD, lock, respaldos) +data/*.db* +data/*.lock +data/*.backup* +!data/.gitkeep + +# Logs y reportes generados +logs/ +notifications/*.html + +# Volcados y archivos de trabajo (payloads y JSON de Shopify descargados) +download/* +!download/.gitkeep + +# IDE / sistema +.idea/ +.vscode/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae7119a --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Integración Shopify → SAP Business One + +Descarga órdenes de tiendas Shopify y crea en SAP Business One (Service Layer) los documentos de venta: +Invoice (reserva), pago con tarjeta o transferencia, solicitud de traslado (ITR) y, si se despacha desde una +tienda física, traslado de stock y entrega. Una tienda = un archivo en `config/stores/`. +Tiendas actuales: `snobbycl` y `on-chile-distributor-prod`. + +## Puesta en marcha + +```bash +python -m venv .venv && .venv/bin/pip install -r requirements.txt +cp .env.example .env # completar tokens y contraseñas de cada tienda +``` + +1. Copiar `config/stores/_example.json` a `config/stores/.json` y completarlo + (`docs/configuracion_tienda.md`). Los secretos van como `${VARIABLE}` y su valor en `.env`. +2. Ejecutar el scheduler: `.venv/bin/python main.py` (ventanas y frecuencia en `scheduler.json`; una sola + instancia a la vez, con lock). + +Variables opcionales: `LOG_LEVEL`, `APP_TIMEZONE` (zona para mostrar horas, ej. `America/Santiago`). + +## Cómo funciona (resumen) + +1. Cada ciclo baja cabeceras nuevas (desde `max(id en BD, initial_since_id)`) a SQLite (`data/shopify_sap.db`). +2. Toma las órdenes no terminales, trae el detalle y las transacciones de pago, y crea los documentos en SAP. + Retoma donde quedó si un documento falló, y adopta documentos que ya existan en SAP para no duplicar. +3. Estados: `pending`, `not_eligible`, `in_progress`, `error` (se reintentan) e `integrated`, `timeout`, + `ignored`, `cancelled` (terminales). + +## Herramientas (`tools/`, ejecutar con `.venv/bin/python -m tools.`) + +| Herramienta | Uso | +|---|---| +| `consultar` | `resumen`, `descargadas`, `integradas`, `errores`, `orden --name "#8064"` (solo lectura) | +| `integrar_ordenes` | dry-run o integración (`--apply`) de órdenes puntuales | +| `probar_pago` | payload del pago sobre la Invoice real | +| `descargar_ordenes_especiales` | guarda en `download/` el JSON de los casos especiales | +| `cargar_items_faltantes`, `entrada_mercancia_ordenes` | artículos y stock para pruebas | + +Las herramientas que escriben en SAP son dry-run por defecto y requieren `--apply`. + +## Documentación (`docs/`) + +| Documento | Contenido | +|---|---| +| `flujo_pipeline.md` | flujo caso por caso y reglas vigentes | +| `casos_especiales.md` | casos fuera del flujo normal, con órdenes de ejemplo de cada tienda | +| `mapeo_campos.md` | campos de Shopify → SAP por documento | +| `configuracion_tienda.md` | guía del archivo de cada tienda, `payment_methods` y secretos | +| `esquema_base_datos.md` | tablas y estados de SQLite | +| `pendientes.md` | roadmap, decisiones abiertas y pruebas pendientes | +| `analisis_tiempos.md` | capacidad y tiempos por orden | diff --git a/config/stores/_example.json b/config/stores/_example.json new file mode 100644 index 0000000..dac2bf0 --- /dev/null +++ b/config/stores/_example.json @@ -0,0 +1,54 @@ +{ + "store_code": "TIENDA1", + "shopify": { + "api_id": "TODO", + "store": "TODO-nombre-de-la-tienda-myshopify", + "api_version": "2026-07", + "access_token": "${TIENDA1_SHOPIFY_ACCESS_TOKEN}", + "page_limit": 50, + "initial_since_id": "TODO-id-de-orden-desde-el-que-se-integra (piso del cursor: max(id en BD, este valor))" + }, + "sap_connection": { + "url": "https://TODO-servidor:50000/b1s/v1", + "company_db": "TODO-SBO_COMPANYDB", + "username": "TODO", + "password": "${TIENDA1_SAP_PASSWORD}", + "timeout": 15, + "verify_ssl": false + }, + "sap": { + "default_warehouse_invoice": "TODO-bodega-virtual-de-esta-tienda", + "generic_card_code": "TODO-CardCode-generico-si-el-RUT-no-es-valido", + "numbering_series": { + "INVOICE": null, + "INCOMING_PAYMENT": null, + "INVENTORY_TRANSFER_REQUEST": null, + "STOCK_TRANSFER": null, + "DELIVERY": null + }, + "locations": { + "TODO-location_id-de-shopify": { + "is_store": true, + "warehouse_code": "TODO-bodega-SAP-de-esa-tienda-fisica" + }, + "TODO-location_id-del-CD": { + "is_store": false, + "warehouse_code": "TODO-bodega-SAP-del-CD" + } + }, + "payment_methods": { + "TODO-nombre-exacto-del-gateway-en-Shopify": { + "type": "credit_card", + "credit_card": 0, + "split_payment": true, + "num_of_payments": 1 + }, + "TODO-gateway-por-transferencia": { + "type": "transfer", + "transfer_account": "TODO-cuenta-contable" + } + } + }, + "timeout_days": 30, + "max_orders_per_cycle": 50 +} diff --git a/config/stores/on-chile-distributor-prod.json b/config/stores/on-chile-distributor-prod.json new file mode 100644 index 0000000..a7f119d --- /dev/null +++ b/config/stores/on-chile-distributor-prod.json @@ -0,0 +1,104 @@ +{ + "store_code": "on-chile-distributor-prod", + "shopify": { + "api_id": "5541a00589b2b1b40afb62433f385891", + "store": "on-chile-distributor-prod", + "api_version": "2026-07", + "access_token": "${ON_CHILE_SHOPIFY_ACCESS_TOKEN}", + "page_limit": 50, + "initial_since_id": "8103221690695" + }, + "sap_connection": { + "url": "https://sap10:50000/b1s/v1", + "company_db": "SBODemoCL", + "username": "manager", + "password": "${ON_CHILE_SAP_PASSWORD}", + "timeout": 30, + "verify_ssl": false + }, + "sap": { + "default_warehouse_invoice": "9902", + "generic_card_code": "C99998", + "numbering_series": { + "INVOICE": 1, + "INCOMING_PAYMENT": 18, + "INVENTORY_TRANSFER_REQUEST": 51, + "STOCK_TRANSFER": 24, + "DELIVERY": 8 + }, + "locations": { + "102370836807": { + "name": "On Parque Arauco", + "is_store": true, + "warehouse_code": "8803" + }, + "101564121415": { + "name": "Geodis", + "is_store": false, + "warehouse_code": "7702" + } + }, + "payment_methods": { + "Transbank Webpay": { + "type": "credit_card", + "credit_card": 4, + "split_payment": true, + "num_of_payments": 1 + }, + "Transbank": { + "type": "credit_card", + "credit_card": 4, + "split_payment": true, + "num_of_payments": 1 + }, + "Checkout Mercado Pago": { + "type": "credit_card", + "credit_card": 5, + "split_payment": true, + "num_of_payments": 1 + }, + "VentiPay": { + "type": "credit_card", + "credit_card": 6, + "split_payment": true, + "num_of_payments": 1 + }, + "Venti": { + "type": "credit_card", + "credit_card": 6, + "split_payment": true, + "num_of_payments": 1 + }, + "Fintoc": { + "type": "credit_card", + "credit_card": 7, + "split_payment": true, + "num_of_payments": 1 + }, + "Mercado Pago Tarjetas": { + "type": "credit_card", + "credit_card": 8, + "split_payment": true, + "num_of_payments": 1 + }, + "PayPal": { + "type": "credit_card", + "credit_card": 9, + "split_payment": true, + "num_of_payments": 1 + }, + "Mercado Pago Checkout Pro": { + "type": "credit_card", + "credit_card": 10, + "split_payment": true, + "num_of_payments": 1 + }, + "reversso": { + "type": "transfer", + "transfer_account": "1-1-010-20-000" + } + } + }, + "timeout_days": 60, + "max_orders_per_cycle": 50 +} \ No newline at end of file diff --git a/config/stores/snobbycl.json b/config/stores/snobbycl.json new file mode 100644 index 0000000..07c77ad --- /dev/null +++ b/config/stores/snobbycl.json @@ -0,0 +1,97 @@ +{ + "store_code": "snobbycl", + "shopify": { + "api_id": "34936c643039076a0008433c3283e98a", + "store": "snobbycl", + "api_version": "2026-07", + "access_token": "${SNOBBYCL_SHOPIFY_ACCESS_TOKEN}", + "page_limit": 50, + "initial_since_id": "7425850048790" + }, + "sap_connection": { + "url": "https://sap10:50000/b1s/v1", + "company_db": "SBODemoCL", + "username": "manager", + "password": "${SNOBBYCL_SAP_PASSWORD}", + "timeout": 30, + "verify_ssl": false + }, + "sap": { + "default_warehouse_invoice": "9901", + "generic_card_code": "C99998", + "numbering_series": { + "INVOICE": 1, + "INCOMING_PAYMENT": 18, + "INVENTORY_TRANSFER_REQUEST": 51, + "STOCK_TRANSFER": 24, + "DELIVERY": 8 + }, + "locations": { + "113688871190": { + "name": "Geodis", + "is_store": false, + "warehouse_code": "7701" + }, + "90870743318": { + "name": "Snobby Casa Costanera", + "is_store": true, + "warehouse_code": "8801" + }, + "115304300822": { + "name": "Snobby Cenco Costanera", + "is_store": true, + "warehouse_code": "8802" + } + }, + "payment_methods": { + "Transbank Webpay": { + "type": "credit_card", + "credit_card": 4, + "split_payment": true, + "num_of_payments": 1 + }, + "Checkout Mercado Pago": { + "type": "credit_card", + "credit_card": 5, + "split_payment": true, + "num_of_payments": 1 + }, + "VentiPay": { + "type": "credit_card", + "credit_card": 6, + "split_payment": true, + "num_of_payments": 1 + }, + "Fintoc": { + "type": "credit_card", + "credit_card": 7, + "split_payment": true, + "num_of_payments": 1 + }, + "Mercado Pago Tarjetas": { + "type": "credit_card", + "credit_card": 8, + "split_payment": true, + "num_of_payments": 1 + }, + "PayPal": { + "type": "credit_card", + "credit_card": 9, + "split_payment": true, + "num_of_payments": 1 + }, + "Mercado Pago Checkout Pro": { + "type": "credit_card", + "credit_card": 10, + "split_payment": true, + "num_of_payments": 1 + }, + "reversso": { + "type": "transfer", + "transfer_account": "1-1-010-20-000" + } + } + }, + "timeout_days": 60, + "max_orders_per_cycle": 50 +} diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..1b5f726 --- /dev/null +++ b/core/config.py @@ -0,0 +1,52 @@ +import json +import os +import re +from pathlib import Path +from typing import Any, Dict + +from dotenv import load_dotenv + +ROOT = Path(__file__).resolve().parent.parent +_PLACEHOLDER = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") +_env_cargado = False + + +class ConfigError(Exception): + """Falta una variable de entorno referenciada en la configuración de una tienda.""" + + +def cargar_env() -> None: + """Carga /.env una sola vez (no pisa variables que ya estén definidas en el entorno).""" + global _env_cargado + if not _env_cargado: + load_dotenv(ROOT / ".env", override=False) + _env_cargado = True + + +def _expandir(valor: Any, origen: str) -> Any: + if isinstance(valor, dict): + return {k: _expandir(v, origen) for k, v in valor.items()} + if isinstance(valor, list): + return [_expandir(v, origen) for v in valor] + if isinstance(valor, str): + def reemplazo(m: re.Match) -> str: + nombre = m.group(1) + if nombre not in os.environ: + raise ConfigError(f"{origen}: falta la variable de entorno {nombre} (defínala en .env o en el entorno)") + return os.environ[nombre] + + return _PLACEHOLDER.sub(reemplazo, valor) + return valor + + +def cargar_config_tienda(path: Path | str) -> Dict[str, Any]: + """ + Lee el JSON de configuración de una tienda. Cualquier texto con el formato + ${NOMBRE} se reemplaza por la variable de entorno NOMBRE (del entorno o de + .env), para no guardar tokens ni contraseñas en el JSON. Los valores + escritos directamente siguen funcionando. + """ + cargar_env() + path = Path(path) + with open(path, "r", encoding="utf-8") as f: + return _expandir(json.load(f), path.name) diff --git a/core/db.py b/core/db.py new file mode 100644 index 0000000..d3ad587 --- /dev/null +++ b/core/db.py @@ -0,0 +1,352 @@ +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Dict, Optional + +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "shopify_sap.db" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS orders ( + sys_id INTEGER PRIMARY KEY AUTOINCREMENT, + store_code TEXT NOT NULL, + shopify_order_id INTEGER NOT NULL, + order_number TEXT, + tags TEXT, + payment_gateway_names TEXT, + shopify_created_at TEXT NOT NULL, + time_insert TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + time_update TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + financial_status TEXT, + fulfillment_status TEXT, + card_code TEXT, + status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT, + UNIQUE(store_code, shopify_order_id) +); + +CREATE TABLE IF NOT EXISTS order_fulfillments ( + sys_id INTEGER PRIMARY KEY AUTOINCREMENT, + order_sys_id INTEGER NOT NULL REFERENCES orders(sys_id), + shopify_fulfillment_id INTEGER NOT NULL, + location_id TEXT, + is_store INTEGER NOT NULL, + warehouse_code TEXT, + time_insert TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + UNIQUE(order_sys_id, shopify_fulfillment_id) +); + +CREATE TABLE IF NOT EXISTS order_documents ( + sys_id INTEGER PRIMARY KEY AUTOINCREMENT, + order_sys_id INTEGER NOT NULL REFERENCES orders(sys_id), + fulfillment_sys_id INTEGER REFERENCES order_fulfillments(sys_id), + doc_type TEXT NOT NULL, + sequence INTEGER NOT NULL, + required INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'pending', + sap_doc_entry INTEGER, + sap_doc_num INTEGER, + base_doc_entry INTEGER, + error_message TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + time_update TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')) +); + +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); +CREATE INDEX IF NOT EXISTS idx_orders_store ON orders(store_code, shopify_order_id); +CREATE INDEX IF NOT EXISTS idx_fulfillments_order ON order_fulfillments(order_sys_id); +CREATE INDEX IF NOT EXISTS idx_documents_order ON order_documents(order_sys_id); +CREATE INDEX IF NOT EXISTS idx_documents_status ON order_documents(status); +""" + + +def get_connection() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + conn = get_connection() + try: + conn.executescript(SCHEMA) + conn.commit() + finally: + conn.close() + + +@contextmanager +def db_session(): + conn = get_connection() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +# ============================================================================ +def get_last_shopify_order_id(store_code: str) -> int: + """Id de Shopify más alto ya guardado para esta tienda (0 si no hay ninguna).""" + with db_session() as conn: + row = conn.execute( + "SELECT MAX(shopify_order_id) AS max_id FROM orders WHERE store_code = ?", + (store_code,), + ).fetchone() + return row["max_id"] or 0 + + +def insert_order_header(store_code: str, header: Dict[str, Any]) -> bool: + """ + Inserta la cabecera de una orden nueva. Devuelve True si se insertó, + False si ya existía (store_code + shopify_order_id duplicado). + """ + with db_session() as conn: + try: + conn.execute( + """ + INSERT INTO orders ( + store_code, shopify_order_id, order_number, tags, + shopify_created_at, financial_status, fulfillment_status + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + store_code, + header["shopify_order_id"], + header.get("order_number"), + header.get("tags"), + header["shopify_created_at"], + header.get("financial_status"), + header.get("fulfillment_status"), + ), + ) + return True + except sqlite3.IntegrityError: + return False + + +# ============================================================================ +def mark_orders_timeout(store_code: str, timeout_days: int) -> int: + """ + Marca como 'timeout' las órdenes de esta tienda que superaron + timeout_days desde su creación en Shopify y no llegaron a 'integrated'. + Devuelve la cantidad de órdenes marcadas. + """ + with db_session() as conn: + cursor = conn.execute( + """ + UPDATE orders + SET status = 'timeout', time_update = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE store_code = ? + AND status NOT IN ('integrated', 'timeout', 'ignored', 'cancelled') + AND julianday('now') - julianday(shopify_created_at) > ? + """, + (store_code, timeout_days), + ) + return cursor.rowcount + + +def get_pending_orders(store_code: str) -> list[Dict[str, Any]]: + """ + Órdenes de esta tienda que aún no llegaron a un estado terminal. + Ordenadas por time_update ASC (la que hace más tiempo no se toca, + primero): así una orden que falla repetido no bloquea el cupo por + ciclo para siempre, porque cada intento le actualiza time_update y la + manda al final de la fila hasta la próxima vuelta. + """ + with db_session() as conn: + rows = conn.execute( + """ + SELECT * FROM orders + WHERE store_code = ? + AND status NOT IN ('integrated', 'timeout', 'ignored', 'cancelled') + ORDER BY time_update ASC, shopify_order_id ASC + """, + (store_code,), + ).fetchall() + return [dict(r) for r in rows] + + +# ============================================================================ +def get_order_sys_id(store_code: str, shopify_order_id: int) -> Optional[int]: + with db_session() as conn: + row = conn.execute( + "SELECT sys_id FROM orders WHERE store_code = ? AND shopify_order_id = ?", + (store_code, shopify_order_id), + ).fetchone() + return row["sys_id"] if row else None + + +def ensure_order_document( + order_sys_id: int, + doc_type: str, + sequence: int, + fulfillment_sys_id: Optional[int] = None, + required: bool = True, +) -> int: + """ + Devuelve el sys_id de la fila order_documents para (order, fulfillment, + doc_type), creándola en estado 'pending' si todavía no existe. + """ + with db_session() as conn: + row = conn.execute( + """ + SELECT sys_id FROM order_documents + WHERE order_sys_id = ? + AND doc_type = ? + AND fulfillment_sys_id IS ? + """, + (order_sys_id, doc_type, fulfillment_sys_id), + ).fetchone() + if row: + return row["sys_id"] + + cursor = conn.execute( + """ + INSERT INTO order_documents ( + order_sys_id, fulfillment_sys_id, doc_type, sequence, required + ) VALUES (?, ?, ?, ?, ?) + """, + (order_sys_id, fulfillment_sys_id, doc_type, sequence, int(required)), + ) + return cursor.lastrowid + + +def mark_document_error(document_sys_id: int, error_message: str) -> None: + with db_session() as conn: + conn.execute( + """ + UPDATE order_documents + SET status = 'error', + error_message = ?, + attempts = attempts + 1, + time_update = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE sys_id = ? + """, + (error_message, document_sys_id), + ) + order_sys_id = conn.execute( + "SELECT order_sys_id FROM order_documents WHERE sys_id = ?", (document_sys_id,) + ).fetchone()["order_sys_id"] + conn.execute( + """ + UPDATE orders + SET status = 'error', last_error = ?, time_update = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE sys_id = ? + """, + (error_message, order_sys_id), + ) + + +def mark_document_created(document_sys_id: int, sap_doc_entry: int, sap_doc_num: int) -> None: + with db_session() as conn: + conn.execute( + """ + UPDATE order_documents + SET status = 'created', + sap_doc_entry = ?, + sap_doc_num = ?, + error_message = NULL, + time_update = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE sys_id = ? + """, + (sap_doc_entry, sap_doc_num, document_sys_id), + ) + + +def get_doc_entries(order_sys_id: int, doc_type: str) -> list[int]: + """DocEntry de SAP ya registrados para esta orden y tipo de documento.""" + with db_session() as conn: + rows = conn.execute( + "SELECT sap_doc_entry FROM order_documents WHERE order_sys_id = ? AND doc_type = ? AND sap_doc_entry IS NOT NULL", + (order_sys_id, doc_type), + ).fetchall() + return [r["sap_doc_entry"] for r in rows] + + +def get_order_document(document_sys_id: int) -> Optional[Dict[str, Any]]: + with db_session() as conn: + row = conn.execute("SELECT * FROM order_documents WHERE sys_id = ?", (document_sys_id,)).fetchone() + return dict(row) if row else None + + +# ============================================================================ +def set_order_status(order_sys_id: int, status: str, last_error: Optional[str] = None) -> None: + with db_session() as conn: + conn.execute( + """ + UPDATE orders + SET status = ?, last_error = ?, time_update = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE sys_id = ? + """, + (status, last_error, order_sys_id), + ) + + +def set_order_card_code(order_sys_id: int, card_code: str) -> None: + with db_session() as conn: + conn.execute( + "UPDATE orders SET card_code = ?, time_update = strftime('%Y-%m-%d %H:%M:%f','now') WHERE sys_id = ?", + (card_code, order_sys_id), + ) + + +def update_order_snapshot(order_sys_id: int, order: Dict[str, Any]) -> None: + """Refresca los campos de Shopify que pueden cambiar entre corridas.""" + gateways = order.get("payment_gateway_names") or [] + with db_session() as conn: + conn.execute( + """ + UPDATE orders + SET financial_status = ?, + fulfillment_status = ?, + tags = ?, + payment_gateway_names = ?, + time_update = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE sys_id = ? + """, + ( + order.get("financial_status"), + order.get("fulfillment_status"), + order.get("tags"), + " + ".join(gateways) if gateways else None, + order_sys_id, + ), + ) + + +# ============================================================================ +def ensure_order_fulfillment( + order_sys_id: int, + shopify_fulfillment_id: int, + location_id: str, + is_store: bool, + warehouse_code: str, +) -> int: + """ + Devuelve el sys_id de order_fulfillments para este fulfillment, + creándolo si todavía no existe (una orden puede recibir fulfillments + nuevos en corridas posteriores). + """ + with db_session() as conn: + row = conn.execute( + "SELECT sys_id FROM order_fulfillments WHERE order_sys_id = ? AND shopify_fulfillment_id = ?", + (order_sys_id, shopify_fulfillment_id), + ).fetchone() + if row: + return row["sys_id"] + + cursor = conn.execute( + """ + INSERT INTO order_fulfillments ( + order_sys_id, shopify_fulfillment_id, location_id, is_store, warehouse_code + ) VALUES (?, ?, ?, ?, ?) + """, + (order_sys_id, shopify_fulfillment_id, location_id, int(is_store), warehouse_code), + ) + return cursor.lastrowid diff --git a/core/errors.py b/core/errors.py new file mode 100644 index 0000000..d79f307 --- /dev/null +++ b/core/errors.py @@ -0,0 +1,13 @@ +class SAPServiceLayerError(Exception): + def __init__(self, status_code: int, detail: str, code: int = None): + self.status_code = status_code + self.detail = detail + self.code = code + super().__init__(f"[{status_code}] {detail}") + + +class ShopifyAPIError(Exception): + def __init__(self, status_code: int, detail: str): + self.status_code = status_code + self.detail = detail + super().__init__(f"[{status_code}] {detail}") diff --git a/core/lock.py b/core/lock.py new file mode 100644 index 0000000..10604ba --- /dev/null +++ b/core/lock.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from filelock import FileLock, Timeout + +LOCK_PATH = Path(__file__).resolve().parent.parent / "data" / "shopify_runrun.lock" + + +class SingleInstanceError(Exception): + pass + + +def acquire_single_instance_lock() -> FileLock: + LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) + lock = FileLock(str(LOCK_PATH), timeout=0) + try: + lock.acquire() + except Timeout: + raise SingleInstanceError( + f"Ya existe una instancia en ejecución (lock: {LOCK_PATH})" + ) + return lock diff --git a/core/logger_manager.py b/core/logger_manager.py new file mode 100644 index 0000000..5fc7865 --- /dev/null +++ b/core/logger_manager.py @@ -0,0 +1,183 @@ +import contextvars +import json +import logging +import os +from contextlib import contextmanager +from datetime import datetime +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path +from typing import Optional + +LOG_DIR = Path(__file__).resolve().parent.parent / "logs" +LOG_FILE = LOG_DIR / "shopify-sap.log" +EVENTS_FILE = LOG_DIR / "events.jsonl" +_LOG_CONFIGURED = False + +_stack: contextvars.ContextVar[list] = contextvars.ContextVar("log_stack", default=[]) +_order_ctx: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar("order_ctx", default=None) + +# ============================================================================ +# Contexto de orden: taggea automáticamente los logs dentro del bloque con +# store_code/shopify_order_id, sin tener que repetirlos en cada llamada. +@contextmanager +def order_context(store_code: str, shopify_order_id: int): + token = _order_ctx.set({"store_code": store_code, "shopify_order_id": shopify_order_id}) + try: + yield + finally: + _order_ctx.reset(token) + + +# ============================================================================ +class TreeFormatter(logging.Formatter): + def format(self, record): + ctx = _order_ctx.get() + tag = f"[{ctx['store_code']}#{ctx['shopify_order_id']}] " if ctx else "" + return f"{self.formatTime(record)} ─── {tag}{record.getMessage()}" + + def formatTime(self, record, datefmt=None): + return datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S") + + +# ============================================================================ +def _build_prefix(stack, is_last): + if not stack: + return "" + parts = [] + for sib_is_last in stack[:-1]: + parts.append(" " if sib_is_last else "│ ") + parts.append("└── " if is_last else "├── ") + return "".join(parts) + + +# ============================================================================ +class _TaskContext: + def __init__(self, logger, msg, emoji, is_last): + self._logger = logger + self._msg = msg + self._emoji = emoji + self._is_last = is_last + + def __enter__(self): + stack = _stack.get() + prefix = _build_prefix(stack, self._is_last) + emoji_str = f"{self._emoji} " if self._emoji else "" + self._logger._logger.info(f"{prefix}{emoji_str}{self._msg}") + _stack.set(stack + [self._is_last]) + return TaskLogger(self._logger._logger) + + def __exit__(self, exc_type, exc_val, exc_tb): + _stack.set(_stack.get()[:-1]) + + +# ============================================================================ +class TaskLogger: + def __init__(self, logger): + self._logger = logger + + @contextmanager + def task(self, msg, emoji=""): + with _TaskContext(self, msg, emoji, is_last=False): + yield + + @contextmanager + def last_task(self, msg, emoji=""): + with _TaskContext(self, msg, emoji, is_last=True): + yield + + def info(self, msg, *args, _last=False, **kwargs): + self._log(logging.INFO, msg, args, kwargs, _last) + + def warning(self, msg, *args, _last=False, **kwargs): + self._log(logging.WARNING, msg, args, kwargs, _last) + + def error(self, msg, *args, _last=False, **kwargs): + self._log(logging.ERROR, msg, args, kwargs, _last) + + def _log(self, level, msg, args, kwargs, is_last): + stack = _stack.get() + prefix = _build_prefix(stack, is_last) + self._logger.log(level, f"{prefix}{msg}", *args, **kwargs) + + +# ============================================================================ +def setup_logging(level=None, console=True): + global _LOG_CONFIGURED + if _LOG_CONFIGURED: + return + + if level is None: + level_name = os.getenv("LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + + LOG_DIR.mkdir(exist_ok=True) + + formatter = TreeFormatter() + + file_handler = TimedRotatingFileHandler( + LOG_FILE, + when="midnight", + interval=1, + backupCount=30, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + + handlers = [file_handler] + if console: + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + handlers.append(console_handler) + + logging.basicConfig(level=level, handlers=handlers, force=True) + _LOG_CONFIGURED = True + + +# ============================================================================ +def get_logger(name="shopify-sap"): + setup_logging() + return TaskLogger(logging.getLogger(name)) + + +# ============================================================================ +# Log de eventos de negocio (JSON Lines), separado del log técnico en árbol. +# Pensado para alimentar más adelante un visor de eventos de integración. +_events_logger: Optional[logging.Logger] = None + + +def _get_events_logger() -> logging.Logger: + global _events_logger + if _events_logger is not None: + return _events_logger + + LOG_DIR.mkdir(exist_ok=True) + handler = TimedRotatingFileHandler( + EVENTS_FILE, + when="midnight", + interval=1, + backupCount=30, + encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + + logger = logging.getLogger("shopify-sap-events") + logger.setLevel(logging.INFO) + logger.addHandler(handler) + logger.propagate = False + + _events_logger = logger + return logger + + +def log_event(event: str, level: str = "info", message: str = "", **fields) -> None: + ctx = _order_ctx.get() or {} + payload = { + "timestamp": datetime.now().isoformat(timespec="seconds"), + "level": level.upper(), + "event": event, + "message": message, + **ctx, + **fields, + } + log_level = getattr(logging, level.upper(), logging.INFO) + _get_events_logger().log(log_level, json.dumps(payload, ensure_ascii=False, default=str)) diff --git a/core/notifications.py b/core/notifications.py new file mode 100644 index 0000000..96b3fb9 --- /dev/null +++ b/core/notifications.py @@ -0,0 +1,80 @@ +import html +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from core.db import db_session + +REPORTS_DIR = Path(__file__).resolve().parent.parent / "notifications" + + +def obtener_errores_pendientes(store_code: Optional[str] = None) -> List[Dict[str, Any]]: + query = """ + SELECT o.store_code, o.shopify_order_id, o.order_number, + d.doc_type, d.attempts, d.error_message, d.time_update + FROM order_documents d + JOIN orders o ON o.sys_id = d.order_sys_id + WHERE d.status = 'error' + """ + params: tuple = () + if store_code: + query += " AND o.store_code = ?" + params = (store_code,) + query += " ORDER BY d.time_update DESC" + + with db_session() as conn: + rows = conn.execute(query, params).fetchall() + return [dict(r) for r in rows] + + +def generar_reporte_errores_html(store_code: Optional[str] = None) -> Path: + """ + Genera un HTML (tabla) con los order_documents en estado 'error' y lo + guarda en notifications/. Por ahora solo se guarda en disco; el envío + (mail/mensaje) se implementa más adelante. + """ + errores = obtener_errores_pendientes(store_code) + + filas = "\n".join( + "" + f"{html.escape(e['store_code'])}" + f"{html.escape(e['order_number'] or '')}" + f"{e['shopify_order_id']}" + f"{html.escape(e['doc_type'])}" + f"{e['attempts']}" + f"{html.escape(e['error_message'] or '')}" + f"{html.escape(e['time_update'])}" + "" + for e in errores + ) + + contenido = f""" + + + +Errores de integración Shopify -> SAP + + + +

Errores de integración Shopify -> SAP

+

Generado: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Total: {len(errores)}

+ + + + + {filas} +
TiendaOrdenShopify IDDocumentoIntentosErrorÚltima actualización
+ + +""" + + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + archivo = REPORTS_DIR / f"errores_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html" + archivo.write_text(contenido, encoding="utf-8") + return archivo diff --git a/core/sap.py b/core/sap.py new file mode 100644 index 0000000..2652825 --- /dev/null +++ b/core/sap.py @@ -0,0 +1,211 @@ +import json +import logging +from typing import Any, Dict, Optional + +import requests + +from core.errors import SAPServiceLayerError + +logger = logging.getLogger(__name__) + + +class SAPServiceLayerClient: + """ + Cliente de SAP B1 Service Layer para una conexión (URL + CompanyDB) + específica. Cada tienda Shopify puede apuntar a una base SAP distinta, + por eso la sesión (B1SESSION) vive por instancia y no en un estado + global del módulo. + """ + + def __init__( + self, + url: str, + company_db: str, + username: str, + password: str, + timeout: float = 15.0, + verify_ssl: bool = False, + ): + self.url = url.rstrip("/") + self.company_db = company_db + self.username = username + self.password = password + self.timeout = timeout + self.verify_ssl = verify_ssl + self._headers: Optional[Dict[str, str]] = None + + @classmethod + def from_store_config(cls, sap_connection: Dict[str, Any]) -> "SAPServiceLayerClient": + """ + Construye el cliente a partir del bloque "sap_connection" del JSON + de configuración de una tienda. + """ + return cls( + url=sap_connection["url"], + company_db=sap_connection["company_db"], + username=sap_connection["username"], + password=sap_connection["password"], + timeout=float(sap_connection.get("timeout", 15.0)), + verify_ssl=bool(sap_connection.get("verify_ssl", False)), + ) + + # ======================================================================== + def _log_response( + self, + method: str, + endpoint: str, + response: requests.Response, + request_data: Optional[Dict] = None, + ) -> None: + extra = { + "http_method": method, + "endpoint": endpoint, + "status_code": response.status_code, + "company_db": self.company_db, + } + if response.status_code >= 400: + try: + error_detail = response.json() + respuesta = json.dumps(error_detail, indent=2)[:500] + except Exception: + respuesta = response.text[:500] + logger.error( + f"[{self.company_db}] [{response.status_code}] {method} {endpoint} - " + f"Datos enviados: {json.dumps(request_data, indent=2, default=str)[:500]} - " + f"Respuesta: {respuesta}", + extra=extra, + ) + else: + logger.info(f"[{self.company_db}] [{response.status_code}] {method} {endpoint}", extra=extra) + + # ======================================================================== + def ensure_session(self, force_refresh: bool = False) -> Dict[str, str]: + """ + Verifica si existe una sesión (B1SESSION) para esta conexión. + Si no existe o force_refresh es True, hace Login. + """ + if self._headers and not force_refresh: + return self._headers + + logger.info(f"[{self.company_db}] Iniciando sesión en SAP Service Layer...") + auth_url = f"{self.url}/Login" + payload = { + "CompanyDB": self.company_db, + "UserName": self.username, + "Password": self.password, + } + + try: + response = requests.post( + auth_url, + json=payload, + timeout=self.timeout, + verify=self.verify_ssl, + ) + + if response.status_code != 200: + logger.error( + f"[{self.company_db}] Error crítico: no se pudo iniciar sesión en SAP. " + f"Status: {response.status_code}" + ) + self._headers = None + raise SAPServiceLayerError(response.status_code, "Conexión fallida con el servidor SAP") + + sap_session = response.json() + b1_session_id = sap_session.get("SessionId") + route_id = response.cookies.get("ROUTEID") + + headers = {"Cookie": f"B1SESSION={b1_session_id}; ROUTEID={route_id}"} + self._headers = headers + logger.info(f"[{self.company_db}] Sesión establecida. B1SESSION: {b1_session_id[:10]}...") + return headers + except requests.RequestException as exc: + logger.error(f"[{self.company_db}] Error de conexión al autenticar contra SAP: {exc}") + self._headers = None + raise SAPServiceLayerError(500, f"Error al conectar con SAP Service Layer: {exc}") + + # ======================================================================== + def forward( + self, + method: str, + endpoint_path: str, + json_data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + extra_headers: Optional[Dict[str, str]] = None, + ) -> requests.Response: + """ + Realiza una llamada a SAP B1 Service Layer, manejando autenticación + transparente con sesión única (por instancia) y reintento en 401. + """ + if not method or not endpoint_path: + raise ValueError("method y endpoint_path son requeridos") + + method = method.upper() + headers = self.ensure_session() + url_destino = f"{self.url}/{endpoint_path}" + + req_headers = {"Content-Type": "application/json", "Accept": "application/json"} + req_headers.update(headers) + if extra_headers: + req_headers.update(extra_headers) + + log_extra = {"http_method": method, "endpoint": endpoint_path, "company_db": self.company_db} + logger.info(f"[{self.company_db}] [{method}] {endpoint_path}", extra=log_extra) + + try: + response = requests.request( + method=method, + url=url_destino, + json=json_data, + params=params, + headers=req_headers, + timeout=self.timeout, + verify=self.verify_ssl, + ) + except requests.exceptions.Timeout: + logger.error(f"[{self.company_db}] Timeout ({self.timeout}s) en {endpoint_path}", extra=log_extra) + raise ConnectionError(f"Timeout al conectar con {endpoint_path}") + except requests.exceptions.ConnectionError as exc: + logger.error(f"[{self.company_db}] Error de conexión: {exc}", extra=log_extra) + raise ConnectionError(f"No se pudo conectar con SAP B1: {exc}") + except requests.RequestException as exc: + logger.error(f"[{self.company_db}] Error en solicitud: {exc}", extra=log_extra) + raise ConnectionError(f"Error en solicitud HTTP: {exc}") + + if response.status_code == 401: + logger.warning( + f"[{self.company_db}] Sesión expirada (401). Reautenticando e intentando una sola vez...", + extra=log_extra, + ) + try: + headers = self.ensure_session(force_refresh=True) + req_headers.update(headers) + + logger.info(f"[{self.company_db}] Reintentando solicitud [RETRY]", extra=log_extra) + response = requests.request( + method=method, + url=url_destino, + json=json_data, + params=params, + headers=req_headers, + timeout=self.timeout, + verify=self.verify_ssl, + ) + except Exception as exc: + logger.error(f"[{self.company_db}] Error en reintento: {exc}", extra=log_extra) + raise ConnectionError(f"Error en reintento tras reautenticación: {exc}") + + self._log_response(method, endpoint_path, response, json_data) + + if response.status_code >= 400: + error_msg = response.text + error_code = None + try: + err = response.json() + error_msg = err.get("error", {}).get("message", {}).get("value", error_msg) + error_code = err.get("error", {}).get("code") + except Exception: + pass + raise SAPServiceLayerError(response.status_code, error_msg, code=error_code) + + return response diff --git a/core/shopify_client.py b/core/shopify_client.py new file mode 100644 index 0000000..56cf755 --- /dev/null +++ b/core/shopify_client.py @@ -0,0 +1,114 @@ +import logging +import time +from typing import Any, Dict, List + +import truststore + +# Verifica certificados usando el almacén de confianza del sistema operativo +# en vez del bundle estático de certifi. Necesario detrás de firewalls que +# hacen inspección TLS (ej. Fortinet) y, en el servicio Windows, para +# confiar automáticamente en los CA que administre IT. +truststore.inject_into_ssl() + +import requests + +from core.errors import ShopifyAPIError + +logger = logging.getLogger(__name__) + +NETWORK_RETRIES = 4 # intentos totales ante fallos de red +NETWORK_BACKOFF = 2 # segundos; espera = backoff * nº de intento + +ORDER_HEADER_FIELDS ="created_at,id,gateway,order_number,name,financial_status,fulfillment_status,total_price,tags" + + +class ShopifyClient: + """ + Cliente de la Admin API de Shopify para una tienda específica. + """ + + def __init__( + self, + store: str, + api_id: str, + access_token: str, + api_version: str, + timeout: float = 30.0, + ): + self.store = store + self.base_url = f"https://{store}.myshopify.com/admin/api/{api_version}" + self.auth = (api_id, access_token) + self.timeout = timeout + + @classmethod + def from_store_config(cls, shopify_config: Dict[str, Any]) -> "ShopifyClient": + return cls( + store=shopify_config["store"], + api_id=shopify_config["api_id"], + access_token=shopify_config["access_token"], + api_version=shopify_config["api_version"], + ) + + # ======================================================================== + def _request(self, url: str, params: Dict[str, Any]) -> requests.Response: + """ + GET con reintentos ante fallos transitorios de red (SSL EOF, reset de + conexión, timeout), con espera creciente. Los errores HTTP (4xx/5xx) no + se reintentan aquí: los maneja _get. + """ + for intento in range(1, NETWORK_RETRIES + 1): + try: + return requests.get(url, params=params, auth=self.auth, timeout=self.timeout) + except requests.RequestException as exc: + if intento == NETWORK_RETRIES: + raise + espera = NETWORK_BACKOFF * intento + logger.warning(f"[{self.store}] Fallo de red ({type(exc).__name__}), reintento {intento}/{NETWORK_RETRIES - 1} en {espera}s") + time.sleep(espera) + + def _get(self, path: str, params: Dict[str, Any]) -> requests.Response: + url = f"{self.base_url}/{path}" + + response = self._request(url, params) + + if response.status_code == 429: + retry_after = float(response.headers.get("Retry-After", 2)) + logger.warning(f"[{self.store}] Rate limit alcanzado, reintentando en {retry_after}s") + time.sleep(retry_after) + response = self._request(url, params) + + if response.status_code >= 400: + raise ShopifyAPIError(response.status_code, response.text[:500]) + + return response + + # ======================================================================== + def obtener_cabeceras_ordenes(self, since_id: int = 0, limit: int = 50) -> List[Dict[str, Any]]: + """ + Descarga cabeceras de órdenes (status=any) con id mayor a since_id, + ordenadas ascendente por id (comportamiento por defecto de Shopify). + """ + params = { + "fields": ORDER_HEADER_FIELDS, + "status": "any", + "since_id": since_id, + "limit": limit, + } + + logger.info(f"[{self.store}] GET orders.json since_id={since_id} limit={limit}") + response = self._get("orders.json", params) + return response.json().get("orders", []) + + # ======================================================================== + def obtener_orden(self, shopify_order_id: int) -> Dict[str, Any]: + """Descarga el detalle completo (cabecera + line_items) de una orden.""" + logger.info(f"[{self.store}] GET orders/{shopify_order_id}.json") + response = self._get(f"orders/{shopify_order_id}.json", params={}) + return response.json().get("order", {}) + + # ======================================================================== + def obtener_transacciones(self, shopify_order_id: int) -> List[Dict[str, Any]]: + """Transacciones de pago de la orden (monto, gateway, estado, tarjeta).""" + logger.info(f"[{self.store}] GET orders/{shopify_order_id}/transactions.json") + response = self._get(f"orders/{shopify_order_id}/transactions.json", params={}) + return response.json().get("transactions", []) diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/analisis_tiempos.md b/docs/analisis_tiempos.md new file mode 100644 index 0000000..13696ca --- /dev/null +++ b/docs/analisis_tiempos.md @@ -0,0 +1,84 @@ +# Análisis de tiempos del pipeline Shopify → SAP + +Fecha de medición: 2026-09-25. Entorno: SAP Service Layer (`SBODemoCL`) en la misma +red/VPN, Shopify Admin API por internet. + +## Latencias individuales medidas (llamadas aisladas) + +| Llamada | Tiempo promedio | +|---|---| +| SAP GET (misma red/VPN) | ~0.056 s | +| Shopify GET orden completa | ~0.469 s | + +## Ciclo real completo (20 órdenes, `snobbycl`, sin RUT) + +| Métrica | Valor | +|---|---| +| Duración total | 87 s | +| Promedio por orden | ~4.3 s | +| Intervalo configurado ("rapido") | 600 s | +| Margen usado | ~14% del ciclo | + +Esta medición corresponde a órdenes **sin RUT** (ninguna llamó a `BusinessPartners`), +caso "tienda" completo: Invoice → Payment → InventoryTransferRequest → StockTransfer +→ Delivery, con sus referencias cruzadas. + +## Ruta más larga: tienda + RUT nuevo (creación de BusinessPartner) + +16 llamadas en total: + +| # | Servicio | Llamada | Tiempo aprox. | +|---|---|---|---| +| 1 | Shopify | GET `orders/{id}.json` | ~0.47 s | +| 2 | SAP | GET `BusinessPartners` (buscar por RUT) | ~0.06 s | +| 3 | SAP | POST `BusinessPartners` (crear, RUT no existía) | ~0.40 s | +| 4 | SAP | POST `Invoices` | ~0.35 s | +| 5 | SAP | POST `IncomingPayments` | ~0.35 s | +| 6 | SAP | POST `InventoryTransferRequests` | ~0.35 s | +| 7 | SAP | GET `Invoices` (leer referencias) | ~0.06 s | +| 8 | SAP | PATCH `Invoices` (agregar ref. ITR) | ~0.35 s | +| 9 | SAP | GET `InventoryTransferRequests` (traer líneas completas) | ~0.06 s | +| 10 | SAP | POST `StockTransfers` | ~0.35 s | +| 11 | SAP | GET `Invoices` (leer referencias) | ~0.06 s | +| 12 | SAP | PATCH `Invoices` (agregar ref. StockTransfer) | ~0.35 s | +| 13 | SAP | GET `StockTransfers` (traer líneas completas) | ~0.06 s | +| 14 | SAP | POST `DeliveryNotes` | ~0.35 s | +| 15 | SAP | GET `Invoices` (leer referencias) | ~0.06 s | +| 16 | SAP | PATCH `Invoices` (agregar ref. Delivery) | ~0.35 s | + +### Subtotales + +| Tipo | Cantidad | Tiempo total | +|---|---|---| +| Shopify GET | 1 | ~0.47 s | +| SAP GET | 6 | ~0.36 s | +| SAP POST | 6 | ~2.15 s | +| SAP PATCH | 3 | ~1.05 s | +| **Total** | **16** | **~4.0-4.6 s** | + +Coincide con lo medido en vivo (~4.3 s/orden en `snobbycl` sin RUT — a esa ruta le +faltan las llamadas 2 y 3 de la tabla, por eso da un poco menos que esta ruta +completa con creación de BusinessPartner). + +Caso CD (solo ITR, sin StockTransfer ni Delivery, sin RUT): ~5 llamadas, bastante +más rápido que el caso tienda completo. + +## Capacidad estimada por ciclo + +Con ~4.3-4.6 s/orden real, dejando colchón de seguridad (~70-80% del intervalo +usado, el resto para variabilidad de red, reintentos por 401, rate limit de +Shopify): + +| Modo | Intervalo | Teórico máx. | Recomendado con margen | +|---|---|---|---| +| rapido | 600 s | ~139 órdenes | ~95-110 | +| full | 1800 s | ~418 órdenes | ~290-330 | + +## Configuración aplicada + +`max_orders_per_cycle = 50` para ambas tiendas (`snobbycl` y +`on-chile-distributor-prod`), muy por debajo de la capacidad real medida — +deja margen amplio para picos de tráfico o llamadas más lentas de lo normal. + +Con 50 órdenes/ciclo y ~4.6 s/orden en el peor caso (todas con RUT nuevo), +un ciclo tardaría ~230 s, menos del 40% del intervalo "rapido" de 600 s. diff --git a/docs/casos_especiales.md b/docs/casos_especiales.md new file mode 100644 index 0000000..1d8fe8c --- /dev/null +++ b/docs/casos_especiales.md @@ -0,0 +1,103 @@ +# Casos especiales de integración (Shopify → SAP) + +Casos que se salen del flujo normal (`docs/flujo_pipeline.md`): cómo se detectan, qué hace el pipeline y **una orden +real de ejemplo por tienda**. "sin caso" significa que no se encontró un ejemplo real en esa tienda (no que no pueda ocurrir). +Los JSON de Shopify de la mayoría de los ejemplos están en `download/` (`shopify_#.json` = orden, +`shopify_transactions_#.json` = transacciones). + +Tiendas: **snobbycl** (órdenes `#NNNN`) y **on-chile-distributor-prod** (órdenes `#CLNNNN`). + +## Regla general de montos + +La **Invoice siempre iguala lo realmente cobrado** en Shopify (suma de las transacciones efectivas). +Las líneas se arman en `domain/invoice.py::_lineas_por_cobrado`: + +1. Si el total de las líneas originales (`quantity`) coincide con lo cobrado → se usan esas. +2. Si no, y el total de las líneas vigentes (`current_quantity`) coincide → se usan esas. +3. Si ninguna coincide → se usan las originales y el control de suma del pago deja la orden en `error` + (no se crea el pago). + +Transacciones efectivas (`domain/payment.py::transacciones_efectivas`): solo `status = success` y `kind` +`sale` o `capture`. Si una `authorization` tiene su `capture` (`parent_id`), cuenta solo la `capture`. +Se ignoran `failure`, `pending`, `refund` y `void`. + +--- + +## A. Pagos + +| # | Caso | Cómo se detecta | Tratamiento | snobbycl | on-chile | +|---|---|---|---|---|---| +| A1 | Venta normal | una `sale` exitosa = total de la orden | Invoice + pago + ITR (+ ST + Delivery si es tienda) | #8064 | #CL1467 | +| A2 | Intentos fallidos o pendientes antes del pago exitoso | varias transacciones, solo una `success`. `payment_gateway_names` lista todos los intentos y **no** indica quién pagó | solo la exitosa suma | #7880 (Transbank `failure` → Fintoc ok), #7903 (Mercado Pago `pending` → Transbank ok), #8063 (4 intentos, uno con status `error`, y 1 éxito) | #CL9958, #CL9994, #CL10041 (36 en septiembre) | +| A3 | `authorization` + `capture` (reversso) | dos `success`, la `capture` con `parent_id` = la `authorization` | solo cuenta la `capture`; reversso va como transferencia (`1-1-010-20-000`) | #7902 | #CL9948, #CL9950, #CL9969 (46 en septiembre) | +| A4 | Pago dividido real | dos `sale` `success` de gateways distintos | una línea de `PaymentCreditCards` por transacción, cada una con su tarjeta | sin caso | #CL10236 (Fintoc 152.991 + Transbank 54.000), #CL10594, #CL10967; #CL10947 (Transbank + Transbank Webpay, misma tarjeta SAP 4 → dos líneas) | +| A5 | Gateway con nombre distinto al configurado | el `gateway` no coincide con la clave de `sap.payment_methods` (comparación exacta) | agregar las variantes al config con la misma tarjeta | sin caso (sus nombres coinciden) | `Venti` (#CL9933, #CL10054) y `Transbank` (#CL10236), equivalentes a `VentiPay` y `Transbank Webpay` | +| A6 | Gateway sin configurar | no está en `sap.payment_methods`, o sin `credit_card`, o `type` inválido | `PaymentConfigError`: el documento de pago queda en `error` con el mensaje y **no se crea el pago** (la Invoice ya existe y se retoma después) | prueba sintética con un gateway inexistente | prueba sintética | +| A7 | `VoucherNum` | SAP rechaza más de 20 caracteres (`Value too long in property 'VoucherNum'`) | `VoucherNum` = `name` de la orden (máx. 20) | — | #CL1467 (falló con el `payment_id` de 25 caracteres) | + +Datos fijos de las tarjetas (`domain/payment.py`, provisorios): últimos dígitos `1234`, vencimiento `2030-12-31`, +`CreditType cr_Regular`; `SplitPayments` y `NumOfPayments` según el config de cada medio. + +## B. Líneas canceladas, devueltas o reembolsadas + +| # | Caso | Cómo se detecta | Tratamiento | snobbycl | on-chile | +|---|---|---|---|---|---| +| B1 | Línea cancelada **antes** del pago | `refund_line_items` con `restock_type = cancel`; cobrado = total neto (`current_total_price`) | la Invoice usa las líneas vigentes | #7998 (4 unidades, cobrado 339.980; Invoice original DocNum 460 anulada y reintegrada como DocNum 516) | no detectado en septiembre | +| B2 | Línea cancelada con dinero reembolsado **después** de cobrar el total | `cancel` + transacción `refund` exitosa; cobrado = total original | la Invoice usa las líneas **originales** (el reembolso es un tratamiento interno de comercial) | sin caso | #CL10072 (cobrado 305.973, reembolsado 71.991) | +| B3 | Línea devuelta (`return`) **sin cobro** | `restock_type = return`, sin transacción `refund`, cobrado = total vigente | la Invoice usa las líneas vigentes y `fulfillments_facturados` limita los fulfillments a lo facturado: no se hace ITR, ST ni Delivery de la unidad devuelta (el Delivery fallaría con `KeyError` al no existir la línea base) | sin caso | 15 órdenes, entre ellas #CL10144, #CL10764, #CL10903 | +| B4 | Despachada, pagada y **luego reembolsada** (total o parcial) | `financial_status` `refunded` o `partially_refunded`, con fulfillments y sin `cancelled_at` | se integra completa como venta; el reembolso no se refleja en SAP (`ESTADOS_FINANCIEROS_FACTURABLES`) | #7954, #7963, #7978, #7983, #7987, #8020, #8029 (parcial) | #CL9947, #CL9968, #CL9994, #CL9998, #CL10039, #CL10047 (23 en septiembre, aún sin integrar) | +| B5 | Reembolso de dinero pendiente, sin líneas | `refund` en `pending` y `refunds` sin `refund_line_items`, total vigente = cobrado | no afecta Invoice ni pago: se integra normal | #8009 | sin caso | +| B6 | Pedido de prueba | decisión manual | `status = ignored` (terminal) | #8019 | sin caso | + +## C. Estado de la orden en Shopify + +| # | Caso | Cómo se detecta | Tratamiento | snobbycl | on-chile | +|---|---|---|---|---|---| +| C1 | Orden **anulada** | `cancelled_at` no vacío, aunque siga `paid` | `status = cancelled` (terminal) con fecha y `cancel_reason` en `last_error`; evento `order_cancelled`; nunca se crean documentos | #7893, #7985, #8003, #7862 (14 en total) | #CL9983, #CL10015, #CL10161 (22 en septiembre) | +| C2 | Pago pendiente o no pagada | `financial_status` fuera de `paid`/`partially_refunded`/`refunded` (ej. `pending`, `voided`) | `not_eligible`; se reevalúa en cada ciclo | sin caso vigente (#8010 y #8021 eran `voided` y están anuladas) | #CL10283, #CL10733, #CL10828 (`pending`) | +| C3 | Pagada y sin despachar | sin `fulfillments` | `not_eligible` hasta que se despache; al despacharse se integra sola | #8055 | #CL9951, #CL10006 (73 en septiembre) | +| C4 | Costo de envío mayor que cero | `total_shipping_price_set > 0` | `error`: el mapeo del envío no está soportado | sin caso | #CL10169, #CL1452, #CL1453 | +| C5 | Orden sin líneas vigentes | todas las líneas canceladas | `not_eligible` ("Orden sin líneas vigentes") | sin caso | sin caso | + +Estados terminales: `integrated`, `timeout`, `ignored`, `cancelled` (ver `docs/esquema_base_datos.md`). + +## D. SAP + +| # | Caso | Síntoma | Tratamiento | snobbycl | on-chile | +|---|---|---|---|---|---| +| D1 | SKU inexistente | `POST Invoices` 400 `Item number is missing` (`-5009`) | crear los artículos (`tools/cargar_items_faltantes.py`, grupo 108); la orden se reintenta | #8064 (primer intento) | detectado antes de integrar: 874 artículos faltantes en las órdenes de septiembre | +| D2 | Sin stock en la bodega de origen | el ITR/transferencia es rechazado | entrada de mercancía (`tools/entrada_mercancia_ordenes.py`) | bodegas `8801`, `8802` | bodegas `8803`, `7702` | +| D3 | Anular una Invoice | `Cancellation is not possible; posting date exceeds limit of 10 days`. El **DocNum no es el DocEntry** | reintentar cuando SAP lo permita; anular siempre por DocEntry | #7998 (DocNum 460 = DocEntry 514) | — | +| D4 | Documento base cerrado | Delivery: `One of the base documents has already been closed` | `error` en cada ciclo hasta resolverlo en SAP | #7863 (antiguo) | — | +| D5 | Cliente con RUT | `note_attributes` con un RUT válido | se busca/crea el BusinessPartner `C` (crea clientes reales en SAP) | sin caso (ninguna orden trae RUT) | #CL1467 → `C` (ej. `C12345678-5`) (977 de 1.049 elegibles traen RUT) | +| D6 | Cliente sin RUT | sin RUT válido en `note_attributes` | cliente genérico `sap.generic_card_code` (`C99998`); no se consultan los metafields del customer | #8064 | #CL9948 | + +## E. Datos, cursor y reprocesos + +| # | Caso | Descripción | Tratamiento | snobbycl | on-chile | +|---|---|---|---|---|---| +| E1 | Cursor `since_id` | se descarga desde `max(id máximo en BD, initial_since_id)`; `initial_since_id` es un **piso**. Órdenes de prueba con ids altos en la BD hacían saltar el cursor | alinear la BD (insertar cabeceras) o borrar las de prueba | — | #CL10809 y #CL10851 saltaban el cursor a fines de septiembre | +| E2 | Orden integrada en SAP sin registro local | si se borra su fila y vuelve a descargarse, se integraría de nuevo | reinsertarla como `ignored`; además la idempotencia (E6) adopta los documentos existentes | — | #CL10809 (Invoice 319, pago 67, ITR 23) | +| E3 | Timeout | `timeout_days` (60) desde la creación en Shopify | `timeout` (terminal). Corte operativo de on-chile: 01/09/2026 (`initial_since_id` = 8103221690695) | — | órdenes de septiembre en adelante | +| E4 | API de Shopify caída | error 401/403/red | el error se propaga: el ciclo registra "Error procesando tienda" y reintenta en el siguiente. Ya no hay fallback con archivos manuales (se retiró; se usó mientras faltaba el scope `read_orders` de on-chile) | — | — | +| E5 | Casos para análisis | el pipeline **no** guarda solo los casos especiales | `tools/descargar_ordenes_especiales.py ` los baja a `download/` | 26 órdenes | 55 órdenes | +| E6 | Documento ya existente en SAP (idempotencia) | la BD local no registra un documento que sí existe en SAP (BD perdida, copiada o editada) | antes de crear Invoice, ITR, StockTransfer o Delivery se busca por `U_ShopifyOrderId` + `U_ShopifyStore` con las mismas líneas y se **adopta**; solo Invoices vigentes (`CancelStatus = csNo`); una Invoice ya pagada no genera otro pago; evento `documento_adoptado`. **Límite:** no hay respaldo para el pago si la Invoice no figura pagada | #8064, #7998 (adopta la Invoice 516, no la anulada 460) | #CL1467 | +| E7 | Corte de red | `SSLEOFError`, reset de conexión, timeout | `ShopifyClient` reintenta hasta 4 veces con espera creciente; los errores HTTP no se reintentan | — | corte SSL al paginar cabeceras | + +## F. No cubierto todavía + +- **Reembolso o anulación posterior a integrar:** `integrated` es terminal, por lo que SAP queda con la venta completa y + sin nota de crédito ni devolución. Decisión pendiente con contabilidad (`docs/pendientes.md`). +- **Fulfillments vs. Invoice:** `fulfillments_facturados` reparte las cantidades de la Invoice entre los fulfillments en + orden; si una misma línea se devolviera parcialmente entre varios fulfillments, el reparto sigue ese orden y no el real. +- **Transferencias a cuentas distintas:** un solo `TransferAccount` por pago; con dos cuentas el pago da error. + +## Herramientas útiles + +| Herramienta | Uso | +|---|---| +| `python -m tools.consultar resumen / descargadas / integradas / errores / orden --name "#8064"` | estado de la BD (solo lectura, hora local; `APP_TIMEZONE`) | +| `python -m tools.integrar_ordenes [--apply]` | dry-run o integración de órdenes puntuales | +| `python -m tools.probar_pago ` | payload del pago sobre la Invoice real | +| `python -m tools.descargar_ordenes_especiales ` | guarda casos especiales en `download/` | +| `python -m tools.entrada_mercancia_ordenes --warehouse ... --orders ...` | stock para los SKUs de órdenes puntuales | diff --git a/docs/configuracion_tienda.md b/docs/configuracion_tienda.md new file mode 100644 index 0000000..57b2074 --- /dev/null +++ b/docs/configuracion_tienda.md @@ -0,0 +1,198 @@ +# Guía de configuración de tienda + +Cada tienda Shopify que se integra vive en su propio archivo +`config/stores/.json`. `pipeline/runner.py::cargar_tiendas()` +lee **todos** los `.json` de esa carpeta (ignorando los que empiezan con +`_`, como `_example.json`) y corre el pipeline completo para cada uno en +cada ciclo. Agregar una tienda nueva es tan simple como agregar un archivo +más — no requiere tocar código. + +El nombre del archivo no lo usa el código (el loader lee cualquier +`*.json`), pero por claridad debe coincidir con el `store_code` de adentro, +en minúsculas (ej. `snobbycl.json`, `on-chile-distributor-prod.json`). + +## Plantilla + +Usa `config/stores/_example.json` como punto de partida (no se carga en el +pipeline por el `_` inicial): + +```json +{ + "store_code": "TIENDA1", + "shopify": { + "api_id": "TODO", + "store": "TODO-nombre-de-la-tienda-myshopify", + "api_version": "2026-07", + "access_token": "${TIENDA1_SHOPIFY_ACCESS_TOKEN}", + "page_limit": 50, + "initial_since_id": "TODO-id-de-orden-a-partir-del-cual-empezar-a-integrar-la-primera-vez" + }, + "sap_connection": { + "url": "https://TODO-servidor:50000/b1s/v1", + "company_db": "TODO-SBO_COMPANYDB", + "username": "TODO", + "password": "${TIENDA1_SAP_PASSWORD}", + "timeout": 15, + "verify_ssl": false + }, + "sap": { + "default_warehouse_invoice": "TODO-bodega-virtual-de-esta-tienda", + "generic_card_code": "TODO-CardCode-generico-si-el-RUT-no-es-valido", + "numbering_series": { + "INVOICE": null, + "INCOMING_PAYMENT": null, + "INVENTORY_TRANSFER_REQUEST": null, + "STOCK_TRANSFER": null, + "DELIVERY": null + }, + "locations": { + "TODO-location_id-de-shopify": { "is_store": true, "warehouse_code": "TODO-bodega-SAP-de-esa-tienda-fisica" }, + "TODO-location_id-del-CD": { "is_store": false, "warehouse_code": "TODO-bodega-SAP-del-CD" } + } + }, + "timeout_days": 30, + "max_orders_per_cycle": 50 +} +``` + +--- + +## Campo por campo + +### `store_code` + +Identificador corto de la tienda. Se usa en `orders.store_code`, en los +logs, en `NumAtCard`/UDFs (`U_ShopifyStore`), y para diferenciar tiendas que +comparten la misma base SAP. + +### `shopify` + +| Campo | Descripción | +|---|---| +| `api_id` | Usuario para autenticación HTTP Basic contra la Admin API (junto con `access_token`) | +| `store` | Subdominio de `myshopify.com` (sin el `.myshopify.com`) | +| `api_version` | Versión de la Admin API, ej. `"2026-07"` | +| `access_token` | Token de acceso de la app privada/custom de esa tienda. **No se escribe en el JSON**: va como `${TIENDA_SHOPIFY_ACCESS_TOKEN}` y el valor real en `.env` (ver *Secretos*). **Requiere el scope `read_orders` aprobado** (si no, la descarga de cabeceras falla con 403 `"This action requires merchant approval"`) | +| `page_limit` | Tope de cabeceras por página al descargar (`GET orders.json?limit=...`). No limita cuántas se **procesan** por ciclo — eso lo controla `max_orders_per_cycle` | +| `initial_since_id` | **Piso** del cursor de descarga: cada ciclo baja desde `max(id máximo guardado en la BD, initial_since_id)`. Evita traer el historial completo y permite saltar órdenes anteriores aunque haya cabeceras más antiguas en la BD (en on-chile, `8103221690695` = último id del 31/08/2026). Las órdenes con id menor nunca se descargan por el ciclo normal; para cargarlas hay que insertar sus cabeceras a mano | + +### `sap_connection` + +Credenciales de SAP Service Layer para esta tienda. Cada tienda puede +apuntar a una base SAP distinta (`SAPServiceLayerClient` se instancia una +vez por tienda, con su propia sesión). + +| Campo | Descripción | +|---|---| +| `url` | URL base de Service Layer, ej. `https://servidor:50000/b1s/v1` | +| `company_db` | Nombre de la base de datos SAP (`CompanyDB` del login) | +| `username` / `password` | Usuario de SAP (ej. `manager`). La contraseña va como `${TIENDA_SAP_PASSWORD}` y el valor real en `.env` | +| `timeout` | Timeout en segundos para las llamadas HTTP a SAP | +| `verify_ssl` | Si se valida el certificado TLS del servidor SAP (`false` es común con certificados autofirmados internos) | + +### `sap` + +| Campo | Descripción | +|---|---| +| `default_warehouse_invoice` | Bodega virtual fija de esta tienda, usada en **todas** las líneas del Invoice (no la bodega real de la tienda/CD) | +| `generic_card_code` | `CardCode` a usar cuando no hay RUT válido, o no se pudo crear el `BusinessPartner` | +| `numbering_series` | Serie SAP (`Series`) para cada tipo de documento: `INVOICE`, `INCOMING_PAYMENT`, `INVENTORY_TRANSFER_REQUEST`, `STOCK_TRANSFER`, `DELIVERY`. Se consiguen desde SAP Business One → Administración → Definición de numeración de documentos | +| `payment_methods` | Mapeo del nombre **exacto** del gateway de Shopify (`transactions[].gateway`) a su forma de pago en SAP. Ver la sección *payment_methods* más abajo | +| `locations` | Mapeo `location_id` de Shopify → `{is_store, warehouse_code}`. Define si ese `location_id` es una tienda física (genera `StockTransfer` + `Delivery`) o un CD (solo `InventoryTransferRequest`), y a qué bodega SAP corresponde. El campo opcional `"name"` es solo referencia legible, no lo usa el código | + +**Cómo obtener los `location_id`**: `GET +/admin/api/{version}/locations.json` contra esa tienda (hay un ejemplo +armado en `tools/shopify.http` → `obtenerLocations`). + +### `timeout_days` + +Días desde `shopify_created_at` antes de marcar una orden como `timeout` +(estado terminal, deja de reintentarse) si no llegó a `integrated`. + +### `max_orders_per_cycle` + +Tope de órdenes pendientes que se procesan por ciclo, para esta tienda. Ver +`docs/analisis_tiempos.md` para la estimación de capacidad — con el tiempo +real medido (~4-5 s/orden), el valor recomendado por defecto es `50`. + +--- + +## Pasos para agregar una tienda nueva + +1. Crear la app privada/custom en Shopify para esa tienda y conseguir + `api_id` + `access_token`. Confirmar que el scope `read_orders` esté + **aprobado** por el comerciante (si no, la descarga de cabeceras falla + con 403). +2. Conseguir los datos de conexión a la base SAP que corresponda + (`sap_connection`). +3. Consultar `GET locations.json` de esa tienda en Shopify y armar el mapeo + `sap.locations` (cuáles son tiendas físicas, cuáles CD, y su bodega SAP). +4. Confirmar/crear en SAP: la bodega virtual (`default_warehouse_invoice`), + el `CardCode` genérico, y las series de numeración de cada tipo de + documento. +5. Copiar `_example.json` a `config/stores/.json` y completar + todos los campos. +6. Ubicar el `initial_since_id` (desde qué orden se quiere empezar a + integrar) y ponerlo en `shopify.initial_since_id`. +7. Confirmar que los UDFs `U_ShopifyOrderId` / `U_ShopifyStore` existan en + las tablas de SAP que va a usar el pipeline (`OINV`, ITR, StockTransfer, + DeliveryNotes) — si no, hay que crearlos en SAP antes de la primera + corrida real. +8. Correr un ciclo (`ejecutar_ciclo`) y revisar `notifications/` / + `logs/events.jsonl` por si aparece algún error de configuración. + +## Nota sobre secretos + +Cada archivo de tienda tiene credenciales reales (token de Shopify, usuario +y clave de SAP). El proyecto todavía no es un repositorio git — cuando se +inicialice, hay que agregar `config/stores/*.json` (excepto `_example.json`) +al `.gitignore` para no subir secretos por accidente. + +--- + +## `payment_methods` (dentro de `sap`) + +La clave es el nombre **exacto** del gateway tal como llega en `transactions[].gateway` de +Shopify (no el de `payment_gateway_names`), sensible a mayúsculas. Si un pago usa un gateway +que no está aquí, el pago **no se crea** (documento en `error`, con el mensaje). + +```json +"payment_methods": { + "Transbank Webpay": { "type": "credit_card", "credit_card": 4, "split_payment": true, "num_of_payments": 1 }, + "Transbank": { "type": "credit_card", "credit_card": 4, "split_payment": true, "num_of_payments": 1 }, + "reversso": { "type": "transfer", "transfer_account": "1-1-010-20-000" } +} +``` + +| Campo | Descripción | +|---|---| +| `type` | `credit_card` (línea en `PaymentCreditCards`) o `transfer` (`TransferAccount`/`TransferSum`). Otro valor da error | +| `credit_card` | Código de la tarjeta en SAP (`CreditCards`); cada medio tiene la suya, con su cuenta contable. Obligatorio si `type = credit_card` | +| `split_payment` | `SplitPayments` de la línea de tarjeta (`tYES`/`tNO`) | +| `num_of_payments` | `NumOfPayments` (cuotas) | +| `transfer_account` | Cuenta contable de la transferencia. Obligatorio si `type = transfer`. Solo una cuenta distinta por pago | +| `payment_method_code` | Opcional: `PaymentMethodCode` de SAP para la línea de tarjeta (hoy no se usa) | + +Un mismo medio puede aparecer con variantes de nombre (ej. `Venti` y `VentiPay`). Datos fijos de la línea +de tarjeta (en `domain/payment.py`): últimos dígitos `1234` y vencimiento `2030-12-31`, `VoucherNum` = `name` de la orden. +Para descubrir los nombres reales: `python -m tools.descargar_ordenes_especiales ` o revisar +`transactions.json` de una orden. Casos y ejemplos: `docs/casos_especiales.md`. + +--- + +## Secretos (`.env`) + +Los tokens y contraseñas **no se guardan en el JSON**. Se escriben como `${NOMBRE}` y el valor real va en el +archivo `.env` de la raíz (o en variables de entorno del equipo/servicio): + +``` +ON_CHILE_SHOPIFY_ACCESS_TOKEN=shpat_... +ON_CHILE_SAP_PASSWORD=... +``` + +- `core/config.py::cargar_config_tienda()` reemplaza cada `${NOMBRE}` al leer el JSON (desde el entorno o `.env`; + el entorno tiene prioridad). Si falta alguna variable, falla con un mensaje claro (`ConfigError`). +- Los valores escritos directamente en el JSON siguen funcionando. +- `.env` está en `.gitignore`; `.env.example` lista las variables necesarias. Una pareja + `_SHOPIFY_ACCESS_TOKEN` / `_SAP_PASSWORD` por tienda. +- Todas las herramientas de `tools/` leen la configuración con este mismo cargador. diff --git a/docs/esquema_base_datos.md b/docs/esquema_base_datos.md new file mode 100644 index 0000000..bab32ad --- /dev/null +++ b/docs/esquema_base_datos.md @@ -0,0 +1,169 @@ +# Esquema de base de datos (SQLite) + +Archivo: `data/shopify_sap.db`. Definido y accedido desde `core/db.py`. Tres +tablas: `orders` (cabecera), `order_fulfillments` (un fulfillment de +Shopify) y `order_documents` (un documento SAP requerido/creado). + +```mermaid +erDiagram + orders ||--o{ order_fulfillments : "tiene" + orders ||--o{ order_documents : "tiene" + order_fulfillments ||--o{ order_documents : "tiene (si aplica)" + + orders { + int sys_id PK + string store_code + int shopify_order_id + string order_number + string tags + string payment_gateway_names + string shopify_created_at + string time_insert + string time_update + string financial_status + string fulfillment_status + string card_code + string status + string last_error + } + order_fulfillments { + int sys_id PK + int order_sys_id FK + int shopify_fulfillment_id + string location_id + bool is_store + string warehouse_code + string time_insert + } + order_documents { + int sys_id PK + int order_sys_id FK + int fulfillment_sys_id FK + string doc_type + int sequence + bool required + string status + int sap_doc_entry + int sap_doc_num + int base_doc_entry + string error_message + int attempts + string time_update + } +``` + +--- + +## `orders` + +Una fila por orden de Shopify (`UNIQUE(store_code, shopify_order_id)`, así +el mismo id de Shopify puede repetirse entre tiendas distintas). + +| Columna | Tipo | Descripción | +|---|---|---| +| `sys_id` | INTEGER PK | id interno, autoincremental | +| `store_code` | TEXT | qué tienda (`config/stores/.json`) | +| `shopify_order_id` | INTEGER | `id` de Shopify | +| `order_number` | TEXT | campo `name` de Shopify (ej. `"#7863"`) | +| `tags` | TEXT | tal cual viene de Shopify, separado por coma | +| `payment_gateway_names` | TEXT | medio(s) de pago, unidos con `" + "` si hay más de uno | +| `shopify_created_at` | TEXT | fecha de creación real en Shopify — dispara el `timeout` | +| `time_insert` | TEXT | cuándo se descargó/insertó por primera vez (auditoría) | +| `time_update` | TEXT | última vez que se tocó esta orden — define el orden de la cola (ver `status`) | +| `financial_status` | TEXT | último valor conocido de Shopify (puede cambiar entre corridas) | +| `fulfillment_status` | TEXT | ídem — `null`/`partial`/`fulfilled` | +| `card_code` | TEXT | `CardCode` de SAP ya resuelto (se cachea, no se vuelve a resolver) | +| `status` | TEXT | ver tabla de estados abajo | +| `last_error` | TEXT | último mensaje de error (ya diagnosticado, no el crudo de SAP si se reconoció) | + +### Valores de `status` + +| Valor | Terminal | Significado | +|---|---|---| +| `pending` | No | Cabecera descargada, aún no analizada | +| `not_eligible` | No | No está `paid`, o no tiene `fulfillments` todavía | +| `in_progress` | No | Tiene al menos un fulfillment procesado, pero `fulfillment_status != "fulfilled"` (pueden llegar más) | +| `error` | No | El último intento falló en algún documento; se reintenta en la próxima rotación | +| `integrated` | **Sí** | Todos los documentos requeridos están `created` y `fulfillment_status == "fulfilled"` | +| `timeout` | **Sí** | Pasaron más de `timeout_days` desde `shopify_created_at` sin llegar a `integrated` | +| `cancelled` | **Sí** | La orden tiene `cancelled_at` en Shopify (anulada). El pipeline la marca solo, con la fecha y el `cancel_reason` en `last_error`; nunca crea documentos en SAP | +| `ignored` | **Sí** | Marcada a mano para que el pipeline no la procese nunca (ej. pedidos de prueba). No se asigna automáticamente | + +Solo `integrated`, `timeout`, `ignored` y `cancelled` son terminales — +`get_pending_orders` y `mark_orders_timeout` excluyen esos cuatro, todo lo demás +se sigue reintentando. + +--- + +## `order_fulfillments` + +Una fila por `fulfillment` de Shopify (`UNIQUE(order_sys_id, +shopify_fulfillment_id)`). Una orden puede tener varios (envío parcial). + +| Columna | Tipo | Descripción | +|---|---|---| +| `sys_id` | INTEGER PK | | +| `order_sys_id` | FK → `orders.sys_id` | | +| `shopify_fulfillment_id` | INTEGER | `id` del fulfillment en Shopify | +| `location_id` | TEXT | `location_id` de Shopify de ese fulfillment | +| `is_store` | INTEGER (bool) | snapshot de `config.sap.locations[location_id].is_store` al momento de procesarlo | +| `warehouse_code` | TEXT | bodega SAP resuelta para ese `location_id` | +| `time_insert` | TEXT | cuándo se detectó este fulfillment | + +--- + +## `order_documents` + +Una fila por documento SAP requerido (exista o no todavía). Documentos a +nivel orden (`INVOICE`, `INCOMING_PAYMENT`) tienen `fulfillment_sys_id = +NULL`; los de nivel fulfillment (`INVENTORY_TRANSFER_REQUEST`, +`STOCK_TRANSFER`, `DELIVERY`) lo tienen seteado. + +| Columna | Tipo | Descripción | +|---|---|---| +| `sys_id` | INTEGER PK | | +| `order_sys_id` | FK → `orders.sys_id` | | +| `fulfillment_sys_id` | FK → `order_fulfillments.sys_id`, nullable | `NULL` para documentos de orden | +| `doc_type` | TEXT | `INVOICE`, `INCOMING_PAYMENT`, `INVENTORY_TRANSFER_REQUEST`, `STOCK_TRANSFER`, `DELIVERY` | +| `sequence` | INTEGER | orden de creación (10/20/30/40/50 — incrementos de 10 para insertar tipos nuevos sin reordenar) | +| `required` | INTEGER (bool) | si este documento aplica para esta orden/fulfillment | +| `status` | TEXT | `pending` / `created` / `error` | +| `sap_doc_entry` / `sap_doc_num` | INTEGER | identificadores reales en SAP, una vez creado | +| `base_doc_entry` | INTEGER | *(reservado, no usado todavía — el `BaseEntry` real se arma al vuelo en `domain/*` a partir del documento base)* | +| `error_message` | TEXT | mensaje ya diagnosticado (ver `domain/documents.py::diagnosticar_error`) | +| `attempts` | INTEGER | cuántas veces falló | +| `time_update` | TEXT | última vez que se tocó esta fila | + +### Valores de `status` + +| Valor | Significado | +|---|---| +| `pending` | Fila creada (el documento es requerido), todavía no se intentó | +| `created` | Ya existe en SAP (`sap_doc_entry`/`sap_doc_num` poblados) — en un reintento, se relee con `GET` en vez de volver a crear | +| `error` | Falló el último intento — se reintenta en la próxima corrida | + +--- + +## Funciones de `core/db.py` por tabla + +**`orders`**: `insert_order_header`, `get_last_shopify_order_id`, +`get_order_sys_id`, `mark_orders_timeout`, `get_pending_orders`, +`set_order_status`, `set_order_card_code`, `update_order_snapshot`. + +**`order_fulfillments`**: `ensure_order_fulfillment` (insert-si-no-existe, +por `shopify_fulfillment_id`). + +**`order_documents`**: `ensure_order_document` (insert-si-no-existe, por +`doc_type` + `fulfillment_sys_id`), `get_order_document`, +`mark_document_created`, `mark_document_error`. + +--- + +## Nota: precisión de `time_update` + +Todos los `UPDATE`/`DEFAULT` de timestamp usan +`strftime('%Y-%m-%d %H:%M:%f','now')` (milisegundos), no `datetime('now')` +(que solo tiene precisión de segundo). Esto es necesario para que la +rotación justa de `get_pending_orders` (`ORDER BY time_update ASC`) +funcione bien cuando el pipeline toca varias órdenes rápido dentro del +mismo segundo. diff --git a/docs/flujo_pipeline.md b/docs/flujo_pipeline.md new file mode 100644 index 0000000..3eb5c5d --- /dev/null +++ b/docs/flujo_pipeline.md @@ -0,0 +1,281 @@ +# Flujo del pipeline Shopify → SAP Business One + +Este documento describe, caso por caso, cómo `analizar_y_procesar_orden` +(`pipeline/runner.py`) procesa una orden. El pipeline evalúa condiciones en +orden y cada orden termina en uno de estos casos según cómo venga desde +Shopify. + +## Punto de entrada + +Cada ciclo (`ejecutar_ciclo`, disparado por el scheduler en `main.py`): + +1. `descargar_cabeceras_nuevas`: baja cabeceras nuevas desde Shopify + (`since_id` = último id guardado para esa tienda) y las guarda en `orders` + con `status = 'pending'`. +2. `obtener_ordenes_pendientes`: marca como `timeout` las vencidas (ver + Caso F) y devuelve las que no llegaron a un estado terminal, ordenadas + por `time_update ASC` (rotación justa — ver nota al final). +3. Por cada orden pendiente (hasta `max_orders_per_cycle`), se llama a + `analizar_y_procesar_orden`. + +### Diagrama general (color por caso) + +```mermaid +flowchart TD + Cycle["Inicio del ciclo:\nmark_orders_timeout\n(sobre TODAS las pendientes)"] -->|vencidas| CaseG["Caso G\ntimeout"] + Cycle -->|el resto| Start(["Por cada orden pendiente\n(analizar_y_procesar_orden)"]) + + Start --> ChkPaid{"¿paid AND\ntiene fulfillments?"} + ChkPaid -->|No| CaseA["Caso A\nnot_eligible"] + ChkPaid -->|Sí| ChkShip{"¿costo de\nenvío > 0?"} + ChkShip -->|Sí| CaseB["Caso B\nerror: envío\nno soportado"] + ChkShip -->|No| CaseC["Caso C\nResolver RUT\ny CardCode"] + CaseC --> DocInv["POST Invoices\n+ POST IncomingPayments"] + DocInv -->|error| CaseH["Caso H\nerror, se\nreintenta luego"] + DocInv -->|ok| LoopF{"¿Quedan\nfulfillments por\nprocesar?"} + LoopF -->|No| ChkMore{"¿fulfillment_status\n== fulfilled?"} + LoopF -->|Sí| ChkStore{"¿location_id\nes tienda?"} + ChkStore -->|Sí| CaseD["Caso D\nITR + StockTransfer\n+ Delivery"] + ChkStore -->|No| CaseE["Caso E\nSolo ITR"] + CaseD -->|ok, siguiente| LoopF + CaseE -->|ok, siguiente| LoopF + CaseD -->|error| CaseH + CaseE -->|error| CaseH + ChkMore -->|No, esperando más| CaseF["Caso F\nin_progress"] + ChkMore -->|Sí| Done["integrated"] + + classDef caseA fill:#fde68a,stroke:#92400e,color:#000 + classDef caseB fill:#fca5a5,stroke:#991b1b,color:#000 + classDef caseC fill:#bfdbfe,stroke:#1e3a8a,color:#000 + classDef caseD fill:#86efac,stroke:#14532d,color:#000 + classDef caseE fill:#5eead4,stroke:#134e4a,color:#000 + classDef caseF fill:#d8b4fe,stroke:#581c87,color:#000 + classDef caseG fill:#d1d5db,stroke:#1f2937,color:#000 + classDef caseH fill:#fdba74,stroke:#7c2d12,color:#000 + classDef terminal fill:#4ade80,stroke:#14532d,color:#000 + + class CaseA caseA + class CaseB caseB + class CaseC caseC + class CaseD caseD + class CaseE caseE + class CaseF caseF + class CaseG caseG + class CaseH caseH + class Done terminal +``` + +*Nota: `LoopF` representa el `for fulfillment in detalle["fulfillments"]` del código — se +evalúa una vez por cada fulfillment de la orden, y `ChkMore` (¿ya no quedan?) se +resuelve recién cuando termina de recorrerlos todos.* + +--- + +## Caso A — Orden no elegible + +**Filtro:** `financial_status != "paid"` **o** `fulfillments` vacío. + +**Resultado:** `orders.status = "not_eligible"`. No se crea ningún documento +en SAP. La orden se vuelve a revisar en el próximo ciclo (por si cambia de +estado en Shopify), hasta que cumpla la condición o llegue al timeout +(Caso F). + +--- + +## Caso B — Costo de envío no soportado + +**Filtro:** `total_shipping_price_set.shop_money.amount > 0`. + +**Resultado:** `orders.status = "error"`, con `last_error` explicando que el +mapeo de envío aún no está soportado. No se crea ningún documento. Queda +pendiente definir cómo reflejar el flete en el Invoice. + +--- + +## Caso C — Resolución de cliente (sub-flujo, aplica a todos los casos D/E) + +Antes de crear la Invoice, se resuelve el `CardCode`: + +1. **Buscar RUT** (`buscar_rut_en_order`): solo en `note_attributes` de la + orden (clave que contenga "rut", validado con módulo 11). No se consultan + los metafields del customer. +2. **Sin RUT válido** → se usa el `CardCode` genérico de la tienda + (`sap.generic_card_code`). +3. **Con RUT válido** → se busca `BusinessPartner` en SAP por `FederalTaxID` + (formato `NNNNNNNN-D`): + - Si existe → se usa ese `CardCode`. + - Si no existe → se **crea** un `BusinessPartner` nuevo (`CardCode = "C" + + RUT`, `CardType: cCustomer`, `GroupCode: 100`, con `BPAddresses` + Bill-To/Ship-To). Si la creación falla, se cae al genérico. +4. En cualquier caso donde haya RUT válido, se guarda también en formato SAP + (`rut_sap`) para sobrescribir `FederalTaxID` en la Invoice — así el RUT + real del cliente queda en el documento aunque se use el `CardCode` + genérico. + +El `CardCode` resuelto se guarda en `orders.card_code` y no se vuelve a +resolver en corridas posteriores de la misma orden. + +--- + +## Caso D — Fulfillment por tienda física (`is_store: true`) + +**Filtro:** el `location_id` del fulfillment está mapeado en +`config/stores/.json → sap.locations` con `is_store: true`. + +**Secuencia de documentos** (una Invoice/Payment por orden, el resto por +cada fulfillment): + +| Orden | Documento | Base de datos previa | Notas | +|---|---|---|---| +| 1 | `Invoices` (`ReserveInvoice: tYES`) | — | `CardCode`/`CardName`/`FederalTaxID`/direcciones sobrescritas con datos reales del cliente. Bodega = `default_warehouse_invoice`. | +| 2 | `IncomingPayments` | Invoice | Aplicado a la Invoice (`PaymentInvoices`). `TransferAccount` fijo, `Remarks` = medio(s) de pago de Shopify. | +| 3 | `InventoryTransferRequests` | — | Una por fulfillment. Bodega origen = la del `location_id` (bodega real); destino = `default_warehouse_invoice` (virtual). | +| 4 | `StockTransfers` | ITR (`BaseType/BaseEntry/BaseLine`) | Mismo sentido que la ITR que satisface. | +| 5 | `DeliveryNotes` | Invoice (`BaseType/BaseEntry/BaseLine`) | Solo entrega los ítems de ese fulfillment (line items del StockTransfer, no toda la orden). | + +Cada documento creado a partir de la Invoice (ITR, StockTransfer, Delivery) +se enlaza de vuelta a ella vía `DocumentReferences` (`PATCH Invoices`). + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#86efac', 'primaryBorderColor': '#14532d', 'primaryTextColor': '#000', 'actorBkg': '#86efac', 'actorBorder': '#14532d', 'signalColor': '#14532d', 'signalTextColor': '#000'}}}%% +sequenceDiagram + participant P as Pipeline + participant S as Shopify + participant SAP as SAP Service Layer + + P->>S: GET orders/{id}.json + opt hay RUT válido (note_attributes) + P->>SAP: GET BusinessPartners (por RUT) + opt no existe el BP + P->>SAP: POST BusinessPartners + end + end + P->>SAP: POST Invoices + P->>SAP: POST IncomingPayments + loop por cada fulfillment de tienda + P->>SAP: POST InventoryTransferRequests + P->>SAP: GET Invoices / PATCH Invoices (ref. ITR) + P->>SAP: GET InventoryTransferRequests (líneas) + P->>SAP: POST StockTransfers + P->>SAP: GET Invoices / PATCH Invoices (ref. StockTransfer) + P->>SAP: GET StockTransfers (líneas) + P->>SAP: POST DeliveryNotes + P->>SAP: GET Invoices / PATCH Invoices (ref. Delivery) + end +``` + +**Resultado final:** si todos los documentos de todos los fulfillments se +crean bien y `fulfillment_status == "fulfilled"` → `status = "integrated"`. +Si falta algún fulfillment por llegar → `status = "in_progress"` (ver Caso +E). Si algo falla → `status = "error"` (ver Caso G). + +--- + +## Caso E — Fulfillment por centro de distribución (`is_store: false`) + +Mismo flujo que el Caso D, pero se **detiene después de la ITR**: no se +crea `StockTransfer` ni `DeliveryNotes` para ese fulfillment. + +| Orden | Documento | +|---|---| +| 1 | `Invoices` | +| 2 | `IncomingPayments` | +| 3 | `InventoryTransferRequests` | + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#5eead4', 'primaryBorderColor': '#134e4a', 'primaryTextColor': '#000', 'actorBkg': '#5eead4', 'actorBorder': '#134e4a', 'signalColor': '#134e4a', 'signalTextColor': '#000'}}}%% +sequenceDiagram + participant P as Pipeline + participant SAP as SAP Service Layer + + P->>SAP: POST Invoices + P->>SAP: POST IncomingPayments + loop por cada fulfillment de CD + P->>SAP: POST InventoryTransferRequests + P->>SAP: GET Invoices / PATCH Invoices (ref. ITR) + end +``` + +--- + +## Caso F — Multi-fulfillment / envío parcial + +Una orden puede tener varios `fulfillments` a lo largo del tiempo (envío +parcial). Cada corrida: + +1. Vuelve a descargar el detalle completo de la orden. +2. Recorre **todos** los fulfillments que trae Shopify en ese momento. +3. Para cada uno, `ensure_order_fulfillment`/`ensure_order_document` crean + la fila solo si no existe — los fulfillments/documentos ya procesados en + corridas anteriores se detectan (`status == "created"`) y se saltan (se + hace `GET` en vez de `POST`), nunca se duplican. +4. Si aparece un fulfillment nuevo que antes no estaba, se procesa como + Caso D o E según su `location_id`, sin tocar lo ya integrado. + +**Resultado:** la orden queda en `in_progress` mientras +`fulfillment_status != "fulfilled"`, y pasa a `integrated` recién cuando +Shopify indica que ya no se esperan más fulfillments. + +--- + +## Caso G — Timeout + +**Filtro** (`mark_orders_timeout`, se corre al inicio de cada ciclo): la +orden no llegó a `integrated`, y pasaron más de `timeout_days` (config por +tienda) desde `shopify_created_at`. + +**Resultado:** `orders.status = "timeout"` (estado terminal, no se vuelve a +procesar). + +--- + +## Caso H — Error y reintento + +Cualquier documento puede fallar (SAP rechaza el POST). Al fallar: + +1. `mark_document_error` marca esa fila de `order_documents` como `error` + (guarda el mensaje) y `orders.status = "error"` con ese mismo mensaje. +2. Antes de relanzar el error, `domain/documents.py::diagnosticar_error` + intenta reconocer la causa real y devolver un mensaje claro: + - **Tipo de cambio faltante**: "No hay tipo de cambio cargado en SAP + para la moneda X". + - **ItemCode inexistente**: revisa cada SKU contra SAP y dice cuál(es) + no existen, en vez del mensaje genérico de SAP. + - Cualquier otro error: se deja el mensaje original de SAP tal cual. +3. La orden sigue apareciendo en `obtener_ordenes_pendientes` (no es un + estado terminal) y se reintenta en una corrida futura. +4. Al reintentar, cada documento que ya estaba `created` se **reutiliza** + (se lee de SAP, no se vuelve a crear) — solo se reintenta lo que falló. +5. Los errores quedan disponibles para revisión en + `core/notifications.py::generar_reporte_errores_html()` (HTML guardado + en `notifications/`, el envío por mail queda pendiente para más + adelante). + +--- + +## Nota: rotación justa entre reintentos + +`obtener_ordenes_pendientes` ordena por `time_update ASC`: la orden que +hace más tiempo no se toca va primero. Cada vez que se procesa una orden +(éxito o error) se actualiza su `time_update`, así que pasa al final de la +fila. Esto evita que una orden que falla repetido monopolice el cupo de +`max_orders_per_cycle` y bloquee a las órdenes nuevas. + +--- + +## Reglas vigentes que amplían este flujo + +Este documento describe el flujo base. Las reglas siguientes lo complementan (detalle y órdenes de ejemplo en +`docs/casos_especiales.md`): + +- **Elegibilidad:** `cancelled_at` no vacío → `cancelled` (terminal, nunca se integra). Se integran las órdenes + `paid`, `partially_refunded` y `refunded` con fulfillments; el reembolso es un tratamiento interno de comercial y no + se refleja en SAP. `pending`/`voided` y las órdenes sin despachar siguen como `not_eligible`. +- **Montos:** la Invoice iguala lo realmente cobrado (transacciones `success` de tipo `sale`/`capture`); se elige + entre líneas originales y vigentes, y los fulfillments se limitan a lo facturado. +- **Pago:** por transacción efectiva, con la tarjeta o cuenta de `sap.payment_methods`. +- **Idempotencia contra SAP:** antes de crear Invoice, ITR, StockTransfer o Delivery se busca un documento ya + existente para la orden (`U_ShopifyOrderId` + `U_ShopifyStore`, y mismas líneas); si existe se adopta y no se + duplica. Una Invoice ya pagada en SAP no genera otro pago. +- **Red:** `ShopifyClient` reintenta hasta 4 veces ante fallos de red (SSL, conexión, timeout), con espera creciente. +- **Cursor:** `max(id máximo en BD, initial_since_id)`. diff --git a/docs/mapeo_campos.md b/docs/mapeo_campos.md new file mode 100644 index 0000000..202c055 --- /dev/null +++ b/docs/mapeo_campos.md @@ -0,0 +1,235 @@ +# Mapeo de campos Shopify → SAP Business One + +Referencia de cómo se arma cada documento SAP a partir del JSON de una orden +de Shopify. Los constructores viven en `domain/*.py`. + +## RUT del cliente (`domain/business_partner.py`) + +| Prioridad | Fuente | Detalle | +|---|---|---| +| 1 | `order.note_attributes` | Se busca una entrada cuyo `name` contenga "rut" (sin distinguir mayúsculas). Se valida el `value` con el formato y el dígito verificador (módulo 11). | +| 2 (respaldo) | Metafields del `customer` (Shopify) | Solo si no apareció en `note_attributes`. Se recorre cada metafield buscando un `value` con formato de RUT válido. | + +- `normalizar_rut`: quita puntos/guión, mayúsculas (ej. `"123456785"`) — uso + interno para validar. +- `formatear_rut`: formato que usa SAP, `"NNNNNNNN-D"` (ej. `"12345678-5"`) + — se usa en `FederalTaxID`, `CardCode` y el filtro de búsqueda en SAP. + +## Resolución de CardCode (`resolver_card_code`) + +| Caso | CardCode usado | +|---|---| +| Sin RUT válido | `sap.generic_card_code` (config de la tienda) | +| RUT válido, existe `BusinessPartner` con ese `FederalTaxID` | El `CardCode` de ese BP | +| RUT válido, no existe | Se **crea** un BP nuevo, `CardCode = "C" + rut_sap` | +| RUT válido, pero falla la creación del BP | Cae a `sap.generic_card_code` | + +En los tres primeros casos con RUT válido, `rut_sap` se guarda igual (para +`FederalTaxID` del Invoice), aunque termine usándose el `CardCode` genérico. + +--- + +## `BusinessPartners` (creación, `domain/business_partner.py`) + +| Campo SAP | Origen Shopify | Nota | +|---|---|---| +| `CardCode` | — | `"C" + rut_sap` | +| `CardName` | `billing_address` (nombre completo), si no `customer` | fallback `"Cliente Shopify"` | +| `CardType` | — | fijo `"cCustomer"` | +| `GroupCode` | — | fijo `100` ("Clientes", mismo grupo que el genérico) | +| `FederalTaxID` | — | `rut_sap` | +| `Phone1` | `billing_address.phone`, si no `customer.phone` | | +| `EmailAddress` | `order.email`, si no `order.contact_email` | | + +### `BPAddresses` (dos entradas) + +| Campo SAP | Origen Shopify | Nota | +|---|---|---| +| `AddressName` | — | `"BillTo"` / `"ShipTo"` | +| `AddressType` | — | `bo_BillTo` / `bo_ShipTo` | +| `Street` | `address1` | | +| `Block` | `address2` | | +| `City` | `city` | | +| `ZipCode` | `zip` | | +| `County` | `province` | texto libre, no se traduce a código de región | +| `Country` | `country_code` | solo si es `"CL"` | +| `TaxCode` | — | fijo `"IVA"`, solo en la dirección `bo_ShipTo` | + +`bo_BillTo` sale de `order.billing_address`; `bo_ShipTo` de +`order.shipping_address` (si no hay, usa `billing_address`). + +--- + +## `Invoices` (factura de reserva, `domain/invoice.py`) + +| Campo SAP | Origen Shopify | Nota | +|---|---|---| +| `CardCode` | — | resuelto (ver arriba) | +| `CardName` | `billing_address` (nombre completo), si no `customer` | fallback `"Cliente Shopify"` | +| `DocDate` / `DocDueDate` | `created_at` (solo fecha) | mismo valor en ambos | +| `Series` | — | `sap.numbering_series.INVOICE` (config tienda) | +| `DocType` | — | fijo `"dDocument_Items"` | +| `DocCurrency` | — | fijo `"CLP"` | +| `ReserveInvoice` | — | fijo `"tYES"` (factura de reserva, no releva stock al crearse) | +| `NumAtCard` | `order.name` | ej. `"#7863"`, para ubicarla fácil | +| `FederalTaxID` | `rut_sap` | solo si hay RUT válido; sobrescribe el del CardCode genérico | +| `U_ShopifyOrderId` | `order.id` | UDF | +| `U_ShopifyStore` | — | `store_code` de la config | + +### `AddressExtension` (prefijo `BillTo`/`ShipTo`) + +| Campo SAP | Origen Shopify | Nota | +|---|---|---| +| `{p}Street` | `address1` | | +| `{p}Block` | `address2` | | +| `{p}Building` | `phone` | reutilizado para guardar el teléfono | +| `{p}City` | `city` | | +| `{p}ZipCode` | `zip` | | +| `{p}County` | `province` | | +| `{p}Country` | `country_code` | solo si es `"CL"` | + +`BillTo*` sale de `billing_address`, `ShipTo*` de `shipping_address`. SAP +calcula `Address`/`Address2` (texto plano) solo a partir de estos campos — +no se arman a mano. + +### `DocumentLines` (una por `line_item` con `sku`) + +| Campo SAP | Origen Shopify | Nota | +|---|---|---| +| `ItemCode` | `sku` | | +| `Quantity` | `quantity` | | +| `WarehouseCode` | — | `sap.default_warehouse_invoice` (bodega virtual, fija por tienda) | +| `PriceAfterVAT` | `price` menos descuento, ver nota | precio unitario, IVA incluido | +| `TaxCode` | — | fijo `"IVA"` (hardcode, sin lista de precios) | + +**Cálculo de `PriceAfterVAT`**: `item.price` es el precio unitario **antes** +de descuento. El descuento real está en `discount_allocations` (el campo +`total_discount` no siempre lo refleja). Fórmula: + +``` +PriceAfterVAT = (price × quantity − Σ discount_allocations.amount) / quantity +``` + +--- + +## `IncomingPayments` (`domain/payment.py`) + +| Campo SAP | Origen | Nota | +|---|---|---| +| `CardCode` | `invoice.CardCode` | mismo cliente que la Invoice | +| `CardName` | `invoice.CardName` | | +| `DocType` | — | fijo `"rCustomer"` | +| `DocDate` | `invoice.DocDate` | misma fecha que la Invoice | +| `Series` | — | `sap.numbering_series.INCOMING_PAYMENT` | +| `DocCurrency` | — | fijo `"CLP"` | +| `PaymentCreditCards[]` | una línea por transacción efectiva con `type = credit_card` | `CreditCard` = `sap.payment_methods[gateway].credit_card`; `CreditCardNumber` fijo `"1234"`; `CardValidUntil` fijo `"2030-12-31"`; `VoucherNum` = `order.name` (máx. 20); `CreditSum` = `transaction.amount`; `CreditCur` `"CLP"`; `CreditType` `"cr_Regular"`; `NumOfPayments` y `SplitPayments` del config; `FirstPaymentDue` = `invoice.DocDate` | +| `TransferAccount` / `TransferSum` / `TransferDate` / `TransferReference` | solo si hay transacciones con `type = transfer` (reversso) | cuenta de `sap.payment_methods[gateway].transfer_account`; `TransferSum` = suma de esas transacciones; `TransferReference` = `order.name` | +| `Remarks` | gateways de las transacciones efectivas | join con `" + "`, sin repetir. **No** se usa `payment_gateway_names` (lista también los intentos fallidos) | +| `PaymentInvoices[0].DocEntry` | `invoice.DocEntry` | | +| `PaymentInvoices[0].InvoiceType` | — | fijo `"it_Invoice"` | +| `PaymentInvoices[0].SumApplied` | total de la Invoice | debe igualar la suma de las transacciones efectivas; si no, `error` y no se crea el pago. Ver `docs/casos_especiales.md` | + +--- + +## `InventoryTransferRequests` (`domain/inventory_transfer.py`) + +Una por cada `fulfillment` de la orden (tienda o CD). + +| Campo SAP | Origen | Nota | +|---|---|---| +| `CardCode` / `CardName` | `invoice.CardCode` / `invoice.CardName` | | +| `Comments` | `order.name` | | +| `DocDate` / `DueDate` | `order.created_at` (solo fecha) | | +| `Series` | — | `sap.numbering_series.INVENTORY_TRANSFER_REQUEST` | +| `FromWarehouse` | `sap.locations[location_id].warehouse_code` | bodega real de ese `location_id` | +| `ToWarehouse` | — | `sap.default_warehouse_invoice` (misma bodega virtual del Invoice) | +| `U_ShopifyOrderId` / `U_ShopifyStore` | `order.id` / `store_code` | | + +### `StockTransferLines` (una por `line_item` del fulfillment) + +| Campo SAP | Origen | Nota | +|---|---|---| +| `ItemCode` | `sku` del line_item del fulfillment | no toda la orden, solo lo de ese fulfillment | +| `Quantity` | `quantity` | | +| `FromWarehouseCode` | igual a `FromWarehouse` | | +| `WarehouseCode` | igual a `ToWarehouse` | | + +`location_id → {is_store, warehouse_code}` sale de +`config/stores/.json → sap.locations`. + +--- + +## `StockTransfers` (`domain/stock_transfer.py`, solo si `is_store: true`) + +Se arma a partir de la **ITR ya creada en SAP** (se relee completa para +tener `LineNum`). + +| Campo SAP | Origen | Nota | +|---|---|---| +| `CardCode` / `CardName` / `Comments` | copiados de la ITR | | +| `DocDate` | `itr.DocDate` | | +| `FromWarehouse` / `ToWarehouse` | copiados de la ITR | mismo sentido que la solicitud | +| `Series` | — | `sap.numbering_series.STOCK_TRANSFER` | +| `U_ShopifyOrderId` / `U_ShopifyStore` | copiados de la ITR | | + +### `StockTransferLines` (una por línea de la ITR) + +| Campo SAP | Origen | Nota | +|---|---|---| +| `ItemCode` / `Quantity` / `FromWarehouseCode` / `WarehouseCode` | copiados de la línea de la ITR | | +| `BaseType` | — | `1250000001` (`DocObjectCode` de `InventoryTransferRequests`) | +| `BaseEntry` | `itr.DocEntry` | | +| `BaseLine` | `line.LineNum` de la ITR | | + +--- + +## `DeliveryNotes` (`domain/delivery.py`, solo si `is_store: true`) + +Se arma a partir de la **Invoice** (base) y el **StockTransfer** ya creado +(para saber qué se transfirió realmente en este fulfillment). + +| Campo SAP | Origen | Nota | +|---|---|---| +| `CardCode` / `CardName` | `invoice.CardCode` / `invoice.CardName` | | +| `Comments` | `invoice.NumAtCard` | | +| `DocDate` | `invoice.DocDate` | | +| `Series` | — | `sap.numbering_series.DELIVERY` | +| `U_ShopifyOrderId` / `U_ShopifyStore` | copiados de la Invoice | | + +### `DocumentLines` (una por línea del StockTransfer, emparejada con la Invoice por `ItemCode`) + +| Campo SAP | Origen | Nota | +|---|---|---| +| `ItemCode` / `Quantity` | línea del StockTransfer | solo lo de este fulfillment, no toda la orden | +| `WarehouseCode` | línea de la Invoice con el mismo `ItemCode` | | +| `BaseType` | — | `13` (ObjType clásico de Sales Invoice, `oInvoices`) | +| `BaseEntry` | `invoice.DocEntry` | | +| `BaseLine` | `LineNum` de la línea de la Invoice | | + +--- + +## Referencias cruzadas en la Invoice (`agregar_referencia_documento`) + +Cada vez que se crea ITR, StockTransfer o Delivery, se hace `PATCH +Invoices` agregando una entrada a `DocumentReferences` (sin pisar las que ya +existan): + +| Documento creado | `RefObjType` | +|---|---| +| `InventoryTransferRequests` | `rot_InventoryTransferRequest` | +| `StockTransfers` | `rot_InventoryTransfer` | +| `DeliveryNotes` | `rot_DeliveryNotes` | + +--- + +## Campos fijos / hardcode (pendientes de hacer configurables si hace falta) + +| Campo | Valor | Dónde | +|---|---|---| +| `TaxCode` (líneas de Invoice) | `"IVA"` | `domain/invoice.py` | +| `TaxCode` (dirección ShipTo del BP) | `"IVA"` | `domain/business_partner.py` | +| `DocCurrency` | `"CLP"` | Invoice, Payment | +| Datos de tarjeta (Payment) | últimos dígitos `"1234"`, vencimiento `"2030-12-31"` | `domain/payment.py` (provisorios) | +| `GroupCode` (BusinessPartner) | `100` | `domain/business_partner.py` | +| `BaseType` ITR→StockTransfer | `1250000001` | `domain/stock_transfer.py` | +| `BaseType` Invoice→Delivery | `13` | `domain/delivery.py` | diff --git a/docs/pendientes.md b/docs/pendientes.md new file mode 100644 index 0000000..2135651 --- /dev/null +++ b/docs/pendientes.md @@ -0,0 +1,87 @@ +# Pendientes / roadmap + +Estado al 2026-09-30. Ver `docs/flujo_pipeline.md`, `docs/mapeo_campos.md` y +`docs/casos_especiales.md` para lo construido y probado contra SAP/Shopify reales +(ambas tiendas: snobbycl y on-chile-distributor-prod). + +## Antes de pasar al QA del cliente + +1. **Correr `main.py` solo** varios ciclos seguidos (el loop real del scheduler, no las + herramientas): lock, cursor, estados nuevos (`cancelled`, `ignored`), rotación de reintentos. +2. **Revisar la configuración de cada tienda contra el SAP de QA**: tarjetas de crédito + (`sap.payment_methods[*].credit_card`) y sus cuentas, series de numeración, bodegas + (`default_warehouse_invoice`, `locations`), `generic_card_code`, cuenta de reversso + (`1-1-010-20-000`), grupo de artículos (108), grupo de clientes (100), `TaxCode IVA`, + tipo de cambio USD cargado. +3. **Datos provisorios de tarjeta** (`domain/payment.py`): últimos dígitos `1234` y vencimiento + `2030-12-31`. Confirmar con contabilidad. +4. **Secretos**: ya están fuera del JSON (`.env` / variables de entorno). Falta definirlos en el + equipo de QA, y evaluar `verify_ssl: true` en `sap_connection`. +5. **Zona horaria**: el QA está en UTC-3 (America/Santiago) y el desarrollo en UTC-5. La BD guarda UTC y + `tools/consultar` muestra hora local (`APP_TIMEZONE`). **`main.py` usa la hora local del equipo** + para las ventanas de `scheduler.json`; hay que confirmar que el servidor de QA tenga su zona correcta + o fijar la zona en el scheduler. +6. **Limpieza de artefactos de prueba**: `download/`, respaldos de la BD en `data/`, + artículos y entradas de mercancía de prueba (precio 5000, cuenta `1-1-080-40-000`) de `SBODemoCL`. +7. **Repositorio git**: el proyecto **no es un repositorio** (no hay `.git`); el `.gitignore` ya existe. Falta + `git init`, primer commit y definir el remoto. Antes de subir, verificar que `git status` no incluya `.env`, + `data/*.db`, `logs/` ni `download/`. +8. ~~Fallback de órdenes manuales~~: retirado (carpeta `ordenes_manuales/` y `pipeline/manual_orders.py`); un error de + la API vuelve a verse como error de tienda. +9. **Gateways nuevos**: un gateway que no esté en `sap.payment_methods` solo se detecta al crear el pago (la + Invoice ya existe y la orden queda en `error`). Conviene validar los gateways de la orden **antes** de crear la + Invoice, o alertar apenas aparezca uno nuevo (hoy: `Venti`/`Transbank` en on-chile, `PayPal` configurado sin uso). +10. **Tests automáticos**: no hay carpeta de tests. Los JSON reales de `download/` sirven de datos para probar + `construir_invoice`, `construir_incoming_payment` y `fulfillments_facturados` sin tocar SAP. + +## Decisiones de negocio abiertas + +- **Pedidos de prueba**: hoy se marcan a mano como `ignored` (#8019). Definir si se detectan automáticamente (por + etiqueta, monto o cliente) para que no lleguen a SAP de QA. #8020 (1.000 CLP) se integró y podría ser de prueba. +- **Costo de envío**: toda orden con `total_shipping_price_set > 0` queda en `error` (no se definió cómo + reflejar el flete en la Invoice). En septiembre fue 1 de 1.160 órdenes en on-chile (#CL10169). +- **Reembolsos o anulaciones posteriores a integrar**: `integrated` es terminal; SAP queda con la venta + completa, sin nota de crédito ni devolución. Hoy el reembolso se trata como interno de comercial. +- **Envío real de la notificación de errores**: `generar_reporte_errores_html()` solo guarda el HTML en + `notifications/`. Falta definir a quién, con qué frecuencia y qué contenido. +- **Instalación como servicio de Windows**: falta empaquetarlo (ej. NSSM) en el equipo de destino. + +## Datos que quedaron abiertos en la BD de desarrollo + +- **on-chile — carga inicial**: 1.159 órdenes `pending` desde el 01/09 (1.049 elegibles, 73 sin despachar, 22 anuladas, + 15 con pago pendiente, 1 con envío). Artículos y stock ya creados en `SBODemoCL` y gateways configurados; falta + integrarlas (en tandas, deteniéndose ante el primer error) y revisar #CL10851 (volvió a entrar como `pending`). +- **snobbycl — #7863**: queda en `error` (Delivery: documento base cerrado) y se reintenta en cada ciclo sin resolverse. +- **snobbycl — #8055**: pagada, sin despachar; se integra sola al despacharse. +- **Clientes**: integrar las órdenes de on-chile crea ~977 BusinessPartners nuevos en SAP (casi todas traen RUT). + +## Operación + +- **Respaldo y retención** de la BD SQLite (`data/shopify_sap.db`) y de `logs/` (hoy solo hay rotación de logs). +- **`docs/analisis_tiempos.md`** mide ~4-5 s por orden; en las pruebas de hoy fue ~5 s (CD) a ~7 s (tienda, con 5 + documentos). Actualizar la estimación de capacidad (`max_orders_per_cycle`). + +## Pruebas pendientes + +- **Fulfillment parcial real** (`in_progress` que se completa en un ciclo posterior). Nunca se probó de punta + a punta con una orden real (hay órdenes `partial` en on-chile). +- **Desincronización SQLite/SAP**: la adopción de documentos existentes (ver abajo) está probada con la BD + bloqueada para escrituras; falta observarla en un ciclo real. +- **Volumen**: ~7 s por orden. La carga inicial de on-chile desde el 01/09 son ~1.150 órdenes (~2 horas). + +## Ya resuelto (no repetir) + +- Pipeline completo en ambas tiendas: Invoice, pago con tarjeta por transacción (incluye pagos divididos), + ITR, StockTransfer y Delivery; camino de tienda y de CD. +- Descarga real de cabeceras en ambas tiendas (scope `read_orders` aprobado en on-chile). +- **Idempotencia contra SAP**: antes de crear Invoice, ITR, StockTransfer o Delivery se busca un documento + existente de la orden (`U_ShopifyOrderId`, `U_ShopifyStore`, mismas líneas) y se adopta; una Invoice ya + pagada no genera otro pago. +- **Reintentos de red** en `ShopifyClient` (4 intentos con espera creciente). +- Invoice igual a lo cobrado (líneas canceladas o devueltas, reembolsos), fulfillments limitados a lo facturado, + órdenes anuladas (`cancelled`), pedidos ignorados (`ignored`), despachadas y luego reembolsadas se integran. +- Cursor con piso `initial_since_id`; corte de on-chile en el 01/09/2026. +- Secretos en `.env` (`core/config.py`), `.gitignore`, `requirements.txt` fijado. +- Fallback de órdenes manuales retirado (solo se usó mientras no se podía conectar a on-chile). +- Herramientas: `consultar`, `integrar_ordenes`, `probar_pago`, `descargar_ordenes_especiales`, + `entrada_mercancia_ordenes`, `cargar_items_faltantes`. diff --git a/domain/business_partner.py b/domain/business_partner.py new file mode 100644 index 0000000..0986ea6 --- /dev/null +++ b/domain/business_partner.py @@ -0,0 +1,117 @@ +import re +from typing import Any, Dict, Optional + +from core.errors import SAPServiceLayerError +from core.sap import SAPServiceLayerClient +from domain.rut import formatear_rut, normalizar_rut, validar_rut + +RUT_PATTERN = re.compile(r"^\d{1,2}\.?\d{3}\.?\d{3}-?[\dkK]$") +BUSINESS_PARTNER_GROUP_CODE = 100 # "Clientes", mismo grupo que usa el CardCode genérico + + +def _nombre_completo(persona: Dict[str, Any]) -> str: + nombre = f"{persona.get('first_name', '') or ''} {persona.get('last_name', '') or ''}".strip() + return nombre or persona.get("name", "") or "" + + +def buscar_rut_en_order(order: Dict[str, Any]) -> Optional[str]: + """ + Busca el RUT en note_attributes de la orden, donde algunas tiendas lo + capturan en el checkout (ej. {"name": "rut", "value": "11111109-K"}). + No requiere llamada extra a Shopify: ya viene en el detalle de la orden. + """ + for attr in order.get("note_attributes") or []: + nombre = str(attr.get("name", "")).strip().lower() + if "rut" not in nombre: + continue + valor = str(attr.get("value", "")).strip() + if RUT_PATTERN.match(valor) and validar_rut(valor): + return normalizar_rut(valor) + + return None + + +def _bp_address(tipo: str, nombre: str, direccion: Dict[str, Any]) -> Dict[str, Any]: + address = { + "AddressName": nombre, + "AddressType": tipo, + "Street": direccion.get("address1"), + "Block": direccion.get("address2"), + "City": direccion.get("city"), + "ZipCode": direccion.get("zip"), + "County": direccion.get("province"), + } + if direccion.get("country_code") == "CL": + address["Country"] = "CL" + if tipo == "bo_ShipTo": + address["TaxCode"] = "IVA" + return address + + +def crear_business_partner(sap: SAPServiceLayerClient, rut_sap: str, order: Dict[str, Any]) -> str: + """ + Crea un BusinessPartner (cliente) nuevo para este RUT. CardCode = "C" + + rut_sap. Devuelve el CardCode creado. + """ + billing = order.get("billing_address") or {} + shipping = order.get("shipping_address") or {} + customer = order.get("customer") or {} + card_code = f"C{rut_sap}" + + payload = { + "CardCode": card_code, + "CardName": _nombre_completo(billing) or _nombre_completo(customer) or "Cliente Shopify", + "CardType": "cCustomer", + "GroupCode": BUSINESS_PARTNER_GROUP_CODE, + "FederalTaxID": rut_sap, + "Phone1": billing.get("phone") or customer.get("phone"), + "EmailAddress": order.get("email") or order.get("contact_email"), + "BPAddresses": [ + _bp_address("bo_BillTo", "BillTo", billing), + _bp_address("bo_ShipTo", "ShipTo", shipping or billing), + ], + } + + sap.forward("POST", "BusinessPartners", json_data=payload) + return card_code + + +def resolver_card_code( + sap: SAPServiceLayerClient, + store_config: Dict[str, Any], + rut: Optional[str], + order: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Si hay un RUT válido, busca el BusinessPartner en SAP por FederalTaxID + (con guión, que es el formato que usa SAP: "NNNNNNNN-D"). Si existe, + devuelve su CardCode. Si no existe, crea uno nuevo (CardCode "C"+rut). + Si no hay RUT, o falla la creación, usa el CardCode genérico + configurado para la tienda. + + Siempre devuelve "rut_sap" (el RUT en formato SAP) cuando hay RUT, para + poder sobrescribir el FederalTaxID del documento aunque se use el + CardCode genérico. + """ + generic_card_code = store_config["sap"]["generic_card_code"] + + if not rut: + return {"card_code": generic_card_code, "rut": None, "rut_sap": None, "encontrado_por_rut": False} + + rut_sap = formatear_rut(rut) + + resp = sap.forward( + "GET", + "BusinessPartners", + params={"$select": "CardCode", "$filter": f"FederalTaxID eq '{rut_sap}'"}, + ) + resultados = resp.json().get("value", []) + + if resultados: + return {"card_code": resultados[0]["CardCode"], "rut": rut, "rut_sap": rut_sap, "encontrado_por_rut": True} + + try: + card_code = crear_business_partner(sap, rut_sap, order or {}) + return {"card_code": card_code, "rut": rut, "rut_sap": rut_sap, "encontrado_por_rut": True} + except SAPServiceLayerError: + return {"card_code": generic_card_code, "rut": rut, "rut_sap": rut_sap, "encontrado_por_rut": False} diff --git a/domain/delivery.py b/domain/delivery.py new file mode 100644 index 0000000..dae1ad8 --- /dev/null +++ b/domain/delivery.py @@ -0,0 +1,38 @@ +from typing import Any, Dict + +INVOICE_BASE_TYPE = 13 # ObjType clásico de Sales Invoice (oInvoices) en SAP B1 + + +def construir_delivery(invoice: Dict[str, Any], stock_transfer: Dict[str, Any], store_config: Dict[str, Any]) -> Dict[str, Any]: + """ + Arma el JSON para POST DeliveryNotes con la Invoice de reserva como base + (BaseType/BaseEntry/BaseLine). Solo se entregan los ítems que + efectivamente se transfirieron en este fulfillment (StockTransferLines), + emparejados con la línea correspondiente de la Invoice por ItemCode. + """ + invoice_lines_por_sku = {line["ItemCode"]: line for line in invoice["DocumentLines"]} + + document_lines = [] + for st_line in stock_transfer["StockTransferLines"]: + inv_line = invoice_lines_por_sku[st_line["ItemCode"]] + document_lines.append( + { + "ItemCode": st_line["ItemCode"], + "Quantity": st_line["Quantity"], + "WarehouseCode": inv_line["WarehouseCode"], + "BaseType": INVOICE_BASE_TYPE, + "BaseEntry": invoice["DocEntry"], + "BaseLine": inv_line["LineNum"], + } + ) + + return { + "CardCode": invoice.get("CardCode"), + "CardName": invoice.get("CardName"), + "Comments": invoice.get("NumAtCard"), + "DocDate": invoice["DocDate"][:10], + "Series": store_config["sap"]["numbering_series"]["DELIVERY"], + "U_ShopifyOrderId": invoice.get("U_ShopifyOrderId"), + "U_ShopifyStore": invoice.get("U_ShopifyStore"), + "DocumentLines": document_lines, + } diff --git a/domain/documents.py b/domain/documents.py new file mode 100644 index 0000000..2de4c55 --- /dev/null +++ b/domain/documents.py @@ -0,0 +1,86 @@ +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 diff --git a/domain/inventory_transfer.py b/domain/inventory_transfer.py new file mode 100644 index 0000000..3d4baae --- /dev/null +++ b/domain/inventory_transfer.py @@ -0,0 +1,61 @@ +from typing import Any, Dict, Optional + + +def resolver_location(store_config: Dict[str, Any], location_id: Any) -> Optional[Dict[str, Any]]: + """Config (is_store/warehouse_code) para un location_id de Shopify, o None si no está mapeado.""" + return store_config["sap"]["locations"].get(str(location_id)) + + +def es_tienda(store_config: Dict[str, Any], location_id: Any) -> bool: + """ + True si este location_id es una tienda física (genera StockTransfer + + Delivery además de la ITR); False si es un CD (solo ITR). + """ + location = resolver_location(store_config, location_id) + return bool(location and location.get("is_store")) + + +def construir_inventory_transfer_request( + order: Dict[str, Any], + fulfillment: Dict[str, Any], + store_config: Dict[str, Any], + warehouse_code: str, + invoice: Dict[str, Any], +) -> Dict[str, Any]: + """ + Arma el JSON para POST InventoryTransferRequests: una solicitud por cada + fulfillment, moviendo stock desde la bodega real del location_id hacia + la bodega virtual usada en el Invoice (default_warehouse_invoice). Usa + los line_items propios del fulfillment (no los de toda la orden), ya + que una orden puede tener varios fulfillments con distintos + ítems/cantidades. CardCode/CardName se toman del Invoice; Comments lleva + el "name" de Shopify para poder ubicarla fácil. + """ + to_warehouse = store_config["sap"]["default_warehouse_invoice"] + series = store_config["sap"]["numbering_series"]["INVENTORY_TRANSFER_REQUEST"] + doc_date = order["created_at"][:10] + + document_lines = [ + { + "ItemCode": item["sku"], + "Quantity": item["quantity"], + "FromWarehouseCode": warehouse_code, + "WarehouseCode": to_warehouse, + } + for item in fulfillment.get("line_items", []) + if item.get("sku") + ] + + return { + "CardCode": invoice["CardCode"], + "CardName": invoice["CardName"], + "Comments": order.get("name"), + "DocDate": doc_date, + "DueDate": doc_date, + "Series": series, + "FromWarehouse": warehouse_code, + "ToWarehouse": to_warehouse, + "U_ShopifyOrderId": order["id"], + "U_ShopifyStore": store_config["store_code"], + "StockTransferLines": document_lines, + } diff --git a/domain/invoice.py b/domain/invoice.py new file mode 100644 index 0000000..dc47b7d --- /dev/null +++ b/domain/invoice.py @@ -0,0 +1,222 @@ +from typing import Any, Dict, List, Optional + +from core.sap import SAPServiceLayerClient +from domain.documents import crear_documento_sap +from domain.payment import transacciones_efectivas + + +def _nombre_completo(persona: Dict[str, Any]) -> str: + nombre = f"{persona.get('first_name', '') or ''} {persona.get('last_name', '') or ''}".strip() + return nombre or persona.get("name", "") or "" + + +def _precio_unitario_con_descuento(item: Dict[str, Any]) -> float: + """ + item["price"] es el precio unitario ANTES de descuento. El descuento + real de la línea viene en discount_allocations (total_discount no + siempre lo refleja). Se descuenta antes de armar PriceAfterVAT. + """ + cantidad = item.get("quantity") or 1 + precio_total = float(item["price"]) * cantidad + descuento = sum(float(d.get("amount", 0)) for d in item.get("discount_allocations", [])) + return round((precio_total - descuento) / cantidad, 2) + + +def _cantidad_cancelada(order: Dict[str, Any], line_item_id: Any) -> int: + """ + Unidades de una línea canceladas antes de la venta (refund_line_items con + restock_type "cancel", ej. líneas eliminadas de la orden antes del pago). + Las devoluciones posteriores a la entrega ("return") no se descuentan aquí. + """ + return sum( + int(rli.get("quantity") or 0) + for refund in order.get("refunds") or [] + for rli in refund.get("refund_line_items") or [] + if rli.get("line_item_id") == line_item_id and rli.get("restock_type") == "cancel" + ) + + +def _armar_lineas(order: Dict[str, Any], warehouse_code: str, cantidad_fn) -> List[Dict[str, Any]]: + lineas = [] + for item in order.get("line_items", []): + cantidad = cantidad_fn(item) + if item.get("sku") and cantidad > 0: + lineas.append( + { + "ItemCode": item["sku"], + "Quantity": cantidad, + "WarehouseCode": warehouse_code, + "PriceAfterVAT": _precio_unitario_con_descuento(item), + "TaxCode": "IVA", + } + ) + return lineas + + +def _total(lineas: List[Dict[str, Any]]) -> float: + return sum(l["Quantity"] * l["PriceAfterVAT"] for l in lineas) + + +def _lineas_por_cobrado( + order: Dict[str, Any], warehouse_code: str, transactions: Optional[List[Dict[str, Any]]] +) -> List[Dict[str, Any]]: + """ + La Invoice debe igualar lo realmente cobrado (transacciones efectivas). Se + prueban dos juegos de líneas y se usa el que cuadre: + 1. líneas originales (quantity): ventas normales y órdenes despachadas + que se reembolsaron después de cobrar el total (#CL10072, #8029); + 2. líneas vigentes (current_quantity): líneas canceladas o devueltas que + nunca se cobraron (#7998, #CL10144). + Si ninguno cuadra se devuelven las originales y el control de suma del pago + deja la orden en error. Sin transacciones (API caída) se descuentan solo las + canceladas antes de la venta (restock_type "cancel"). + """ + if transactions is None: + return _armar_lineas(order, warehouse_code, lambda i: i["quantity"] - _cantidad_cancelada(order, i.get("id"))) + + originales = _armar_lineas(order, warehouse_code, lambda i: i["quantity"]) + cobrado = sum(float(t["amount"]) for t in transacciones_efectivas(transactions)) + if abs(_total(originales) - cobrado) < 1: + return originales + + vigentes = _armar_lineas( + order, warehouse_code, lambda i: i["current_quantity"] if i.get("current_quantity") is not None else i["quantity"] + ) + if vigentes and abs(_total(vigentes) - cobrado) < 1: + return vigentes + return originales + + +def fulfillments_facturados(fulfillments: List[Dict[str, Any]], invoice: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Limita cada fulfillment a lo que quedó facturado: líneas devueltas o + canceladas que no se cobraron (ver _lineas_por_cobrado) no están en la + Invoice, y mover o entregar esas unidades rompería el Delivery (la línea + base no existe) o dejaría stock transferido sin venta. Por cada SKU se + permite como máximo la cantidad de la Invoice, repartida en orden entre los + fulfillments; los que quedan sin líneas se omiten. + """ + permitido: Dict[str, float] = {} + for line in invoice["DocumentLines"]: + permitido[line["ItemCode"]] = permitido.get(line["ItemCode"], 0) + line["Quantity"] + + resultado = [] + for fulfillment in fulfillments: + lineas = [] + for item in fulfillment.get("line_items", []): + disponible = permitido.get(item.get("sku"), 0) + if disponible <= 0: + continue + cantidad = min(item["quantity"], disponible) + permitido[item["sku"]] = disponible - cantidad + lineas.append({**item, "quantity": cantidad}) + if lineas: + resultado.append({**fulfillment, "line_items": lineas}) + return resultado + + +def _campos_direccion(prefijo: str, direccion: Dict[str, Any]) -> Dict[str, Any]: + if not direccion: + return {} + + campos = { + f"{prefijo}Street": direccion.get("address1"), + f"{prefijo}Block": direccion.get("address2"), + f"{prefijo}Building": direccion.get("phone"), + f"{prefijo}City": direccion.get("city"), + f"{prefijo}ZipCode": direccion.get("zip"), + f"{prefijo}County": direccion.get("province"), + } + + if direccion.get("country_code") == "CL": + campos[f"{prefijo}Country"] = "CL" + + return campos + + +def construir_invoice( + order: Dict[str, Any], + store_config: Dict[str, Any], + card_code: str, + federal_tax_id: Optional[str] = None, + transactions: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """ + Arma el JSON para POST Invoices (factura de reserva). Las líneas se arman + por lo realmente cobrado (ver _lineas_por_cobrado). Sobrescribe + CardName y direcciones con los datos reales del cliente de Shopify, + aunque el CardCode sea el genérico. Si se conoce el RUT (federal_tax_id, + en formato SAP "NNNNNNNN-D"), también sobrescribe FederalTaxID, para que + el RUT real del cliente quede en la factura aunque se use el CardCode + genérico. + """ + customer = order.get("customer") or {} + billing = order.get("billing_address") or {} + shipping = order.get("shipping_address") or {} + + card_name = _nombre_completo(billing) or _nombre_completo(customer) or "Cliente Shopify" + doc_date = order["created_at"][:10] + + warehouse_code = store_config["sap"]["default_warehouse_invoice"] + series = store_config["sap"]["numbering_series"]["INVOICE"] + + document_lines = _lineas_por_cobrado(order, warehouse_code, transactions) + + address_extension = { + **_campos_direccion("BillTo", billing), + **_campos_direccion("ShipTo", shipping), + } + + payload = { + "CardCode": card_code, + "CardName": card_name, + "DocDate": doc_date, + "DocDueDate": doc_date, + "Series": series, + "DocType": "dDocument_Items", + "DocCurrency": "CLP", + "ReserveInvoice": "tYES", + "NumAtCard": order.get("name"), + "AddressExtension": address_extension, + "U_ShopifyOrderId": order["id"], + "U_ShopifyStore": store_config["store_code"], + "DocumentLines": document_lines, + } + + if federal_tax_id: + payload["FederalTaxID"] = federal_tax_id + + return payload + + +def crear_invoice(sap: SAPServiceLayerClient, payload: Dict[str, Any]) -> Dict[str, Any]: + """POST Invoices, con diagnóstico de errores conocidos (ver domain/documents.py).""" + item_codes = [line["ItemCode"] for line in payload["DocumentLines"]] + return crear_documento_sap(sap, "Invoices", payload, item_codes=item_codes) + + +def agregar_referencia_documento( + sap: SAPServiceLayerClient, + invoice_doc_entry: int, + ref_doc_entry: int, + ref_doc_num: int, + ref_obj_type: str, +) -> None: + """ + PATCH a la Invoice para dejar registrada la referencia a un documento + generado a partir de ella (ITR, StockTransfer, etc.). Lee las + DocumentReferences actuales y agrega la nueva, sin pisar las que ya + existan (una orden puede tener varios fulfillments -> varios documentos). + """ + actual = sap.forward("GET", f"Invoices({invoice_doc_entry})", params={"$select": "DocumentReferences"}) + referencias = actual.json().get("DocumentReferences", []) + + referencias.append( + { + "RefDocEntr": ref_doc_entry, + "RefDocNum": ref_doc_num, + "RefObjType": ref_obj_type, + } + ) + + sap.forward("PATCH", f"Invoices({invoice_doc_entry})", json_data={"DocumentReferences": referencias}) diff --git a/domain/items.py b/domain/items.py new file mode 100644 index 0000000..1e8d521 --- /dev/null +++ b/domain/items.py @@ -0,0 +1,29 @@ +from typing import List +from urllib.parse import quote + +from core.errors import SAPServiceLayerError +from core.sap import SAPServiceLayerClient + + +def _item_key(sku: str) -> str: + escaped = quote(sku.replace("'", "''"), safe="") + return f"Items('{escaped}')" + + +def item_existe(sap: SAPServiceLayerClient, sku: str) -> bool: + try: + sap.forward("GET", _item_key(sku)) + return True + except SAPServiceLayerError as exc: + if exc.status_code == 404: + return False + raise + + +def identificar_skus_faltantes(sap: SAPServiceLayerClient, skus: List[str]) -> List[str]: + """ + Revisa cada SKU contra SAP (GET Items) y devuelve los que no existen. + Se usa cuando un POST de Invoice falla con el código -5009 + ("Item number is missing"), que no indica cuál ItemCode es el problema. + """ + return [sku for sku in skus if not item_existe(sap, sku)] diff --git a/domain/payment.py b/domain/payment.py new file mode 100644 index 0000000..de519c6 --- /dev/null +++ b/domain/payment.py @@ -0,0 +1,136 @@ +from decimal import Decimal +from typing import Any, Dict, List + +CARD_LAST_DIGITS = "1234" +CARD_VALID_UNTIL = "2030-12-31" # 12/30 para todas las tarjetas +VOUCHER_MAX_LEN = 20 # SAP rechaza VoucherNum más largo; VoucherNum = name de la orden de Shopify + + +class PaymentConfigError(Exception): + """El pago no se puede armar (medio sin configurar, montos que no cuadran, etc.).""" + + +# ============================================================================ +def transacciones_efectivas(transactions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Transacciones que representan dinero realmente cobrado: status success y + kind sale/capture. Se ignoran failure/pending y refund/void. Si una + authorization tiene su capture (parent_id), solo cuenta la capture. + """ + exitosas = [t for t in transactions if t.get("status") == "success"] + autorizaciones_capturadas = {t.get("parent_id") for t in exitosas if t.get("kind") == "capture" and t.get("parent_id")} + + efectivas = [] + for t in exitosas: + if t.get("kind") == "sale": + efectivas.append(t) + elif t.get("kind") == "capture": + efectivas.append(t) + # authorization sin capture y el resto de kinds no suman + return [t for t in efectivas if t.get("id") not in autorizaciones_capturadas] + + +# ============================================================================ +def _metodo(store_config: Dict[str, Any], gateway: str) -> Dict[str, Any]: + metodos = store_config["sap"].get("payment_methods") or {} + metodo = metodos.get(gateway) + if not metodo: + raise PaymentConfigError(f"Medio de pago '{gateway}' no está configurado en sap.payment_methods") + return metodo + + +def construir_incoming_payment( + order: Dict[str, Any], + transactions: List[Dict[str, Any]], + store_config: Dict[str, Any], + invoice: Dict[str, Any], + invoice_doc_entry: int, + invoice_total: float, +) -> Dict[str, Any]: + """ + Arma el JSON para POST IncomingPayments, aplicado a la Invoice de reserva + ya creada (CardCode, CardName y fecha salen de esa Invoice). + + Los medios de pago salen de las transacciones efectivas (no de + payment_gateway_names): cada una según sap.payment_methods[gateway]: + - credit_card -> una línea en PaymentCreditCards + - transfer -> suma a TransferSum con su transfer_account + La suma debe igualar el total de la Invoice. + """ + series = store_config["sap"]["numbering_series"]["INCOMING_PAYMENT"] + doc_date = invoice["DocDate"] + + efectivas = transacciones_efectivas(transactions) + if not efectivas: + raise PaymentConfigError("La orden no tiene transacciones de pago exitosas") + + tarjetas: List[Dict[str, Any]] = [] + transferencia = Decimal("0") + cuentas_transfer = set() + gateways: List[str] = [] + + for t in efectivas: + gateway = t.get("gateway") + metodo = _metodo(store_config, gateway) + monto = Decimal(str(t["amount"])) + gateways.append(gateway) + + if metodo["type"] == "credit_card": + if not metodo.get("credit_card"): + raise PaymentConfigError(f"Medio de pago '{gateway}' sin tarjeta de crédito (credit_card) asociada") + linea = { + "CreditCard": metodo["credit_card"], + "CreditCardNumber": CARD_LAST_DIGITS, + "CardValidUntil": CARD_VALID_UNTIL, + "VoucherNum": str(order.get("name") or "")[:VOUCHER_MAX_LEN], + "CreditSum": float(monto), + "CreditCur": "CLP", + "CreditType": "cr_Regular", + "NumOfPayments": metodo.get("num_of_payments", 1), + "FirstPaymentDue": doc_date, + "SplitPayments": "tYES" if metodo.get("split_payment") else "tNO", + } + if metodo.get("payment_method_code"): + linea["PaymentMethodCode"] = metodo["payment_method_code"] + tarjetas.append(linea) + elif metodo["type"] == "transfer": + if not metodo.get("transfer_account"): + raise PaymentConfigError(f"Medio de pago '{gateway}' sin transfer_account") + cuentas_transfer.add(metodo["transfer_account"]) + transferencia += monto + else: + raise PaymentConfigError(f"Medio de pago '{gateway}' con type inválido: {metodo['type']!r}") + + if len(cuentas_transfer) > 1: + raise PaymentConfigError(f"Transferencias con cuentas distintas no soportado: {sorted(cuentas_transfer)}") + + total_pagado = transferencia + sum((Decimal(str(l["CreditSum"])) for l in tarjetas), Decimal("0")) + if total_pagado != Decimal(str(invoice_total)): + raise PaymentConfigError(f"Suma de pagos ({total_pagado}) no coincide con el total de la Invoice ({invoice_total})") + + payload: Dict[str, Any] = { + "CardCode": invoice["CardCode"], + "CardName": invoice["CardName"], + "DocType": "rCustomer", + "DocDate": doc_date, + "Series": series, + "DocCurrency": "CLP", + "Remarks": " + ".join(dict.fromkeys(gateways)), + "PaymentInvoices": [ + { + "DocEntry": invoice_doc_entry, + "InvoiceType": "it_Invoice", + "SumApplied": invoice_total, + } + ], + } + + if tarjetas: + payload["PaymentCreditCards"] = tarjetas + if transferencia: + payload["TransferAccount"] = next(iter(cuentas_transfer)) + payload["TransferSum"] = float(transferencia) + payload["TransferDate"] = doc_date + payload["TransferReference"] = order.get("name") + + return payload diff --git a/domain/rut.py b/domain/rut.py new file mode 100644 index 0000000..568632e --- /dev/null +++ b/domain/rut.py @@ -0,0 +1,47 @@ +def _limpiar(rut: str) -> str: + return rut.upper().replace(".", "").replace("-", "").strip() + + +def _calcular_dv(numero: str) -> str: + suma = 0 + multiplicador = 2 + for digito in reversed(numero): + suma += int(digito) * multiplicador + multiplicador = multiplicador + 1 if multiplicador < 7 else 2 + + resto = 11 - (suma % 11) + if resto == 11: + return "0" + if resto == 10: + return "K" + return str(resto) + + +def validar_rut(rut: str) -> bool: + """ + Valida un RUT chileno usando el dígito verificador módulo 11. + Acepta con o sin puntos/guión: "12345678-5", "12.345.678-5", "123456785". + """ + limpio = _limpiar(rut) + if len(limpio) < 2: + return False + + numero, dv = limpio[:-1], limpio[-1] + if not numero.isdigit(): + return False + + return _calcular_dv(numero) == dv + + +def normalizar_rut(rut: str) -> str: + """Devuelve el RUT sin puntos ni guión, en mayúsculas (ej. "123456785").""" + return _limpiar(rut) + + +def formatear_rut(rut: str) -> str: + """ + Devuelve el RUT en el formato que usa SAP para FederalTaxID: + "NNNNNNNN-D" (sin puntos, con guión antes del dígito verificador). + """ + limpio = _limpiar(rut) + return f"{limpio[:-1]}-{limpio[-1]}" diff --git a/domain/stock_transfer.py b/domain/stock_transfer.py new file mode 100644 index 0000000..eda2b3c --- /dev/null +++ b/domain/stock_transfer.py @@ -0,0 +1,38 @@ +from typing import Any, Dict + +ITR_BASE_TYPE = 1250000001 # DocObjectCode de InventoryTransferRequests + + +def construir_stock_transfer(itr: Dict[str, Any], store_config: Dict[str, Any]) -> Dict[str, Any]: + """ + Arma el JSON para POST StockTransfers a partir de una + InventoryTransferRequest ya creada en SAP (itr = el documento completo + leído de SAP, con LineNum reales). Copia CardCode/CardName/Comments/ + almacenes/UDFs de la ITR, y cada línea referencia su línea base vía + BaseType/BaseEntry/BaseLine. + """ + document_lines = [ + { + "ItemCode": line["ItemCode"], + "Quantity": line["Quantity"], + "FromWarehouseCode": line["FromWarehouseCode"], + "WarehouseCode": line["WarehouseCode"], + "BaseType": ITR_BASE_TYPE, + "BaseEntry": itr["DocEntry"], + "BaseLine": line["LineNum"], + } + for line in itr["StockTransferLines"] + ] + + return { + "CardCode": itr.get("CardCode"), + "CardName": itr.get("CardName"), + "Comments": itr.get("Comments"), + "DocDate": itr["DocDate"][:10], + "FromWarehouse": itr["FromWarehouse"], + "ToWarehouse": itr["ToWarehouse"], + "Series": store_config["sap"]["numbering_series"]["STOCK_TRANSFER"], + "U_ShopifyOrderId": itr.get("U_ShopifyOrderId"), + "U_ShopifyStore": itr.get("U_ShopifyStore"), + "StockTransferLines": document_lines, + } diff --git a/download/.gitkeep b/download/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..1ab0416 --- /dev/null +++ b/main.py @@ -0,0 +1,144 @@ +import json +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path + +from core.db import init_db +from core.lock import SingleInstanceError, acquire_single_instance_lock +from core.logger_manager import get_logger +from pipeline.runner import ejecutar_ciclo + +logger = get_logger() + +SCHEDULER_PATH = Path(__file__).resolve().parent / "scheduler.json" + +config: dict = {} + + +# ============================================================================ +def cargar_programacion() -> dict: + global config + with open(SCHEDULER_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + data["HORARIOS"] = [tuple(h) for h in data["HORARIOS"]] + config = data + return config + + +def obtener_horarios() -> list: + return config.get("HORARIOS", []) + + +def obtener_dias_activos() -> list: + return config.get("DIAS_ACTIVOS", []) + + +# ============================================================================ +def obtener_intervalo(dt: datetime) -> tuple[int, str] | None: + minutos = dt.hour * 60 + dt.minute + + for hi, mi, hf, mf, intervalo, modo in obtener_horarios(): + inicio = hi * 60 + mi + fin = hf * 60 + mf + + if inicio <= minutos < fin: + return (intervalo, modo) + + return None + + +# ============================================================================ +def segundos_hasta_proximo_inicio(dt: datetime) -> int: + """ + Revisa todos los horarios de hoy (no solo el primero) y toma el próximo + inicio que aún no haya pasado. Si ninguno queda hoy, salta al próximo + día activo. + """ + candidatos = [] + for hi, mi, *_ in obtener_horarios(): + inicio = dt.replace(hour=hi, minute=mi, second=0, microsecond=0) + if inicio > dt: + candidatos.append(inicio) + + if candidatos: + return int((min(candidatos) - dt).total_seconds()) + + return segundos_hasta_proximo_dia_activo(dt) + + +# ============================================================================ +def segundos_hasta_proximo_dia_activo(dt: datetime) -> int: + horarios = obtener_horarios() + hi, mi = (horarios[0][0], horarios[0][1]) if horarios else (6, 0) + dias_activos = obtener_dias_activos() + + for dias in range(1, 8): + prox = dt + timedelta(days=dias) + if prox.weekday() in dias_activos: + target = prox.replace(hour=hi, minute=mi, second=0, microsecond=0) + return int((target - dt).total_seconds()) + return 86400 + + +# ============================================================================ +def main(): + init_db() + cargar_programacion() + + logger.info("=" * 60) + logger.info("Servicio Shopify -> SAP iniciado") + logger.info("=" * 60) + + while True: + inicio_ciclo = datetime.now() + cargar_programacion() + + if inicio_ciclo.weekday() not in obtener_dias_activos(): + dormir = segundos_hasta_proximo_dia_activo(inicio_ciclo) + logger.info( + f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] Día no activo. " + f"Próxima ejecución: {(inicio_ciclo + timedelta(seconds=dormir)):%Y-%m-%d %H:%M:%S}" + ) + time.sleep(dormir) + continue + + resultado = obtener_intervalo(inicio_ciclo) + + if resultado is not None: + intervalo, modo = resultado + logger.info(f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] Iniciando ciclo [{modo}] (cada {intervalo}s)") + + ejecutar_ciclo(modo) + + duracion = (datetime.now() - inicio_ciclo).total_seconds() + if duracion > intervalo: + logger.warning(f"El ciclo demoró {duracion:.1f}s (intervalo configurado {intervalo}s)") + + dormir = max(0, intervalo - duracion) + logger.info(f"Próximo ciclo [{modo}] en {dormir:.0f}s...") + else: + dormir = segundos_hasta_proximo_inicio(inicio_ciclo) + despertar = inicio_ciclo + timedelta(seconds=dormir) + logger.info( + f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] Fuera de horario. " + f"Próxima ejecución: {despertar:%Y-%m-%d %H:%M:%S}" + ) + + time.sleep(dormir) + + +# ============================================================================ +if __name__ == "__main__": + try: + lock = acquire_single_instance_lock() + except SingleInstanceError as exc: + logger.error(str(exc)) + sys.exit(1) + + try: + main() + except KeyboardInterrupt: + logger.info("Servicio detenido por el usuario.") + finally: + lock.release() diff --git a/pipeline/runner.py b/pipeline/runner.py new file mode 100644 index 0000000..9cccbac --- /dev/null +++ b/pipeline/runner.py @@ -0,0 +1,363 @@ +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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..98c85ed --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requests==2.34.2 +python-dotenv==1.2.3 +filelock==4.0.3 +truststore==0.10.4 +tzdata==2026.4 # zoneinfo en Windows necesita la base de zonas horarias diff --git a/scheduler.json b/scheduler.json new file mode 100644 index 0000000..6a7814b --- /dev/null +++ b/scheduler.json @@ -0,0 +1,9 @@ +{ + "HORARIOS": [ + [6, 0, 13, 15, 600, "rapido"], + [13, 15, 14, 0, 1800, "full"], + [14, 0, 17, 30, 600, "rapido"], + [17, 30, 21, 30, 1800, "full"] + ], + "DIAS_ACTIVOS": [0, 1, 2, 3, 4] +} diff --git a/tools/.env.example b/tools/.env.example new file mode 100644 index 0000000..4a2cd93 --- /dev/null +++ b/tools/.env.example @@ -0,0 +1,10 @@ +# Variables para las peticiones de REST Client (tools/*.http). Copiar a tools/.env y completar. +SHOPIFY_API_ID= +SHOPIFY_STORE= +SHOPIFY_API_VERSION= +SHOPIFY_ACCESS_TOKEN= +# SAP (tools/sap.http), si se usa: +# SAP_URL= +# SAP_COMPANYDB= +# SAP_USER= +# SAP_PASSWORD= diff --git a/tools/cargar_entrada_mercancia.py b/tools/cargar_entrada_mercancia.py new file mode 100644 index 0000000..577f1be --- /dev/null +++ b/tools/cargar_entrada_mercancia.py @@ -0,0 +1,82 @@ +""" +Utilidad puntual (no forma parte del pipeline recurrente): crea una Entrada +de Mercancía (InventoryGenEntries) en SAP con todos los SKU del grupo de +artículos indicado, para dejar el stock inicial en un almacén. + +Uso: + .venv/bin/python tools/cargar_entrada_mercancia.py config/stores/snobbycl.json \ + --warehouse 8801 --price 5000 --account 1-1-080-40-000 --quantity 1000 + + Agregar --apply para ejecutar el POST real (por defecto es dry-run). +""" + +import argparse +import json +import sys +from pathlib import Path +from typing import List + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.config import cargar_config_tienda +from core.errors import SAPServiceLayerError +from core.sap import SAPServiceLayerClient + +ITEMS_GROUP_CODE = 108 + + +def obtener_skus_grupo(sap: SAPServiceLayerClient, group_code: int) -> List[str]: + resp = sap.forward( + "GET", + "Items", + params={"$select": "ItemCode", "$filter": f"ItemsGroupCode eq {group_code}"}, + extra_headers={"B1S-PageSize": "1000"}, + ) + data = resp.json() + return [i["ItemCode"] for i in data.get("value", [])] + + +def main(store_config_path: str, warehouse: str, price: float, account: str, quantity: float, dry_run: bool) -> None: + store_config = cargar_config_tienda(store_config_path) + sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"]) + + skus = obtener_skus_grupo(sap, ITEMS_GROUP_CODE) + print(f"SKUs encontrados en grupo {ITEMS_GROUP_CODE}: {len(skus)}") + + document_lines = [ + { + "ItemCode": sku, + "WarehouseCode": warehouse, + "Quantity": quantity, + "UnitPrice": price, + "AccountCode": account, + } + for sku in skus + ] + + payload = {"DocumentLines": document_lines} + + if dry_run: + print(json.dumps(payload["DocumentLines"][:3], indent=2, ensure_ascii=False)) + print(f"... ({len(document_lines)} líneas en total, almacén={warehouse}, precio={price}, cuenta={account}, cantidad={quantity})") + print("(dry-run: no se envió nada a SAP, correr con --apply para crear el documento)") + return + + try: + resp = sap.forward("POST", "InventoryGenEntries", json_data=payload) + data = resp.json() + print(f"Entrada de mercancía creada. DocEntry={data.get('DocEntry')} DocNum={data.get('DocNum')}") + except SAPServiceLayerError as exc: + print(f"ERROR creando la entrada de mercancía: {exc.detail}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("store_config") + parser.add_argument("--warehouse", required=True) + parser.add_argument("--price", type=float, required=True) + parser.add_argument("--account", required=True) + parser.add_argument("--quantity", type=float, required=True) + parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)") + args = parser.parse_args() + main(args.store_config, args.warehouse, args.price, args.account, args.quantity, dry_run=not args.apply) diff --git a/tools/cargar_items_faltantes.py b/tools/cargar_items_faltantes.py new file mode 100644 index 0000000..c84c23a --- /dev/null +++ b/tools/cargar_items_faltantes.py @@ -0,0 +1,108 @@ +""" +Utilidad puntual (no forma parte del pipeline recurrente): recorre las +órdenes ya descargadas en SQLite para una tienda, trae el detalle completo +de cada una desde Shopify, extrae SKU + nombre de line_items, deduplica por +SKU, y crea en SAP los Items faltantes (grupo 108, artículo de venta + +inventario). + +Uso: + .venv/bin/python tools/cargar_items_faltantes.py config/stores/snobbycl.json + .venv/bin/python tools/cargar_items_faltantes.py config/stores/snobbycl.json --apply +""" + +import argparse +import json +import sys +from pathlib import Path +from typing import Dict, List + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.config import cargar_config_tienda +from core.db import db_session +from core.errors import SAPServiceLayerError +from core.sap import SAPServiceLayerClient +from core.shopify_client import ShopifyClient +from domain.items import item_existe + +ITEMS_GROUP_CODE = 108 + + +def obtener_shopify_order_ids(store_code: str) -> List[int]: + with db_session() as conn: + rows = conn.execute( + "SELECT shopify_order_id FROM orders WHERE store_code = ? ORDER BY shopify_order_id", + (store_code,), + ).fetchall() + return [r["shopify_order_id"] for r in rows] + + +def extraer_skus(shopify: ShopifyClient, order_ids: List[int]) -> Dict[str, str]: + """Devuelve {sku: nombre} deduplicado, recorriendo el detalle de cada orden.""" + skus: Dict[str, str] = {} + for idx, order_id in enumerate(order_ids, start=1): + detalle = shopify.obtener_orden(order_id) + for item in detalle.get("line_items", []): + sku = (item.get("sku") or "").strip() + nombre = item.get("name") or item.get("title") or sku + if sku and sku not in skus: + skus[sku] = nombre + print(f" [{idx}/{len(order_ids)}] orden {order_id}: {len(detalle.get('line_items', []))} line_items") + return skus + + +def crear_item(sap: SAPServiceLayerClient, sku: str, nombre: str) -> None: + payload = { + "ItemCode": sku, + "ItemName": nombre[:100], + "ItemsGroupCode": ITEMS_GROUP_CODE, + "SalesItem": "tYES", + "InventoryItem": "tYES", + "PurchaseItem": "tNO", + } + sap.forward("POST", "Items", json_data=payload) + + +def main(store_config_path: str, dry_run: bool) -> None: + store_config = cargar_config_tienda(store_config_path) + store_code = store_config["store_code"] + + shopify = ShopifyClient.from_store_config(store_config["shopify"]) + sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"]) + + order_ids = obtener_shopify_order_ids(store_code) + print(f"Órdenes a revisar: {len(order_ids)}") + + skus = extraer_skus(shopify, order_ids) + print(f"\nSKUs únicos encontrados: {len(skus)}\n") + + existentes, creados, errores = 0, 0, 0 + for sku, nombre in skus.items(): + if item_existe(sap, sku): + existentes += 1 + continue + + if dry_run: + print(f"[DRY RUN] Crearía: {sku!r} - {nombre!r}") + continue + + try: + crear_item(sap, sku, nombre) + creados += 1 + print(f"Creado: {sku} - {nombre}") + except SAPServiceLayerError as exc: + errores += 1 + print(f"ERROR creando {sku}: {exc.detail}") + + faltantes = len(skus) - existentes + print(f"\nResumen: ya existían={existentes} | faltantes detectados={faltantes} | creados={creados} | errores={errores}") + if dry_run: + print("(dry-run: no se escribió nada en SAP, corré con --apply para crear los items)") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("store_config", help="Ruta al JSON de configuración de la tienda") + parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)") + args = parser.parse_args() + main(args.store_config, dry_run=not args.apply) diff --git a/tools/consultar.py b/tools/consultar.py new file mode 100644 index 0000000..9117f2a --- /dev/null +++ b/tools/consultar.py @@ -0,0 +1,200 @@ +""" +Consultas de solo lectura sobre data/shopify_sap.db (y logs/events.jsonl). + +Uso (desde la raíz del proyecto): + .venv/bin/python -m tools.consultar resumen + .venv/bin/python -m tools.consultar descargadas [--store snobbycl] [--limit 50] + .venv/bin/python -m tools.consultar integradas [--store snobbycl] [--limit 50] + .venv/bin/python -m tools.consultar errores [--store snobbycl] [--limit 50] + .venv/bin/python -m tools.consultar orden --name "#8064" [--store snobbycl] + +- resumen: órdenes por tienda y estado, con barras. +- descargadas: últimas órdenes descargadas (por fecha de descarga). +- integradas: últimas órdenes integradas, con los documentos SAP creados. +- errores: órdenes en error, documentos con error y eventos de error del log. + +Las horas de la BD (UTC) se muestran en hora local (APP_TIMEZONE o la del sistema); las del log de eventos ya vienen en hora local. +- orden: cabecera, fulfillments y todos los pasos (documentos) de una orden. +""" + +import argparse +import json +import os +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, List, Sequence +from zoneinfo import ZoneInfo + +from core.config import cargar_env + +ROOT = Path(__file__).resolve().parent.parent +DB_PATH = ROOT / "data" / "shopify_sap.db" +EVENTS_PATH = ROOT / "logs" / "events.jsonl" + + +def _zona(): + cargar_env() + nombre = os.getenv("APP_TIMEZONE") + return ZoneInfo(nombre) if nombre else None # None = zona horaria del sistema + + +def hora_local(valor: Any) -> Any: + """ + La BD guarda time_insert/time_update en UTC (strftime 'now'); se muestran en hora local. + Zona: variable APP_TIMEZONE (ej. America/Santiago) si está definida; si no, la del sistema. + """ + if not valor: + return valor + try: + dt = datetime.strptime(str(valor), "%Y-%m-%d %H:%M:%S.%f" if "." in str(valor) else "%Y-%m-%d %H:%M:%S") + return dt.replace(tzinfo=timezone.utc).astimezone(_zona()).strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + return valor + + +def conectar() -> sqlite3.Connection: + # Solo lectura: no bloquea ni modifica la BD aunque el scheduler esté corriendo. + conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + return conn + + +def tabla(filas: Sequence[Sequence[Any]], columnas: List[str], max_ancho: int = 60) -> None: + if not filas: + print("(sin resultados)") + return + celdas = [[("" if v is None else str(v))[:max_ancho] for v in fila] for fila in filas] + anchos = [max(len(c), *(len(f[i]) for f in celdas)) for i, c in enumerate(columnas)] + linea = " ".join(c.ljust(anchos[i]) for i, c in enumerate(columnas)) + print(linea) + print(" ".join("-" * a for a in anchos)) + for f in celdas: + print(" ".join(v.ljust(anchos[i]) for i, v in enumerate(f))) + print(f"\n{len(filas)} fila(s)") + + +def filtro_tienda(store: str | None, alias: str = "o") -> tuple[str, list]: + return (f"AND {alias}.store_code = ?", [store]) if store else ("", []) + + +# ============================================================================ +def resumen(conn: sqlite3.Connection, args) -> None: + filas = conn.execute( + "SELECT store_code, status, COUNT(*) n FROM orders GROUP BY store_code, status ORDER BY store_code, n DESC" + ).fetchall() + maximo = max((r["n"] for r in filas), default=1) + salida = [(r["store_code"], r["status"], r["n"], "█" * max(1, round(r["n"] / maximo * 40))) for r in filas] + tabla(salida, ["tienda", "estado", "órdenes", ""]) + + +def descargadas(conn: sqlite3.Connection, args) -> None: + extra, params = filtro_tienda(args.store) + filas = conn.execute( + f""" + SELECT o.time_insert, o.store_code, o.order_number, o.shopify_order_id, o.financial_status, + o.fulfillment_status, o.status + FROM orders o WHERE 1=1 {extra} + ORDER BY o.time_insert DESC, o.sys_id DESC LIMIT ? + """, + params + [args.limit], + ).fetchall() + tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["descargada (local)", "tienda", "orden", "shopify_id", "financiero", "fulfillment", "estado"]) + + +def integradas(conn: sqlite3.Connection, args) -> None: + extra, params = filtro_tienda(args.store) + filas = conn.execute( + f""" + SELECT o.time_update, o.store_code, o.order_number, o.card_code, + (SELECT group_concat(d.doc_type || ' ' || d.sap_doc_num, ' | ') + FROM order_documents d WHERE d.order_sys_id = o.sys_id AND d.status = 'created') docs + FROM orders o WHERE o.status = 'integrated' {extra} + ORDER BY o.time_update DESC LIMIT ? + """, + params + [args.limit], + ).fetchall() + tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["integrada (local)", "tienda", "orden", "cliente", "documentos SAP"], max_ancho=110) + + +def errores(conn: sqlite3.Connection, args) -> None: + extra, params = filtro_tienda(args.store) + print("== Órdenes en estado error ==") + filas = conn.execute( + f"SELECT time_update, store_code, order_number, last_error FROM orders o WHERE status = 'error' {extra} ORDER BY time_update DESC LIMIT ?", + params + [args.limit], + ).fetchall() + tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["actualizada (local)", "tienda", "orden", "último error"], max_ancho=110) + + print("\n== Documentos con error ==") + filas = conn.execute( + f""" + SELECT d.time_update, o.store_code, o.order_number, d.doc_type, d.attempts, d.error_message + FROM order_documents d JOIN orders o ON o.sys_id = d.order_sys_id + WHERE d.status = 'error' {extra} ORDER BY d.time_update DESC LIMIT ? + """, + params + [args.limit], + ).fetchall() + tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["actualizado (local)", "tienda", "orden", "documento", "intentos", "error"], max_ancho=90) + + print("\n== Eventos de error recientes (logs/events.jsonl) ==") + eventos = [] + if EVENTS_PATH.exists(): + for linea in EVENTS_PATH.read_text(encoding="utf-8").splitlines(): + try: + e = json.loads(linea) + except json.JSONDecodeError: + continue + if e.get("level") in ("ERROR", "CRITICAL") and (not args.store or e.get("store_code") == args.store): + eventos.append((e.get("timestamp"), e.get("store_code"), e.get("shopify_order_id"), e.get("event"), e.get("message"))) + tabla(eventos[-args.limit:][::-1], ["cuándo", "tienda", "shopify_id", "evento", "mensaje"], max_ancho=90) + + +def orden(conn: sqlite3.Connection, args) -> None: + extra, params = filtro_tienda(args.store) + nombre = args.name if args.name.startswith("#") else f"#{args.name}" + ordenes = conn.execute(f"SELECT * FROM orders o WHERE o.order_number = ? {extra}", [nombre] + params).fetchall() + if not ordenes: + print(f"No hay órdenes con name {nombre}") + return + + for o in ordenes: + print(f"\n===== {o['order_number']} | {o['store_code']} | shopify_id {o['shopify_order_id']} =====") + for campo in ("status", "last_error", "financial_status", "fulfillment_status", "payment_gateway_names", + "card_code", "tags", "shopify_created_at", "time_insert", "time_update"): + print(f" {campo:<22} {hora_local(o[campo]) if campo in ('time_insert', 'time_update') else o[campo]}") + + print("\n Fulfillments") + fulfillments = conn.execute("SELECT * FROM order_fulfillments WHERE order_sys_id = ?", (o["sys_id"],)).fetchall() + tabla([(f["sys_id"], f["shopify_fulfillment_id"], f["location_id"], "tienda" if f["is_store"] else "CD", f["warehouse_code"]) + for f in fulfillments], ["id", "shopify_fulfillment", "location", "tipo", "bodega"]) + + print("\n Pasos (documentos SAP)") + docs = conn.execute( + "SELECT * FROM order_documents WHERE order_sys_id = ? ORDER BY sequence, fulfillment_sys_id, sys_id", (o["sys_id"],) + ).fetchall() + tabla([(d["sequence"], d["doc_type"], d["fulfillment_sys_id"], d["status"], d["sap_doc_entry"], d["sap_doc_num"], + d["attempts"], hora_local(d["time_update"]), d["error_message"]) for d in docs], + ["seq", "documento", "fulfillment", "estado", "DocEntry", "DocNum", "intentos", "actualizado (local)", "error"], max_ancho=70) + + +# ============================================================================ +def main() -> None: + parser = argparse.ArgumentParser(description="Consultas de solo lectura sobre la BD de Shopify → SAP") + sub = parser.add_subparsers(dest="comando", required=True) + sub.add_parser("resumen") + for nombre in ("descargadas", "integradas", "errores"): + p = sub.add_parser(nombre) + p.add_argument("--store") + p.add_argument("--limit", type=int, default=50) + p = sub.add_parser("orden") + p.add_argument("--name", required=True) + p.add_argument("--store") + + args = parser.parse_args() + with conectar() as conn: + {"resumen": resumen, "descargadas": descargadas, "integradas": integradas, "errores": errores, "orden": orden}[args.comando](conn, args) + + +if __name__ == "__main__": + main() diff --git a/tools/descargar_ordenes_especiales.py b/tools/descargar_ordenes_especiales.py new file mode 100644 index 0000000..ac3e6e4 --- /dev/null +++ b/tools/descargar_ordenes_especiales.py @@ -0,0 +1,78 @@ +""" +Descarga a download/ el JSON de Shopify (orden + transacciones) de los casos +especiales de una tienda, para analizarlos aparte. Solo lectura sobre Shopify +y la BD; solo escribe archivos locales en download/. + +Se consideran especiales las órdenes de la BD que no están integradas (pending, +error, not_eligible, in_progress) y que, al traer el detalle, cumplen alguno de: + - tienen refunds + - financial_status distinto de paid + - no tienen fulfillments + - tienen costo de envío > 0 + - están en status error en la BD + +Uso: + .venv/bin/python -m tools.descargar_ordenes_especiales +""" + +import json +import logging +import sys +from pathlib import Path + +from core.config import cargar_config_tienda +from core.db import db_session +from core.shopify_client import ShopifyClient + +ROOT = Path(__file__).resolve().parent.parent +DOWNLOAD_DIR = ROOT / "download" + + +def motivos(detalle: dict, status_bd: str) -> list[str]: + razones = [] + if detalle.get("refunds"): + razones.append("reembolsos") + if detalle.get("financial_status") != "paid": + razones.append(f"financial_status={detalle.get('financial_status')}") + if not detalle.get("fulfillments"): + razones.append("sin fulfillments") + envio = float(((detalle.get("total_shipping_price_set") or {}).get("shop_money") or {}).get("amount", 0) or 0) + if envio > 0: + razones.append(f"envío={envio:g}") + if status_bd == "error": + razones.append("status error en BD") + return razones + + +def main(store_code: str) -> None: + cfg = cargar_config_tienda(ROOT / "config" / "stores" / f"{store_code}.json") + shopify = ShopifyClient.from_store_config(cfg["shopify"]) + + with db_session() as conn: + filas = conn.execute( + "SELECT shopify_order_id, order_number, status FROM orders WHERE store_code = ? AND status != 'integrated' " + "AND status != 'timeout' ORDER BY shopify_order_id", + (store_code,), + ).fetchall() + + DOWNLOAD_DIR.mkdir(exist_ok=True) + guardadas = [] + for f in filas: + detalle = shopify.obtener_orden(f["shopify_order_id"]) + razones = motivos(detalle, f["status"]) + if not razones: + continue + nombre = detalle.get("name") + transacciones = shopify.obtener_transacciones(f["shopify_order_id"]) + json.dump({"order": detalle}, open(DOWNLOAD_DIR / f"shopify_{nombre}.json", "w", encoding="utf-8"), indent=2, ensure_ascii=False) + json.dump({"transactions": transacciones}, open(DOWNLOAD_DIR / f"shopify_transactions_{nombre}.json", "w", encoding="utf-8"), indent=2, ensure_ascii=False) + guardadas.append((nombre, f["status"], "; ".join(razones))) + + print(f"{store_code}: {len(filas)} órdenes revisadas, {len(guardadas)} casos especiales guardados en download/") + for g in guardadas: + print(f" {g[0]:<8} BD={g[1]:<12} {g[2]}") + + +if __name__ == "__main__": + logging.disable(logging.CRITICAL) + main(sys.argv[1]) diff --git a/tools/entrada_mercancia_ordenes.py b/tools/entrada_mercancia_ordenes.py new file mode 100644 index 0000000..e8421ad --- /dev/null +++ b/tools/entrada_mercancia_ordenes.py @@ -0,0 +1,65 @@ +""" +Entrada de Mercancía (InventoryGenEntries) solo con los SKUs de órdenes +puntuales de Shopify (no todo el grupo de artículos). DRY-RUN por defecto. + +Uso: + .venv/bin/python -m tools.entrada_mercancia_ordenes config/stores/snobbycl.json \ + --warehouse 8801 --price 5000 --account 1-1-080-40-000 --quantity 2 \ + --orders 18909739614486 18909352329494 [--apply] +""" + +import argparse +import json +import logging + +from core.config import cargar_config_tienda +from core.errors import SAPServiceLayerError +from core.sap import SAPServiceLayerClient +from core.shopify_client import ShopifyClient + + +def main(store_config_path: str, warehouse: str, price: float, account: str, quantity: float, orders: list, dry_run: bool) -> None: + cfg = cargar_config_tienda(store_config_path) + shopify = ShopifyClient.from_store_config(cfg["shopify"]) + sap = SAPServiceLayerClient.from_store_config(cfg["sap_connection"]) + + skus = {} + for order_id in orders: + for li in shopify.obtener_orden(order_id).get("line_items", []): + sku = (li.get("sku") or "").strip() + if sku: + skus.setdefault(sku, li.get("name")) + + payload = { + "Comments": f"Stock inicial pruebas ({cfg['store_code']})", + "DocumentLines": [ + {"ItemCode": sku, "WarehouseCode": warehouse, "Quantity": quantity, "UnitPrice": price, "AccountCode": account} + for sku in skus + ], + } + + print(f"{cfg['store_code']}: {len(skus)} SKUs de {len(orders)} órdenes | almacén={warehouse} precio={price} cuenta={account} cantidad={quantity}") + if dry_run: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + print("(dry-run: no se envió nada a SAP, correr con --apply para crear el documento)") + return + + try: + data = sap.forward("POST", "InventoryGenEntries", json_data=payload).json() + print(f"Entrada de mercancía creada. DocEntry={data.get('DocEntry')} DocNum={data.get('DocNum')}") + except SAPServiceLayerError as exc: + print(f"ERROR creando la entrada de mercancía: {exc.detail}") + + +if __name__ == "__main__": + logging.disable(logging.CRITICAL) + parser = argparse.ArgumentParser() + parser.add_argument("store_config") + parser.add_argument("--warehouse", required=True) + parser.add_argument("--price", type=float, required=True) + parser.add_argument("--account", required=True) + parser.add_argument("--quantity", type=float, required=True) + parser.add_argument("--orders", type=int, nargs="+", required=True) + parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)") + args = parser.parse_args() + main(args.store_config, args.warehouse, args.price, args.account, args.quantity, args.orders, dry_run=not args.apply) diff --git a/tools/integrar_ordenes.py b/tools/integrar_ordenes.py new file mode 100644 index 0000000..52c1c65 --- /dev/null +++ b/tools/integrar_ordenes.py @@ -0,0 +1,122 @@ +""" +Integra órdenes puntuales de una tienda, de inicio a fin (Invoice -> Payment +-> ITR -> StockTransfer -> Delivery), sin pasar por el ciclo del scheduler. + +Por defecto es DRY-RUN: valida cada orden y muestra el payload de la Invoice +y del Payment (este último armado sobre un total equivalente) sin escribir en +SAP ni en la base de datos. Con --apply inserta la orden en `orders` y corre +el pipeline real para ese id (crea documentos en SAP). + +Uso: + python -m tools.integrar_ordenes [...] # dry-run + python -m tools.integrar_ordenes [...] --apply +""" +import json +import sys +from pathlib import Path + +from core.config import cargar_config_tienda +from core.db import get_order_sys_id, insert_order_header, db_session +from core.logger_manager import order_context +from core.sap import SAPServiceLayerClient +from core.shopify_client import ShopifyClient +from domain.business_partner import buscar_rut_en_order +from domain.inventory_transfer import resolver_location +from domain.invoice import construir_invoice +from domain.payment import PaymentConfigError, construir_incoming_payment +from domain.rut import formatear_rut +from pipeline.runner import ESTADOS_FINANCIEROS_FACTURABLES, analizar_y_procesar_orden + +STORES_DIR = Path(__file__).resolve().parent.parent / "config" / "stores" + + +def _dry_run(cfg: dict, shopify: ShopifyClient, order_id: int) -> None: + detalle = shopify.obtener_orden(order_id) + print(f"{detalle.get('name')} | financial={detalle.get('financial_status')} fulfillment={detalle.get('fulfillment_status')}") + + if detalle.get("cancelled_at"): + print(f"-> cancelled (anulada el {detalle['cancelled_at'][:10]}): no se crearía nada") + return + if detalle.get("financial_status") not in ESTADOS_FINANCIEROS_FACTURABLES or not detalle.get("fulfillments"): + print("-> not_eligible (no está pagada o no tiene fulfillments): no se crearía nada") + return + + envio = float(((detalle.get("total_shipping_price_set") or {}).get("shop_money") or {}).get("amount", 0) or 0) + if envio > 0: + print(f"-> error: costo de envío ({envio}) no soportado") + return + + rut = buscar_rut_en_order(detalle) + card_code = cfg["sap"]["generic_card_code"] if not rut else "(se resuelve en SAP por RUT)" + print(f"RUT: {rut or 'sin RUT en note_attributes'} -> CardCode: {card_code}") + + for f in detalle["fulfillments"]: + loc = resolver_location(cfg, f["location_id"]) + print(f"fulfillment {f['id']} location {f['location_id']} -> {loc}") + + if rut: + print("(orden con RUT: la Invoice real depende del BusinessPartner en SAP; se muestra con CardCode genérico)") + transacciones = shopify.obtener_transacciones(order_id) + payload = construir_invoice( + detalle, cfg, cfg["sap"]["generic_card_code"], federal_tax_id=formatear_rut(rut) if rut else None, transactions=transacciones + ) + print("--- Invoice ---") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + + total = sum(l["Quantity"] * l["PriceAfterVAT"] for l in payload["DocumentLines"]) + invoice_sim = {"CardCode": payload["CardCode"], "CardName": payload.get("CardName", ""), "DocDate": payload["DocDate"]} + try: + pago = construir_incoming_payment(detalle, transacciones, cfg, invoice_sim, 0, total) + print("--- IncomingPayment (DocEntry de la Invoice = 0 en dry-run) ---") + print(json.dumps(pago, indent=2, ensure_ascii=False)) + except PaymentConfigError as exc: + print(f"--- IncomingPayment: ERROR, no se crearía el pago: {exc}") + + +def _apply(cfg: dict, shopify: ShopifyClient, order_id: int) -> None: + store_code = cfg["store_code"] + orden = shopify.obtener_orden(order_id) + insert_order_header( + store_code, + { + "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"), + }, + ) + sys_id = get_order_sys_id(store_code, order_id) + with db_session() as conn: + row = dict(conn.execute("SELECT * FROM orders WHERE sys_id = ?", (sys_id,)).fetchone()) + with order_context(store_code, order_id): + ok = analizar_y_procesar_orden(cfg, row) + with db_session() as conn: + estado = conn.execute("SELECT status, last_error FROM orders WHERE sys_id = ?", (sys_id,)).fetchone() + docs = conn.execute( + "SELECT doc_type, status, sap_doc_num, error_message FROM order_documents WHERE order_sys_id = ? ORDER BY sequence", + (sys_id,), + ).fetchall() + print(f"resultado={ok} status={estado['status']} last_error={estado['last_error']}") + for d in docs: + print(f" {d['doc_type']}: {d['status']} DocNum={d['sap_doc_num']} {d['error_message'] or ''}") + + +def main() -> None: + args = sys.argv[1:] + apply = "--apply" in args + args = [a for a in args if a != "--apply"] + store_code, ids = args[0], [int(x) for x in args[1:]] + + cfg = cargar_config_tienda(STORES_DIR / f"{store_code}.json") + shopify = ShopifyClient.from_store_config(cfg["shopify"]) + + print(f"MODO: {'APPLY (escribe en SAP)' if apply else 'DRY-RUN (no escribe nada)'} | tienda {store_code} | {len(ids)} órdenes") + for order_id in ids: + print(f"\n===== Orden {order_id} =====") + (_apply if apply else (lambda c, s, o: _dry_run(c, s, o)))(cfg, shopify, order_id) + + +if __name__ == "__main__": + main() diff --git a/tools/probar_pago.py b/tools/probar_pago.py new file mode 100644 index 0000000..eb2522b --- /dev/null +++ b/tools/probar_pago.py @@ -0,0 +1,68 @@ +""" +Dry-run del IncomingPayment: para una o más órdenes de una tienda descarga +detalle y transacciones de Shopify (GET), toma la Invoice de SAP si la orden +ya está integrada (GET) o una sintética si no, y muestra el JSON de pago que +se enviaría. NO escribe en SAP ni en la base de datos. + +Uso: python -m tools.probar_pago [...] +""" +import json +import sys +from decimal import Decimal +from pathlib import Path + +from core.config import cargar_config_tienda +from core.db import db_session +from core.sap import SAPServiceLayerClient +from core.shopify_client import ShopifyClient +from domain.payment import PaymentConfigError, construir_incoming_payment, transacciones_efectivas + +STORES_DIR = Path(__file__).resolve().parent.parent / "config" / "stores" + + +def _invoice(sap: SAPServiceLayerClient, store_code: str, order_id: int, detalle: dict) -> tuple[dict, str]: + with db_session() as conn: + row = conn.execute( + """ + SELECT d.sap_doc_entry FROM order_documents d JOIN orders o ON o.sys_id = d.order_sys_id + WHERE o.store_code = ? AND o.shopify_order_id = ? AND d.doc_type = 'INVOICE' AND d.status = 'created' + """, + (store_code, order_id), + ).fetchone() + if row: + return sap.forward("GET", f"Invoices({row['sap_doc_entry']})").json(), f"Invoice real DocEntry {row['sap_doc_entry']}" + + sintetica = { + "DocEntry": 0, + "CardCode": "C99998", + "CardName": "(Invoice sintética)", + "DocDate": detalle["created_at"][:10], + "DocumentLines": [{"Quantity": 1, "PriceAfterVAT": float(detalle["total_price"])}], + } + return sintetica, "Invoice sintética (orden aún sin Invoice en SAP)" + + +def main() -> None: + store_code, ids = sys.argv[1], [int(x) for x in sys.argv[2:]] + cfg = cargar_config_tienda(STORES_DIR / f"{store_code}.json") + shopify = ShopifyClient.from_store_config(cfg["shopify"]) + sap = SAPServiceLayerClient.from_store_config(cfg["sap_connection"]) + + for order_id in ids: + print(f"\n===== Orden {order_id} =====") + detalle = shopify.obtener_orden(order_id) + transacciones = shopify.obtener_transacciones(order_id) + invoice, origen = _invoice(sap, store_code, order_id, detalle) + total = sum(Decimal(str(l["Quantity"])) * Decimal(str(l["PriceAfterVAT"])) for l in invoice["DocumentLines"]) + + print(f"{detalle.get('name')} | payment_gateway_names={detalle.get('payment_gateway_names')} | total_price={detalle.get('total_price')}") + print(f"{len(transacciones)} transacciones -> {len(transacciones_efectivas(transacciones))} efectivas | {origen} | total Invoice={total}") + try: + payload = construir_incoming_payment(detalle, transacciones, cfg, invoice, invoice["DocEntry"], float(total)) + print(json.dumps(payload, indent=2, ensure_ascii=False)) + except PaymentConfigError as exc: + print(f"ERROR (no se crearía el pago): {exc}") + + +if __name__ == "__main__": + main() diff --git a/tools/sap.http b/tools/sap.http new file mode 100644 index 0000000..7efd55e --- /dev/null +++ b/tools/sap.http @@ -0,0 +1,46 @@ +### +# @noRejectUnauthorized +POST {{SAP_URL}}/Login +Content-Type: application/json + +{ + "CompanyDB": "{{SAP_COMPANY_DB}}", + "UserName": "{{SAP_USERNAME}}", + "Password": "{{SAP_PASSWORD}}" +} + + +### +# @noRejectUnauthorized +POST {{SAP_URL}}/Login +Content-Type: application/json + +{ + "CompanyDB": "{{SAP_COMPANY_DB}}", + "UserName": "{{SAP_USERNAME}}", + "Password": "{{SAP_PASSWORD}}" +} + + +### +# @noRejectUnauthorized +GET {{SAP_URL}}/Invoices(334) +Content-Type: application/json + + +### +# @noRejectUnauthorized +GET {{SAP_URL}}/IncomingPayments(47) +Content-Type: application/json + + +### +# @noRejectUnauthorized +GET {{SAP_URL}}/BusinessPartners('C70000') +Content-Type: application/json + +### +# @noRejectUnauthorized +GET {{SAP_URL}}/IncomingPayments(89) +Content-Type: application/json + diff --git a/tools/shopify.http b/tools/shopify.http new file mode 100644 index 0000000..8239824 --- /dev/null +++ b/tools/shopify.http @@ -0,0 +1,42 @@ + +### +# @name obtenerOrdenes +GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders.json + + +### +# @name obtenerOrdenesFields +GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders.json?fields=created_at,id,gateway,order_number,name,financial_status,fulfillment_status,total_price,tags&status=any&since_id=7425850048790&limit=50 + + +### +# @name obtenerOrden +GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders/7554120581398.json + + + +### +# @name obtenerTransactions +GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders/7429185339670/transactions.json + + +### +# @name obtenerLocations +GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/locations.json + +# 90870743318 + + +### +# @name obtenerOrdenesPorRangoFecha +GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders.json?fields=created_at,id,gateway,order_number,name,financial_status,fulfillment_status,total_price,tags&status=any&created_at_min=2026-08-01T00:00:00-04:00&created_at_max=2026-08-02T23:59:59-04:00&limit=50 + + +### +# @name obtenerCliente +GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/customers/12324710613270.json + + +### +# @name obtenerClienteMetafields +GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/customers/12324710613270/metafields.json diff --git a/tools/transferir_stock.py b/tools/transferir_stock.py new file mode 100644 index 0000000..828098d --- /dev/null +++ b/tools/transferir_stock.py @@ -0,0 +1,67 @@ +""" +Utilidad puntual (no forma parte del pipeline recurrente): crea un +StockTransfer directo entre dos almacenes para todos los SKU del grupo de +artículos indicado. + +Uso: + .venv/bin/python tools/transferir_stock.py config/stores/snobbycl.json \ + --from 8801 --to 7701 --quantity 500 + + Agregar --apply para ejecutar el POST real (por defecto es dry-run). +""" + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.config import cargar_config_tienda +from core.errors import SAPServiceLayerError +from core.sap import SAPServiceLayerClient +from tools.cargar_entrada_mercancia import obtener_skus_grupo, ITEMS_GROUP_CODE + + +def main(store_config_path: str, from_wh: str, to_wh: str, quantity: float, dry_run: bool) -> None: + store_config = cargar_config_tienda(store_config_path) + sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"]) + + skus = obtener_skus_grupo(sap, ITEMS_GROUP_CODE) + print(f"SKUs encontrados en grupo {ITEMS_GROUP_CODE}: {len(skus)}") + + document_lines = [ + { + "ItemCode": sku, + "Quantity": quantity, + "FromWarehouseCode": from_wh, + "WarehouseCode": to_wh, + } + for sku in skus + ] + + payload = {"StockTransferLines": document_lines} + + if dry_run: + print(json.dumps(payload["StockTransferLines"][:3], indent=2, ensure_ascii=False)) + print(f"... ({len(document_lines)} líneas en total, de {from_wh} a {to_wh}, cantidad={quantity})") + print("(dry-run: no se envió nada a SAP, correr con --apply para crear el documento)") + return + + try: + resp = sap.forward("POST", "StockTransfers", json_data=payload) + data = resp.json() + print(f"StockTransfer creado. DocEntry={data.get('DocEntry')} DocNum={data.get('DocNum')}") + except SAPServiceLayerError as exc: + print(f"ERROR creando el StockTransfer: {exc.detail}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("store_config") + parser.add_argument("--from", dest="from_wh", required=True) + parser.add_argument("--to", dest="to_wh", required=True) + parser.add_argument("--quantity", type=float, required=True) + parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)") + args = parser.parse_args() + main(args.store_config, args.from_wh, args.to_wh, args.quantity, dry_run=not args.apply)