Files
georgeandClaude Sonnet 5.5 901dd8763d Integración Shopify → SAP Business One (snobbycl y on-chile)
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 <noreply@anthropic.com>
2026-09-30 19:22:40 -05:00

123 lines
5.5 KiB
Python

"""
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 <store_code> <shopify_order_id> [...] # dry-run
python -m tools.integrar_ordenes <store_code> <shopify_order_id> [...] --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()