openapi: 3.1.0
info:
  title: Orderafy API — Integración de Traslados (v1)
  description: |
    API pública de Orderafy para que los sistemas de International (y futuros
    clientes) se integren **desde adentro hacia afuera**: nuestros endpoints
    viven en Internet y sus sistemas, en redes privadas, solo necesitan salida
    HTTPS.

    **Autenticación:** API key por cliente vía header `X-API-Key`. La llave se
    entrega una sola vez al momento de generarla (`npm run api:key`). Si se
    pierde, se revoca la anterior y se genera otra.

    **Estado de una orden:** `imported` → `assigned` → `in_transit` →
    `delivered`. `on_hold` (incidencia abierta) y `cancelled` son estados
    laterales.

    **Idempotencia:** crear una orden con un `orderNumber` existente devuelve
    409; confirmar una entrega ya confirmada devuelve 200 con
    `alreadyDelivered: true`. Los reintentos de webhooks son seguros.

    **Errores:** siempre `{"error": "mensaje"}` con el status HTTP correcto.
  version: 1.0.0
  contact:
    name: Orderafy — Soporte de integración
    email: integracion@orderafy.mx
servers:
  - url: https://orderafy.app/api/v1
    description: Producción
  - url: http://localhost:3000/api/v1
    description: Desarrollo local

tags:
  - name: Órdenes
    description: Creación y consulta de órdenes de traslado
  - name: Webhooks
    description: Notificaciones entrantes de los sistemas del cliente
  - name: Operación
    description: Endpoints públicos de operación
  - name: Configuración (PWA)
    description: Catálogos server-driven que la PWA del chofer consume para renderizar sus formularios (secciones de foto, campos del vehículo, checklist). Al cambiar estos catálogos, la PWA cambia sin redeploy.
  - name: Administración de catálogos
    description: CRUD de catálogos (solo rol admin del portal). Cada cambio afecta a la PWA inmediatamente.
  - name: Telemetría (Fase A)
    description: Tracking de recorrido GPS con consentimiento auditable del chofer y configuración por tenant (diseño TELEMETRIA_HIMEX_v0.1). Ingesta desde la PWA (JWT del chofer) y vistas de monitoreo/reportes (sesión del portal).

