Delivery Adapter — última milla

Adapter que encapsula la integración con la plataforma de entregas de última milla (Bringoz, con Nexus como alternativa) detrás de una API interna consumida por el CSB vía Cloud Tasks, y que convierte los callbacks del proveedor en eventos de Pub/Sub.

NestJS 11 · Node 20 Bringoz (default) Nexus (feature flag) Bringoz QAS cableado (6 endpoints) Nexus en stub Cloud Run · puerto 8080 env: — v—

Resumen

Qué hace hoy

Expone crear / consultar / cancelar orden de entrega con un patrón Strategy por proveedor, recibe webhooks de Bringoz y publica el estado resultante en Pub/Sub. Toda petición lleva X-Correlation-ID (recibido o generado) y queda registrada con método, ruta, código y duración.

Qué falta

El provider de Nexus (sigue en stub), la consulta de estado de orden en Bringoz (track, no cubierta por la doc de Bebbia), la publicación en Pub/Sub tras create-order, la validación de firma de los webhooks y la cola de Cloud Tasks del lado del CSB. Ver pendientes.

Identidad del servicio

Componente
csb-delivery-adapter
Cloud Run
csb-delivery-adapter-<env>-api
Imagen
us-central1-docker.pkg.dev/<project>/csb-adapters/csb-delivery-adapter:<sha>
Región
us-central1
Proyecto
rtp-transversal-dev

Equipo y origen

Stakeholder
Hugo Quintero (ArchOps)
Desarrollo
Héctor Cruz, Carlos Martínez
Origen
Scaffold nuevo (ago 2026), no migrado del CSB
Rama activa
dev → despliega con Cloud Build

Arquitectura

Cómo encaja con el CSB

  1. El CSB encola una tarea de Cloud Tasks hacia POST /delivery/create-order firmada con la SA csb-dlv-adapter-<env>-tasks-sa (única con run.invoker).
  2. El adapter selecciona el proveedor activo (DELIVERY_PROVIDER) y responde con el DeliveryResult en la misma llamada.
  3. Cuando el proveedor cambia el estado de la entrega, llama a POST /webhooks/bringoz; el adapter publica el resultado en delivery.completed.v1.
  4. El CSB consume la suscripción y continúa el flujo de negocio.
Webhooks e ingress interno Con ingress INTERNAL_LOAD_BALANCER, Bringoz no puede alcanzar /webhooks/bringoz desde internet. Hará falta exponer esa ruta por un balanceador externo (Cloud Armor + NEG serverless, como hace el CSB) y validar la firma del webhook antes de abrirla.

Módulos internos

