openapi: 3.1.0

info:
  title: FichaAPI
  version: "1.0"
  description: |
    API REST para control de jornada laboral. Multi-tenant, diseñada para integrarse
    con terminales físicos (NFC/PIN), apps móviles y sistemas de terceros (ERPs, nóminas).

    ## Autenticación

    Todos los endpoints requieren un token Bearer en la cabecera `Authorization`.
    Existen dos tipos de token:

    - **Token de dispositivo** — generado al registrar un dispositivo físico en el panel.
      Uso exclusivo para terminales NFC/PIN. Sin expiración, revocable individualmente.
    - **Token de API (Sanctum)** — generado en el panel → Integraciones → Tokens de API.
      Con scopes configurables (`clockings:read`, `clockings:write`, `workers:read`, etc.).

    Nunca envíes tokens en query strings. Solo en cabecera `Authorization: Bearer {token}`.

    ## Formato de respuesta

    Todas las respuestas siguen esta estructura:

    ```json
    {
      "success": true,
      "data": { ... },
      "meta": {
        "timestamp": "2026-05-06T09:30:00Z",
        "version": "1.0",
        "request_id": "uuid"
      }
    }
    ```

    Los errores:

    ```json
    {
      "success": false,
      "error": {
        "code": "WORKER_NOT_FOUND",
        "message": "El trabajador no existe"
      },
      "meta": { "timestamp": "...", "version": "1.0" }
    }
    ```

    ## Rate limiting

    | Tipo | Límite |
    |------|--------|
    | Lectura (`api:read`) | 60 req/min |
    | Escritura (`api:write`) | 30 req/min |
    | Dispositivo (`api:device`) | 120 req/min |

    ## Idempotencia en fichajes

    El endpoint `POST /clockings` acepta la cabecera `Idempotency-Key: {uuid-v4}`.
    Si se recibe la misma key en 10 minutos, se devuelve el registro original con HTTP 200
    (no se crea duplicado). Imprescindible en terminales físicos con reintentos automáticos.

  contact:
    name: FichaAPI
    url: https://fichaapi.com
  license:
    name: Propietario
    url: https://fichaapi.com/terminos

servers:
  - url: https://api.fichaapi.com/api/v1
    description: Producción
  - url: http://localhost:8000/api/v1
    description: Desarrollo local

tags:
  - name: Terminal
    description: Endpoints usados por terminales físicos de fichaje (NFC/PIN).
  - name: Fichajes
    description: Registro y consulta de fichajes. Los fichajes son **inmutables** — nunca se modifican ni eliminan.
  - name: Trabajadores
    description: Gestión de trabajadores del tenant.
  - name: Turnos
    description: Configuración de horarios y turnos.
  - name: Informes
    description: Informes de jornada, incidencias y presencia.
  - name: Dispositivos
    description: Registro y gestión de dispositivos físicos.
  - name: Webhooks
    description: Endpoints externos registrados para recibir eventos.

security:
  - BearerToken: []