paths:
  /api/config:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Configuración (PWA)]
      summary: Configuración server-driven para la PWA
      description: >
        Devuelve los catálogos activos del tenant (secciones de foto, campos
        del vehículo, checklist de inspección) ordenados por sort_order.
        Autenticación dual: JWT bearer del chofer (PWA) o sesión del portal.
      security:
        - driverJwt: []
        - portalSession: []
      parameters:
        - name: tenant
          in: query
          required: false
          schema:
            type: string
            default: himex
          description: Slug del tenant (por defecto "himex")
        - name: moment
          in: query
          required: false
          schema:
            type: string
            enum: [receipt, delivery]
          description: Filtra por momento (applies_to IN (momento,'both')); omitir devuelve todos
      responses:
        "200":
          description: Catálogos del tenant
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CatalogConfig"
        "400":
          description: moment inválido o tenant desactivado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin autenticación (ni JWT de chofer ni sesión de portal)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Tenant no encontrado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/admin/evidence-sections:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Administración de catálogos]
      summary: Lista secciones de foto (incluye inactivas)
      security:
        - portalSession: []
      parameters:
        - name: tenant_id
          in: query
          required: false
          schema:
            type: integer
          description: Filtra por tenant; omitir devuelve todas
      responses:
        "200":
          description: Secciones de foto
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/EvidenceSection"
    post:
      tags: [Administración de catálogos]
      summary: Crea una sección de foto (rol admin)
      security:
        - portalSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EvidenceSectionInput"
      responses:
        "201":
          description: Sección creada
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EvidenceSection"
        "400":
          description: Payload inválido o tenant inválido/desactivado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Se requiere rol admin
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Código duplicado para el tenant
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/admin/evidence-sections/{id}:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    put:
      tags: [Administración de catálogos]
      summary: Actualiza una sección de foto (rol admin)
      security:
        - portalSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EvidenceSectionInput"
      responses:
        "200":
          description: Sección actualizada
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EvidenceSection"
        "404":
          description: Sección no existe
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Administración de catálogos]
      summary: Borra una sección de foto (rol admin)
      description: >
        409 si el código está en uso por órdenes existentes — en ese caso
        desactívala con active=false.
      security:
        - portalSession: []
      responses:
        "200":
          description: Borrada
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                    example: true
        "404":
          description: Sección no existe
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Código en uso por órdenes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/admin/vehicle-fields:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Administración de catálogos]
      summary: Lista campos del vehículo (incluye inactivos)
      security:
        - portalSession: []
      parameters:
        - name: tenant_id
          in: query
          required: false
          schema:
            type: integer
          description: Filtra por tenant; omitir devuelve todos
      responses:
        "200":
          description: Campos del vehículo
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/VehicleField"
    post:
      tags: [Administración de catálogos]
      summary: Crea un campo del vehículo (rol admin)
      security:
        - portalSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VehicleFieldInput"
      responses:
        "201":
          description: Campo creado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VehicleField"
        "400":
          description: Payload inválido o tenant inválido/desactivado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Se requiere rol admin
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Código duplicado para el tenant
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/admin/vehicle-fields/{id}:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    put:
      tags: [Administración de catálogos]
      summary: Actualiza un campo del vehículo (rol admin)
      security:
        - portalSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VehicleFieldInput"
      responses:
        "200":
          description: Campo actualizado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VehicleField"
        "404":
          description: Campo no existe
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Administración de catálogos]
      summary: Borra un campo del vehículo (rol admin)
      description: >
        409 si el código está en uso por órdenes existentes — en ese caso
        desactívalo con active=false.
      security:
        - portalSession: []
      responses:
        "200":
          description: Borrado
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                    example: true
        "404":
          description: Campo no existe
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Código en uso por órdenes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/admin/inspection-items:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Administración de catálogos]
      summary: Lista ítems de inspección (incluye inactivos)
      security:
        - portalSession: []
      parameters:
        - name: tenant_id
          in: query
          required: false
          schema:
            type: integer
          description: Filtra por tenant; omitir devuelve todos
      responses:
        "200":
          description: Ítems de inspección
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/InspectionItem"
    post:
      tags: [Administración de catálogos]
      summary: Crea un ítem de inspección (rol admin)
      security:
        - portalSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InspectionItemInput"
      responses:
        "201":
          description: Ítem creado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InspectionItem"
        "400":
          description: Payload inválido o tenant inválido/desactivado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Se requiere rol admin
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Código duplicado para el tenant
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/admin/inspection-items/{id}:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    put:
      tags: [Administración de catálogos]
      summary: Actualiza un ítem de inspección (rol admin)
      security:
        - portalSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InspectionItemInput"
      responses:
        "200":
          description: Ítem actualizado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InspectionItem"
        "404":
          description: Ítem no existe
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Administración de catálogos]
      summary: Borra un ítem de inspección (rol admin)
      description: >
        409 si el código está en uso por órdenes existentes — en ese caso
        desactívalo con active=false.
      security:
        - portalSession: []
      responses:
        "200":
          description: Borrado
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                    example: true
        "404":
          description: Ítem no existe
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Código en uso por órdenes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /health:
    get:
      tags: [Operación]
      summary: Health check público
      description: >
        Verifica conectividad con la API y su base de datos. Sin
        autenticación; útil para validar el enlace antes de integrar.
      security: []
      responses:
        "200":
          description: API y base de datos disponibles
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok]
                  service:
                    type: string
                    example: orderafy-api-v1
                  database:
                    type: boolean
                    example: true
                  time:
                    type: string
                    format: date-time
        "503":
          description: API en pie pero base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /orders:
    post:
      tags: [Órdenes]
      summary: Crear una orden de traslado
      description: >
        Registra una orden de traslado en Orderafy con el número de orden del
        sistema del cliente. Si `orderNumber` ya existe devuelve 409 (la
        consulta de estado es por GET /orders/{orderNumber}).
      operationId: createOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrderRequest"
            examples:
              unidadInternational:
                summary: Unidad International hacia concesionario
                value:
                  orderNumber: "INT-2026-0831-001"
                  unitVIN: "3HCDZAPR9SL123456"
                  unitDescription: "Camión International HV 2026"
                  origin: "Planta Escobedo, Nuevo León"
                  destination: "Distribuidor International Monterrey, NL"
                  dealerName: "International Monterrey"
                  scheduledDate: "2026-08-31"
                  observations: "Entrega programada antes de las 12:00"
      responses:
        "201":
          description: Orden creada (incluye estado inicial y evidencia vacía)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderStatus"
        "400":
          description: Campos obligatorios faltantes o formato inválido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: API key faltante o inválida
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Ya existe una orden con ese orderNumber
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /orders/{orderNumber}:
    get:
      tags: [Órdenes]
      summary: Consultar el estado de una orden
      description: >
        Devuelve los datos de la orden, su estado, el chofer asignado (si
        aplica), el resumen de evidencia fotográfica y las incidencias
        abiertas/resueltas. Es el endpoint principal de seguimiento para el
        sistema del cliente.
      operationId: getOrderStatus
      parameters:
        - name: orderNumber
          in: path
          required: true
          description: Número de orden (tal como se envió al crearla)
          schema:
            type: string
            example: "INT-2026-0831-001"
      responses:
        "200":
          description: Estado completo de la orden
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderStatus"
        "401":
          description: API key faltante o inválida
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: La orden no existe
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /webhooks/delivery:
    post:
      tags: [Webhooks]
      summary: Confirmación de entrega (webhook)
      description: >
        El sistema del cliente notifica que la unidad llegó a su destino.
        Orderafy cierra la orden como `delivered`. Cada llamada queda registrada
        en la bitácora de webhooks (payload íntegro) para trazabilidad.

        **Idempotente:** si la orden ya estaba entregada responde 200 con
        `alreadyDelivered: true` sin cambios — los reintentos son seguros.

        **Recomendación de integración:** reintentar con backoff exponencial
        (1s, 2s, 4s...) ante 503 y 5xx; no reintentar ante 400/401/404/409.
      operationId: confirmDelivery
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeliveryWebhookRequest"
            examples:
              llegadaConfirmada:
                summary: Llegada registrada en el sistema del cliente
                value:
                  orderNumber: "INT-2026-0831-001"
                  deliveredAt: "2026-08-31T16:45:00-06:00"
                  receivedBy: "Almacén de producto terminado"
                  reference: "GR-88213"
                  observations: "Unidad sin novedad"
      responses:
        "200":
          description: Entrega confirmada (o ya estaba confirmada)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OrderStatus"
                  - type: object
                    required: [alreadyDelivered]
                    properties:
                      alreadyDelivered:
                        type: boolean
                        description: true si la orden ya estaba entregada (sin cambios)
        "400":
          description: Cuerpo inválido (orderNumber obligatorio, deliveredAt ISO-8601)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: API key faltante o inválida
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: La orden no existe (el evento queda en bitácora)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: La orden está cancelada; no se puede confirmar entrega
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /openapi:
    get:
      tags: [Operación]
      summary: Especificación OpenAPI 3.1 (YAML)
      description: >
        Este mismo documento, servido en texto plano. Puede pegarse en
        editor.swagger.io o importarse en Postman para generar el cliente.
      security: []
      responses:
        "200":
          description: Documento OpenAPI en YAML
          content:
            text/yaml:
              schema:
                type: string

  /api/drivers/{driverId}/telemetria:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Telemetría (Fase A)]
      summary: Configuración de telemetría + consentimiento del chofer
      description: >
        La PWA lo consume al abrir la app: con la config arma el aviso de
        privacidad y el intervalo adaptativo del GPS (server-driven); con el
        consentimiento decide si puede encender el tracking. consent es null
        si el chofer aún no ha aceptado el aviso.
      security:
        - driverJwt: []
      parameters:
        - name: driverId
          in: path
          required: true
          schema:
            type: integer
          description: Id del chofer (drivers.id); debe coincidir con el del token
      responses:
        "200":
          description: Config + consentimiento
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant:
                    type: object
                    properties:
                      id: { type: string, example: "1" }
                      slug: { type: string, example: "himex" }
                      name: { type: string, example: "Orderafy" }
                  config:
                    $ref: "#/components/schemas/TelemetryConfig"
                  consent:
                    $ref: "#/components/schemas/DriverConsent"
                  noticeVersion:
                    type: string
                    example: "v1"
                  fetchedAt:
                    type: string
                    format: date-time
        "401":
          description: Sin JWT válido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: El token no corresponde al chofer de la ruta
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/drivers/{driverId}/consent:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    post:
      tags: [Telemetría (Fase A)]
      summary: Registra/actualiza el consentimiento del chofer
      description: >
        Upsert por (driver_id, version). Apagar una bandera es la revocación
        (§7.2 del diseño): el servidor rechaza la ingesta del bloque revocado.
      security:
        - driverJwt: []
      parameters:
        - name: driverId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [gps, behavior, analytics]
              properties:
                version:
                  type: string
                  default: v1
                  description: Versión del aviso de privacidad
                gps:
                  type: boolean
                  description: Acepta tracking de recorrido (Fase A)
                behavior:
                  type: boolean
                  description: Acepta sensores de manejo (Fase B)
                analytics:
                  type: boolean
                  description: Acepta análisis de uso de la app (Fase C)
      responses:
        "200":
          description: Consentimiento registrado/actualizado
          content:
            application/json:
              schema:
                type: object
                properties:
                  consent:
                    $ref: "#/components/schemas/DriverConsent"
        "400":
          description: Payload inválido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin JWT válido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: El token no corresponde al chofer de la ruta
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Chofer inexistente
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/drivers/{driverId}/tracks:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    post:
      tags: [Telemetría (Fase A)]
      summary: Ingesta de puntos de recorrido (batch)
      description: >
        Idempotente por UNIQUE (order_id, device_ts): un reintento del mismo
        lote (mismo batchId) no duplica puntos. Validaciones: cada orden del
        batch pertenece al chofer y está en tránsito; consentimiento vigente
        si consent_required y gps_tracking_enabled; lat/lng en rango;
        deviceTs no más de 5 min en el futuro. Se rechaza el batch completo
        si el consentimiento se revocó o la telemetría está desactivada.
      security:
        - driverJwt: []
      parameters:
        - name: driverId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [batchId, points]
              properties:
                batchId:
                  type: string
                  format: uuid
                  description: Id del lote offline (reintento idempotente)
                points:
                  type: array
                  maxItems: 500
                  items:
                    $ref: "#/components/schemas/TrackPointInput"
      responses:
        "202":
          description: Batch aceptado (aceptados + duplicados)
          content:
            application/json:
              schema:
                type: object
                required: [accepted, duplicates]
                properties:
                  accepted:
                    type: integer
                    example: 30
                  duplicates:
                    type: integer
                    example: 0
        "400":
          description: Payload o punto inválido (orden no en tránsito, coordenadas fuera de rango, deviceTs futuro...)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin JWT válido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Token de otro chofer, telemetría desactivada o consentimiento revocado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/drivers/{driverId}/events:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    post:
      tags: [Telemetría (Fase B)]
      summary: Ingesta de eventos de manejo (batch)
      description: >
        Eventos de comportamiento de manejo detectados por la PWA (frenadas,
        aceleraciones, curvas, exceso de velocidad). Idempotente por UNIQUE
        (order_id, device_ts, type): un reintento del mismo lote (mismo
        batchId) no duplica eventos. Validaciones: cada orden del batch
        pertenece al chofer y está en tránsito; consentimiento de
        comportamiento vigente si consent_required y driver_events_enabled;
        type en el enum; deviceTs no más de 5 min en el futuro. Se rechaza
        el batch completo si el consentimiento se revocó o la telemetría
        está desactivada.
      security:
        - driverJwt: []
      parameters:
        - name: driverId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [batchId, events]
              properties:
                batchId:
                  type: string
                  format: uuid
                  description: Id del lote offline (reintento idempotente)
                events:
                  type: array
                  maxItems: 500
                  items:
                    $ref: "#/components/schemas/DriverEventInput"
      responses:
        "202":
          description: Batch aceptado (aceptados + duplicados)
          content:
            application/json:
              schema:
                type: object
                required: [accepted, duplicates]
                properties:
                  accepted:
                    type: integer
                    example: 30
                  duplicates:
                    type: integer
                    example: 0
        "400":
          description: Payload o evento inválido (orden no en tránsito, type fuera del enum, deviceTs futuro...)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin JWT válido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Token de otro chofer, eventos desactivados o consentimiento de comportamiento revocado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/ordenes/{id}/recorrido:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Telemetría (Fase A)]
      summary: Recorrido de una orden (polyline + estadísticas)
      description: >
        Mapa de recorrido: puntos ordenados por device_ts, distancia real
        (suma Haversine), duración, zonas de parada (speed < 5 km/h por
        > 2 min) y distancia estimada origen→destino. Rol admin/monitoreo
        (admin|staff|dealer; el dealer solo ve sus órdenes).
        estimatedDistanceKm es null en v0.1 (sin geocodificación).
      security:
        - portalSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          description: Id de la orden
      responses:
        "200":
          description: Recorrido de la orden
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecorridoResponse"
        "400":
          description: Id inválido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin sesión de portal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Orden no encontrada o fuera del alcance del dealer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/ordenes/{id}/eventos:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Telemetría (Fase B)]
      summary: Eventos de manejo de una orden (con estado acknowledged)
      description: >
        Lista los eventos de comportamiento de manejo de una orden (frenadas,
        aceleraciones, curvas, exceso de velocidad) con el estado
        acknowledged para el monitoreo. Rol admin/monitoreo (admin|staff|
        dealer; el dealer solo ve sus órdenes). Los eventos se
        ordenan por device_ts descendente (los más recientes primero).
      security:
        - portalSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          description: Id de la orden
      responses:
        "200":
          description: Eventos de la orden
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderEventsResponse"
        "400":
          description: Id inválido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin sesión de portal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Orden no encontrada o fuera del alcance del dealer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/ordenes/{id}/eventos/{eventId}/ack:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    post:
      tags: [Telemetría (Fase B)]
      summary: Confirmar o descartar un evento de manejo
      description: >
        El monitoreo confirma (acknowledged: true) o descarta (false) un
        evento de manejo de la orden. Rol admin/monitoreo (admin|staff|
        dealer; el dealer solo sobre sus órdenes).
      security:
        - portalSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          description: Id de la orden
        - name: eventId
          in: path
          required: true
          schema:
            type: integer
          description: Id del evento de manejo
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [acknowledged]
              properties:
                acknowledged:
                  type: boolean
                  description: true = confirmado, false = descartado
      responses:
        "200":
          description: Evento actualizado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderEvent"
        "400":
          description: Id o body inválido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin sesión de portal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Orden o evento no encontrado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/reports/telemetria/heatmap:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Telemetría (Fase A)]
      summary: Heatmap de actividad de recorrido
      description: >
        Puntos agregados por celda de ~4 decimales (≈ 11 m) para leaflet.heat.
        Agregado por defecto (privacidad); el filtro por chofer (desglose)
        exige rol admin. Sin from, el periodo por defecto son los últimos
        90 días (retención).
      security:
        - portalSession: []
      parameters:
        - name: driverId
          in: query
          required: false
          schema:
            type: integer
          description: Desglose por chofer (requiere rol admin)
        - name: from
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inicio del periodo (ISO 8601)
        - name: to
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Fin del periodo (ISO 8601); default ahora
      responses:
        "200":
          description: Celdas agregadas
          content:
            application/json:
              schema:
                type: object
                required: [points, total, from, to, driverId]
                properties:
                  points:
                    type: array
                    items:
                      type: object
                      properties:
                        lat: { type: number, example: 41.0793 }
                        lng: { type: number, example: -85.1394 }
                        count: { type: integer, example: 12 }
                        intensity: { type: number, example: 0.8 }
                  total:
                    type: integer
                    example: 240
                  from:
                    type: string
                    format: date-time
                  to:
                    type: string
                    format: date-time
                  driverId:
                    type: string
                    nullable: true
        "400":
          description: Parámetros inválidos
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin sesión de portal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Desglose por chofer sin rol admin
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/reports/telemetria/score:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Telemetría (Fase B)]
      summary: Score de manejo 0–100 + tendencia semanal + modo de manejo
      description: >
        Score de manejo (patrón Geotab): max(0, 100 − Σ_tipo (peso × N /
        km) × 100), normalizado por 100 km con km reales de order_tracks.
        Pesos desde telemetry_config.score_weights (defaults: hard_brake 8,
        hard_accel 6, sharp_curve 10, overspeed 12). Score null si km < 50
        en el periodo ("sin datos suficientes"). Incluye tendencia semanal
        y modo de manejo derivado por tramo entre paradas (urbano < 40 km/h
        prom / mixto 40–80 / carretera > 80). driverId opcional: sin él,
        agregado de flota; el desglose por chofer exige rol admin.
      security:
        - portalSession: []
      parameters:
        - name: driverId
          in: query
          required: false
          schema:
            type: integer
          description: Desglose por chofer (requiere rol admin)
        - name: from
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inicio del periodo (ISO 8601)
        - name: to
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Fin del periodo (ISO 8601); default ahora
      responses:
        "200":
          description: Score, tendencia y modos de manejo
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DrivingScoreResponse"
        "400":
          description: Parámetros inválidos
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin sesión de portal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Desglose por chofer sin rol admin
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/reports/telemetria/puntualidad:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Telemetría (Fase B)]
      summary: Puntualidad de entregas (KPI #1 "Actividad") del periodo
      description: >-
        Entregas a tiempo ÷ entregas totales del periodo (KPI #1, pantalla
        "Actividad" del monitoreo rediseñado 2026-09). "A tiempo" = entrega
        cerrada el día programado o antes (delivered_at::date <=
        orders.scheduled_date, día inclusive; entregar antes no se penaliza).
        Entregas sin scheduled_date no son evaluables (sinPrograma, fuera del
        numerador pero dentro del denominador). driverId opcional: sin él,
        agregado de flota del tenant; el desglose por chofer exige rol admin.
      security:
        - portalSession: []
      parameters:
        - name: driverId
          in: query
          required: false
          schema:
            type: integer
          description: Desglose por chofer (requiere rol admin)
        - name: from
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inicio del periodo (ISO 8601)
        - name: to
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Fin del periodo (ISO 8601); default ahora
      responses:
        "200":
          description: Puntualidad del periodo (agregado o por chofer)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PuntualidadResponse"
        "400":
          description: Parámetros inválidos
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin sesión de portal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Desglose por chofer sin rol admin
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/admin/telemetria:
    servers:
      - url: https://orderafy.app
        description: Producción
      - url: http://localhost:3000
        description: Desarrollo local
    get:
      tags: [Telemetría (Fase A)]
      summary: Configuración de telemetría del tenant (rol admin)
      security:
        - portalSession: []
      parameters:
        - name: tenant_id
          in: query
          required: false
          schema:
            type: integer
          description: Tenant; omitir = primer tenant activo
      responses:
        "200":
          description: Configuración del tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  config:
                    $ref: "#/components/schemas/TelemetryConfig"
        "401":
          description: Sin sesión de portal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Se requiere rol admin
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Sin configuración para el tenant
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    put:
      tags: [Telemetría (Fase A)]
      summary: Actualiza la configuración de telemetría (rol admin)
      description: >
        Actualización parcial (upsert si la fila no existe). score_weights se
        fusiona con los pesos vigentes.
      security:
        - portalSession: []
      parameters:
        - name: tenant_id
          in: query
          required: false
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                gpsTrackingEnabled: { type: boolean }
                gpsIntervalSec: { type: integer, minimum: 5, maximum: 300 }
                gpsAdaptive: { type: boolean }
                driverEventsEnabled: { type: boolean }
                overspeedLimitKmh: { type: integer, nullable: true, minimum: 20, maximum: 200 }
                appEventsEnabled: { type: boolean }
                consentRequired: { type: boolean }
                retentionDays: { type: integer, minimum: 7, maximum: 365 }
                scoreWeights:
                  type: object
                  properties:
                    hard_brake: { type: number, minimum: 0 }
                    hard_accel: { type: number, minimum: 0 }
                    sharp_curve: { type: number, minimum: 0 }
                    overspeed: { type: number, minimum: 0 }
      responses:
        "200":
          description: Configuración actualizada
          content:
            application/json:
              schema:
                type: object
                properties:
                  config:
                    $ref: "#/components/schemas/TelemetryConfig"
        "400":
          description: Payload inválido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Sin sesión de portal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Se requiere rol admin
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de datos no disponible
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: API key por cliente (se entrega una sola vez al generarla).
    driverJwt:
      type: http
      scheme: bearer
      description: JWT de la PWA del chofer (login de chofer).
    portalSession:
      type: apiKey
      in: cookie
      name: authjs.session-token
      description: Sesión del portal (NextAuth). El rol requerido se indica por endpoint.

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Mensaje legible del error (español)
          example: "No existe la orden 'INT-2026-0831-001'."

    Tenant:
      type: object
      description: Tenant (cliente) del sistema SAAS
      properties:
        id:
          type: string
          example: "1"
        name:
          type: string
          example: "Orderafy"
        slug:
          type: string
          example: "himex"
        active:
          type: boolean

    OptionValue:
      type: object
      required: [value, label]
      properties:
        value:
          type: string
          example: "minor"
        label:
          type: string
          example: "Daño menor"

    EvidenceSection:
      type: object
      description: Sección de fotos de evidencia (catálogo server-driven)
      properties:
        id:
          type: string
          example: "1"
        tenantId:
          type: string
          example: "1"
        code:
          type: string
          example: "front"
        title:
          type: string
          example: "Frente (exterior)"
        description:
          type: [string, "null"]
        appliesTo:
          type: string
          enum: [receipt, delivery, both]
        required:
          type: boolean
        minPhotos:
          type: integer
          example: 1
        maxPhotos:
          type: integer
          example: 2
        sortOrder:
          type: integer
          example: 2
        active:
          type: boolean

    EvidenceSectionInput:
      type: object
      description: Payload para crear/actualizar una sección (PUT acepta campos parciales)
      properties:
        tenant_id:
          type: integer
          description: Solo en POST; por defecto el tenant himex
        code:
          type: string
          pattern: "^[a-z][a-z0-9_]{1,63}$"
          example: "front"
        title:
          type: string
          example: "Frente (exterior)"
        description:
          type: [string, "null"]
        applies_to:
          type: string
          enum: [receipt, delivery, both]
        required:
          type: boolean
        min_photos:
          type: integer
          minimum: 0
          maximum: 100
        max_photos:
          type: integer
          minimum: 0
          maximum: 100
        sort_order:
          type: integer
        active:
          type: boolean

    VehicleField:
      type: object
      description: Campo de datos del vehículo (catálogo server-driven)
      properties:
        id:
          type: string
          example: "1"
        tenantId:
          type: string
          example: "1"
        code:
          type: string
          example: "diesel_level"
        label:
          type: string
          example: "Nivel de diésel"
        fieldType:
          type: string
          enum: [text, number, select, date]
        unit:
          type: [string, "null"]
          example: "%"
        options:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/OptionValue"
        required:
          type: boolean
        appliesTo:
          type: string
          enum: [receipt, delivery, both]
        sortOrder:
          type: integer
        active:
          type: boolean

    VehicleFieldInput:
      type: object
      description: Payload para crear/actualizar un campo (PUT acepta campos parciales)
      properties:
        tenant_id:
          type: integer
          description: Solo en POST; por defecto el tenant himex
        code:
          type: string
          pattern: "^[a-z][a-z0-9_]{1,63}$"
        label:
          type: string
          example: "Nivel de diésel"
        field_type:
          type: string
          enum: [text, number, select, date]
        unit:
          type: [string, "null"]
        options:
          type: [array, "null"]
          description: Obligatorio cuando field_type=select
          items:
            $ref: "#/components/schemas/OptionValue"
        required:
          type: boolean
        applies_to:
          type: string
          enum: [receipt, delivery, both]
        sort_order:
          type: integer
        active:
          type: boolean

    InspectionItem:
      type: object
      description: Ítem del checklist de inspección (catálogo server-driven)
      properties:
        id:
          type: string
          example: "1"
        tenantId:
          type: string
          example: "1"
        code:
          type: string
          example: "scratches"
        label:
          type: string
          example: "Rayones"
        category:
          type: [string, "null"]
          example: "carrocería"
        states:
          type: array
          items:
            $ref: "#/components/schemas/OptionValue"
        requiresPhoto:
          type: boolean
          description: true → si el estado != ok, el chofer debe adjuntar foto
        appliesTo:
          type: string
          enum: [receipt, delivery, both]
        sortOrder:
          type: integer
        active:
          type: boolean

    InspectionItemInput:
      type: object
      description: Payload para crear/actualizar un ítem (PUT acepta campos parciales)
      properties:
        tenant_id:
          type: integer
          description: Solo en POST; por defecto el tenant himex
        code:
          type: string
          pattern: "^[a-z][a-z0-9_]{1,63}$"
        label:
          type: string
          example: "Rayones"
        category:
          type: [string, "null"]
        states:
          type: array
          description: Array no vacío de {value,label}
          items:
            $ref: "#/components/schemas/OptionValue"
        requires_photo:
          type: boolean
        applies_to:
          type: string
          enum: [receipt, delivery, both]
        sort_order:
          type: integer
        active:
          type: boolean

    CatalogConfig:
      type: object
      description: Respuesta de GET /api/config (catálogos activos del tenant)
      properties:
        tenant:
          $ref: "#/components/schemas/Tenant"
        moment:
          type: [string, "null"]
          enum: [receipt, delivery]
          description: Momento solicitado (null = todos)
        sections:
          type: array
          items:
            $ref: "#/components/schemas/EvidenceSection"
        vehicleFields:
          type: array
          items:
            $ref: "#/components/schemas/VehicleField"
        inspectionItems:
          type: array
          items:
            $ref: "#/components/schemas/InspectionItem"

    CreateOrderRequest:
      type: object
      required: [orderNumber, origin, destination]
      properties:
        orderNumber:
          type: string
          description: Número de orden en el sistema del cliente (identificador único)
          example: "INT-2026-0831-001"
        origin:
          type: string
          description: Origen del traslado (planta, almacén o dirección)
          example: "Planta Escobedo, Nuevo León"
        destination:
          type: string
          description: Destino del traslado
          example: "Distribuidor International Monterrey, NL"
        unitVIN:
          type: string
          description: VIN / serie de la unidad
          example: "3HCDZAPR9SL123456"
        unitDescription:
          type: string
          description: Descripción de la unidad (modelo, año, color...)
          example: "Camión International HV 2026"
        dealerName:
          type: string
          description: Distribuidor que coloca la orden
          example: "International Monterrey"
        scheduledDate:
          type: string
          format: date
          description: Fecha programada de entrega (YYYY-MM-DD)
          example: "2026-08-31"
        observations:
          type: string
          description: Observaciones generales de la orden
          example: "Entrega programada antes de las 12:00"

    DeliveryWebhookRequest:
      type: object
      required: [orderNumber]
      properties:
        orderNumber:
          type: string
          description: Número de orden a confirmar
          example: "INT-2026-0831-001"
        deliveredAt:
          type: string
          format: date-time
          description: "Momento de la llegada (ISO-8601); default: hora del servidor"
          example: "2026-08-31T16:45:00-06:00"
        receivedBy:
          type: string
          description: Quién recibe la unidad en destino
          example: "Almacén de producto terminado"
        reference:
          type: string
          description: Folio/referencia interna del cliente para esta entrega
          example: "GR-88213"
        observations:
          type: string
          description: Observaciones de la entrega (van a la bitácora)
          example: "Unidad sin novedad"

    Order:
      type: object
      required: [id, orderNumber, origin, destination, status, createdAt, updatedAt]
      properties:
        id:
          type: string
          description: ID interno en Orderafy
          example: "42"
        orderNumber:
          type: string
          example: "INT-2026-0831-001"
        unitVIN:
          type: [string, "null"]
          example: "3HCDZAPR9SL123456"
        unitDescription:
          type: [string, "null"]
          example: "Camión International HV 2026"
        origin:
          type: string
          example: "Planta Escobedo, Nuevo León"
        destination:
          type: string
          example: "Distribuidor International Monterrey, NL"
        dealerName:
          type: [string, "null"]
          example: "International Monterrey"
        status:
          type: string
          enum: [imported, assigned, in_transit, delivered, on_hold, cancelled]
          description: Estado actual de la orden
        driver:
          type: [object, "null"]
          description: Chofer asignado (null si aún no hay asignación)
          properties:
            name:
              type: string
              example: "Carlos Ramírez"
            phone:
              type: [string, "null"]
              example: "+52 81 1234 5678"
        scheduledDate:
          type: [string, "null"]
          format: date
        receivedAt:
          type: [string, "null"]
          format: date-time
          description: Fecha de recepción de la unidad por el chofer (foto con candado)
        deliveredAt:
          type: [string, "null"]
          format: date-time
          description: Fecha de entrega (webhook del cliente o cierre en campo)
        dieselLiters:
          type: [number, "null"]
          description: Litros de diésel al recibir la unidad
        observations:
          type: [string, "null"]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    Evidence:
      type: object
      description: Resumen de evidencia fotográfica (fotos con candado)
      properties:
        receiptPhotos:
          type: integer
          description: Fotos del estado al recibir la unidad
          example: 3
        deliveryPhotos:
          type: integer
          description: Fotos del estado al entregar la unidad
          example: 4
        incidentPhotos:
          type: integer
          description: Fotos de incidencias
          example: 0
        signedOrderPhoto:
          type: boolean
          description: true si existe foto de la orden firmada
          example: true

    Incident:
      type: object
      properties:
        id:
          type: string
          example: "7"
        type:
          type: string
          description: Tipo de incidencia (daño, retraso, desviación...)
          example: "damage"
        description:
          type: string
          example: "Rasguño en puerta izquierda"
        status:
          type: string
          enum: [open, resolved]
        createdAt:
          type: string
          format: date-time

    OrderStatus:
      type: object
      required: [order, evidence, incidents]
      properties:
        order:
          $ref: "#/components/schemas/Order"
        evidence:
          $ref: "#/components/schemas/Evidence"
        incidents:
          type: array
          items:
            $ref: "#/components/schemas/Incident"

    TelemetryConfig:
      type: object
      description: Configuración de telemetría por tenant (tabla telemetry_config)
      properties:
        tenantId:
          type: string
          example: "1"
        gpsTrackingEnabled:
          type: boolean
        gpsIntervalSec:
          type: integer
          example: 20
        gpsAdaptive:
          type: boolean
        driverEventsEnabled:
          type: boolean
        overspeedLimitKmh:
          type: integer
          nullable: true
          description: null = exceso de velocidad desactivado
        appEventsEnabled:
          type: boolean
        consentRequired:
          type: boolean
        retentionDays:
          type: integer
          example: 90
        scoreWeights:
          type: object
          description: Pesos del score 0–100 (Fase B)
          example: { hard_brake: 8, hard_accel: 6, sharp_curve: 10, overspeed: 12 }
        updatedAt:
          type: string
          format: date-time

    DriverConsent:
      type: object
      description: Consentimiento del chofer (tabla driver_consents); null si nunca ha aceptado el aviso
      properties:
        id:
          type: string
          example: "3"
        driverId:
          type: string
          example: "2"
        version:
          type: string
          example: "v1"
        gpsOk:
          type: boolean
        behaviorOk:
          type: boolean
        analyticsOk:
          type: boolean
        acceptedAt:
          type: string
          format: date-time

    TrackPointInput:
      type: object
      required: [orderId, lat, lng, source, deviceTs]
      properties:
        orderId:
          type: integer
          description: Orden en tránsito del chofer
        lat:
          type: number
          minimum: -90
          maximum: 90
        lng:
          type: number
          minimum: -180
          maximum: 180
        speedKmh:
          type: number
          nullable: true
          description: null = estático
        heading:
          type: number
          nullable: true
        accuracyM:
          type: number
          nullable: true
        source:
          type: string
          enum: [gps, network]
        deviceTs:
          type: string
          format: date-time
          description: Reloj del dispositivo (clave de dedupe con orderId)

    RecorridoResponse:
      type: object
      description: Recorrido de una orden (polyline + estadísticas)
      properties:
        orden:
          type: object
          properties:
            id: { type: string }
            orderNumber: { type: string }
            status: { type: string }
            origin: { type: string }
            destination: { type: string }
            dealerName: { type: string, nullable: true }
            unitVin: { type: string, nullable: true }
            unitDescription: { type: string, nullable: true }
            driverId: { type: string, nullable: true }
            driverName: { type: string, nullable: true }
        points:
          type: array
          description: Puntos ordenados por device_ts; vacío = sin tracking aún
          items:
            type: object
            properties:
              lat: { type: number }
              lng: { type: number }
              speedKmh: { type: number, nullable: true }
              heading: { type: number, nullable: true }
              accuracyM: { type: number, nullable: true }
              source: { type: string, enum: [gps, network] }
              deviceTs: { type: string, format: date-time }
        stats:
          type: object
          properties:
            points: { type: integer }
            distanceKm: { type: number, description: Suma Haversine entre puntos consecutivos }
            durationSec: { type: integer }
            startTs: { type: string, format: date-time, nullable: true }
            endTs: { type: string, format: date-time, nullable: true }
            stops:
              type: array
              description: Zonas de parada (speed < 5 km/h por > 2 min)
              items:
                type: object
                properties:
                  lat: { type: number }
                  lng: { type: number }
                  startTs: { type: string, format: date-time }
                  endTs: { type: string, format: date-time }
                  durationSec: { type: integer }
                  avgSpeedKmh: { type: number }
            estimatedDistanceKm:
              type: number
              nullable: true
              description: null en v0.1 (sin geocodificación de origen/destino)

    DriverEventInput:
      type: object
      required: [orderId, type, deviceTs]
      properties:
        orderId:
          type: integer
          description: Orden en tránsito del chofer
        type:
          type: string
          enum: [hard_brake, hard_accel, sharp_curve, overspeed]
        lat:
          type: number
          minimum: -90
          maximum: 90
          nullable: true
          description: Último fix GPS conocido al disparar el evento
        lng:
          type: number
          minimum: -180
          maximum: 180
          nullable: true
        magnitude:
          type: number
          minimum: 0
          nullable: true
          description: "Pico |g| del evento, o km/h sobre el límite (overspeed)"
        deviceTs:
          type: string
          format: date-time
          description: Reloj del dispositivo (clave de dedupe con orderId + type)

    OrderEvent:
      type: object
      description: Evento de manejo de una orden (GET /eventos, ack)
      required: [id, orderId, driverId, type, deviceTs, receivedAt, acknowledged]
      properties:
        id: { type: string, description: Id del evento (bigint serializado) }
        orderId: { type: integer }
        driverId: { type: integer }
        driverName: { type: string, nullable: true }
        type:
          type: string
          enum: [hard_brake, hard_accel, sharp_curve, overspeed]
        lat: { type: number, nullable: true }
        lng: { type: number, nullable: true }
        magnitude: { type: number, nullable: true }
        deviceTs: { type: string, format: date-time }
        receivedAt: { type: string, format: date-time }
        acknowledged:
          type: boolean
          description: true = confirmado por monitoreo, false = pendiente/descartado

    OrderEventsResponse:
      type: object
      required: [orderId, orderNumber, events, total]
      properties:
        orderId: { type: integer }
        orderNumber: { type: string }
        events:
          type: array
          description: Ordenados por device_ts descendente; vacío = sin eventos aún
          items:
            $ref: "#/components/schemas/OrderEvent"
        total: { type: integer }

    DrivingScoreResponse:
      type: object
      description: |
        Score de manejo 0–100 (patrón Geotab, §5.3):
        max(0, 100 − Σ_tipo (peso_tipo × N_tipo / km) × 100), normalizado
        por 100 km. Score null si km < 50 en el periodo (sin datos
        suficientes, §13.6). driverId null = agregado de flota.
      required: [driverId, from, to, km, events, weights, score, trend, drivingModes]
      properties:
        driverId: { type: string, nullable: true }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        km: { type: number, description: km reales (suma Haversine) en el periodo }
        events:
          type: object
          description: Eventos por tipo en el periodo
          properties:
            hard_brake: { type: integer, example: 3 }
            hard_accel: { type: integer, example: 1 }
            sharp_curve: { type: integer, example: 0 }
            overspeed: { type: integer, example: 0 }
        weights:
          type: object
          description: Pesos aplicados (telemetry_config.score_weights)
          properties:
            hard_brake: { type: number, example: 8 }
            hard_accel: { type: number, example: 6 }
            sharp_curve: { type: number, example: 10 }
            overspeed: { type: number, example: 12 }
        score: { type: number, nullable: true, example: 92 }
        trend:
          type: array
          description: Tendencia semanal (mismo cálculo por semana ISO)
          items:
            type: object
            properties:
              weekStart: { type: string, format: date, example: "2026-08-03" }
              km: { type: number }
              events:
                type: object
                properties:
                  hard_brake: { type: integer }
                  hard_accel: { type: integer }
                  sharp_curve: { type: integer }
                  overspeed: { type: integer }
              score: { type: number, nullable: true }
        drivingModes:
          type: object
          description: Modo de manejo por tramo entre paradas (columna derivada, §5.3)
          properties:
            urbano:
              type: object
              description: Velocidad promedio del tramo < 40 km/h
              properties:
                segments: { type: integer }
                km: { type: number }
            mixto:
              type: object
              description: 40–80 km/h
              properties:
                segments: { type: integer }
                km: { type: number }
            carretera:
              type: object
              description: "> 80 km/h"
              properties:
                segments: { type: integer }
                km: { type: number }

    PuntualidadResponse:
      type: object
      description: |
        Puntualidad de entregas del periodo (KPI #1, pantalla "Actividad" del
        monitoreo rediseñado 2026-09): entregas a tiempo ÷ entregas totales.
        "A tiempo" = delivered_at::date <= orders.scheduled_date (día
        programado inclusive; entregar antes no se penaliza). Entregas sin
        scheduled_date no son evaluables (sinPrograma, fuera del numerador
        pero dentro del denominador, que es la fórmula literal del diseño).
      required: [driverId, from, to, totalEntregas, aTiempo, tarde, sinPrograma, puntualidad]
      properties:
        driverId: { type: string, nullable: true }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        totalEntregas: { type: integer, description: Entregas cerradas en el periodo (denominador del KPI) }
        aTiempo: { type: integer, description: Cerradas el día programado o antes }
        tarde: { type: integer, description: Cerradas después del día programado }
        sinPrograma: { type: integer, description: Entregadas sin scheduled_date (no evaluables) }
        puntualidad:
          type: number
          nullable: true
          description: aTiempo / totalEntregas × 100 (0–100, 2 decimales); null sin entregas en el periodo
          example: 92.5

security:
  - apiKey: []