MóduloResponsabilidadArchivos clave
DeliveryModuleEndpoints /delivery/*; DeliveryService elige el proveedor al arrancar y delega.delivery/delivery.controller.ts, delivery/delivery.service.ts
BringozProvider / NexusProviderImplementan DeliveryProvider (createOrder, trackOrder, cancelOrder) y, Bringoz, también SchedulingProvider (depots, cotización, polling, dispatch). Bringoz llama a sandbox.bringoz.com vía BringozClient; Nexus sigue en stub.delivery/providers/bringoz.client.ts, bringoz.types.ts, *.provider.ts, delivery-provider.interface.ts
WebhooksModuleRecibe callbacks de Bringoz, mapea eventType → DeliveryResult.status y publica.webhooks/webhooks.controller.ts, webhooks/webhooks.service.ts
PubSubModulePubSubService.publishResult() sobre el topic de resultados.pubsub/pubsub.service.ts
HealthModuleLiveness y readiness basada en configuración del proveedor.health/health.controller.ts
DocsModuleEste portal, la guía, Swagger UI (CDN) y el openapi.json mantenido a mano.docs/
common/CorrelationIdMiddleware, RequestLoggerMiddleware (todas las rutas) y GlobalExceptionFilter.common/middlewares/*.ts, common/filters/global-exception.filter.ts
config/loadDeliveryConfig() dual-mode env / Secret Manager; parseProvider() normaliza el flag.config/app.config.ts

Bootstrap (main.ts): ValidationPipe global (transform, whitelist), GlobalExceptionFilter, CORS restringido a CORS_ORIGINS con métodos GET/POST y headers Content-Type, X-Correlation-ID, Authorization.

Flujos

A · Crear orden (Cloud Tasks → proveedor)

  1. Cloud Tasks → POST /delivery/create-orderCuerpo DeliveryPayloadDto. El middleware fija X-Correlation-ID (del header o UUID nuevo) y lo devuelve en la respuesta.
  2. Validaciónenvelope.id y envelope.data obligatorios; el resto opcional. Campos desconocidos se descartan.
  3. Provider activoDeliveryService resolvió el proveedor al arrancar (provider_selected). createOrder(envelope.data).
  4. Respuesta 200{ status: "ok", provider, result }. Con Bringoz: result.status es accepted (2xx de Bringoz) o rejected (4xx, con el body de Bringoz en raw); 5xx agotados tras los reintentos → 500. No se publica en Pub/Sub en este flujo.

B · Crear orden desde un push de Pub/Sub

  1. Pub/Sub → POST /delivery/eventsPush subscription con OIDC de sa-csb-api-<env> (tiene run.invoker sobre el servicio). El envelope CloudEvents v1.0 viaja en message.data (base64).
  2. Decodificación y validacióndecodeEventEnvelope(): base64 → JSON → EventEnvelopeDto (id, type, source, specversion y data obligatorios). Cualquier defecto → 400.
  3. Filtro por tipoSolo delivery.order.requested.v1 y order.placed.v1 (ORDER_EVENT_TYPES). Otro tipo → 400: Pub/Sub no reintenta un 4xx, así que un mensaje irrecuperable no se queda en bucle.
  4. Mismo camino que create-ordertaskMetadata sintético — taskName = message.messageId, retryCount 0, queueName = subscription. Respuesta 204 sin cuerpo (ack); un 500 sí se reintenta hasta el ack deadline.

C · Webhook de Bringoz → Pub/Sub

  1. Bringoz → POST /webhooks/bringozWebhookEventDto: eventType y orderId obligatorios.
  2. Mapeo de estadoorder_accepted → accepted, order_rejected → rejected, order_delivered → delivered, order_cancelled → cancelled, order_in_transit → pending, cualquier otro → pending.
  3. PublicaciónPubSubService.publishResult(orderId, result, { webhookEvent }) en PUBSUB_RESULT_TOPIC. Si falla → 500 y Bringoz debe reintentar.
  4. Respuesta 200 { status: "ok", acknowledged: true }

Endpoints

Referencia interactiva en Swagger UI (carga /docs/openapi.json, mantenido a mano porque este adapter no usa @nestjs/swagger).

POST/delivery/eventspush subscriptionPub/Sub (CSB) · OIDC sa-csb-api-<env>
Request body — PubSubPushDto
{
  "message": {
    // EventEnvelope (CloudEvents v1.0) serializado como JSON y en base64
    "data": "eyJpZCI6ImExYjJjM2Q0LWU1ZjYtNzg5MC1hYmNkLWVmMTIzNDU2Nzg5MCIsIC4uLn0=",
    "messageId": "11209384756102",
    "publishTime": "2026-09-11T18:20:00Z"
  },
  "subscription": "projects/rtp-transversal-dev/subscriptions/csb-delivery-orders-sub"
}

// message.data decodificado — EventEnvelopeDto:
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "type": "delivery.order.requested.v1",
  "source": "//bebbia/order-service",
  "specversion": "1.0",
  "time": "2026-09-11T18:19:58Z",
  "entityid": "FC-1001",
  "data": { … mismo body Bringoz que envelope.data de create-order … }
}

Desenvuelve el push, valida el envelope y — si type es delivery.order.requested.v1 u order.placed.v1 — lo procesa igual que create-order, sintetizando taskMetadata (taskName = messageId, retryCount 0, queueName = subscription).

204 sin cuerpo400 envelope inválido o type no soportado500 error del proveedor

Todo lo irrecuperable responde 400 a propósito: Pub/Sub no reintenta un 4xx. Un 500 sí se reintenta hasta el ack deadline de la suscripción.

POST/delivery/create-orderPUT /v2/oms/orders-v2Cloud Tasks (CSB)
Request body — DeliveryPayloadDto
{
  "envelope": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "entityId": "ORD-000123",
    "correlationId": "corr-7f3a",
    // data = body de Bringoz ya mapeado por el CSB (doc "Conexión Bringoz QAS")
    "data": {
      "externalId": "FC-1001-Installation",
      "fulfillmentLines": [
        {
          "id": "FC-1001",
          "type": "Delivery",
          "programName": "INSTALACION",
          "destinationTask": {
            "purpose": "Dropoff",
            "contact": { "name": "Ana López", "phoneNo": "5512345678" },
            "location": { "address": { … street, city, zipCode, country, location … } }
          },
          "lineItems": [ { "id": "300432" } ]
        }
      ],
      "itemLines": [ { "id": "300432", "sku": "300432", "count": 1, "type": "BasePack" } ],
      "metaData": [ { "key": "ServiceType", "value": "Instalacion-NuevoActivo" } ],
      "tags": [ "Entrega" ]
    }
  },
  "taskMetadata": {
    "queueName": "csb-delivery-adapter-queue",
    "retryCount": 0
  }
}
// envelope.id y envelope.data son obligatorios; entityId, correlationId y taskMetadata son opcionales
Response 200
{
  "status": "ok",
  "provider": "bringoz",
  "result": {
    "orderId": "FC-1001-Installation",
    "status": "accepted",
    "providerRef": "FC-1001-Installation",
    "raw": { … respuesta de Bringoz … }
  }
// 4xx de Bringoz → status "rejected", providerRef "" y su body en raw (sigue siendo HTTP 200)
}
200400 envelope inválido500 error del proveedor
GET/delivery/track/:orderIdsin endpoint en la docCSB

La doc "Conexión Bringoz QAS" no incluye consulta de orden por externalId (el cierre llega por webhook). Devuelve { status: "ok", result: { orderId, status: "pending", providerRef: orderId } } sin llamar a Bringoz hasta confirmar el endpoint.

POST/delivery/cancel/:orderIdPOST /v2/oms/orders-v2/cancel/{externalId}CSB

Cancela una orden en Bringoz (compensación). :orderId es el externalId {IDFluent}-{Kindflag}. Cuerpo opcional { "reason": "Cancelacion-Flow_failed" }; reason y description van al body de Bringoz. Devuelve { status: "ok", result: { orderId, status: "cancelled", providerRef: orderId, raw } }.

POST/delivery/:orderId/lines/:lineId/depotsPOST …/options/locationsCSB (GetTimes)

Depots candidatos del área. :orderId = {IDFluent}-{Kindflag}, :lineId = IDFluent. Body opcional { "taskPurpose": "Dropoff", "zoneId": "America/Mexico_City" }. Estrategia LOCATION_DEPOT_IN_OPERATIONAL_UNIT. Se filtran como Bebbia: areaIds no vacío y externalId de 8 caracteres (el locationRef con el que se consulta inventario en Fluent). Devuelve { status, provider, depots: [{ id, externalId, zoneId, raw }] }.

POST/delivery/:orderId/lines/:lineId/time-slotsPOST …/options/summaryCSB (GetTimes)

Abre la cotización de horarios (internalQuoteType=DynamicTimeSlot). Body { depotIdList: [máx. 5], taskPurpose?, notBefore, notAfter (epoch ms), zoneId?, secToWait? (2; 20 para auronix) }. Devuelve { status, provider, quote: { quoteId, status, pending, options: [{ optionId, notBefore, notAfter, pickupNotBefore?, pickupNotAfter?, depotId, depotExternalId, zoneId }], raw } }. Si pending es true, consultar el quote por id.

GET/delivery/:orderId/lines/:lineId/time-slots/:quoteIdGET …/options/summary/{quoteId}CSB (GetTimes async)

Polling del quote asíncrono. Misma forma de respuesta que la cotización.

POST/delivery/:orderId/lines/:lineId/dispatch/:optionIdPOST /v2/oms/orders/…/dispatch/{optionId}/CSB (AssignTimes)

Asigna el slot elegido (optionId de la cotización). Sin body. Devuelve { status, provider, result: { orderId, optionId, status: "assigned" | "rejected", providerRef: orderExternalId, raw } }. Un 404 de Bringoz (HTTP o embebido en un 200 con "status":404) se devuelve como rejected para que el CSB vuelva a cotizar y revierta suscripción/mantenimiento.

POST/webhooks/bringozpublica en Pub/SubBringoz (callback)
Request body — WebhookEventDto
{
  "eventType": "order_delivered",
  "orderId": "ORD-000123",
  "status": "DELIVERED",
  "trackingUrl": "https://track.bringoz.com/ORD-000123",
  "timestamp": "2026-09-09T18:20:00Z",
  "details": {
    "driver": "…",
    "signature": "…"
  }
}
Response 200
{ "status": "ok", "acknowledged": true }

Sin autenticación ni verificación de firma por ahora; no exponer públicamente hasta resolverlo.

GET/healthStartup + liveness probe · uptime check
{ "status": "ok", "timestamp": "…", "service": "csb-delivery-adapter", "version": "1.0.0", "provider": "bringoz" }
GET/health/readyHumanos · smoke

status: "ok" si el proveedor activo tiene host configurado, "not_configured" si no. Siempre HTTP 200. Terraform apunta ambos probes a /health, no a esta ruta.

GET/docs · /docs/api · /docs/openapi.json · /docs/how-it-worksPersonas

Portal, Swagger UI (assets desde cdnjs), OpenAPI 3.1 y guía de despliegue. GET / redirige a /docs. Activos fuera de production; DOCS_ENABLED fuerza el estado.

Contratos y payloads

DeliveryPayloadDto

CampoTipoReq.Descripción
envelope.idstringsíID del evento; se registra como event_id.
envelope.entityIdstringnoEntidad de negocio (ordering key en el CSB).
envelope.correlationIdstringnoCorrelación de negocio; el header X-Correlation-ID es independiente.
envelope.dataobjectsíBody de Bringoz ya mapeado por el CSB: externalId ({IDFluent}-{Kindflag}), fulfillmentLines, itemLines, metaData, tags. accountId se completa desde BRINGOZ_ACCOUNT_ID si no viene. El orderId de la respuesta es el externalId.
taskMetadata.queueNamestringno (sí si se envía taskMetadata)Nombre de la cola.
taskMetadata.retryCountnumbernoIntentos previos.
Diferencia con los otros adapters CRM y ERP exigen un envelope CloudEvents completo (source, type, specversion, entityid) y taskMetadata.retryCount. Aquí el envelope es mínimo y taskMetadata es opcional. Al publicar @rtp-csb/contracts conviene alinear los tres.

DeliveryResult

CampoTipoDescripción
orderIdstringOrden de negocio.
statusaccepted · rejected · pending · delivered · cancelledEstado normalizado entre proveedores.
providerRefstringReferencia en el proveedor (vacía en stubs; en webhooks se copia orderId).
trackingUrlstring?URL de seguimiento si el proveedor la entrega.
rawobject?Payload crudo (orden enviada o details del webhook).

Mensaje publicado en Pub/Sub (webhooks)

// topic: PUBSUB_RESULT_TOPIC (default delivery.completed.v1)
{
  "eventId": "ORD-000123",
  "result": {
    "orderId": "ORD-000123",
    "status": "delivered",
    "providerRef": "ORD-000123",
    "trackingUrl": "https://track.bringoz.com/ORD-000123",
    "raw": { … details del webhook … }
  },
  "publishedAt": "2026-09-09T18:20:01.000Z",
  "source": "csb-delivery-adapter"
}
// atributos: eventId, provider ("bringoz" si providerRef no esta vacio, si no "unknown"), status, webhookEvent

No es un envelope CloudEvents como en CRM/ERP: el consumidor del CSB debe tratarlo como JSON plano.

Errores y reintentos

{
  "success": false,
  "statusCode": 400,
  "error": "Bad Request Exception",
  "timestamp": "2026-09-09T15:04:05.000Z"
}
  • 400DTO inválido. El detalle de campos no viaja en la respuesta (solo error: "Bad Request Exception"); está en el log unhandled_exception.
  • 500Excepción del proveedor o de Pub/Sub. Con los stubs actuales solo puede ocurrir en /webhooks/bringoz (publicación).
  • 200Operación aceptada; el estado de negocio va en result.status.

La política de reintentos la define la cola de Cloud Tasks del CSB (pendiente de crear). Los webhooks dependen de la política de reintento de Bringoz. El adapter no deduplica: un webhook repetido publica dos mensajes.

Integración con el CSB: cómo lo consume el bus

De extremo a extremo: qué sistema dispara, por qué puerta entra al Core Service Bus, cómo llega hoy el bus a Bringoz (directo, sin este adapter), qué ya está cableado hacia el adapter, qué falta para que los flujos pasen por él y cómo la tienda o Data Mesh usan el resultado. Fuente: rtp-csb en dev (seed, seed-data/*, infra/envs/dev) al 10 sep 2026.

Mapa de extremo a extremo

Situación real: Bringoz es hoy un nodo external-api de los pipelines Bebbia. Este adapter está desplegado, publicado en el balanceador y cableado por remote state en el CSB, pero ningún flujo lo llama y sus providers son stubs. Esta página documenta ambos estados para que la migración sea un cambio de flujo, no un rediseño.

Quién dispara y por qué puerta entra al CSB

PuertaQuién la usaAutenticaciónCuándoQué pasa con Bringoz
Topic bebbia.order.paid.v1 (JSON Schema: order_number, store_key, payment_id)CommerceTools (OrderPaymentAdded) vía backend BebbiaIAM pubsub.publisher; ordering key order_numberPago confirmadocrear-orden-trabajo-bebbia crea la orden de campo {idFluent}-Installation (PUT /v2/oms/orders-v2)
Topic bebbia.purifier.order.requested.v1Tienda BebbiaIAM pubsub.publisherPedido de purificadorStock en Fluent → orden + instalador (Fluent) + WS1; Bringoz no interviene
API trigger (ejecución del flujo con inputTemplate)Backend Bebbia (GetTimes / AssignTimes)JWT del CSB o WebHook firmadoEl cliente pide horarios y elige unoobtener-horarios-instalacion: depots + options; asignar-horario-instalacion: dispatch del slot con compensación
WebHook de flujo POST https://csb-dev.rotoplas.com/api/flows/{flowId}/webhookBringoz al cerrar la orden de instalación (CSB-01, origin: "BRINGOZ")HMAC-SHA256, header X-CSB-SignatureEquipo entregado / instaladoEl CSB verifica GET /v2/oms/orders/{orderId} = COMPLETED y dispersa el status a SAP ECC, Data Mesh y Portal
Ejecución manual / editorPersonasLogin CSBPruebas y demosMismos flujos contra mock-bringoz-api en local

Ejemplo · cierre de orden reportado por Bringoz (CSB-01, contrato inputTemplate)

{
  "eventId": "EVT-ASSET-0001",
  "origin": "BRINGOZ",
  "occurredAt": "2026-09-02T15:00:00.000Z",
  "assetSerialNumber": "ACT-000123",
  "assetSku": "SKU-DEMO-01",
  "status": "EN_CASA_CLIENTE",
  "custodianId": "",
  "custodianName": "",
  "locationId": "",
  "orderRef": "ORD-DEMO-001",
  "bringozOrderId": "FC-ORDER-1001-Installation",
  "customerRef": "CLIENT-KEY-1",
  "config": {
    "sapPlant": "MX01",
    "bringozTenantId": "rotoplas",
    "sapMovementTypes": { … },
    "sapStorageLocations": { … },
    "custodianTypes": { … },
    "locationTypes": { … }
  }
}
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOKS_SIGNING_KEY" | awk '{print $2}')
curl -s -X POST "https://csb-dev.rotoplas.com/api/flows/$FLOW_ID/webhook" \
  -H "Content-Type: application/json" -H "X-CSB-Signature: $SIG" -d "$BODY"

Cómo llega el CSB a este adapter (cableado real)

  1. Outputs del adapterrtp-delivery-adapter/infra/terraform/environments/dev expone service_url, service_account_email, cloud_tasks_invoker_email (csb-dlv-adapter-dev-tasks-sa), cloud_tasks_queue y result_topic (commit 63e28d4). Estado en gs://rtp-delivery-adapter-dev-tf-state/terraform/state.
  2. Remote state en el CSBdata "terraform_remote_state" "delivery_adapter" → local.adapters.delivery = { url, queue = null, tasks_sa, result_topic = null } (commit 40352ce). Esos null ya son corregibles: el adapter expone cloud_tasks_queue y result_topic; falta que el CSB los lea del remote state.
  3. Variable de entornoDELIVERY_ADAPTER_URL llega a csb-api y al job de seed, pero el seed no la lee todavía.
  4. Identidadsa-csb-api-dev@… tiene iam.serviceAccountUser sobre csb-dlv-adapter-dev-tasks-sa, que a su vez tiene run.invoker en el adapter. csb-api ya puede encolar tareas con OIDC hacia /delivery/*.
  5. RegistryEl seed registra la ApiDefinition Bringoz Logistics v2 (proveedor externo, no el adapter): POST /auth/token, POST /v2/oms/depots, PUT /v2/oms/orders-v2, GET /v2/oms/orders/{orderId}, GET …/options, POST …/dispatch/{slotId}; API key x-api-key desde el secreto csb-dev-bringoz-credential. No hay Adapter ni Connector de delivery.
  6. ResultadoEl adapter publica en PUBSUB_RESULT_TOPIC solo desde /webhooks/bringoz. El topic ya lo declara este repo (Terraform) y se expone como output result_topic; falta la suscripción del lado del CSB.

Flujos que hablan con Bringoz hoy (directo)

FlujoDisparadorMomento de negocioLlamadas a BringozCompensaciónPublica
crear-orden-trabajo-bebbia · etapa logistica-fluent-bringozbebbia.order.validated.v1 (tras bebbia.order.paid.v1)Orden pagada y validada: crear la orden de campo para instalarPOST /auth/token → PUT /v2/oms/orders-v2 con externalId = {idFluent}-Installation, Dropoff, dimensiones, coordenadas, contacto, metaDataPUT /v2/oms/orders-v2 con action: CANCELbebbia.work.order.created.v1
obtener-horarios-instalacionAPI trigger (GT_Installation / GT_Maintenance)El cliente va a elegir horarioPOST /v2/oms/depots → GET /v2/oms/orders/{idFluent}-{Kindflag}/line/{idFluent}/options?siteId=—bebbia.installation.times.fetched.v1
asignar-horario-instalacionAPI trigger (AT_Installation / AT_Maintenance)El cliente eligió horarioPOST …/options/dispatch/{slotId} (sin cuerpo) tras reservar el equipoLiberar reserva; bebbia.installation.dispatch.failed.v1 → rollback + re-consultabebbia.installation.times.assigned.v1
actualizar-status-activo-inventario (CSB-01)WebHook de Bringoz o topic del PortalEquipo instalado / recogidoGET /v2/oms/orders/{bringozOrderId} para confirmar COMPLETED y tomar al técnico—inventory.asset.status.validated.v1 → SAP ECC, Data Mesh, Portal

Ejemplo del paso actual (nodo try del editor, credencial del catálogo):

{
  "url": "{{BRINGOZ}}/v2/oms/orders-v2",
  "method": "PUT",
  "headers": [ { "name": "Authorization", "value": "${\"Bearer \" + brzToken.body.token}" } ],
  "args": {
    "externalId": "${string(fluentWO.body.data.createOrder.id) + \"-Installation\"}",
    "siteId": "${args.payload.config.SiteIDBringoz}",
    "accountId": "${args.payload.config.BringozAccountId}",
    "programName": "…",
    "fulfillmentLines": [ { "purpose": "Dropoff", "itemLines": [ … ] } ],
    "address": { … calle, número, ciudad, estado, cp, lat, lon … },
    "contact": { … nombre, apellidos, email, teléfonos … },
    "metaData": [ { "key": "OrderNumber_CT_FC", "value": "${args.payload.orderNumber}" }, … ]
  },
  "result": "bringozOrder",
  "retryPolicy": { "maxAttempts": 3, "initialDelaySeconds": 1, "maxDelaySeconds": 10, "multiplier": 2 }
}

Cómo quedaría vía adapter

Paso del flujo (call con OIDC)

{
  "url": "{{DELIVERY}}/delivery/create-order",
  "method": "POST",
  "auth": "OIDC",
  "args": {
    "envelope": {
      "id": "${args.metadata.correlationId}",
      "entityId": "${args.payload.orderNumber}",
      "data": {
        "externalId": "${string(fluentWO.body.data.createOrder.id) + \"-Installation\"}",
        "siteId": "${args.payload.config.SiteIDBringoz}",
        "fulfillmentLines": [ … ],
        "address": { … },
        "contact": { … },
        "metaData": [ … ]
      }
    }
  },
  "result": "bringozOrder"
}
// condición: ${bringozOrder.body.result.status == "accepted"}
// referencia: ${bringozOrder.body.result.providerRef}

Qué gana el CSB

  • Token y API key de Bringoz solo en el adapter (Secret Manager del adapter); el CSB usa OIDC.
  • Un DeliveryResult normalizado para Bringoz y Nexus; cambiar de proveedor es un secreto, no un flujo.
  • Los webhooks de Bringoz entran por /webhooks/bringoz y salen como evento Pub/Sub, en lugar de que Bringoz firme HMAC contra un flujo concreto.
  • Cancelación y tracking (/delivery/cancel/:id, /delivery/track/:id) como pasos reutilizables en cualquier flujo.

Costo: implementar BringozProvider real, publicar el resultado en create-order, y exponer la cola y el topic en los outputs del adapter para que el remote state del CSB los lea.

Qué pasa con el resultado

Hoy

Los flujos publican su propio resultado (bebbia.work.order.created.v1, bebbia.installation.times.*, inventory.asset.status.validated.v1) y los consumers del CSB (audit, notification) o el backend Bebbia los leen. Bringoz reporta cambios de estado con el WebHook HMAC del flujo CSB-01.

Vía adapter

/webhooks/bringoz convierte cada callback en un mensaje JSON en delivery.completed.v1 (eventId, result.status ∈ accepted · rejected · pending · delivered · cancelled, atributos webhookEvent, status). El CSB necesitaría el topic en Terraform, una suscripción (csb-subscription) y un flujo disparado por ese topic (por ejemplo CSB-01 con origin: "BRINGOZ").

Cómo lo consume un externo (tienda Bebbia, Data Mesh, Portal)

Necesidad del externoPatrón en el CSBEjemplo vivoPasos
La tienda quiere que una orden pagada termine agendada con un instaladorPublicar bebbia.order.paid.v1; la cadena de orquestadores hace Fluent + Bringoz + mantenimiento + SAP en paralelocrear-orden-trabajo-bebbia (4 orquestadores encadenados por topics)pubsub.publisher, cumplir el JSON Schema (order_number, store_key, payment_id)
La tienda quiere horarios y elegir uno con respuesta inmediataAPI trigger: ejecutar el flujo con su inputTemplate y leer el resultado síncronoobtener-horarios-instalacion → bebbia.installation.times.fetched.v1; asignar-horario-instalacionPOST /api/provisioning/flows/{id}/execute con JWT, o WebHook firmado si se agrega un nodo webhook al flujo
Bringoz quiere avisar que instalóHoy: WebHook HMAC del flujo CSB-01. Objetivo: POST /webhooks/bringoz del adapter → Pub/SubCSB-01 validar-cambio-status-activo (origin: BRINGOZ)Hoy: entregarle a Bringoz flowId y secreto de firma. Objetivo: exponer /webhooks/bringoz por el balanceador con firma y cablear el topic
Data Mesh quiere la línea de tiempo del activo (custodio, ubicación, entrega)Destino del CSB: nodo external-api con idempotencia por lastEventIdCSB-01 actualizar-data-mesh: POST /v1/domains/inventory/assets/{serialNumber}/status-eventsConfirmar el endpoint real (DATA_MESH_API_URL, secreto csb-dev-data-mesh-credential); ya está cableado contra mock
Un dominio quiere enterarse de entregas (analítica, notificaciones)Suscripción pull propia con DLQ e idempotencia por eventIdMódulo csb-subscription, consumers audit/analyticsTerraform sobre delivery.completed.v1 (cuando exista) o sobre bebbia.installation.times.assigned.v1

Cuándo · para qué · por qué

Cuándo (evento)Para qué (resultado)Por qué pasa por el CSB (y por qué convendría el adapter)
Orden pagada y validadaOrden de campo en Bringoz ligada a la orden Fluent, mantenimiento y suscripciónLogística y finanzas en paralelo con compensación por etapa; un fallo de Bringoz cancela solo la orden de campo. El adapter aislaría credenciales y el formato Bringoz del grafo
El cliente elige horarioSlot despachado y confirmado por correoReserva de equipo antes del dispatch y rollback declarado si el slot ya no está; el adapter daría track/cancel reutilizables
Bringoz cierra la ordenActivo en casa del cliente en SAP ECC, Data Mesh y PortalSe verifica el cierre en Bringoz antes de dispersar; idempotencia por eventId; cada destino falla por separado

Paso a paso: conectar este adapter en el CSB

  1. Leer los outputs del adaptercloud_tasks_queue y result_topic ya salen de infra/terraform/environments/<env>/outputs.tf (hecho del lado del adapter). Falta en el CSB mapear el remote state a local.adapters.delivery.queue/result_topic en vez de null.
  2. Proveedor realHecho: BringozProvider cableado contra QAS con Basic estático (sin /auth/token) — PUT /v2/oms/orders-v2, cancelación, depots, cotización, polling y dispatch. Falta publicar DeliveryResult en Pub/Sub también tras create-order y confirmar el endpoint de consulta (track).
  3. Sembrar el registryEn seed.ts: DELIVERY: process.env.DELIVERY_ADAPTER_URL en pipelineBases, ApiDefinition Delivery Adapter (Cloud Run) con /delivery/create-order, /delivery/events, /delivery/cancel/{orderId} y las rutas de agendado /delivery/{orderId}/lines/{lineId}/…, Connector bringoz-delivery-connector y Adapter csb-delivery-adapter (cloudRunUrl = DELIVERY_ADAPTER_URL, probe /health).
  4. Identidad de los workflowscsb-api ya puede firmar como csb-dlv-adapter-dev-tasks-sa; las SAs de Cloud Workflows necesitan run.invoker en csb-delivery-adapter-dev-api.
  5. Cambiar el paso del flujoEn crear-orden-trabajo-bebbia reemplazar el try Bringoz por {{DELIVERY}}/delivery/create-order con auth: OIDC, compensación {{DELIVERY}}/delivery/cancel/{orderId}; ajustar condiciones a body.result.status.
  6. Cerrar el ciclo con los webhooksPublicar /webhooks/bringoz por el balanceador (ruta solo para ese prefijo, firma de Bringoz), crear en el CSB la suscripción a delivery.completed.v1 y disparar CSB-01 desde ahí.
  7. Deploy, ejecución y observaciónPreview GCP → Deploy → POST /api/provisioning/flows/{id}/execute con el inputTemplate; ver /monitoring y en el adapter create_order_received → create_order_completed con el X-Correlation-ID.

Brechas entre lo que el CSB necesita y lo que el adapter expone

  • deudaEl CSB modela Bringoz distinto a como es. El seed y mock-bringoz-api asumen x-api-key → POST /auth/token → Bearer y rutas /v2/oms/depots, /v2/oms/times. La doc "Conexión Bringoz QAS" (Bebbia, 10 sep 2026) fija Basic estático, x-bringoz-tenant-id, siteId en query y las rutas orders-v2/…/options/locations|summary. Este adapter ya sigue la doc; el CSB debe migrar sus flujos a él o corregir su ApiDefinition y mock.
  • bloqueanteSin registro en el CSB. No hay Adapter, Connector ni ApiDefinition del adapter en el seed; DELIVERY_ADAPTER_URL llega a csb-api pero nadie la usa.
  • resueltoOutputs de cola y topic. Terraform ya declara la cola csb-delivery-adapter-<env>-queue y el topic de resultados, y los expone como cloud_tasks_queue / result_topic (63e28d4). Pendiente del lado del CSB: consumirlos por remote state en vez de dejarlos en null.
  • deudaContrato de envelope distinto por Cloud Tasks. DeliveryPayloadDto (id, data, taskMetadata opcional) no coincide con el SyncPayloadDto CloudEvents de CRM/ERP ni con @rtp-csb/contracts. Por Pub/Sub ya no aplica: POST /delivery/events acepta el envelope CloudEvents v1.0 tal cual (EventEnvelopeDto).
  • deudaWebhooks. /webhooks/bringoz no valida firma y el servicio tiene ingress interno: Bringoz no puede llegar. Mientras tanto Bringoz usa el WebHook HMAC del flujo CSB-01.
  • deudaPush a /events: falta el prefijo. El adapter ya expone POST /delivery/events (envelope CloudEvents, 204/400), pero un edge topic→adapter en el editor sigue provisionando la suscripción push a <cloudRunUrl>/events. Alinear el editor a /delivery/events (o montar un alias) y usar OIDC con sa-csb-api-<env>, que ya tiene run.invoker.
  • hechoCloud Run desplegado con VPC y monitoring, documentación pública, remote state en el CSB con URL y SA de tasks, actAs de csb-api sobre la SA de tasks, pipelines Bebbia funcionando contra Bringoz directo con compensaciones.

Configuración

loadDeliveryConfig() lee todo de variables de entorno con USE_SECRET_MANAGER=false; con true lee los secretos csb-<env>-* por API (los de Nexus con tolerancia a fallo). Terraform además monta seis de esos secretos (los cinco de Bringoz y delivery-provider) como variables de entorno vía secret_key_ref, así que en Cloud Run los valores llegan por dos caminos; gana la lectura por API.

VariableSecreto (csb-<env>-…)DefaultDescripción
DELIVERY_PROVIDERdelivery-providerbringozbringoz | nexus; cualquier otro valor → bringoz.
BRINGOZ_HOST / BRINGOZ_BASIC_AUTH / BRINGOZ_TENANT_ID / BRINGOZ_SITE_ID / BRINGOZ_ACCOUNT_IDbringoz-host, bringoz-basic-auth, bringoz-tenant-id, bringoz-site-id, bringoz-account-id—Requeridos con Secret Manager (fallan el arranque si no existen). QAS: host https://sandbox.bringoz.com, tenant rotoplas; BRINGOZ_BASIC_AUTH es base64(id:secret) (el prefijo Basic es opcional, se normaliza), estático (sin OAuth ni refresh).
BRINGOZ_ZONE_ID / BRINGOZ_TIMEOUT_MS—America/Mexico_City / 30000Zona horaria por defecto (zoneId de depots y cotización) y timeout por request.
NEXUS_HOST / NEXUS_API_KEY / NEXUS_CLIENT_IDnexus-host, nexus-api-key, nexus-client-id—Opcionales.
PUBSUB_RESULT_TOPIC— (ya no sale de un secreto)delivery.completed.v1Topic de resultados. Terraform lo declara (google_pubsub_topic.result, variable result_topic_name) e inyecta su nombre como env var plana. El secreto csb-<env>-pubsub-result-topic se sigue creando pero ya no se usa.
GOOGLE_CLOUD_PROJECT——Proyecto para Secret Manager y Pub/Sub.
USE_SECRET_MANAGER—falsetrue en Cloud Run.
CORS_ORIGINS—vacío (ninguno)Lista separada por comas.
PORT / NODE_ENV—8080 / developmentTerraform: dev, qa, o production para prd.
DOCS_ENABLED—autotrue/false fuerza el portal. Sin definir: activo salvo production.
Sufijo de ambiente Terraform usa el ambiente prd, pero el código deriva el sufijo de secretos de NODE_ENV: production → prod. En producción los secretos deberán llamarse csb-prod-* aunque el resto de la infra use prd.

Infraestructura (Terraform)

Módulos networking, secrets, compute y monitoring en infra/terraform/modules, con ambientes dev, qa y prd. Es el único adapter con VPC propia y alertas declaradas.

Cloud Run (compute)

Nombre
csb-delivery-adapter-<env>-api
Ingress
INTERNAL_LOAD_BALANCER
SA
csb-dlv-adapter-<env>-sa — logWriter, metricWriter, secretAccessor, pubsub.publisher
Recursos dev
1 vCPU · 512 Mi · min 0 · max 3
Probes
startup y liveness en /health
Egress
VPC connector, PRIVATE_RANGES_ONLY
Invoker
csb-dlv-adapter-<env>-tasks-sa y sa-csb-api-<env> → run.invoker

Networking

VPC
csb-delivery-adapter-<env>-vpc · subred 10.0.0.0/24
NAT
Cloud Router + NAT (logs de errores)
Connector
csb-dlv-adp-<env>-conn · 10.8.0.0/28

Monitoring

Canal
email csb-alerts-dev@rotoplas.com
Alertas
5xx > 5 % (5 min) · p95 > 5 s (5 min) · instancias ≥ 80 % del máximo
Uptime
check HTTPS a /health cada 60 s

Secrets, cola y topic

Secrets
10 secretos csb-<env>-* creados por Terraform (valores manuales o vía bootstrap); 6 se montan como env var
Cloud Tasks
csb-delivery-adapter-<env>-queue declarada aquí; sa-csb-api-<env> con cloudtasks.enqueuer
Pub/Sub
Topic de resultados declarado aquí (result_topic_name); su nombre viaja al contenedor como PUBSUB_RESULT_TOPIC
Outputs
service_url, service_account_email, cloud_tasks_invoker_email, cloud_tasks_queue, result_topic

CI/CD — Cloud Build

Trigger: push a dev (y main). Sustituciones _REGION, _ENV, _SERVICE. Tags de imagen $SHORT_SHA y latest.

  1. audit → install → lint + testnpm audit --audit-level=high, luego lint y jest --coverage en paralelo (sin umbral obligatorio).
  2. docker build · pushMulti-stage non-root; .dockerignore excluye infra, tests y markdown.
  3. terraformImporta los secretos existentes al state si faltan, luego plan + apply con image_tag=$SHORT_SHA. Aquí Terraform sí actualiza la imagen del servicio (no hay paso --no-traffic: la revisión nueva recibe tráfico directamente).
  4. health-checkComprueba que latestCreatedRevisionName == latestReadyRevisionName vía Admin API.
Documentación en cada deploy src/docs/public/ (portal, guía, Swagger UI, openapi.json) se copia a dist/docs/public/ en nest build y viaja en la imagen. Un commit a dev publica la documentación con la siguiente revisión.

Observabilidad

Origen / actionNivelCamposCuándo
HTTP (RequestLoggerMiddleware)INFOmethod, path, statusCode, duration_ms, correlationId, userAgentCada respuesta.
provider_selectedINFOproviderArranque.
create_order_received / create_order_completed / create_order_failedINFO / ERRORevent_id, provider, status, duration_ms, error_messageFlujo A.
create_order / track_order / cancel_orderINFOprovider, orderId, reasonDentro del provider (stub).
bringoz_webhook_hit / webhook_receivedINFOeventType, orderId, statusFlujo B.
pubsub_initialized / result_publishedINFOtopic, project, messageId, statusPub/Sub.
unhandled_exceptionERRORmethod, path, statusCode, error, correlationIdGlobalExceptionFilter.
resource.type="cloud_run_revision" resource.labels.service_name="csb-delivery-adapter-dev-api"
jsonPayload.correlationId="<X-Correlation-ID>"

Las alertas de 5xx, latencia p95 e instancias y el uptime check están en el módulo monitoring.

Runbook

La revisión no queda Ready

  • Faltan secretos bringoz-* o delivery-provider: loadDeliveryConfig lanza al arrancar. Crear versiones (bash scripts/bootstrap.sh dev) y redeplegar.
  • Terraform intenta montar secret_key_ref de un secreto sin versiones: la revisión falla antes de arrancar el contenedor.

/health/ready devuelve not_configured

El proveedor activo no tiene host. Revisar DELIVERY_PROVIDER y el secreto *-host correspondiente. Cloud Run no reinicia por esto (los probes usan /health).

Webhook responde 500

Fallo al publicar: la SA sin pubsub.publisher, topic inexistente en el proyecto o GOOGLE_CLOUD_PROJECT vacío. Buscar unhandled_exception con el correlationId de la respuesta.

Probar un webhook a mano

curl -s -X POST "$DELIVERY_ADAPTER_URL/webhooks/bringoz" \
  -H "Authorization: Bearer $(gcloud auth print-identity-token)" \
  -H "Content-Type: application/json" -H "X-Correlation-ID: test-$(date +%s)" \
  -d '{"eventType":"order_delivered","orderId":"ORD-TEST-1"}'
# requiere run.invoker y una ruta de red interna al servicio

Cambiar de proveedor

printf 'nexus' | gcloud secrets versions add csb-dev-delivery-provider --data-file=-
# redeploy: el proveedor se resuelve una sola vez al arrancar

Ver la documentación desplegada

El servicio no es alcanzable desde internet. En dev está publicada por el balanceador del CSB: csb-dev.rotoplas.com/adapters/delivery/docs (solo el prefijo /docs; los endpoints de negocio no se exponen). En local: npm run start:dev y abrir http://localhost:8080/docs.

Desarrollo local y pruebas

Comandos

npm ci
cp .env.example .env         # DELIVERY_PROVIDER, BRINGOZ_*, USE_SECRET_MANAGER=false
npm run start:dev            # → http://localhost:8080/docs
npm test · npm run test:cov · npm run test:e2e · npm run lint · npm run build
docker compose up            # app + emulador de Pub/Sub (:8085)

Integración con mocks

Desde rotoplas/, docker-compose.integration.yml levanta CSB + adapters + mock Bringoz (:4003). Este adapter queda en http://localhost:8083, Swagger en /docs/api. Ver INTEGRATION.md.

Pruebas

23 unitarias (delivery.service, providers, webhooks.service, pubsub.service) + e2e de health (test/health.e2e-spec.ts, requiere jest --config test/jest-e2e.json). El módulo docs agrega sus propias suites. INSTALL.md menciona un mínimo de cobertura del 80 % que el pipeline no aplica.

Convenciones

  • feature/* → dev → main; nunca push directo a main.
  • Conventional Commits; secretos solo en Secret Manager; recursos GCP solo por Terraform.
  • Tipos compartidos desde @rtp-csb/contracts cuando se publique.

Pendientes y deuda técnica

  • hechoBringoz QAS cableado (10 sep 2026) a partir de la doc "Conexión Bringoz QAS" de Bebbia: crear, cancelar, depots, cotización, polling y dispatch contra sandbox.bringoz.com, con los mismos reintentos por endpoint que Bebbia.
  • pendienteConsulta de orden en Bringoz (track): la doc de Bebbia no la incluye; confirmar endpoint con Bringoz (el CSB-01 asume GET /v2/oms/orders/{orderId}). Spec de Nexus sigue pendiente.
  • pendientePublicar resultado tras create/cancel: hoy solo los webhooks publican en Pub/Sub; el CSB no recibe evento por una orden creada.
  • hechoCola de Cloud Tasks y topic de resultados declarados en este repo (63e28d4): csb-delivery-adapter-<env>-queue, topic result_topic_name, run.invoker y cloudtasks.enqueuer para sa-csb-api-<env>, más los outputs para el remote state del CSB.
  • hechoEntrada por Pub/Sub: POST /delivery/events acepta el envelope CloudEvents v1.0 de los topics del CSB (204 / 400 sin reintento). Queda alinear también el DeliveryPayloadDto de Cloud Tasks con @rtp-csb/contracts.
  • pendienteSeguridad del webhook: firma/secreto compartido y exposición controlada por balanceador externo.
  • pendienteConvención de naming (revisión de Hugo, 26 ago 2026): secretos delivery-<env>-bringoz-* y Artifact Registry propio delivery-adapter; las rutas ya cumplen (/delivery/* en inglés).
  • deudaAtributo provider del mensaje Pub/Sub se infiere de providerRef (siempre bringoz/unknown), no del proveedor real.
  • deudaPipeline sin canary (--no-traffic) ni umbral de cobertura; e2e no se ejecutan en CI.
  • hechoScaffold completo: strategy de proveedores, webhooks → Pub/Sub, middlewares de correlación y logging, Terraform con VPC/secrets/monitoring por ambiente, pipeline, portal de documentación.

Referencias

RecursoDónde
Repositoriogitlab.com/…/adapters/rtp-delivery-adapter
Core Service Busgitlab.com/…/rtp-csb · web csb-dev.rotoplas.com
CRM Adapter (HubSpot)rtp-crm-adapter · Cloud Run <project>-<env>-crm-adapter · csb-dev.rotoplas.com/adapters/crm/docs
ERP Adapter (SAP)rtp-erp-adapter · Cloud Run csb-erp-adapter · csb-dev.rotoplas.com/adapters/erp/docs
Guía de integración y despliegue/docs/how-it-works
API Reference/docs/api · /docs/openapi.json
Documentos del repoREADME.md, INSTALL.md, CLAUDE.md, SOUL.md, NEXT_STEPS.md, COMPONENTS.mermaid, FLOWS.mermaid