paths:

  # ── TERMINAL ──────────────────────────────────────────────────────────────

  /workers/identify:
    post:
      tags: [Terminal]
      summary: Identificar trabajador
      description: |
        Verifica las credenciales de un trabajador (PIN o NFC) y devuelve su ID y nombre.
        Es el primer paso del flujo de fichaje en un terminal físico.

        **Verificación de setup:** Para comprobar que un token es válido sin identificar
        a ningún trabajador real, envía `worker_code: "__verify__"` con cualquier PIN.
        Respuestas 404 o 422 indican que el token es válido y el servidor está accesible.
      operationId: identifyWorker
      security:
        - BearerToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/IdentifyByPin'
                - $ref: '#/components/schemas/IdentifyByNfc'
            examples:
              pin:
                summary: Identificación por PIN
                value:
                  method: manual
                  worker_code: "001"
                  pin: "1234"
              nfc:
                summary: Identificación por NFC
                value:
                  method: nfc
                  nfc_card_id: "04A32B1C9F2E80"
      responses:
        "200":
          description: Trabajador identificado correctamente.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          worker:
                            $ref: '#/components/schemas/WorkerIdentity'
              example:
                success: true
                data:
                  worker:
                    id: 3
                    name: "Juan García"
                    role: "worker"
                meta:
                  timestamp: "2026-05-06T09:30:00Z"
                  version: "1.0"
                  request_id: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
        "401":
          $ref: '#/components/responses/InvalidPin'
        "404":
          $ref: '#/components/responses/WorkerNotFound'
        "422":
          $ref: '#/components/responses/ValidationError'
        "429":
          $ref: '#/components/responses/RateLimited'

  /devices/heartbeat:
    post:
      tags: [Terminal]
      summary: Heartbeat del terminal
      description: |
        El terminal llama a este endpoint cada 60 segundos para indicar que está
        operativo y para validar que su token sigue siendo válido.

        Si la respuesta incluye un campo `data.token` diferente al actual, el terminal
        debe guardarlo y usarlo en todas las peticiones siguientes (rotación de token).

        **Al arrancar:** el terminal ejecuta un heartbeat inmediato antes de habilitar
        el teclado para verificar la conectividad.
      operationId: deviceHeartbeat
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/XDeviceId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id, status]
              properties:
                device_id:
                  type: integer
                  description: ID del dispositivo tal como está guardado en localStorage.
                  example: 5
                status:
                  type: string
                  enum: [online, idle]
                  example: online
      responses:
        "200":
          description: Token válido. El terminal puede operar con normalidad.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          token:
                            type: string
                            nullable: true
                            description: |
                              Token actual (o nuevo si se ha rotado). Si es diferente al
                              almacenado en localStorage, el terminal debe guardarlo.
                          expires_at:
                            type: string
                            format: date-time
                            nullable: true
                            description: Fecha de expiración. `null` para tokens de dispositivo sin expiración.
              example:
                success: true
                data:
                  token: "Zq4tff5Gv3WlR8ZA4RHqB5SgOY6kyX2yDcjx13cK"
                  expires_at: null
                meta:
                  timestamp: "2026-05-06T09:30:00Z"
                  version: "1.0"
                  request_id: "a3f1b2c3-d4e5-6789-abcd-ef0123456789"
        "401":
          $ref: '#/components/responses/Unauthenticated'
        "403":
          $ref: '#/components/responses/DeviceUnauthorized'

  # ── FICHAJES ──────────────────────────────────────────────────────────────

  /clockings:
    get:
      tags: [Fichajes]
      summary: Listar fichajes
      description: Devuelve los fichajes del tenant con filtros opcionales. Ordenados por fecha descendente.
      operationId: listClockings
      security:
        - BearerToken: []
      parameters:
        - name: worker_id
          in: query
          schema:
            type: integer
          description: Filtrar por trabajador.
        - name: type
          in: query
          schema:
            type: string
            enum: [clock_in, clock_out, break_start, break_end]
          description: Filtrar por tipo de fichaje.
        - name: from
          in: query
          schema:
            type: string
            format: date
          example: "2026-05-01"
          description: Fecha de inicio (YYYY-MM-DD).
        - name: to
          in: query
          schema:
            type: string
            format: date
          example: "2026-05-31"
          description: Fecha de fin (YYYY-MM-DD).
        - name: geofence_status
          in: query
          schema:
            type: string
            enum: [inside, outside, unverified, not_required]
        - name: per_page
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
        - $ref: '#/components/parameters/XDeviceId'
      responses:
        "200":
          description: Lista paginada de fichajes.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Clocking'
                      meta:
                        allOf:
                          - $ref: '#/components/schemas/Meta'
                          - $ref: '#/components/schemas/Pagination'

    post:
      tags: [Fichajes]
      summary: Registrar fichaje
      description: |
        Crea un nuevo fichaje. Los fichajes son **inmutables** — una vez creados no pueden
        modificarse ni eliminarse. Las correcciones se realizan a través del panel de administración.

        **Idempotencia:** Usa la cabecera `Idempotency-Key` con un UUID v4 único por intento.
        Si se recibe la misma key en 10 minutos, se devuelve el fichaje original con HTTP 200.

        **Geofencing:** Si se envían coordenadas GPS, el sistema evalúa si el trabajador
        está dentro de su zona asignada y establece `geofence_status` en consecuencia.
        Un fichaje fuera de zona genera una incidencia automática.

        **Zona de dispositivo:** Si se envía `device_id` sin coordenadas GPS (típico en
        terminales físicos), el fichaje hereda la zona del dispositivo.
      operationId: createClocking
      security:
        - BearerToken: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            format: uuid
          description: UUID v4 único por intento. Previene duplicados por reintentos de red.
          example: "a0fa841f-28a1-45f0-a5a6-36992222a482"
        - $ref: '#/components/parameters/XDeviceId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateClockingRequest'
            examples:
              terminal_pin:
                summary: Fichaje desde terminal PIN (sin GPS)
                value:
                  worker_id: 3
                  type: clock_in
                  method: manual
                  device_id: 5
                  latitude: null
                  longitude: null
              mobile_gps:
                summary: Fichaje desde app móvil (con GPS)
                value:
                  worker_id: 3
                  type: clock_in
                  method: mobile_app
                  latitude: 40.3956
                  longitude: -3.6935
      responses:
        "201":
          description: Fichaje registrado correctamente.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/Clocking'
              example:
                success: true
                data:
                  id: 42
                  worker_id: 3
                  worker:
                    id: 3
                    name: "Juan García"
                  device_id: 5
                  type: clock_in
                  method: manual
                  latitude: null
                  longitude: null
                  geofence_status: not_required
                  work_zone_id: 1
                  hash: "a8f23b1cde4567890abcdef1234567890abcdef1234567890abcdef1234567890"
                  reviewed: false
                  notes: null
                  clocked_at: "2026-05-06T09:34:02Z"
                  created_at: "2026-05-06T09:34:02Z"
                meta:
                  timestamp: "2026-05-06T09:34:02Z"
                  version: "1.0"
                  request_id: "b2c3d4e5-f6a7-8901-bcde-f23456789012"
        "200":
          description: Fichaje idempotente — ya existía con esa `Idempotency-Key`. Se devuelve el registro original.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/Clocking'
        "401":
          $ref: '#/components/responses/Unauthenticated'
        "422":
          $ref: '#/components/responses/ValidationError'

  /clockings/{id}:
    get:
      tags: [Fichajes]
      summary: Detalle de fichaje
      operationId: getClocking
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        "200":
          description: Datos del fichaje.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/Clocking'
        "404":
          $ref: '#/components/responses/NotFound'

    put:
      tags: [Fichajes]
      summary: "[BLOQUEADO] Los fichajes son inmutables"
      description: Este endpoint siempre devuelve 405. Los fichajes nunca se modifican directamente.
      operationId: updateClockingImmutable
      responses:
        "405":
          $ref: '#/components/responses/Immutable'

    delete:
      tags: [Fichajes]
      summary: "[BLOQUEADO] Los fichajes son inmutables"
      description: Este endpoint siempre devuelve 405. Los fichajes nunca se eliminan.
      operationId: deleteClockingImmutable
      responses:
        "405":
          $ref: '#/components/responses/Immutable'

  # ── TRABAJADORES ─────────────────────────────────────────────────────────

  /workers:
    get:
      tags: [Trabajadores]
      summary: Listar trabajadores
      operationId: listWorkers
      security:
        - BearerToken: []
      parameters:
        - name: name
          in: query
          schema:
            type: string
          description: Búsqueda parcial por nombre.
        - name: department
          in: query
          schema:
            type: string
        - name: position
          in: query
          schema:
            type: string
        - name: active
          in: query
          schema:
            type: boolean
          description: Si se omite, devuelve todos. `true` solo activos, `false` solo inactivos.
        - name: per_page
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
      responses:
        "200":
          description: Lista paginada de trabajadores.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Worker'
                      meta:
                        allOf:
                          - $ref: '#/components/schemas/Meta'
                          - $ref: '#/components/schemas/Pagination'

    post:
      tags: [Trabajadores]
      summary: Crear trabajador
      operationId: createWorker
      security:
        - BearerToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWorkerRequest'
            example:
              name: "Ana López"
              email: "ana.lopez@empresa.com"
              department: "Producción"
              position: "Oficial 1ª"
              hire_date: "2024-01-15"
              vacation_days_total: 22
              schedule_id: 1
      responses:
        "201":
          description: Trabajador creado.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/Worker'
        "422":
          $ref: '#/components/responses/ValidationError'

  /workers/{id}:
    get:
      tags: [Trabajadores]
      summary: Detalle de trabajador
      operationId: getWorker
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        "200":
          description: Datos del trabajador.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/Worker'
        "404":
          $ref: '#/components/responses/NotFound'

    put:
      tags: [Trabajadores]
      summary: Actualizar trabajador
      operationId: updateWorker
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateWorkerRequest'
      responses:
        "200":
          description: Trabajador actualizado.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/Worker'

    delete:
      tags: [Trabajadores]
      summary: Desactivar trabajador
      description: Desactiva el trabajador (soft-delete). No elimina sus fichajes ni historial.
      operationId: deleteWorker
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        "204":
          description: Trabajador desactivado.

  /workers/{id}/schedule:
    get:
      tags: [Trabajadores]
      summary: Turno activo del trabajador
      operationId: getWorkerSchedule
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        "200":
          description: Turno actualmente asignado al trabajador.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          assignment_id:
                            type: integer
                          starts_at:
                            type: string
                            format: date
                          ends_at:
                            type: string
                            format: date
                            nullable: true
                          schedule:
                            $ref: '#/components/schemas/Schedule'
              example:
                success: true
                data:
                  assignment_id: 7
                  starts_at: "2026-01-01"
                  ends_at: null
                  schedule:
                    id: 1
                    name: "Jornada Completa L–V"
                    active_days: [1, 2, 3, 4, 5]
                    expected_start: "07:30:00"
                    expected_end: "15:30:00"
                    break_minutes: 30
                    active: true
                    created_at: "2026-01-01T00:00:00Z"
        "404":
          description: El trabajador no tiene turno asignado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              example:
                success: false
                error:
                  code: NO_SCHEDULE
                  message: "El trabajador no tiene turno asignado actualmente."

  /workers/{id}/clockings:
    get:
      tags: [Trabajadores]
      summary: Historial de fichajes del trabajador
      description: Devuelve los fichajes de un trabajador concreto. Útil para el terminal (detectar si el siguiente es entrada o salida).
      operationId: getWorkerClockings
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
        - name: type
          in: query
          schema:
            type: string
            enum: [clock_in, clock_out, break_start, break_end]
        - name: from
          in: query
          schema:
            type: string
            format: date
          example: "2026-05-06"
        - name: to
          in: query
          schema:
            type: string
            format: date
          example: "2026-05-06"
        - name: per_page
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
      responses:
        "200":
          description: Lista paginada de fichajes del trabajador.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Clocking'
                      meta:
                        allOf:
                          - $ref: '#/components/schemas/Meta'
                          - $ref: '#/components/schemas/Pagination'

  /workers/{id}/balance:
    get:
      tags: [Trabajadores]
      summary: Saldo de horas del trabajador
      description: Devuelve el saldo de horas ordinarias, extras, vacaciones disponibles y ausencias. Los minutos se almacenan como enteros para evitar decimales.
      operationId: getWorkerBalance
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        "200":
          description: Saldo de horas.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/WorkerBalance'
              example:
                success: true
                data:
                  worker_id: 3
                  vacation_days_total: 22
                  vacation_days_used: 5
                  vacation_days_remaining: 17
                  ordinary_minutes: 10080
                  overtime_minutes: 120
                  break_minutes: 1500
                  absence_minutes: 0
                  period:
                    year: 2026
                    month: 5

  /workers/{id}/absences:
    get:
      tags: [Trabajadores]
      summary: Ausencias y vacaciones del trabajador
      operationId: getWorkerAbsences
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, approved, rejected]
        - name: type
          in: query
          schema:
            type: string
            enum: [vacation, sick_leave, personal_leave, other]
        - name: per_page
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
      responses:
        "200":
          description: Lista de ausencias.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Absence'
                      meta:
                        allOf:
                          - $ref: '#/components/schemas/Meta'
                          - $ref: '#/components/schemas/Pagination'

  # ── TURNOS ────────────────────────────────────────────────────────────────

  /schedules:
    get:
      tags: [Turnos]
      summary: Listar turnos
      operationId: listSchedules
      security:
        - BearerToken: []
      parameters:
        - name: active
          in: query
          schema:
            type: boolean
      responses:
        "200":
          description: Lista de turnos.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Schedule'

    post:
      tags: [Turnos]
      summary: Crear turno
      operationId: createSchedule
      security:
        - BearerToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateScheduleRequest'
            example:
              name: "Jornada Completa L–V"
              active_days: [1, 2, 3, 4, 5]
              expected_start: "07:30"
              expected_end: "15:30"
              break_minutes: 30
      responses:
        "201":
          description: Turno creado.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/Schedule'

  /schedules/{id}:
    get:
      tags: [Turnos]
      summary: Detalle de turno
      operationId: getSchedule
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        "200":
          description: Datos del turno.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/Schedule'

  # ── INFORMES ──────────────────────────────────────────────────────────────

  /reports/monthly:
    get:
      tags: [Informes]
      summary: Informe mensual de jornada
      description: |
        Calcula el informe mensual de un trabajador: sesiones, horas ordinarias,
        horas extra y resumen totales. Puede exportarse como PDF.
      operationId: getMonthlyReport
      security:
        - BearerToken: []
      parameters:
        - name: worker_id
          in: query
          required: true
          schema:
            type: integer
        - name: year
          in: query
          required: true
          schema:
            type: integer
          example: 2026
        - name: month
          in: query
          required: true
          schema:
            type: integer
            minimum: 1
            maximum: 12
          example: 5
        - name: format
          in: query
          schema:
            type: string
            enum: [json, pdf]
            default: json
          description: Usa `pdf` para descargar el informe en PDF (válido para Inspección de Trabajo).
      responses:
        "200":
          description: Informe mensual (JSON o PDF según `format`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/MonthlyReport'
            application/pdf:
              schema:
                type: string
                format: binary

  /reports/incidences:
    get:
      tags: [Informes]
      summary: Informe de incidencias
      description: Devuelve fichajes con anomalías (retraso, salida anticipada, geofencing, olvido).
      operationId: getIncidencesReport
      security:
        - BearerToken: []
      parameters:
        - name: worker_id
          in: query
          schema:
            type: integer
        - name: type
          in: query
          schema:
            type: string
            enum: [late, missing, max_hours, geofence]
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, approved, rejected]
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: per_page
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
      responses:
        "200":
          description: Lista de incidencias paginada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiSuccess'

  /reports/presence:
    get:
      tags: [Informes]
      summary: Presencia en tiempo real
      description: Trabajadores que han fichado entrada hoy y aún no han fichado salida. Refleja quién está en el trabajo ahora mismo.
      operationId: getPresenceReport
      security:
        - BearerToken: []
      responses:
        "200":
          description: Lista de trabajadores presentes.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            worker_id:
                              type: integer
                            name:
                              type: string
                            clock_in_at:
                              type: string
                              format: date-time
                            minutes_today:
                              type: integer

  # ── DISPOSITIVOS ──────────────────────────────────────────────────────────

  /devices:
    get:
      tags: [Dispositivos]
      summary: Listar dispositivos
      operationId: listDevices
      security:
        - BearerToken: []
      parameters:
        - name: per_page
          in: query
          schema:
            type: integer
            default: 50
      responses:
        "200":
          description: Lista de dispositivos registrados.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Device'
                      meta:
                        allOf:
                          - $ref: '#/components/schemas/Meta'
                          - $ref: '#/components/schemas/Pagination'

    post:
      tags: [Dispositivos]
      summary: Registrar dispositivo
      description: |
        Crea un nuevo dispositivo físico y genera su token de acceso.
        **El token se muestra una sola vez.** Guárdalo inmediatamente — no podrás recuperarlo.
      operationId: createDevice
      security:
        - BearerToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, type]
              properties:
                name:
                  type: string
                  maxLength: 100
                  example: "Terminal NFC — Taller Vallecas"
                type:
                  type: string
                  enum: [tablet, nfc_reader, rfid_reader, kiosk]
                  example: nfc_reader
      responses:
        "201":
          description: Dispositivo creado. El campo `token` solo aparece en esta respuesta.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/DeviceWithToken'
              example:
                success: true
                data:
                  id: 3
                  name: "Terminal NFC — Taller Vallecas"
                  type: nfc_reader
                  active: true
                  work_zone_id: 1
                  last_seen_at: null
                  token: "Zq4tff5Gv3WlR8ZA4RHqB5SgOY6kyX2yDcjx13cK"
                  created_at: "2026-05-06T09:00:00Z"

  /devices/{id}:
    delete:
      tags: [Dispositivos]
      summary: Revocar dispositivo
      description: Elimina el dispositivo. Su token deja de ser válido inmediatamente.
      operationId: deleteDevice
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        "204":
          description: Dispositivo revocado.

  # ── WEBHOOKS ──────────────────────────────────────────────────────────────

  /webhooks:
    get:
      tags: [Webhooks]
      summary: Listar webhooks
      operationId: listWebhooks
      security:
        - BearerToken: []
      responses:
        "200":
          description: Lista de webhooks registrados.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Webhook'

    post:
      tags: [Webhooks]
      summary: Registrar webhook
      description: |
        Registra una URL externa para recibir eventos. La firma HMAC se incluye en la
        cabecera `X-FichaAPI-Signature: sha256={hmac}` de cada entrega.

        **Eventos disponibles:**

        | Evento | Cuándo |
        |--------|--------|
        | `clocking.created` | Nuevo fichaje registrado |
        | `clocking.incidence` | Fichaje marcado como anomalía |
        | `absence.approved` | Ausencia aprobada |
        | `absence.rejected` | Ausencia rechazada |
        | `worker.created` | Nuevo trabajador dado de alta |
        | `report.monthly_closed` | Cierre mensual validado |
      operationId: createWebhook
      security:
        - BearerToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  example: "https://mi-erp.com/webhooks/fichaapi"
                events:
                  type: array
                  items:
                    type: string
                    enum:
                      - clocking.created
                      - clocking.incidence
                      - absence.approved
                      - absence.rejected
                      - worker.created
                      - report.monthly_closed
                  example: [clocking.created, absence.approved]
                secret:
                  type: string
                  description: Secreto para verificar la firma HMAC de las entregas.
                  example: "mi_secreto_hmac_123"
      responses:
        "201":
          description: Webhook registrado. El campo `secret` solo aparece en esta respuesta.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiSuccess'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/WebhookWithSecret'

  /webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Eliminar webhook
      operationId: deleteWebhook
      security:
        - BearerToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        "204":
          description: Webhook eliminado.

# ── COMPONENTES ──────────────────────────────────────────────────────────────

components:

  securitySchemes:
    BearerToken:
      type: http
      scheme: bearer
      description: |
        Token de dispositivo (40 caracteres aleatorios) o token de API Sanctum.
        Genera tokens en el panel → Integraciones → Tokens de API o → Dispositivos.

  parameters:
    Id:
      name: id
      in: path
      required: true
      schema:
        type: integer
      description: ID del recurso.

    XDeviceId:
      name: X-Device-ID
      in: header
      required: false
      schema:
        type: string
      description: ID del dispositivo físico. Requerido en peticiones desde terminales.
      example: "5"

  responses:
    Unauthenticated:
      description: Token ausente o inválido.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              code: UNAUTHENTICATED
              message: "No autenticado."
            meta:
              timestamp: "2026-05-06T09:00:00Z"
              version: "1.0"

    InvalidPin:
      description: PIN incorrecto.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              code: INVALID_PIN
              message: "PIN incorrecto."

    WorkerNotFound:
      description: Trabajador no encontrado.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              code: WORKER_NOT_FOUND
              message: "Código de trabajador no encontrado."

    DeviceUnauthorized:
      description: Dispositivo no autorizado o inactivo.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              code: DEVICE_UNAUTHORIZED
              message: "Dispositivo no autorizado."

    ValidationError:
      description: Datos de entrada no válidos.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              code: VALIDATION_ERROR
              message: "Los datos enviados no son válidos."
              details:
                type: ["El campo tipo es obligatorio."]

    NotFound:
      description: Recurso no encontrado.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'

    RateLimited:
      description: Demasiadas peticiones.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              code: RATE_LIMITED
              message: "Demasiados intentos. Espera antes de reintentar."

    Immutable:
      description: Los fichajes son inmutables y no pueden modificarse ni eliminarse.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              code: CLOCKING_IMMUTABLE
              message: "Los fichajes no pueden modificarse ni eliminarse."

  schemas:

    # ── Envolventes globales ─────────────────────────────────────────────────

    ApiSuccess:
      type: object
      required: [success, data, meta]
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
        meta:
          $ref: '#/components/schemas/Meta'

    ApiError:
      type: object
      required: [success, error, meta]
      properties:
        success:
          type: boolean
          example: false
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: WORKER_NOT_FOUND
            message:
              type: string
              example: "El trabajador no existe."
            details:
              type: object
              additionalProperties: true
        meta:
          $ref: '#/components/schemas/Meta'

    Meta:
      type: object
      properties:
        timestamp:
          type: string
          format: date-time
          example: "2026-05-06T09:30:00Z"
        version:
          type: string
          example: "1.0"
        request_id:
          type: string
          format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"

    Pagination:
      type: object
      properties:
        pagination:
          type: object
          properties:
            total:
              type: integer
              example: 44
            per_page:
              type: integer
              example: 50
            current_page:
              type: integer
              example: 1
            last_page:
              type: integer
              example: 1

    # ── Identify ─────────────────────────────────────────────────────────────

    IdentifyByPin:
      type: object
      required: [method, worker_code, pin]
      properties:
        method:
          type: string
          enum: [manual]
        worker_code:
          type: string
          pattern: '^\d{1,6}$'
          description: Código numérico del trabajador (1–6 dígitos con ceros si aplica).
          example: "001"
        pin:
          type: string
          pattern: '^\d{4}$'
          description: PIN de 4 dígitos.
          example: "1234"

    IdentifyByNfc:
      type: object
      required: [method, nfc_card_id]
      properties:
        method:
          type: string
          enum: [nfc]
        nfc_card_id:
          type: string
          maxLength: 50
          description: UID de la tarjeta NFC sin separadores, en mayúsculas.
          example: "04A32B1C9F2E80"

    WorkerIdentity:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        role:
          type: string
          example: worker

    # ── Clocking ─────────────────────────────────────────────────────────────

    Clocking:
      type: object
      properties:
        id:
          type: integer
        worker_id:
          type: integer
        worker:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        device_id:
          type: integer
          nullable: true
        type:
          type: string
          enum: [clock_in, clock_out, break_start, break_end]
        method:
          type: string
          enum: [nfc, rfid, manual, mobile_app, web, api]
        latitude:
          type: number
          format: float
          nullable: true
        longitude:
          type: number
          format: float
          nullable: true
        geofence_status:
          type: string
          enum: [inside, outside, unverified, not_required]
        work_zone_id:
          type: integer
          nullable: true
          description: ID de la zona de trabajo. Se hereda del dispositivo si no hay GPS.
        hash:
          type: string
          description: SHA-256 del payload para garantizar inmutabilidad.
        reviewed:
          type: boolean
          nullable: true
        notes:
          type: string
          nullable: true
        clocked_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time

    CreateClockingRequest:
      type: object
      required: [worker_id, type, method]
      properties:
        worker_id:
          type: integer
        type:
          type: string
          enum: [clock_in, clock_out, break_start, break_end]
        method:
          type: string
          enum: [nfc, rfid, manual, mobile_app, web, api]
        device_id:
          type: integer
          nullable: true
          description: ID del dispositivo desde el que se registra. La API asigna la zona del dispositivo automáticamente.
        latitude:
          type: number
          format: float
          nullable: true
        longitude:
          type: number
          format: float
          nullable: true
        notes:
          type: string
          nullable: true

    # ── Worker ───────────────────────────────────────────────────────────────

    Worker:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          format: email
          nullable: true
        phone:
          type: string
          nullable: true
        employee_number:
          type: string
          nullable: true
        external_id:
          type: string
          nullable: true
          description: ID del trabajador en un sistema externo (ERP, nóminas). Útil para integraciones.
        department:
          type: string
          nullable: true
        position:
          type: string
          nullable: true
        hire_date:
          type: string
          format: date
          nullable: true
        active:
          type: boolean
        vacation_days_total:
          type: integer
          nullable: true
        active_schedule:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            expected_start:
              type: string
              example: "07:30:00"
            expected_end:
              type: string
              example: "15:30:00"
            active_days:
              type: array
              items:
                type: integer
                minimum: 1
                maximum: 7
            assigned_from:
              type: string
              format: date
        created_at:
          type: string
          format: date-time

    CreateWorkerRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 200
        email:
          type: string
          format: email
        phone:
          type: string
          maxLength: 20
        nif:
          type: string
          maxLength: 20
        employee_number:
          type: string
          maxLength: 50
        external_id:
          type: string
          maxLength: 100
          description: ID en sistema externo para integraciones.
        department:
          type: string
          maxLength: 100
        position:
          type: string
          maxLength: 100
        hire_date:
          type: string
          format: date
        vacation_days_total:
          type: integer
          minimum: 0
          maximum: 60
        schedule_id:
          type: integer
          description: Asigna automáticamente este turno al crear al trabajador.

    UpdateWorkerRequest:
      allOf:
        - $ref: '#/components/schemas/CreateWorkerRequest'
        - type: object
          properties:
            active:
              type: boolean
            schedule_id:
              type: integer
              nullable: true
              description: Pasa `null` para desvincular el turno actual.

    WorkerBalance:
      type: object
      properties:
        worker_id:
          type: integer
        vacation_days_total:
          type: integer
        vacation_days_used:
          type: integer
        vacation_days_remaining:
          type: integer
        ordinary_minutes:
          type: integer
          description: Minutos de jornada ordinaria acumulados.
        overtime_minutes:
          type: integer
          description: Minutos extra acumulados.
        break_minutes:
          type: integer
        absence_minutes:
          type: integer
        period:
          type: object
          nullable: true
          properties:
            year:
              type: integer
            month:
              type: integer

    Absence:
      type: object
      properties:
        id:
          type: integer
        type:
          type: string
          enum: [vacation, sick_leave, personal_leave, other]
        status:
          type: string
          enum: [pending, approved, rejected]
        starts_at:
          type: string
          format: date
        ends_at:
          type: string
          format: date
        reason:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time

    # ── Schedule ─────────────────────────────────────────────────────────────

    Schedule:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        active_days:
          type: array
          items:
            type: integer
            minimum: 1
            maximum: 7
          description: "Días activos: 1=lunes, 7=domingo."
          example: [1, 2, 3, 4, 5]
        expected_start:
          type: string
          example: "07:30:00"
          description: Hora de entrada esperada (HH:MM:SS).
        expected_end:
          type: string
          example: "15:30:00"
          description: Hora de salida esperada (HH:MM:SS).
        break_minutes:
          type: integer
          example: 30
        active:
          type: boolean
        created_at:
          type: string
          format: date-time

    CreateScheduleRequest:
      type: object
      required: [name, active_days, expected_start, expected_end]
      properties:
        name:
          type: string
        active_days:
          type: array
          items:
            type: integer
            minimum: 1
            maximum: 7
          minItems: 1
        expected_start:
          type: string
          example: "07:30"
        expected_end:
          type: string
          example: "15:30"
        break_minutes:
          type: integer
          default: 0

    # ── Informe mensual ──────────────────────────────────────────────────────

    MonthlyReport:
      type: object
      properties:
        worker_id:
          type: integer
        worker_name:
          type: string
        year:
          type: integer
        month:
          type: integer
        sessions:
          type: array
          items:
            type: object
            properties:
              date:
                type: string
                format: date
              clock_in:
                type: string
                format: date-time
                nullable: true
              clock_out:
                type: string
                format: date-time
                nullable: true
              ordinary_minutes:
                type: integer
              overtime_minutes:
                type: integer
              break_minutes:
                type: integer
        totals:
          type: object
          properties:
            ordinary_minutes:
              type: integer
            overtime_minutes:
              type: integer
            break_minutes:
              type: integer
            working_days:
              type: integer

    # ── Device ───────────────────────────────────────────────────────────────

    Device:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        type:
          type: string
          enum: [tablet, nfc_reader, rfid_reader, kiosk]
        active:
          type: boolean
        work_zone_id:
          type: integer
          nullable: true
          description: Zona asociada al dispositivo. Los fichajes registrados desde este terminal heredan esta zona.
        last_seen_at:
          type: string
          format: date-time
          nullable: true
        last_ip:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time

    DeviceWithToken:
      allOf:
        - $ref: '#/components/schemas/Device'
        - type: object
          properties:
            token:
              type: string
              description: Token en texto plano. Solo aparece en la respuesta de creación. Guárdalo inmediatamente.
              example: "Zq4tff5Gv3WlR8ZA4RHqB5SgOY6kyX2yDcjx13cK"

    # ── Webhook ──────────────────────────────────────────────────────────────

    Webhook:
      type: object
      properties:
        id:
          type: integer
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            type: string
        active:
          type: boolean
        created_at:
          type: string
          format: date-time

    WebhookWithSecret:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          properties:
            secret:
              type: string
              description: Secreto HMAC. Solo aparece en la respuesta de creación.
